From 989077db95179e9dad47731bf4e162dcee9030c3 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Tue, 23 Jun 2026 17:40:15 +0530 Subject: [PATCH 01/18] init --- webapp/src/action_types.ts | 1 - webapp/src/actions.ts | 16 +++- .../components/call_widget/component.test.tsx | 1 + .../src/components/call_widget/component.tsx | 1 + webapp/src/components/call_widget/index.ts | 2 + webapp/src/components/channel_call_toast.tsx | 2 +- webapp/src/components/expanded_view/index.ts | 2 +- webapp/src/index.tsx | 17 +++- webapp/src/reducers.ts | 49 ++-------- webapp/src/selectors.test.ts | 92 +++++++++++++++++++ webapp/src/selectors.ts | 53 ++++++++--- webapp/src/state/README.md | 32 +++++++ webapp/src/state/active_calls/action_types.ts | 6 ++ webapp/src/state/active_calls/reducer.ts | 48 ++++++++++ webapp/src/state/common_action_types.ts | 7 ++ webapp/src/state/common_actions.ts | 20 ++++ .../src/state/screen_sharing_ids/actions.ts | 2 +- .../src/state/screen_sharing_ids/reducer.ts | 3 +- .../{session => sessions}/action_types.ts | 5 +- .../state/{session => sessions}/actions.ts | 17 +--- .../state/{session => sessions}/reducer.ts | 3 +- .../state/{session => sessions}/selectors.ts | 0 .../state/sip_call_details/action_types.ts | 6 ++ webapp/src/state/sip_call_details/actions.ts | 22 +++++ webapp/src/state/sip_call_details/reducer.ts | 68 ++++++++++++++ webapp/src/state/sip_call_details/selector.ts | 3 + webapp/src/utils.test.ts | 57 ++++++++++++ webapp/src/utils.ts | 24 ++++- webapp/src/websocket_handlers.ts | 8 +- 29 files changed, 471 insertions(+), 96 deletions(-) create mode 100644 webapp/src/selectors.test.ts create mode 100644 webapp/src/state/README.md create mode 100644 webapp/src/state/active_calls/action_types.ts create mode 100644 webapp/src/state/active_calls/reducer.ts create mode 100644 webapp/src/state/common_action_types.ts create mode 100644 webapp/src/state/common_actions.ts rename webapp/src/state/{session => sessions}/action_types.ts (80%) rename webapp/src/state/{session => sessions}/actions.ts (91%) rename webapp/src/state/{session => sessions}/reducer.ts (98%) rename webapp/src/state/{session => sessions}/selectors.ts (100%) create mode 100644 webapp/src/state/sip_call_details/action_types.ts create mode 100644 webapp/src/state/sip_call_details/actions.ts create mode 100644 webapp/src/state/sip_call_details/reducer.ts create mode 100644 webapp/src/state/sip_call_details/selector.ts diff --git a/webapp/src/action_types.ts b/webapp/src/action_types.ts index 898a1a347..9dcce96bd 100644 --- a/webapp/src/action_types.ts +++ b/webapp/src/action_types.ts @@ -3,7 +3,6 @@ import {pluginId} from './manifest'; -export const CALL_STATE = pluginId + '_call_state'; export const CALL_HOST = pluginId + '_call_host'; export const CALL_RECORDING_STATE = pluginId + '_call_recording_state'; export const CALL_LIVE_CAPTIONS_STATE = pluginId + '_call_live_captions_state'; diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index bd03c64ad..c5e8d94f0 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -36,12 +36,16 @@ import { ringingForCall, shouldPlayJoinUserSound, } from 'src/selectors'; +import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; +import {callEnded} from 'src/state/common_actions'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; -import {callEnded, getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/session/actions'; +import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; +import {sipCallDetailsReceived} from 'src/state/sip_call_details/actions'; import {CallsStats, ChannelType} from 'src/types/types'; import { getCallsClientSessionID, getPluginPath, + getSipCallDetailsFromCallState, getUserIDsForSessions, isDMChannel, isGMChannel, @@ -56,7 +60,6 @@ import { CALL_LIVE_CAPTIONS_STATE, CALL_REC_PROMPT_DISMISSED, CALL_RECORDING_STATE, - CALL_STATE, CLIENT_CONNECTING, DID_RING_FOR_CALL, DISMISS_CALL, @@ -562,9 +565,9 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: const actions: AnyAction[] = []; actions.push({ - type: CALL_STATE, + type: ACTIVE_CALL_REGISTERED, data: { - ID: call.id, + callID: call.id, channelID, startAt: call.start_at, ownerID: call.owner_id, @@ -572,6 +575,11 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: }, }); + const sipCallDetails = getSipCallDetailsFromCallState(call); + if (sipCallDetails) { + actions.push(sipCallDetailsReceived(channelID, sipCallDetails)); + } + actions.push({ type: CALL_RECORDING_STATE, data: { diff --git a/webapp/src/components/call_widget/component.test.tsx b/webapp/src/components/call_widget/component.test.tsx index a4feb3e57..a08694bf7 100644 --- a/webapp/src/components/call_widget/component.test.tsx +++ b/webapp/src/components/call_widget/component.test.tsx @@ -80,6 +80,7 @@ const props: Props = { openModal: jest.fn(), openCallsUserSettings: jest.fn(), connectedDMUser: undefined, + isPhoneCall: false, }; describe('CallWidget', () => { diff --git a/webapp/src/components/call_widget/component.tsx b/webapp/src/components/call_widget/component.tsx index cb8088168..62535017b 100644 --- a/webapp/src/components/call_widget/component.tsx +++ b/webapp/src/components/call_widget/component.tsx @@ -140,6 +140,7 @@ interface Props { openModal:

(modalData: ModalData

) => void; openCallsUserSettings: () => void; connectedDMUser: UserProfile | undefined, + isPhoneCall: boolean, } interface DraggingState { diff --git a/webapp/src/components/call_widget/index.ts b/webapp/src/components/call_widget/index.ts index a99d05e72..7eac59913 100644 --- a/webapp/src/components/call_widget/index.ts +++ b/webapp/src/components/call_widget/index.ts @@ -26,6 +26,7 @@ import { hostChangeAtForCurrentCall, hostControlNoticesForCurrentCall, hostIDForCurrentCall, + isPhoneCallForCurrentCall, isRecordingInCurrentCall, profilesInCurrentCallMap, recentlyJoinedUsersInCurrentCall, @@ -98,6 +99,7 @@ const mapStateToProps = (state: GlobalState) => { recordingsEnabled: recordingsEnabled(state), connectedDMUser, otherSessions: sessionsForOtherUsersInCall(state), + isPhoneCall: isPhoneCallForCurrentCall(state), }; }; diff --git a/webapp/src/components/channel_call_toast.tsx b/webapp/src/components/channel_call_toast.tsx index 0f66f2fb1..3a2961e6e 100644 --- a/webapp/src/components/channel_call_toast.tsx +++ b/webapp/src/components/channel_call_toast.tsx @@ -27,7 +27,7 @@ const ChannelCallToast = () => { const limitRestricted = useSelector(isLimitRestricted); const dismissed = useSelector(dismissedCallForCurrentChannel); - const callID = useSelector(callInCurrentChannel)?.ID || ''; + const callID = useSelector(callInCurrentChannel)?.callID || ''; const [onDismiss, onJoin] = useDismissJoin(currChannelID, callID); const hasCall = (call && currChannelID !== connectedID); diff --git a/webapp/src/components/expanded_view/index.ts b/webapp/src/components/expanded_view/index.ts index ee71bdf16..367bbc5ed 100644 --- a/webapp/src/components/expanded_view/index.ts +++ b/webapp/src/components/expanded_view/index.ts @@ -39,7 +39,7 @@ import { threadIDForCallInChannel, transcriptionsEnabled, } from 'src/selectors'; -import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, usersVoiceActivityChanged, userUnmuted} from 'src/state/session/actions'; +import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; import {alphaSortSessions, getUserIdFromDM, isDMChannel, stateSortSessions} from 'src/utils'; import {closeRhs, getIsRhsOpen, getRhsSelectedPostId, modals, selectRhsPost} from 'src/webapp_globals'; diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 11317616a..f995b845c 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -104,13 +104,15 @@ import VideoDevicesSettingsSection from 'src/components/user_settings/video_devi import {CALL_RECORDING_POST_TYPE, CALL_START_POST_TYPE, CALL_TRANSCRIPTION_POST_TYPE, DisabledCallsErr} from 'src/constants'; import {desktopNotificationHandler} from 'src/desktop_notifications'; import slashCommandsHandler from 'src/slash_commands'; -import {getSessionsMapFromSessions, sessionsReceived, unInitialized, userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/session/actions'; +import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; +import {unInitialized} from 'src/state/common_actions'; +import {getSessionsMapFromSessions, sessionsReceived, userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; +import {sipCallDetailsReceived} from 'src/state/sip_call_details/actions'; import {CurrentCallDataDefault, DesktopMessageType} from 'src/types/types'; -import {getWSConnectionURL} from 'src/utils'; +import {getSipCallDetailsFromCallState, getWSConnectionURL} from 'src/utils'; import {modals} from 'src/webapp_globals'; import { - CALL_STATE, DISMISS_CALL, RECEIVED_CHANNEL_STATE, } from './action_types'; @@ -902,9 +904,9 @@ export default class Plugin { if (!callStartAtForCallInChannel(store.getState(), data[i].channel_id)) { actions.push({ - type: CALL_STATE, + type: ACTIVE_CALL_REGISTERED, data: { - ID: call.id, + callID: call.id, channelID: data[i].channel_id, startAt: call.start_at, ownerID: call.owner_id, @@ -912,6 +914,11 @@ export default class Plugin { }, }); + const sipCallDetails = getSipCallDetailsFromCallState(call); + if (sipCallDetails) { + actions.push(sipCallDetailsReceived(data[i].channel_id, sipCallDetails)); + } + actions.push(sessionsReceived(data[i].channel_id, getSessionsMapFromSessions(call.sessions))); if (ringingEnabled(store.getState()) && data[i].call) { diff --git a/webapp/src/reducers.ts b/webapp/src/reducers.ts index 4f8b1cd87..f0eeb3dbb 100644 --- a/webapp/src/reducers.ts +++ b/webapp/src/reducers.ts @@ -6,16 +6,15 @@ import {CallJobState, CallsConfig, CallsVersionInfo, LiveCaption, Reaction, UserSessionState} from '@mattermost/calls-common/lib/types'; import {combineReducers} from 'redux'; import {MAX_NUM_REACTIONS_IN_REACTION_STREAM} from 'src/constants'; -import {reducer as screenSharingIDs} from 'src/state/screen_sharing_ids/reducer'; +import {reducer as activeCalls} from 'src/state/active_calls/reducer'; import { CALL_ENDED, UN_INITIALIZED, - USER_JOINED, - USER_LEFT, - USER_REACTED, - USER_REACTED_TIMEOUT, -} from 'src/state/session/action_types'; -import {reducer as sessions} from 'src/state/session/reducer'; +} from 'src/state/common_action_types'; +import {reducer as screenSharingIDs} from 'src/state/screen_sharing_ids/reducer'; +import {USER_JOINED, USER_LEFT, USER_REACTED, USER_REACTED_TIMEOUT} from 'src/state/sessions/action_types'; +import {reducer as sessions} from 'src/state/sessions/reducer'; +import {reducer as sipCallDetails} from 'src/state/sip_call_details/reducer'; import { CallsConfigDefault, CallsUserPreferences, @@ -34,7 +33,6 @@ import { CALL_LIVE_CAPTIONS_STATE, CALL_REC_PROMPT_DISMISSED, CALL_RECORDING_STATE, - CALL_STATE, CLIENT_CONNECTING, DESKTOP_WIDGET_CONNECTED, DID_NOTIFY_FOR_CALL, @@ -373,43 +371,13 @@ const callLiveCaptionsState = (state: callsJobState = {}, action: jobStateAction // callState should only hold immutable data, meaning those // fields that don't change for the whole duration of a call. export type callState = { - ID: string; + callID: string; startAt: number; channelID: string; threadID: string; ownerID: string; } -type callStateAction = { - type: string; - data: callState; -} - -type callsState = { - [channelID: string]: callState; -} - -const calls = (state: callsState = {}, action: callStateAction) => { - switch (action.type) { - case UN_INITIALIZED: - return {}; - case CALL_STATE: - return { - ...state, - [action.data.channelID]: { - ...action.data, - }, - }; - case CALL_ENDED: { - const nextState = {...state}; - delete nextState[action.data.channelID]; - return nextState; - } - default: - return state; - } -}; - export type hostsState = { [channelID: string]: { hostID: string; @@ -760,9 +728,10 @@ const rootReducer = combineReducers({ clientStateReducer, reactions, sessions, - calls, + activeCalls, hosts, screenSharingIDs, + sipCallDetails, expandedView, switchCallModal, screenSourceModal, diff --git a/webapp/src/selectors.test.ts b/webapp/src/selectors.test.ts new file mode 100644 index 000000000..192d1fbdf --- /dev/null +++ b/webapp/src/selectors.test.ts @@ -0,0 +1,92 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GlobalState} from '@mattermost/types/store'; + +import {pluginId} from './manifest'; +import {callState} from './reducers'; +import {isPhoneCall, isPhoneCallForCurrentCall, sipCallDetailsForCallInChannel} from './selectors'; +import {CallDirection, SipCallDetails} from './state/sip_call_details/reducer'; + +const phoneCall: callState = { + callID: 'call1', + channelID: 'chPhone', + startAt: 100, + threadID: 'thread1', + ownerID: 'owner1', +}; + +const phoneSip: SipCallDetails = { + direction: CallDirection.Outbound, + phone_number: '+15551234567', + display_number: '(555) 123-4567', + label: 'DSN', + user_id: 'user1', +}; + +const audioCall: callState = { + callID: 'call2', + channelID: 'chAudio', + startAt: 200, + threadID: 'thread2', + ownerID: 'owner2', +}; + +const buildState = (currentChannelID = ''): GlobalState => ({ + [`plugins-${pluginId}`]: { + activeCalls: { + chPhone: phoneCall, + chAudio: audioCall, + }, + sipCallDetails: { + chPhone: phoneSip, + }, + clientStateReducer: {channelID: currentChannelID}, + }, +} as unknown as GlobalState); + +describe('selectors phone-call props', () => { + const state = buildState(); + + describe('sipCallDetailsForCallInChannel', () => { + it('returns the sip details for a phone call', () => { + expect(sipCallDetailsForCallInChannel(state, 'chPhone')).toEqual(phoneSip); + }); + + it('returns undefined for an audio call', () => { + expect(sipCallDetailsForCallInChannel(state, 'chAudio')).toBeUndefined(); + }); + + it('returns undefined for an unknown channel', () => { + expect(sipCallDetailsForCallInChannel(state, 'missing')).toBeUndefined(); + }); + }); + + describe('isPhoneCall', () => { + it('is true only for a phone call', () => { + expect(isPhoneCall(state, 'chPhone')).toBe(true); + }); + + it('is false for an audio call', () => { + expect(isPhoneCall(state, 'chAudio')).toBe(false); + }); + + it('is false for an unknown channel', () => { + expect(isPhoneCall(state, 'missing')).toBe(false); + }); + }); + + describe('isPhoneCallForCurrentCall', () => { + it('is true when the current call is a phone call', () => { + expect(isPhoneCallForCurrentCall(buildState('chPhone'))).toBe(true); + }); + + it('is false when the current call is an audio call', () => { + expect(isPhoneCallForCurrentCall(buildState('chAudio'))).toBe(false); + }); + + it('is false when there is no current call', () => { + expect(isPhoneCallForCurrentCall(buildState(''))).toBe(false); + }); + }); +}); diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 52ca29551..14280a997 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -38,6 +38,7 @@ import { RootState, usersReactionsState, } from 'src/reducers'; +import {SipCallDetails} from 'src/state/sip_call_details/reducer'; import { CallJobReduxState, CallsUserPreferences, @@ -55,8 +56,11 @@ const pluginReduxStateKey = `plugins-${pluginId}`; const pluginReduxStore = (state: GlobalState): RootState => (state[pluginReduxStateKey as keyof GlobalState] as unknown as RootState) ?? initialRootState; -const callsStateInPluginReduxStore = (state: GlobalState): { [channelID: string]: callState } => - pluginReduxStore(state).calls; +const activeCallsInPluginReduxStore = (state: GlobalState): { [channelID: string]: callState } => + pluginReduxStore(state).activeCalls; + +const sipCallDetailsInPluginReduxStore = (state: GlobalState): { [channelID: string]: SipCallDetails } => + pluginReduxStore(state).sipCallDetails; export const channelIDForCurrentCall: (state: GlobalState) => string = createSelector( @@ -77,19 +81,19 @@ export const channelForCurrentCall: (state: GlobalState) => Channel | undefined export const getCallIDForCurrentCall: (state: GlobalState) => string | undefined = createSelector( 'getCallIDForCurrentCall', - callsStateInPluginReduxStore, + activeCallsInPluginReduxStore, channelIDForCurrentCall, - (callsStates, channelID) => callsStates[channelID]?.ID, + (callsStates, channelID) => callsStates[channelID]?.callID, ); export const getCallIDForChannel = (state: GlobalState, channelID: string) => { - return callsStateInPluginReduxStore(state)[channelID]?.ID ?? ''; + return activeCallsInPluginReduxStore(state)[channelID]?.callID ?? ''; }; export const threadIDForCurrentCall: (state: GlobalState) => string | undefined = createSelector( 'threadIDForCurrentCall', - callsStateInPluginReduxStore, + activeCallsInPluginReduxStore, channelIDForCurrentCall, (callsStates, channelID) => callsStates[channelID]?.threadID, ); @@ -169,13 +173,13 @@ export const numSessionsInCallInChannel = (state: GlobalState, channelID: string }; export const channelHasCall = (state: GlobalState, channelId: string): boolean => { - return Boolean(callsStateInPluginReduxStore(state)[channelId]); + return Boolean(activeCallsInPluginReduxStore(state)[channelId]); }; export const currentChannelHasCall: (state: GlobalState) => boolean = createSelector( 'currentChannelHasCall', - callsStateInPluginReduxStore, + activeCallsInPluginReduxStore, getCurrentChannelId, (callsStates, currChannelId) => Boolean(callsStates[currChannelId]), ); @@ -238,13 +242,13 @@ export const liveCaptionsInCurrentCall: (state: GlobalState) => LiveCaptions = ); export const callStartAtForCallInChannel = (state: GlobalState, channelID: string): number => { - return pluginReduxStore(state).calls[channelID]?.startAt || 0; + return pluginReduxStore(state).activeCalls[channelID]?.startAt || 0; }; export const callStartAtForCurrentCall: (state: GlobalState) => number = createSelector( 'callStartAtForCurrentCall', - callsStateInPluginReduxStore, + activeCallsInPluginReduxStore, channelIDForCurrentCall, getCallsClientInitTime, (callsStates, channelID, initTime) => callsStates[channelID]?.startAt || initTime || 0, @@ -253,19 +257,38 @@ export const callStartAtForCurrentCall: (state: GlobalState) => number = export const callInCurrentChannel: (state: GlobalState) => callState | undefined = createSelector( 'callInCurrentChannel', - callsStateInPluginReduxStore, + activeCallsInPluginReduxStore, getCurrentChannelId, (callsStates, currChannelId) => callsStates[currChannelId], ); export const idForCallInChannel = (state: GlobalState, channelID: string): string | undefined => { - return pluginReduxStore(state).calls[channelID]?.ID; + return pluginReduxStore(state).activeCalls[channelID]?.callID; }; export const callOwnerIDForCallInChannel = (state: GlobalState, channelID: string): string | undefined => { - return pluginReduxStore(state).calls[channelID]?.ownerID; + return pluginReduxStore(state).activeCalls[channelID]?.ownerID; +}; + +export const sipCallDetailsForCallInChannel = (state: GlobalState, channelID: string): SipCallDetails | undefined => { + return pluginReduxStore(state).sipCallDetails[channelID]; +}; + +// isPhoneCall is true only for SIP/phone calls. The sipCallDetails slice holds an entry +// only for such calls (populated from the server's call props), so presence of +// an entry is the signal. Regular WebRTC calls have no entry and read as false. +export const isPhoneCall = (state: GlobalState, channelID: string): boolean => { + return Boolean(sipCallDetailsForCallInChannel(state, channelID)); }; +export const isPhoneCallForCurrentCall: (state: GlobalState) => boolean = + createSelector( + 'isPhoneCallForCurrentCall', + sipCallDetailsInPluginReduxStore, + channelIDForCurrentCall, + (sipStates, channelID) => Boolean(sipStates[channelID]), + ); + const hostsInCalls = (state: GlobalState): hostsState => { return pluginReduxStore(state).hosts; }; @@ -308,7 +331,7 @@ export const screenSharingSessionForCurrentCall: (state: GlobalState) => UserSes ); export const threadIDForCallInChannel = (state: GlobalState, channelID: string) => { - return pluginReduxStore(state).calls[channelID]?.threadID || ''; + return pluginReduxStore(state).activeCalls[channelID]?.threadID || ''; }; const recordingsForCalls = (state: GlobalState): callsJobState => { @@ -407,7 +430,7 @@ export const dismissedCallForCurrentChannel: (state: GlobalState) => boolean = 'dismissedCallForCurrentChannel', dismissedCalls, callInCurrentChannel, - (dismissed, call) => Boolean(dismissed[call?.ID || '']), + (dismissed, call) => Boolean(dismissed[call?.callID || '']), ); export const ringingForCall = (state: GlobalState, callID: string): boolean => diff --git a/webapp/src/state/README.md b/webapp/src/state/README.md new file mode 100644 index 000000000..f82bc85dd --- /dev/null +++ b/webapp/src/state/README.md @@ -0,0 +1,32 @@ +# Redux state + +This folder holds the plugin's own Redux store (mounted under +`plugins-${pluginId}`). It's split into one self-contained redux **slice** per concern. + +> Work in progress and will be refactored as we go. + +## Folder structure + +Each slice lives in its own folder: + 1. `slice/action_types.ts` + 2. `slice/actions.ts` + 3. `slice/reducer.ts` + 4. `slice/selectors.ts` + +## Action types + +1. Action types are constants that represent **events that already happened**, not commands or setters. They are named using the past tense of the verb in UPPER_SNAKE_CASE eg `USER_JOINED`, `USER_MUTED` etc. +1. Action types used by more than one slice stay in the root in `common_action_types.ts`. + +## Actions + +1. Actions (action creators) are functions that return an action object — a `type` (one of the action types above) plus its `data` — for the reducer to handle. +1. Similar to Action types, convention for naming them is also past tense verbs but in camelCase eg. `userJoined`, `userMuted` etc. +1. Each slice exports a union of its action types named `Actions` from its `actions.ts`, used to type the reducer (`Reducer`). +1. Actions used by more than one slice stay in the root in `common_actions.ts`. + +## Reducers + +1. Reducers are pure: derive the next state only from the current state and the + action. No side effects, no reading other slices. +1. Read state through selectors rather than reaching into the store shape directly, so slice internals can change without breaking call sites. diff --git a/webapp/src/state/active_calls/action_types.ts b/webapp/src/state/active_calls/action_types.ts new file mode 100644 index 000000000..110ed4d1c --- /dev/null +++ b/webapp/src/state/active_calls/action_types.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {pluginId} from 'src/manifest'; + +export const ACTIVE_CALL_REGISTERED = `${pluginId}_active_call_registered` as const; \ No newline at end of file diff --git a/webapp/src/state/active_calls/reducer.ts b/webapp/src/state/active_calls/reducer.ts new file mode 100644 index 000000000..145ffbb69 --- /dev/null +++ b/webapp/src/state/active_calls/reducer.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {UserThread} from '@mattermost/types/threads'; +import {UserProfile} from '@mattermost/types/users'; +import {Reducer} from 'redux'; +import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; + +import {ACTIVE_CALL_REGISTERED} from './action_types'; + +type State = { + [channelID: string]: { + callID: string; + startAt: number; + channelID: Channel['id']; + threadID: UserThread['id']; + ownerID: UserProfile['id']; + }; +} + +const emptyState: State = {}; + +export const reducer: Reducer = (initialState = emptyState, action) => { + switch (action.type) { + case UN_INITIALIZED:{ + return emptyState; + } + + case ACTIVE_CALL_REGISTERED: { + return { + ...initialState, + [action.data.channelID]: { + ...action.data, + }, + }; + } + + case CALL_ENDED: { + const nextState = {...initialState}; + delete nextState[action.data.channelID]; + return nextState; + } + + default: + return initialState; + } +}; \ No newline at end of file diff --git a/webapp/src/state/common_action_types.ts b/webapp/src/state/common_action_types.ts new file mode 100644 index 000000000..377ce0dad --- /dev/null +++ b/webapp/src/state/common_action_types.ts @@ -0,0 +1,7 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {pluginId} from 'src/manifest'; + +export const UN_INITIALIZED = `${pluginId}_un_initialized` as const; +export const CALL_ENDED = `${pluginId}_call_ended` as const; \ No newline at end of file diff --git a/webapp/src/state/common_actions.ts b/webapp/src/state/common_actions.ts new file mode 100644 index 000000000..fd3a4268b --- /dev/null +++ b/webapp/src/state/common_actions.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; + +import {CALL_ENDED, UN_INITIALIZED} from './common_action_types'; + +export const unInitialized = () => ({ + type: UN_INITIALIZED, +}); +export type ActionUnInitialized = ReturnType + +export const callEnded = (channelID: Channel['id'], callID: string) => ({ + type: CALL_ENDED, + data: { + channelID, + callID, + }, +}); +export type ActionCallEnded = ReturnType \ No newline at end of file diff --git a/webapp/src/state/screen_sharing_ids/actions.ts b/webapp/src/state/screen_sharing_ids/actions.ts index 0828401e8..99ea2f9a3 100644 --- a/webapp/src/state/screen_sharing_ids/actions.ts +++ b/webapp/src/state/screen_sharing_ids/actions.ts @@ -8,7 +8,7 @@ import { ActionCallEnded, ActionUnInitialized, ActionUserLeft, -} from 'src/state/session/actions'; +} from 'src/state/sessions/actions'; import {USER_SCREEN_OFF, USER_SCREEN_ON} from './action_types'; diff --git a/webapp/src/state/screen_sharing_ids/reducer.ts b/webapp/src/state/screen_sharing_ids/reducer.ts index d4e5a8e1e..dbd880bf1 100644 --- a/webapp/src/state/screen_sharing_ids/reducer.ts +++ b/webapp/src/state/screen_sharing_ids/reducer.ts @@ -3,7 +3,8 @@ import {UserSessionState} from '@mattermost/calls-common/lib/types'; import {Reducer} from 'redux'; -import {CALL_ENDED, UN_INITIALIZED, USER_LEFT} from 'src/state/session/action_types'; +import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; +import {USER_LEFT} from 'src/state/sessions/action_types'; import {USER_SCREEN_OFF, USER_SCREEN_ON} from './action_types'; import {Actions} from './actions'; diff --git a/webapp/src/state/session/action_types.ts b/webapp/src/state/sessions/action_types.ts similarity index 80% rename from webapp/src/state/session/action_types.ts rename to webapp/src/state/sessions/action_types.ts index deb6749e9..ea9ec011a 100644 --- a/webapp/src/state/session/action_types.ts +++ b/webapp/src/state/sessions/action_types.ts @@ -3,7 +3,6 @@ import {pluginId} from 'src/manifest'; -export const UN_INITIALIZED = `${pluginId}_un_initialized` as const; export const SESSIONS_RECEIVED = `${pluginId}_sessions_received` as const; export const USER_JOINED = `${pluginId}_user_joined` as const; export const USERS_VOICE_ACTIVITY_CHANGED = `${pluginId}_users_voice_activity_changed` as const; @@ -13,6 +12,4 @@ export const USER_HAND_RAISED = `${pluginId}_user_hand_raised` as const; export const USER_HAND_LOWERED = `${pluginId}_user_hand_lowered` as const; export const USER_REACTED = `${pluginId}_user_reacted` as const; export const USER_REACTED_TIMEOUT = `${pluginId}_user_reacted_timeout` as const; -export const USER_LEFT = `${pluginId}_user_left` as const; -export const CALL_ENDED = `${pluginId}_call_ended` as const; - +export const USER_LEFT = `${pluginId}_user_left` as const; \ No newline at end of file diff --git a/webapp/src/state/session/actions.ts b/webapp/src/state/sessions/actions.ts similarity index 91% rename from webapp/src/state/session/actions.ts rename to webapp/src/state/sessions/actions.ts index 13663471d..f698a8fe5 100644 --- a/webapp/src/state/session/actions.ts +++ b/webapp/src/state/sessions/actions.ts @@ -4,11 +4,10 @@ import {Reaction, UserSessionState} from '@mattermost/calls-common/lib/types'; import {Channel} from '@mattermost/types/channels'; import {UserProfile} from '@mattermost/types/users'; +import {ActionCallEnded, ActionUnInitialized} from 'src/state/common_actions'; import { - CALL_ENDED, SESSIONS_RECEIVED, - UN_INITIALIZED, USER_HAND_LOWERED, USER_HAND_RAISED, USER_JOINED, @@ -20,11 +19,6 @@ import { USERS_VOICE_ACTIVITY_CHANGED, } from './action_types'; -export const unInitialized = () => ({ - type: UN_INITIALIZED, -}); -export type ActionUnInitialized = ReturnType - export const sessionsReceived = (channelID: Channel['id'], sessions: {[session_id: string]: UserSessionState}) => ({ type: SESSIONS_RECEIVED, data: { @@ -129,15 +123,6 @@ export const userLeft = (channelID: Channel['id'], sessionID: string, userID: Us }); export type ActionUserLeft = ReturnType -export const callEnded = (channelID: Channel['id'], callID: string) => ({ - type: CALL_ENDED, - data: { - channelID, - callID, - }, -}); -export type ActionCallEnded = ReturnType - export type Actions = | ActionUnInitialized | ActionSessionsReceived diff --git a/webapp/src/state/session/reducer.ts b/webapp/src/state/sessions/reducer.ts similarity index 98% rename from webapp/src/state/session/reducer.ts rename to webapp/src/state/sessions/reducer.ts index cf75fb99f..a43dcf014 100644 --- a/webapp/src/state/session/reducer.ts +++ b/webapp/src/state/sessions/reducer.ts @@ -4,11 +4,10 @@ import {UserSessionState} from '@mattermost/calls-common/lib/types'; import {Channel} from '@mattermost/types/channels'; import {Reducer} from 'redux'; +import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; import { - CALL_ENDED, SESSIONS_RECEIVED, - UN_INITIALIZED, USER_HAND_LOWERED, USER_HAND_RAISED, USER_JOINED, diff --git a/webapp/src/state/session/selectors.ts b/webapp/src/state/sessions/selectors.ts similarity index 100% rename from webapp/src/state/session/selectors.ts rename to webapp/src/state/sessions/selectors.ts diff --git a/webapp/src/state/sip_call_details/action_types.ts b/webapp/src/state/sip_call_details/action_types.ts new file mode 100644 index 000000000..20f0a9a2c --- /dev/null +++ b/webapp/src/state/sip_call_details/action_types.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {pluginId} from 'src/manifest'; + +export const SIP_CALL_DETAILS = `${pluginId}_sip_call_details` as const; diff --git a/webapp/src/state/sip_call_details/actions.ts b/webapp/src/state/sip_call_details/actions.ts new file mode 100644 index 000000000..8a60e53fe --- /dev/null +++ b/webapp/src/state/sip_call_details/actions.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {ActionCallEnded, ActionUnInitialized} from 'src/state/common_actions'; + +import {SIP_CALL_DETAILS} from './action_types'; +import {SipCallDetails} from './reducer'; + +export const sipCallDetailsReceived = (channelID: Channel['id'], details: SipCallDetails) => ({ + type: SIP_CALL_DETAILS, + data: { + channelID, + details, + }, +}); +export type ActionSipCallDetailsReceived = ReturnType + +export type Actions = + | ActionUnInitialized + | ActionCallEnded + | ActionSipCallDetailsReceived; diff --git a/webapp/src/state/sip_call_details/reducer.ts b/webapp/src/state/sip_call_details/reducer.ts new file mode 100644 index 000000000..93214b8c1 --- /dev/null +++ b/webapp/src/state/sip_call_details/reducer.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {UserProfile} from '@mattermost/types/users'; +import {Reducer} from 'redux'; +import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; + +import {SIP_CALL_DETAILS} from './action_types'; +import {Actions} from './actions'; + +// PHONE_CALL_TYPE is the wire value of `call.props.type` that marks a call as a +// SIP/phone call. It is the only signal the server sends to discriminate phone +// calls from regular WebRTC calls; the client stores presence, not the value. +export const PHONE_CALL_TYPE = 'phone'; + +// CallDirection distinguishes inbound from outbound SIP/phone calls. Today only +// outbound calls are placed; the inbound value is reserved for incoming-SIP +// support and is read from the wire defensively (see getSipCallDetailsFromCallState). +export enum CallDirection { + Outbound = 'outbound', + Inbound = 'inbound', +} + +// SipCallDetails holds the contact metadata for a SIP/phone call, derived from the +// server's `call.props`. The presence of an entry in this slice IS the +// "is this a phone/SIP call" signal — there is no separate `type` flag. Kept +// plugin-local: the calls-common CallState type does not declare these props +// yet (server proposal P1), so they are read defensively from the wire. +export type SipCallDetails = { + direction: CallDirection; + phone_number: string; + display_number: string; + label: string; + user_id: UserProfile['id']; +} + +// State is keyed by channelID and only holds entries for SIP/phone calls. +// Regular WebRTC calls have no entry here, mirroring how hosts/screenSharingIDs +// only hold entries for the channels they apply to. +type State = { + [channelID: string]: SipCallDetails; +} + +const emptyState: State = {}; + +export const reducer: Reducer = (initialState = emptyState, action): State => { + switch (action.type) { + case UN_INITIALIZED: { + return emptyState; + } + + case SIP_CALL_DETAILS: { + return { + ...initialState, + [action.data.channelID]: action.data.details, + }; + } + + case CALL_ENDED: { + const nextState = {...initialState}; + delete nextState[action.data.channelID]; + return nextState; + } + + default: + return initialState; + } +}; diff --git a/webapp/src/state/sip_call_details/selector.ts b/webapp/src/state/sip_call_details/selector.ts new file mode 100644 index 000000000..e888d7581 --- /dev/null +++ b/webapp/src/state/sip_call_details/selector.ts @@ -0,0 +1,3 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + diff --git a/webapp/src/utils.test.ts b/webapp/src/utils.test.ts index 1ad05e57b..45d56212f 100644 --- a/webapp/src/utils.test.ts +++ b/webapp/src/utils.test.ts @@ -1,14 +1,17 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {CallState} from '@mattermost/calls-common/lib/types'; import {Post} from '@mattermost/types/posts'; import {Duration} from 'luxon'; import {createIntl} from 'react-intl'; import type CallClient from 'src/clients/call'; +import {CallDirection} from 'src/state/sip_call_details/reducer'; import {pluginId} from './manifest'; import { callStartedTimestampFn, + getSipCallDetailsFromCallState, getCallPropsFromPost, getCallRecordingPropsFromPost, getCallsClient, @@ -689,5 +692,59 @@ describe('utils', () => { }, ); }); + + describe('getSipCallDetailsFromCallState', () => { + // The calls-common CallState type does not declare `props`, so cast + // when injecting fixture props (this mirrors the real wire payload). + const withProps = (props: unknown) => ({props} as unknown as CallState); + + it('returns sip details for a phone call, defaulting direction to outbound', () => { + const call = withProps({ + type: 'phone', + phone_number: '+15551234567', + display_number: '(555) 123-4567', + label: 'DSN', + user_id: 'user1', + }); + expect(getSipCallDetailsFromCallState(call)).toEqual({ + direction: CallDirection.Outbound, + phone_number: '+15551234567', + display_number: '(555) 123-4567', + label: 'DSN', + user_id: 'user1', + }); + }); + + it('reads an inbound direction from the wire', () => { + const call = withProps({type: 'phone', direction: 'inbound'}); + expect(getSipCallDetailsFromCallState(call)?.direction).toBe(CallDirection.Inbound); + }); + + it('returns undefined for any non-phone call', () => { + expect(getSipCallDetailsFromCallState({} as CallState)).toBeUndefined(); + expect(getSipCallDetailsFromCallState(withProps(undefined))).toBeUndefined(); + expect(getSipCallDetailsFromCallState(withProps(null))).toBeUndefined(); + + // Regular WebRTC calls carry props (hosts, screen sharing, etc.) but + // no phone type, so they must not produce sip details. + expect(getSipCallDetailsFromCallState(withProps({type: 'something-else'}))).toBeUndefined(); + expect(getSipCallDetailsFromCallState(withProps({hosts: ['user1']}))).toBeUndefined(); + }); + + it('coerces non-string fields to empty strings for a phone call', () => { + expect(getSipCallDetailsFromCallState(withProps({ + type: 'phone', + phone_number: {}, + display_number: null, + label: 123, + }))).toEqual({ + direction: CallDirection.Outbound, + phone_number: '', + display_number: '', + label: '', + user_id: '', + }); + }); + }); }); diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index 2734e23f2..f01d37184 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls-common'; -import {CallJobMetadata, CallPostProps, CallRecordingPostProps, SessionState, UserSessionState} from '@mattermost/calls-common/lib/types'; +import {CallJobMetadata, CallPostProps, CallRecordingPostProps, CallState, SessionState, UserSessionState} from '@mattermost/calls-common/lib/types'; import {Channel} from '@mattermost/types/channels'; import {ClientConfig} from '@mattermost/types/config'; import {Post} from '@mattermost/types/posts'; @@ -21,6 +21,7 @@ import {parseSemVer} from 'semver-parser'; import type CallClient from 'src/clients/call'; import RestClient from 'src/clients/rest'; import {STORAGE_CALLS_SHARE_AUDIO_WITH_SCREEN} from 'src/constants'; +import {CallDirection, PHONE_CALL_TYPE, SipCallDetails} from 'src/state/sip_call_details/reducer'; import {DesktopMessage} from 'src/types/types'; import {notificationSounds} from 'src/webapp_globals'; @@ -603,6 +604,27 @@ export function getCallPropsFromPost(post: Post): CallPostProps { }; } +// getSipCallDetailsFromCallState defensively reads the server's `call.props` off a +// CallState and projects the SIP/phone contact metadata. The calls-common +// CallState type does not declare `props` yet (server proposal P1), so we read +// it through a narrow local view and guard every field. Returns undefined for +// any non-SIP call (no props, or props whose `type` is not the 'phone' wire +// value) — its presence is what marks a call as phone/SIP for the +// sipCallDetails slice and the isPhoneCall selector. +export function getSipCallDetailsFromCallState(call: CallState): SipCallDetails | undefined { + const props = (call as {props?: Record}).props; + if (!props || !isValidObject(props) || props.type !== PHONE_CALL_TYPE) { + return undefined; + } + return { + direction: props.direction === CallDirection.Inbound ? CallDirection.Inbound : CallDirection.Outbound, + phone_number: typeof props.phone_number === 'string' ? props.phone_number : '', + display_number: typeof props.display_number === 'string' ? props.display_number : '', + label: typeof props.label === 'string' ? props.label : '', + user_id: typeof props.user_id === 'string' ? props.user_id : '', + }; +} + export function getCallRecordingPropsFromPost(post: Post): CallRecordingPostProps { return { call_post_id: typeof post.props?.call_post_id === 'string' ? post.props.call_post_id : '', diff --git a/webapp/src/websocket_handlers.ts b/webapp/src/websocket_handlers.ts index c5f6ac5c5..789526e07 100644 --- a/webapp/src/websocket_handlers.ts +++ b/webapp/src/websocket_handlers.ts @@ -48,8 +48,9 @@ import { LIVE_CAPTION_TIMEOUT, REACTION_TIMEOUT_IN_REACTION_STREAM, } from 'src/constants'; +import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; import {userScreenShared, userScreenUnshared} from 'src/state/screen_sharing_ids/actions'; -import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, userUnmuted} from 'src/state/session/actions'; +import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, userUnmuted} from 'src/state/sessions/actions'; import { HostControlNotice, HostControlNoticeType, @@ -59,7 +60,6 @@ import { CALL_HOST, CALL_LIVE_CAPTIONS_STATE, CALL_RECORDING_STATE, - CALL_STATE, DISMISS_CALL, HOST_CONTROL_NOTICE, HOST_CONTROL_NOTICE_TIMEOUT_EVENT, @@ -119,9 +119,9 @@ export function handleCallStart(store: Store, ev: WebSocketMessage Date: Tue, 23 Jun 2026 18:19:05 +0530 Subject: [PATCH 02/18] This refactor improves code organization and prepares the codebase for future enhancements related to SIP call handling. --- webapp/src/actions.ts | 2 +- webapp/src/index.tsx | 2 +- webapp/src/reducers.ts | 4 +- webapp/src/selectors.test.ts | 92 ------------------- webapp/src/selectors.ts | 2 +- webapp/src/state/active_calls/action_types.ts | 2 +- webapp/src/state/active_calls/actions.ts | 25 +++++ webapp/src/state/active_calls/reducer.ts | 15 +-- .../src/state/screen_sharing_ids/actions.ts | 11 +-- .../src/state/screen_sharing_ids/reducer.ts | 6 +- webapp/src/state/sessions/action_types.ts | 2 +- webapp/src/state/sessions/actions.ts | 8 +- webapp/src/state/sessions/reducer.ts | 8 +- .../action_types.ts | 0 .../actions.ts | 0 .../reducer.ts | 0 .../selector.ts | 0 webapp/src/utils.test.ts | 2 +- webapp/src/utils.ts | 2 +- 19 files changed, 58 insertions(+), 125 deletions(-) delete mode 100644 webapp/src/selectors.test.ts create mode 100644 webapp/src/state/active_calls/actions.ts rename webapp/src/state/{sip_call_details => sip_details}/action_types.ts (100%) rename webapp/src/state/{sip_call_details => sip_details}/actions.ts (100%) rename webapp/src/state/{sip_call_details => sip_details}/reducer.ts (100%) rename webapp/src/state/{sip_call_details => sip_details}/selector.ts (100%) diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index c5e8d94f0..03df6962d 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -40,7 +40,7 @@ import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; import {callEnded} from 'src/state/common_actions'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; -import {sipCallDetailsReceived} from 'src/state/sip_call_details/actions'; +import {sipCallDetailsReceived} from 'src/state/sip_details/actions'; import {CallsStats, ChannelType} from 'src/types/types'; import { getCallsClientSessionID, diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index f995b845c..40b7ab45b 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -107,7 +107,7 @@ import slashCommandsHandler from 'src/slash_commands'; import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; import {unInitialized} from 'src/state/common_actions'; import {getSessionsMapFromSessions, sessionsReceived, userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; -import {sipCallDetailsReceived} from 'src/state/sip_call_details/actions'; +import {sipCallDetailsReceived} from 'src/state/sip_details/actions'; import {CurrentCallDataDefault, DesktopMessageType} from 'src/types/types'; import {getSipCallDetailsFromCallState, getWSConnectionURL} from 'src/utils'; import {modals} from 'src/webapp_globals'; diff --git a/webapp/src/reducers.ts b/webapp/src/reducers.ts index f0eeb3dbb..e8d6c275d 100644 --- a/webapp/src/reducers.ts +++ b/webapp/src/reducers.ts @@ -14,7 +14,7 @@ import { import {reducer as screenSharingIDs} from 'src/state/screen_sharing_ids/reducer'; import {USER_JOINED, USER_LEFT, USER_REACTED, USER_REACTED_TIMEOUT} from 'src/state/sessions/action_types'; import {reducer as sessions} from 'src/state/sessions/reducer'; -import {reducer as sipCallDetails} from 'src/state/sip_call_details/reducer'; +import {reducer as sipDetails} from 'src/state/sip_details/reducer'; import { CallsConfigDefault, CallsUserPreferences, @@ -731,7 +731,7 @@ const rootReducer = combineReducers({ activeCalls, hosts, screenSharingIDs, - sipCallDetails, + sipDetails, expandedView, switchCallModal, screenSourceModal, diff --git a/webapp/src/selectors.test.ts b/webapp/src/selectors.test.ts deleted file mode 100644 index 192d1fbdf..000000000 --- a/webapp/src/selectors.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {GlobalState} from '@mattermost/types/store'; - -import {pluginId} from './manifest'; -import {callState} from './reducers'; -import {isPhoneCall, isPhoneCallForCurrentCall, sipCallDetailsForCallInChannel} from './selectors'; -import {CallDirection, SipCallDetails} from './state/sip_call_details/reducer'; - -const phoneCall: callState = { - callID: 'call1', - channelID: 'chPhone', - startAt: 100, - threadID: 'thread1', - ownerID: 'owner1', -}; - -const phoneSip: SipCallDetails = { - direction: CallDirection.Outbound, - phone_number: '+15551234567', - display_number: '(555) 123-4567', - label: 'DSN', - user_id: 'user1', -}; - -const audioCall: callState = { - callID: 'call2', - channelID: 'chAudio', - startAt: 200, - threadID: 'thread2', - ownerID: 'owner2', -}; - -const buildState = (currentChannelID = ''): GlobalState => ({ - [`plugins-${pluginId}`]: { - activeCalls: { - chPhone: phoneCall, - chAudio: audioCall, - }, - sipCallDetails: { - chPhone: phoneSip, - }, - clientStateReducer: {channelID: currentChannelID}, - }, -} as unknown as GlobalState); - -describe('selectors phone-call props', () => { - const state = buildState(); - - describe('sipCallDetailsForCallInChannel', () => { - it('returns the sip details for a phone call', () => { - expect(sipCallDetailsForCallInChannel(state, 'chPhone')).toEqual(phoneSip); - }); - - it('returns undefined for an audio call', () => { - expect(sipCallDetailsForCallInChannel(state, 'chAudio')).toBeUndefined(); - }); - - it('returns undefined for an unknown channel', () => { - expect(sipCallDetailsForCallInChannel(state, 'missing')).toBeUndefined(); - }); - }); - - describe('isPhoneCall', () => { - it('is true only for a phone call', () => { - expect(isPhoneCall(state, 'chPhone')).toBe(true); - }); - - it('is false for an audio call', () => { - expect(isPhoneCall(state, 'chAudio')).toBe(false); - }); - - it('is false for an unknown channel', () => { - expect(isPhoneCall(state, 'missing')).toBe(false); - }); - }); - - describe('isPhoneCallForCurrentCall', () => { - it('is true when the current call is a phone call', () => { - expect(isPhoneCallForCurrentCall(buildState('chPhone'))).toBe(true); - }); - - it('is false when the current call is an audio call', () => { - expect(isPhoneCallForCurrentCall(buildState('chAudio'))).toBe(false); - }); - - it('is false when there is no current call', () => { - expect(isPhoneCallForCurrentCall(buildState(''))).toBe(false); - }); - }); -}); diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 14280a997..19abce760 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -38,7 +38,7 @@ import { RootState, usersReactionsState, } from 'src/reducers'; -import {SipCallDetails} from 'src/state/sip_call_details/reducer'; +import {SipCallDetails} from 'src/state/sip_details/reducer'; import { CallJobReduxState, CallsUserPreferences, diff --git a/webapp/src/state/active_calls/action_types.ts b/webapp/src/state/active_calls/action_types.ts index 110ed4d1c..da813e4ef 100644 --- a/webapp/src/state/active_calls/action_types.ts +++ b/webapp/src/state/active_calls/action_types.ts @@ -3,4 +3,4 @@ import {pluginId} from 'src/manifest'; -export const ACTIVE_CALL_REGISTERED = `${pluginId}_active_call_registered` as const; \ No newline at end of file +export const ACTIVE_CALL_REGISTERED = `${pluginId}_active_call_registered` as const; diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts new file mode 100644 index 000000000..6adf6bb57 --- /dev/null +++ b/webapp/src/state/active_calls/actions.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {type Channel} from '@mattermost/types/channels'; + +import {type ActionCallEnded, type ActionUnInitialized} from '../common_actions'; +import {ACTIVE_CALL_REGISTERED} from './action_types'; +import {type State as ActiveCall} from './reducer'; + +export const activeCallRegistered = (channelID: Channel['id'], activeCall: Omit) => ({ + type: ACTIVE_CALL_REGISTERED, + data: { + callID: activeCall.callID, + startAt: activeCall.startAt, + channelID, + threadID: activeCall.threadID, + ownerID: activeCall.ownerID, + }, +}); +export type ActionActiveCallRegistered = ReturnType + +export type Actions = +| ActionUnInitialized +| ActionCallEnded +| ActionActiveCallRegistered \ No newline at end of file diff --git a/webapp/src/state/active_calls/reducer.ts b/webapp/src/state/active_calls/reducer.ts index 145ffbb69..d51c01ed5 100644 --- a/webapp/src/state/active_calls/reducer.ts +++ b/webapp/src/state/active_calls/reducer.ts @@ -1,15 +1,16 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Channel} from '@mattermost/types/channels'; -import {UserThread} from '@mattermost/types/threads'; -import {UserProfile} from '@mattermost/types/users'; -import {Reducer} from 'redux'; +import {type Channel} from '@mattermost/types/channels'; +import {type UserThread} from '@mattermost/types/threads'; +import {type UserProfile} from '@mattermost/types/users'; +import {type Reducer} from 'redux'; import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; import {ACTIVE_CALL_REGISTERED} from './action_types'; +import {type Actions} from './actions'; -type State = { +export type State = { [channelID: string]: { callID: string; startAt: number; @@ -21,7 +22,7 @@ type State = { const emptyState: State = {}; -export const reducer: Reducer = (initialState = emptyState, action) => { +export const reducer: Reducer = (initialState = emptyState, action) : State => { switch (action.type) { case UN_INITIALIZED:{ return emptyState; @@ -45,4 +46,4 @@ export const reducer: Reducer = (initialState = emptyState, action) => { default: return initialState; } -}; \ No newline at end of file +}; diff --git a/webapp/src/state/screen_sharing_ids/actions.ts b/webapp/src/state/screen_sharing_ids/actions.ts index 99ea2f9a3..776d0bcb6 100644 --- a/webapp/src/state/screen_sharing_ids/actions.ts +++ b/webapp/src/state/screen_sharing_ids/actions.ts @@ -1,13 +1,12 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {UserSessionState} from '@mattermost/calls-common/lib/types'; -import {Channel} from '@mattermost/types/channels'; -import {UserProfile} from '@mattermost/types/users'; +import {type UserSessionState} from '@mattermost/calls-common/lib/types'; +import {type Channel} from '@mattermost/types/channels'; +import {type UserProfile} from '@mattermost/types/users'; +import {type ActionCallEnded, type ActionUnInitialized} from 'src/state/common_actions'; import { - ActionCallEnded, - ActionUnInitialized, - ActionUserLeft, + type ActionUserLeft, } from 'src/state/sessions/actions'; import {USER_SCREEN_OFF, USER_SCREEN_ON} from './action_types'; diff --git a/webapp/src/state/screen_sharing_ids/reducer.ts b/webapp/src/state/screen_sharing_ids/reducer.ts index dbd880bf1..bfbd5657e 100644 --- a/webapp/src/state/screen_sharing_ids/reducer.ts +++ b/webapp/src/state/screen_sharing_ids/reducer.ts @@ -1,13 +1,13 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {UserSessionState} from '@mattermost/calls-common/lib/types'; -import {Reducer} from 'redux'; +import {type UserSessionState} from '@mattermost/calls-common/lib/types'; +import {type Reducer} from 'redux'; import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; import {USER_LEFT} from 'src/state/sessions/action_types'; import {USER_SCREEN_OFF, USER_SCREEN_ON} from './action_types'; -import {Actions} from './actions'; +import {type Actions} from './actions'; type State = { [channelID: string]: UserSessionState['session_id']; diff --git a/webapp/src/state/sessions/action_types.ts b/webapp/src/state/sessions/action_types.ts index ea9ec011a..a56189a65 100644 --- a/webapp/src/state/sessions/action_types.ts +++ b/webapp/src/state/sessions/action_types.ts @@ -12,4 +12,4 @@ export const USER_HAND_RAISED = `${pluginId}_user_hand_raised` as const; export const USER_HAND_LOWERED = `${pluginId}_user_hand_lowered` as const; export const USER_REACTED = `${pluginId}_user_reacted` as const; export const USER_REACTED_TIMEOUT = `${pluginId}_user_reacted_timeout` as const; -export const USER_LEFT = `${pluginId}_user_left` as const; \ No newline at end of file +export const USER_LEFT = `${pluginId}_user_left` as const; diff --git a/webapp/src/state/sessions/actions.ts b/webapp/src/state/sessions/actions.ts index f698a8fe5..51983215a 100644 --- a/webapp/src/state/sessions/actions.ts +++ b/webapp/src/state/sessions/actions.ts @@ -1,10 +1,10 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Reaction, UserSessionState} from '@mattermost/calls-common/lib/types'; -import {Channel} from '@mattermost/types/channels'; -import {UserProfile} from '@mattermost/types/users'; -import {ActionCallEnded, ActionUnInitialized} from 'src/state/common_actions'; +import {type Reaction, type UserSessionState} from '@mattermost/calls-common/lib/types'; +import {type Channel} from '@mattermost/types/channels'; +import {type UserProfile} from '@mattermost/types/users'; +import {type ActionCallEnded, type ActionUnInitialized} from 'src/state/common_actions'; import { SESSIONS_RECEIVED, diff --git a/webapp/src/state/sessions/reducer.ts b/webapp/src/state/sessions/reducer.ts index a43dcf014..7cb3ac5e9 100644 --- a/webapp/src/state/sessions/reducer.ts +++ b/webapp/src/state/sessions/reducer.ts @@ -1,9 +1,9 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {UserSessionState} from '@mattermost/calls-common/lib/types'; -import {Channel} from '@mattermost/types/channels'; -import {Reducer} from 'redux'; +import {type UserSessionState} from '@mattermost/calls-common/lib/types'; +import {type Channel} from '@mattermost/types/channels'; +import {type Reducer} from 'redux'; import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; import { @@ -18,7 +18,7 @@ import { USER_UNMUTED, USERS_VOICE_ACTIVITY_CHANGED, } from './action_types'; -import {Actions} from './actions'; +import {type Actions} from './actions'; type State = { [channelID: Channel['id']]: { diff --git a/webapp/src/state/sip_call_details/action_types.ts b/webapp/src/state/sip_details/action_types.ts similarity index 100% rename from webapp/src/state/sip_call_details/action_types.ts rename to webapp/src/state/sip_details/action_types.ts diff --git a/webapp/src/state/sip_call_details/actions.ts b/webapp/src/state/sip_details/actions.ts similarity index 100% rename from webapp/src/state/sip_call_details/actions.ts rename to webapp/src/state/sip_details/actions.ts diff --git a/webapp/src/state/sip_call_details/reducer.ts b/webapp/src/state/sip_details/reducer.ts similarity index 100% rename from webapp/src/state/sip_call_details/reducer.ts rename to webapp/src/state/sip_details/reducer.ts diff --git a/webapp/src/state/sip_call_details/selector.ts b/webapp/src/state/sip_details/selector.ts similarity index 100% rename from webapp/src/state/sip_call_details/selector.ts rename to webapp/src/state/sip_details/selector.ts diff --git a/webapp/src/utils.test.ts b/webapp/src/utils.test.ts index 45d56212f..78799ab1c 100644 --- a/webapp/src/utils.test.ts +++ b/webapp/src/utils.test.ts @@ -6,7 +6,7 @@ import {Post} from '@mattermost/types/posts'; import {Duration} from 'luxon'; import {createIntl} from 'react-intl'; import type CallClient from 'src/clients/call'; -import {CallDirection} from 'src/state/sip_call_details/reducer'; +import {CallDirection} from 'src/state/sip_details/reducer'; import {pluginId} from './manifest'; import { diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index f01d37184..dd2bb3c3e 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -21,7 +21,7 @@ import {parseSemVer} from 'semver-parser'; import type CallClient from 'src/clients/call'; import RestClient from 'src/clients/rest'; import {STORAGE_CALLS_SHARE_AUDIO_WITH_SCREEN} from 'src/constants'; -import {CallDirection, PHONE_CALL_TYPE, SipCallDetails} from 'src/state/sip_call_details/reducer'; +import {CallDirection, PHONE_CALL_TYPE, SipCallDetails} from 'src/state/sip_details/reducer'; import {DesktopMessage} from 'src/types/types'; import {notificationSounds} from 'src/webapp_globals'; From 62da4868d6a4b91db6549e4f1a5a3640836f35da Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Tue, 23 Jun 2026 18:32:46 +0530 Subject: [PATCH 03/18] Refactor active call registration to use action creator. Updated action dispatches in various files to utilize the new `activeCallRegistered` function, improving code consistency and readability. Adjusted selectors to reflect changes in state structure for SIP call details. --- webapp/src/actions.ts | 15 +++++++-------- webapp/src/index.tsx | 12 +++++------- webapp/src/selectors.ts | 4 ++-- webapp/src/state/active_calls/actions.ts | 1 + webapp/src/state/active_calls/reducer.ts | 1 + webapp/src/websocket_handlers.ts | 11 ++++------- 6 files changed, 20 insertions(+), 24 deletions(-) diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 03df6962d..0dbdcf882 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -36,7 +36,7 @@ import { ringingForCall, shouldPlayJoinUserSound, } from 'src/selectors'; -import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; +import {activeCallRegistered} from 'src/state/active_calls/actions'; import {callEnded} from 'src/state/common_actions'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; @@ -564,16 +564,15 @@ export const loadProfilesByIdsIfMissing = (ids: string[]) => { export const loadCallState = (channelID: string, call: CallState) => (dispatch: DispatchFunc, getState: GetStateFunc) => { const actions: AnyAction[] = []; - actions.push({ - type: ACTIVE_CALL_REGISTERED, - data: { + actions.push( + activeCallRegistered(channelID, { callID: call.id, - channelID, startAt: call.start_at, - ownerID: call.owner_id, threadID: call.thread_id, - }, - }); + ownerID: call.owner_id, + hostID: call.host_id, + }), + ); const sipCallDetails = getSipCallDetailsFromCallState(call); if (sipCallDetails) { diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 40b7ab45b..c79698337 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -104,7 +104,6 @@ import VideoDevicesSettingsSection from 'src/components/user_settings/video_devi import {CALL_RECORDING_POST_TYPE, CALL_START_POST_TYPE, CALL_TRANSCRIPTION_POST_TYPE, DisabledCallsErr} from 'src/constants'; import {desktopNotificationHandler} from 'src/desktop_notifications'; import slashCommandsHandler from 'src/slash_commands'; -import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; import {unInitialized} from 'src/state/common_actions'; import {getSessionsMapFromSessions, sessionsReceived, userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; import {sipCallDetailsReceived} from 'src/state/sip_details/actions'; @@ -150,6 +149,7 @@ import { sessionsInCurrentCall, } from './selectors'; import {JOIN_CALL, keyToAction} from './shortcuts'; +import {activeCallRegistered} from './state/active_calls/actions'; import {convertStatsToPanels} from './stats'; import {PluginRegistry, Store} from './types/mattermost-webapp'; import { @@ -903,16 +903,14 @@ export default class Plugin { store.dispatch(loadProfilesByIdsIfMissing(getUserIDsForSessions(call.sessions))); if (!callStartAtForCallInChannel(store.getState(), data[i].channel_id)) { - actions.push({ - type: ACTIVE_CALL_REGISTERED, - data: { + actions.push( + activeCallRegistered(data[i].channel_id, { callID: call.id, - channelID: data[i].channel_id, startAt: call.start_at, ownerID: call.owner_id, threadID: call.thread_id, - }, - }); + hostID: call.host_id, + })); const sipCallDetails = getSipCallDetailsFromCallState(call); if (sipCallDetails) { diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 19abce760..7d4d45c4f 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -60,7 +60,7 @@ const activeCallsInPluginReduxStore = (state: GlobalState): { [channelID: string pluginReduxStore(state).activeCalls; const sipCallDetailsInPluginReduxStore = (state: GlobalState): { [channelID: string]: SipCallDetails } => - pluginReduxStore(state).sipCallDetails; + pluginReduxStore(state).sipDetails; export const channelIDForCurrentCall: (state: GlobalState) => string = createSelector( @@ -271,7 +271,7 @@ export const callOwnerIDForCallInChannel = (state: GlobalState, channelID: strin }; export const sipCallDetailsForCallInChannel = (state: GlobalState, channelID: string): SipCallDetails | undefined => { - return pluginReduxStore(state).sipCallDetails[channelID]; + return pluginReduxStore(state).sipDetails[channelID]; }; // isPhoneCall is true only for SIP/phone calls. The sipCallDetails slice holds an entry diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts index 6adf6bb57..57f2d40ce 100644 --- a/webapp/src/state/active_calls/actions.ts +++ b/webapp/src/state/active_calls/actions.ts @@ -15,6 +15,7 @@ export const activeCallRegistered = (channelID: Channel['id'], activeCall: Omit< channelID, threadID: activeCall.threadID, ownerID: activeCall.ownerID, + hostID: activeCall.hostID, }, }); export type ActionActiveCallRegistered = ReturnType diff --git a/webapp/src/state/active_calls/reducer.ts b/webapp/src/state/active_calls/reducer.ts index d51c01ed5..17f1d1156 100644 --- a/webapp/src/state/active_calls/reducer.ts +++ b/webapp/src/state/active_calls/reducer.ts @@ -17,6 +17,7 @@ export type State = { channelID: Channel['id']; threadID: UserThread['id']; ownerID: UserProfile['id']; + hostID: UserProfile['id']; }; } diff --git a/webapp/src/websocket_handlers.ts b/webapp/src/websocket_handlers.ts index 789526e07..242610bd4 100644 --- a/webapp/src/websocket_handlers.ts +++ b/webapp/src/websocket_handlers.ts @@ -48,7 +48,6 @@ import { LIVE_CAPTION_TIMEOUT, REACTION_TIMEOUT_IN_REACTION_STREAM, } from 'src/constants'; -import {ACTIVE_CALL_REGISTERED} from 'src/state/active_calls/action_types'; import {userScreenShared, userScreenUnshared} from 'src/state/screen_sharing_ids/actions'; import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, userUnmuted} from 'src/state/sessions/actions'; import { @@ -72,6 +71,7 @@ import { profilesInCurrentCallMap, ringingEnabled, } from './selectors'; +import {activeCallRegistered} from './state/active_calls/actions'; import {Store} from './types/mattermost-webapp'; import { followThread, @@ -118,17 +118,14 @@ export function handleCallStart(store: Store, ev: WebSocketMessage Date: Tue, 23 Jun 2026 18:34:13 +0530 Subject: [PATCH 04/18] Reorganize imports in utils.test.ts to improve code clarity. Moved `getSipCallDetailsFromCallState` to a more logical position within the import statements, enhancing readability and maintainability of the test file. --- webapp/src/utils.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/utils.test.ts b/webapp/src/utils.test.ts index 78799ab1c..b0d05cfc6 100644 --- a/webapp/src/utils.test.ts +++ b/webapp/src/utils.test.ts @@ -11,12 +11,12 @@ import {CallDirection} from 'src/state/sip_details/reducer'; import {pluginId} from './manifest'; import { callStartedTimestampFn, - getSipCallDetailsFromCallState, getCallPropsFromPost, getCallRecordingPropsFromPost, getCallsClient, getCallsWindow, getPlatformInfo, + getSipCallDetailsFromCallState, getWebappUtils, getWSConnectionURL, maxAttemptsReachedErr, From ba01a45bd32a5ceaa7315f3f9c56a24184155012 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Wed, 24 Jun 2026 23:58:06 +0530 Subject: [PATCH 05/18] Refactor call session handling and availability management. Updated import paths for session actions and improved user ID retrieval from sessions. Introduced new selectors and actions for channel call availability, enhancing the overall structure and maintainability of the codebase. Removed deprecated components and streamlined the call state management process. --- standalone/src/index.ts | 2 +- standalone/src/recording/index.tsx | 4 +- webapp/src/action_types.ts | 1 - webapp/src/actions.ts | 113 ++++++++++++-- .../src/components/channel_header_button.tsx | 4 +- .../channel_header_dropdown_button/index.ts | 4 +- .../channel_header_menu_button/component.tsx | 22 --- .../channel_header_menu_button/index.ts | 14 -- .../channel_header_menu_item/index.tsx | 24 +++ webapp/src/index.tsx | 144 +++--------------- webapp/src/reducers.ts | 47 ++---- webapp/src/selectors.ts | 126 ++++++--------- webapp/src/state/active_calls/actions.ts | 1 - webapp/src/state/active_calls/reducer.ts | 1 - .../state/call_availability/action_types.ts | 6 + webapp/src/state/call_availability/actions.ts | 45 ++++++ webapp/src/state/call_availability/reducer.ts | 39 +++++ .../src/state/call_availability/selectors.ts | 34 +++++ webapp/src/state/common_selectors.ts | 17 +++ webapp/src/utils.ts | 13 +- webapp/src/websocket_handlers.ts | 1 - 21 files changed, 356 insertions(+), 306 deletions(-) delete mode 100644 webapp/src/components/channel_header_menu_button/component.tsx delete mode 100644 webapp/src/components/channel_header_menu_button/index.ts create mode 100644 webapp/src/components/channel_header_menu_item/index.tsx create mode 100644 webapp/src/state/call_availability/action_types.ts create mode 100644 webapp/src/state/call_availability/actions.ts create mode 100644 webapp/src/state/call_availability/reducer.ts create mode 100644 webapp/src/state/call_availability/selectors.ts create mode 100644 webapp/src/state/common_selectors.ts diff --git a/standalone/src/index.ts b/standalone/src/index.ts index fbecc9907..c49598a87 100644 --- a/standalone/src/index.ts +++ b/standalone/src/index.ts @@ -44,7 +44,7 @@ import { } from 'plugin/log'; import {pluginId} from 'plugin/manifest'; import reducer from 'plugin/reducers'; -import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'plugin/state/session/actions'; +import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'plugin/state/sessions/actions'; import {Store} from 'plugin/types/mattermost-webapp'; import { getWSConnectionURL, diff --git a/standalone/src/recording/index.tsx b/standalone/src/recording/index.tsx index 01d34a98a..bfbafd705 100644 --- a/standalone/src/recording/index.tsx +++ b/standalone/src/recording/index.tsx @@ -11,7 +11,7 @@ import {Store} from 'plugin/types/mattermost-webapp'; import { getPluginPath, getTranslations, - getUserIDsForSessions, + getUserIDsFromSessions, runWithRetry, setCallsGlobalCSSVars, } from 'plugin/utils'; @@ -125,7 +125,7 @@ function wsHandlerRecording(store: Store, ev: WebSocketMessage 0) { runWithRetry(() => { - return fetchProfileImages(getUserIDsForSessions(call.sessions)); + return fetchProfileImages(getUserIDsFromSessions(call.sessions)); }).then((images) => { store.dispatch({ type: RECEIVED_CALL_PROFILE_IMAGES, diff --git a/webapp/src/action_types.ts b/webapp/src/action_types.ts index 9dcce96bd..c02149710 100644 --- a/webapp/src/action_types.ts +++ b/webapp/src/action_types.ts @@ -34,7 +34,6 @@ export const TRANSCRIPTIONS_ENABLED = pluginId + '_transcriptions_enabled'; export const LIVE_CAPTIONS_ENABLED = pluginId + '_live_captions_enabled'; export const RTCD_ENABLED = pluginId + '_rtcd_enabled'; export const TRANSCRIBE_API = pluginId + '_transcribe_api'; -export const RECEIVED_CHANNEL_STATE = pluginId + 'received_channel_state'; export const RECEIVED_CALLS_USER_PREFERENCES = pluginId + '_received_calls_user_preferences'; export const DESKTOP_WIDGET_CONNECTED = pluginId + '_desktop_widget_connected'; diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 0dbdcf882..64b80f551 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -2,9 +2,10 @@ // See LICENSE.txt for license information. /* eslint-disable max-lines */ -import {CallsConfig, CallState, CallsVersionInfo} from '@mattermost/calls-common/lib/types'; +import {CallChannelState, CallsConfig, CallState, CallsVersionInfo} from '@mattermost/calls-common/lib/types'; import {ClientError} from '@mattermost/client'; import {Channel} from '@mattermost/types/channels'; +import {UserProfile} from '@mattermost/types/users'; import {UserTypes} from 'mattermost-redux/action_types'; import {getChannel as loadChannel} from 'mattermost-redux/actions/channels'; import {bindClientFunc} from 'mattermost-redux/actions/helpers'; @@ -27,6 +28,7 @@ import {JOINED_USER_NOTIFICATION_TIMEOUT, RING_LENGTH} from 'src/constants'; import {logErr} from 'src/log'; import { callDismissedNotification, + callStartAtForCallInChannel, getCallIDForChannel, getCallIDForCurrentCall, hostChangeAtForCurrentCall, @@ -37,6 +39,7 @@ import { shouldPlayJoinUserSound, } from 'src/selectors'; import {activeCallRegistered} from 'src/state/active_calls/actions'; +import {channelCallsAvailabilityUpdated} from 'src/state/call_availability/actions'; import {callEnded} from 'src/state/common_actions'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; @@ -46,7 +49,7 @@ import { getCallsClientSessionID, getPluginPath, getSipCallDetailsFromCallState, - getUserIDsForSessions, + getUserIDsFromSessions, isDMChannel, isGMChannel, notificationsStopRinging, @@ -542,24 +545,111 @@ export const stopRingingForCall = (callID: string): ActionFunc => { }; }; -export const loadProfilesByIdsIfMissing = (ids: string[]) => { +/** + * Loads user profiles for any users not present in the Mattermost redux store and adds them. + */ +export const loadProfilesByIdsIfMissing = (userIDs: Array) => { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const missingIds = []; - for (const id of ids) { - if (!getState().entities.users.profiles[id]) { - missingIds.push(id); + const missingUserIDs = []; + for (const userID of userIDs) { + if (!getUser(getState(), userID)) { + missingUserIDs.push(userID); } } - if (missingIds.length > 0) { - dispatch({type: UserTypes.RECEIVED_PROFILES, data: await RestClient.getProfilesByIds(missingIds)}); + + if (missingUserIDs.length === 0) { + return; + } + + try { + const missedUserProfiles = await RestClient.getProfilesByIds(missingUserIDs); + dispatch({type: UserTypes.RECEIVED_PROFILES, data: missedUserProfiles}); + } catch (err) { + logErr(err); } }; }; +export const hydradeCallsAndChannelStatesExcept = (skipChannelID?: string) => { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const actions: AnyAction[] = []; + + let callsAndChannelStates: CallChannelState[] = []; + try { + callsAndChannelStates = await RestClient.fetch(`${getPluginPath()}/channels`, {method: 'get'}); + } catch (err) { + logErr(err); + return; + } + + for (const callAndChannelState of callsAndChannelStates) { + if (!callAndChannelState) { + continue; + } + + // State for the current call should only be mutated from websocket events. + if (skipChannelID && callAndChannelState.channel_id && skipChannelID === callAndChannelState.channel_id) { + continue; + } + + actions.push(channelCallsAvailabilityUpdated(callAndChannelState.channel_id, callAndChannelState.enabled)); + + if (!callAndChannelState.call || !callAndChannelState.call.sessions || callAndChannelState.call.sessions.length === 0) { + continue; + } + + dispatch(loadProfilesByIdsIfMissing(getUserIDsFromSessions(callAndChannelState.call.sessions))); + + if (!callStartAtForCallInChannel(getState(), callAndChannelState.channel_id)) { + actions.push( + activeCallRegistered(callAndChannelState.channel_id, { + callID: callAndChannelState.call.id, + startAt: callAndChannelState.call.start_at, + ownerID: callAndChannelState.call.owner_id, + threadID: callAndChannelState.call.thread_id, + }), + ); + + actions.push({ + type: CALL_HOST, + data: { + channelID: callAndChannelState.channel_id, + hostID: callAndChannelState.call.host_id, + hostChangeAt: callAndChannelState.call.start_at, + }, + }); + + actions.push(sessionsReceived(callAndChannelState.channel_id, getSessionsMapFromSessions(callAndChannelState.call.sessions))); + + if (ringingEnabled(getState())) { + // dismissedNotification is populated after the actions array has been batched, so manually check: + const dismissed = callAndChannelState.call.dismissed_notification; + if (dismissed) { + const currentUserID = getCurrentUserId(getState()); + if (Object.hasOwn(dismissed, currentUserID) && dismissed[currentUserID]) { + actions.push({ + type: DISMISS_CALL, + data: { + callID: callAndChannelState.call.id, + }, + }); + continue; + } + } + dispatch(incomingCallOnChannel(callAndChannelState.channel_id, callAndChannelState.call.id, callAndChannelState.call.owner_id, callAndChannelState.call.start_at)); + } + } + } + + dispatch(batchActions(actions)); + }; +}; + /** * This is the hydration action for the call state. It is used to set the initial state of the call when the page is loaded. * It is used to set the initial state of the call when the page is loaded when for example user joins an ongoing call, - * or a client drop the connection and reconnects after a period of time. + * or a client drop the connection and reconnects after a period of time. It contains all the required + * information to set the initial state of the call. */ export const loadCallState = (channelID: string, call: CallState) => (dispatch: DispatchFunc, getState: GetStateFunc) => { const actions: AnyAction[] = []; @@ -570,7 +660,6 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: startAt: call.start_at, threadID: call.thread_id, ownerID: call.owner_id, - hostID: call.host_id, }), ); @@ -627,7 +716,7 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: if (call.sessions.length > 0) { // This is async, which is expected as we are okay with setting the state while we wait // for any missing user profiles. - dispatch(loadProfilesByIdsIfMissing(getUserIDsForSessions(call.sessions))); + dispatch(loadProfilesByIdsIfMissing(getUserIDsFromSessions(call.sessions))); } actions.push(sessionsReceived(channelID, getSessionsMapFromSessions(call.sessions))); diff --git a/webapp/src/components/channel_header_button.tsx b/webapp/src/components/channel_header_button.tsx index 80ebbd914..dcaa8dfda 100644 --- a/webapp/src/components/channel_header_button.tsx +++ b/webapp/src/components/channel_header_button.tsx @@ -12,7 +12,6 @@ import CompassIcon from 'src/components/icons/compassIcon'; import {Header, Spinner, SubHeader} from 'src/components/shared'; import { areGroupCallsAllowed, - callsShowButton, channelIDForCurrentCall, clientConnecting, currentChannelHasCall, @@ -21,6 +20,7 @@ import { isLimitRestricted, maxParticipants, } from 'src/selectors'; +import {shouldShowCallsButtonInChannelHeader} from 'src/state/call_availability/selectors'; import {getUserIdFromDM, isDMChannel} from 'src/utils'; import styled, {css} from 'styled-components'; @@ -30,7 +30,7 @@ const ChannelHeaderButton = () => { const otherUserID = getUserIdFromDM(channel?.name || '', currentUserID); const otherUser = useSelector((state: GlobalState) => getUser(state, otherUserID)); const isDeactivatedDM = isDMChannel(channel) && otherUser?.delete_at > 0; - const show = useSelector((state: GlobalState) => callsShowButton(state, channel?.id || '')); + const show = useSelector((state: GlobalState) => shouldShowCallsButtonInChannelHeader(state, channel?.id)); const inCall = useSelector(channelIDForCurrentCall) === channel?.id; const hasCall = useSelector(currentChannelHasCall); const isAdmin = useSelector(isCurrentUserSystemAdmin); diff --git a/webapp/src/components/channel_header_dropdown_button/index.ts b/webapp/src/components/channel_header_dropdown_button/index.ts index a2df73db0..ca28a941d 100644 --- a/webapp/src/components/channel_header_dropdown_button/index.ts +++ b/webapp/src/components/channel_header_dropdown_button/index.ts @@ -6,7 +6,6 @@ import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {connect} from 'react-redux'; import { - callsShowButton, channelIDForCurrentCall, currentChannelHasCall, isCloudProfessionalOrEnterpriseorEnterpriseAdvanceOrTrial, @@ -14,6 +13,7 @@ import { isLimitRestricted, maxParticipants, } from 'src/selectors'; +import {shouldShowCallsButtonInChannelHeader} from 'src/state/call_availability/selectors'; import ChannelHeaderDropdownButton from './component'; @@ -21,7 +21,7 @@ const mapStateToProps = (state: GlobalState) => { const channel = getCurrentChannel(state); return { - show: callsShowButton(state, channel?.id), + show: shouldShowCallsButtonInChannelHeader(state, channel?.id), inCall: Boolean(channelIDForCurrentCall(state) && channelIDForCurrentCall(state) === channel?.id), hasCall: currentChannelHasCall(state), isAdmin: isCurrentUserSystemAdmin(state), diff --git a/webapp/src/components/channel_header_menu_button/component.tsx b/webapp/src/components/channel_header_menu_button/component.tsx deleted file mode 100644 index 358f2f500..000000000 --- a/webapp/src/components/channel_header_menu_button/component.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -interface Props { - enabled: boolean, -} - -const ChannelHeaderMenuButton = (props: Props) => { - if (props.enabled) { - return ( - - ); - } - return ( - - ); -}; - -export default ChannelHeaderMenuButton; diff --git a/webapp/src/components/channel_header_menu_button/index.ts b/webapp/src/components/channel_header_menu_button/index.ts deleted file mode 100644 index 7c1d122b9..000000000 --- a/webapp/src/components/channel_header_menu_button/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {GlobalState} from '@mattermost/types/store'; -import {connect} from 'react-redux'; -import {callsEnabledInCurrentChannel} from 'src/selectors'; - -import ChannelHeaderMenuButton from './component'; - -const mapStateToProps = (state: GlobalState) => ({ - enabled: callsEnabledInCurrentChannel(state), -}); - -export default connect(mapStateToProps)(ChannelHeaderMenuButton); diff --git a/webapp/src/components/channel_header_menu_item/index.tsx b/webapp/src/components/channel_header_menu_item/index.tsx new file mode 100644 index 000000000..760f71a23 --- /dev/null +++ b/webapp/src/components/channel_header_menu_item/index.tsx @@ -0,0 +1,24 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; +import {callsAvailableInCurrentChannelWithDefault} from 'src/state/call_availability/selectors'; + +export default function ChannelHeaderMenuItem() { + const isEnabled = useSelector(callsAvailableInCurrentChannelWithDefault); + + const isAdmin = useSelector(isCurrentUserSystemAdmin); + + if (isEnabled || isAdmin) { + return ( + + ); + } + + return ( + + ); +} diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index c79698337..4351f05fe 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. /* eslint-disable max-lines */ -import {CallChannelState, EmojiData} from '@mattermost/calls-common/lib/types'; +import {EmojiData} from '@mattermost/calls-common/lib/types'; import WebSocketClient from '@mattermost/client/websocket'; import {PluginAnalyticsRow} from '@mattermost/types/admin'; import {getChannel as getChannelAction} from 'mattermost-redux/actions/channels'; @@ -17,8 +17,6 @@ import React, {useEffect} from 'react'; import {createRoot, Root} from 'react-dom/client'; import {FormattedMessage, injectIntl, IntlProvider} from 'react-intl'; import {Provider} from 'react-redux'; -import {AnyAction} from 'redux'; -import {batchActions} from 'redux-batched-actions'; import { displayCallErrorModal, displayCallsTestModeUser, @@ -27,7 +25,7 @@ import { getCallsConfigEnvOverrides, getCallsStats, getCallsVersionInfo, - incomingCallOnChannel, + hydradeCallsAndChannelStatesExcept, joinUser, leaveUser, loadProfilesByIdsIfMissing, @@ -89,6 +87,7 @@ import { EndCallConfirmation, IDEndCallConfirmation, } from 'src/components/call_widget/end_call_confirmation'; +import ChannelHeaderMenuItem from 'src/components/channel_header_menu_item'; import {PostTypeCloudTrialRequest} from 'src/components/custom_post_types/post_type_cloud_trial_request'; import {PostTypeRecording} from 'src/components/custom_post_types/post_type_recording'; import {AudioInputPermissionsErr} from 'src/components/error_modal/error_messages'; @@ -104,22 +103,18 @@ import VideoDevicesSettingsSection from 'src/components/user_settings/video_devi import {CALL_RECORDING_POST_TYPE, CALL_START_POST_TYPE, CALL_TRANSCRIPTION_POST_TYPE, DisabledCallsErr} from 'src/constants'; import {desktopNotificationHandler} from 'src/desktop_notifications'; import slashCommandsHandler from 'src/slash_commands'; +import {channelCallsAvailabilityUpdated, toggleCallsAvailabilityForChannel} from 'src/state/call_availability/actions'; +import {callsAvailableInChannelWithDefault, callsNotAvailableInChannel} from 'src/state/call_availability/selectors'; import {unInitialized} from 'src/state/common_actions'; -import {getSessionsMapFromSessions, sessionsReceived, userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; -import {sipCallDetailsReceived} from 'src/state/sip_details/actions'; +import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; import {CurrentCallDataDefault, DesktopMessageType} from 'src/types/types'; -import {getSipCallDetailsFromCallState, getWSConnectionURL} from 'src/utils'; +import {getWSConnectionURL} from 'src/utils'; import {modals} from 'src/webapp_globals'; -import { - DISMISS_CALL, - RECEIVED_CHANNEL_STATE, -} from './action_types'; import CallWidget from './components/call_widget'; import ChannelCallToast from './components/channel_call_toast'; import ChannelHeaderButton from './components/channel_header_button'; import ChannelHeaderDropdownButton from './components/channel_header_dropdown_button'; -import ChannelHeaderMenuButton from './components/channel_header_menu_button'; import ChannelLinkLabel from './components/channel_link_label'; import PostType from './components/custom_post_types/post_type'; import {PostTypeTranscription} from './components/custom_post_types/post_type_transcription'; @@ -135,30 +130,23 @@ import {pluginId} from './manifest'; import reducer from './reducers'; import { callsConfig, - callsExplicitlyDisabled, - callsExplicitlyEnabled, - callStartAtForCallInChannel, channelHasCall, channelIDForCurrentCall, - defaultEnabled, hasPermissionsToEnableCalls, hostIDForCallInChannel, isCloudStarter, isLimitRestricted, - ringingEnabled, sessionsInCurrentCall, } from './selectors'; import {JOIN_CALL, keyToAction} from './shortcuts'; -import {activeCallRegistered} from './state/active_calls/actions'; import {convertStatsToPanels} from './stats'; import {PluginRegistry, Store} from './types/mattermost-webapp'; import { followThread, getCallsClient, getChannelURL, - getPluginPath, getTranslations, - getUserIDsForSessions, + getUserIDsFromSessions, isCallsPopOut, playSound, sendDesktopEvent, @@ -204,17 +192,11 @@ export default class Plugin { private registerWebSocketEvents(registry: PluginRegistry, store: Store) { registry.registerWebSocketEventHandler(`custom_${pluginId}_channel_enable_voice`, (ev) => { - store.dispatch({ - type: RECEIVED_CHANNEL_STATE, - data: {id: ev.broadcast.channel_id, enabled: true}, - }); + store.dispatch(channelCallsAvailabilityUpdated(ev.broadcast.channel_id, true)); }); registry.registerWebSocketEventHandler(`custom_${pluginId}_channel_disable_voice`, (ev) => { - store.dispatch({ - type: RECEIVED_CHANNEL_STATE, - data: {id: ev.broadcast.channel_id, enabled: false}, - }); + store.dispatch(channelCallsAvailabilityUpdated(ev.broadcast.channel_id, false)); }); registry.registerWebSocketEventHandler(`custom_${pluginId}_call_start`, (ev) => { @@ -423,11 +405,7 @@ export default class Plugin { // - sysadmins can start a call, but they receive an ephemeral message (server-side) // - non-sysadmins cannot start a call and are shown a prompt - const explicitlyEnabled = callsExplicitlyEnabled(store.getState(), channelId); - const explicitlyDisabled = callsExplicitlyDisabled(store.getState(), channelId); - - // Note: not super happy with using explicitlyDisabled both here and below, but wanted to keep the "able to start" logic confined to one place. - if (channelHasCall(store.getState(), channelId) || explicitlyEnabled || (!explicitlyDisabled && defaultEnabled(store.getState()))) { + if (channelHasCall(store.getState(), channelId) || callsAvailableInChannelWithDefault(store.getState(), channelId)) { if (isLimitRestricted(store.getState())) { if (isCloudStarter(store.getState())) { store.dispatch(displayFreeTrial()); @@ -442,7 +420,7 @@ export default class Plugin { return; } - if (explicitlyDisabled) { + if (callsNotAvailableInChannel(store.getState(), channelId)) { // UI should not have shown, so this is a response to a slash command. throw DisabledCallsErr; } @@ -853,98 +831,11 @@ export default class Plugin { let channelHeaderMenuID: string; const registerChannelHeaderMenuAction = () => { channelHeaderMenuID = registry.registerChannelHeaderMenuAction( - ChannelHeaderMenuButton, - async () => { - try { - const data = await RestClient.fetch<{ enabled: boolean }>(`${getPluginPath()}/${currChannelId}`, { - method: 'post', - body: JSON.stringify({enabled: callsExplicitlyDisabled(store.getState(), currChannelId)}), - }); - - store.dispatch({ - type: RECEIVED_CHANNEL_STATE, - data: {id: currChannelId, enabled: data.enabled}, - }); - } catch (err) { - logErr(err); - } - }, + ChannelHeaderMenuItem, + () => store.dispatch(toggleCallsAvailabilityForChannel()), ); }; - const fetchChannels = async (skipChannelID?: string): Promise => { - const actions = []; - try { - const data = await RestClient.fetch(`${getPluginPath()}/channels`, {method: 'get'}); - - for (let i = 0; i < data.length; i++) { - // Skipping the channel for the current call here is important - // as it can avoid an inconsistent state for the current call due to a race. - // State for the current call should ONLY be mutated as a result of websocket events, not HTTP calls. - if (skipChannelID === data[i].channel_id) { - logDebug('skipping channel from state loading', skipChannelID); - continue; - } - - actions.push({ - type: RECEIVED_CHANNEL_STATE, - data: { - id: data[i].channel_id, - enabled: data[i].enabled, - }, - }); - - const call = data[i].call; - - if (!call || !call.sessions?.length) { - continue; - } - - store.dispatch(loadProfilesByIdsIfMissing(getUserIDsForSessions(call.sessions))); - - if (!callStartAtForCallInChannel(store.getState(), data[i].channel_id)) { - actions.push( - activeCallRegistered(data[i].channel_id, { - callID: call.id, - startAt: call.start_at, - ownerID: call.owner_id, - threadID: call.thread_id, - hostID: call.host_id, - })); - - const sipCallDetails = getSipCallDetailsFromCallState(call); - if (sipCallDetails) { - actions.push(sipCallDetailsReceived(data[i].channel_id, sipCallDetails)); - } - - actions.push(sessionsReceived(data[i].channel_id, getSessionsMapFromSessions(call.sessions))); - - if (ringingEnabled(store.getState()) && data[i].call) { - // dismissedNotification is populated after the actions array has been batched, so manually check: - const dismissed = call.dismissed_notification; - if (dismissed) { - const currentUserID = getCurrentUserId(store.getState()); - if (Object.hasOwn(dismissed, currentUserID) && dismissed[currentUserID]) { - actions.push({ - type: DISMISS_CALL, - data: { - callID: call.id, - }, - }); - continue; - } - } - store.dispatch(incomingCallOnChannel(data[i].channel_id, call.id, call.owner_id, call.start_at)); - } - } - } - } catch (err) { - logErr(err); - } - - return actions; - }; - const registerHeaderMenuComponentIfNeeded = async (channelID: string) => { try { registry.unregisterComponent(channelHeaderMenuID); @@ -1010,7 +901,7 @@ export default class Plugin { // from the ExpandedView component itself. if (isCallsPopOut()) { await Promise.all([ - store.dispatch(loadProfilesByIdsIfMissing(getUserIDsForSessions(sessionsInCurrentCall(store.getState())))), + store.dispatch(loadProfilesByIdsIfMissing(getUserIDsFromSessions(sessionsInCurrentCall(store.getState())))), store.dispatch(getChannelAction(currentCallChannelID)), ]); return; @@ -1018,9 +909,8 @@ export default class Plugin { // We pass currentCallChannelID so that we // can skip loading its state as a result of the HTTP calls in - // fetchChannels since it would be racy. - const actions = await fetchChannels(currentCallChannelID); - store.dispatch(batchActions(actions)); + // hydradeCallsAndChannelStatesExcept since it would be racy. + await store.dispatch(hydradeCallsAndChannelStatesExcept(currentCallChannelID)); // If indeed we are in a call we should request the up-to-date // state from websocket. diff --git a/webapp/src/reducers.ts b/webapp/src/reducers.ts index e8d6c275d..31cac6afe 100644 --- a/webapp/src/reducers.ts +++ b/webapp/src/reducers.ts @@ -3,10 +3,11 @@ /* eslint-disable max-lines */ -import {CallJobState, CallsConfig, CallsVersionInfo, LiveCaption, Reaction, UserSessionState} from '@mattermost/calls-common/lib/types'; +import {CallJobState, CallsConfig, CallsVersionInfo, LiveCaption, Reaction, TranscribeAPI, UserSessionState} from '@mattermost/calls-common/lib/types'; import {combineReducers} from 'redux'; import {MAX_NUM_REACTIONS_IN_REACTION_STREAM} from 'src/constants'; import {reducer as activeCalls} from 'src/state/active_calls/reducer'; +import {reducer as callAvailability} from 'src/state/call_availability/reducer'; import { CALL_ENDED, UN_INITIALIZED, @@ -19,7 +20,6 @@ import { CallsConfigDefault, CallsUserPreferences, CallsUserPreferencesDefault, - ChannelState, ChannelType, HostControlNotice, HostControlNoticeTimeout, @@ -51,7 +51,6 @@ import { RECEIVED_CALLS_CONFIG_ENV_OVERRIDES, RECEIVED_CALLS_USER_PREFERENCES, RECEIVED_CALLS_VERSION_INFO, - RECEIVED_CHANNEL_STATE, RECORDINGS_ENABLED, REMOVE_INCOMING_CALL, RINGING_FOR_CALL, @@ -64,27 +63,6 @@ import { USER_JOINED_TIMEOUT, } from './action_types'; -type channelsState = { - [channelID: string]: ChannelState; -} - -type channelsStateAction = { - type: string; - data: ChannelState; -} - -const channels = (state: channelsState = {}, action: channelsStateAction) => { - switch (action.type) { - case RECEIVED_CHANNEL_STATE: - return { - ...state, - [action.data.id]: action.data, - }; - default: - return state; - } -}; - type clientState = { channelID: string; sessionID: string; @@ -465,18 +443,18 @@ const screenSourceModal = (state = false, action: { type: string }) => { } }; -const callsConfig = (state = CallsConfigDefault, action: { type: string, data: CallsConfig }) => { +const callsConfig = (state = CallsConfigDefault, action: { type: string, data: CallsConfig | boolean | string}): CallsConfig => { switch (action.type) { case RECEIVED_CALLS_CONFIG: - return action.data; + return action.data as CallsConfig; case RECORDINGS_ENABLED: - return {...state, EnableRecordings: action.data}; + return {...state, EnableRecordings: action.data as boolean}; case TRANSCRIPTIONS_ENABLED: - return {...state, EnableTranscriptions: action.data}; + return {...state, EnableTranscriptions: action.data as boolean}; case LIVE_CAPTIONS_ENABLED: - return {...state, EnableLiveCaptions: action.data}; + return {...state, EnableLiveCaptions: action.data as boolean}; case TRANSCRIBE_API: - return {...state, TranscribeAPI: action.data}; + return {...state, TranscribeAPI: action.data as TranscribeAPI}; default: return state; } @@ -724,7 +702,7 @@ const hostControlNotices = (state: hostControlNoticeState = {}, }; const rootReducer = combineReducers({ - channels, + callAvailability, clientStateReducer, reactions, sessions, @@ -755,9 +733,6 @@ const rootReducer = combineReducers({ export default rootReducer; -export const initialRootState = rootReducer( - {} as Parameters[0], - {type: '@@INIT'} as Parameters[1], -); +export type RootReducer = ReturnType; -export type RootState = ReturnType; +export const emptyRootReducer: RootReducer = rootReducer(undefined, {type: '@@INIT'}); diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 7d4d45c4f..241384216 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -32,41 +32,32 @@ import { callState, hostControlNoticeState, hostsState, - initialRootState, liveCaptionState, recentlyJoinedUsersState, - RootState, usersReactionsState, } from 'src/reducers'; +import {getPluginStore} from 'src/state/common_selectors'; import {SipCallDetails} from 'src/state/sip_details/reducer'; import { CallJobReduxState, CallsUserPreferences, - ChannelState, HostControlNotice, IncomingCallNotification, LiveCaptions, } from 'src/types/types'; import {getCallsClientChannelID, getCallsClientInitTime, getCallsClientSessionID, getChannelURL} from 'src/utils'; -import {pluginId} from './manifest'; +const activeCallsIngetPluginStore = (state: GlobalState): { [channelID: string]: callState } => + getPluginStore(state).activeCalls; -const pluginReduxStateKey = `plugins-${pluginId}`; - -const pluginReduxStore = (state: GlobalState): RootState => - (state[pluginReduxStateKey as keyof GlobalState] as unknown as RootState) ?? initialRootState; - -const activeCallsInPluginReduxStore = (state: GlobalState): { [channelID: string]: callState } => - pluginReduxStore(state).activeCalls; - -const sipCallDetailsInPluginReduxStore = (state: GlobalState): { [channelID: string]: SipCallDetails } => - pluginReduxStore(state).sipDetails; +const sipCallDetailsIngetPluginStore = (state: GlobalState): { [channelID: string]: SipCallDetails } => + getPluginStore(state).sipDetails; export const channelIDForCurrentCall: (state: GlobalState) => string = createSelector( 'channelIDForCurrentCall', getCallsClientChannelID, - (state: GlobalState) => pluginReduxStore(state).clientStateReducer, + (state: GlobalState) => getPluginStore(state).clientStateReducer, (channelID, cState) => channelID || cState?.channelID || '', ); @@ -81,19 +72,19 @@ export const channelForCurrentCall: (state: GlobalState) => Channel | undefined export const getCallIDForCurrentCall: (state: GlobalState) => string | undefined = createSelector( 'getCallIDForCurrentCall', - activeCallsInPluginReduxStore, + activeCallsIngetPluginStore, channelIDForCurrentCall, (callsStates, channelID) => callsStates[channelID]?.callID, ); export const getCallIDForChannel = (state: GlobalState, channelID: string) => { - return activeCallsInPluginReduxStore(state)[channelID]?.callID ?? ''; + return activeCallsIngetPluginStore(state)[channelID]?.callID ?? ''; }; export const threadIDForCurrentCall: (state: GlobalState) => string | undefined = createSelector( 'threadIDForCurrentCall', - activeCallsInPluginReduxStore, + activeCallsIngetPluginStore, channelIDForCurrentCall, (callsStates, channelID) => callsStates[channelID]?.threadID, ); @@ -110,8 +101,8 @@ export const teamForCurrentCall: (state: GlobalState) => Team | null = }, ); -const sessionsInCalls = (state: GlobalState): RootState['sessions'] => { - return pluginReduxStore(state).sessions; +const sessionsInCalls = (state: GlobalState) => { + return getPluginStore(state).sessions; }; const userProfiles = (state: GlobalState) => state.entities.users.profiles; @@ -173,13 +164,13 @@ export const numSessionsInCallInChannel = (state: GlobalState, channelID: string }; export const channelHasCall = (state: GlobalState, channelId: string): boolean => { - return Boolean(activeCallsInPluginReduxStore(state)[channelId]); + return Boolean(activeCallsIngetPluginStore(state)[channelId]); }; export const currentChannelHasCall: (state: GlobalState) => boolean = createSelector( 'currentChannelHasCall', - activeCallsInPluginReduxStore, + activeCallsIngetPluginStore, getCurrentChannelId, (callsStates, currChannelId) => Boolean(callsStates[currChannelId]), ); @@ -218,7 +209,7 @@ export const sessionForCurrentCall: (state: GlobalState) => UserSessionState = ); const reactionsInCalls = (state: GlobalState): usersReactionsState => { - return pluginReduxStore(state).reactions; + return getPluginStore(state).reactions; }; export const reactionsInCurrentCall: (state: GlobalState) => Reaction[] = @@ -230,7 +221,7 @@ export const reactionsInCurrentCall: (state: GlobalState) => Reaction[] = ); const liveCaptionsInCalls = (state: GlobalState): liveCaptionState => { - return pluginReduxStore(state).liveCaptions; + return getPluginStore(state).liveCaptions; }; export const liveCaptionsInCurrentCall: (state: GlobalState) => LiveCaptions = @@ -242,13 +233,13 @@ export const liveCaptionsInCurrentCall: (state: GlobalState) => LiveCaptions = ); export const callStartAtForCallInChannel = (state: GlobalState, channelID: string): number => { - return pluginReduxStore(state).activeCalls[channelID]?.startAt || 0; + return getPluginStore(state).activeCalls[channelID]?.startAt || 0; }; export const callStartAtForCurrentCall: (state: GlobalState) => number = createSelector( 'callStartAtForCurrentCall', - activeCallsInPluginReduxStore, + activeCallsIngetPluginStore, channelIDForCurrentCall, getCallsClientInitTime, (callsStates, channelID, initTime) => callsStates[channelID]?.startAt || initTime || 0, @@ -257,21 +248,21 @@ export const callStartAtForCurrentCall: (state: GlobalState) => number = export const callInCurrentChannel: (state: GlobalState) => callState | undefined = createSelector( 'callInCurrentChannel', - activeCallsInPluginReduxStore, + activeCallsIngetPluginStore, getCurrentChannelId, (callsStates, currChannelId) => callsStates[currChannelId], ); export const idForCallInChannel = (state: GlobalState, channelID: string): string | undefined => { - return pluginReduxStore(state).activeCalls[channelID]?.callID; + return getPluginStore(state).activeCalls[channelID]?.callID; }; export const callOwnerIDForCallInChannel = (state: GlobalState, channelID: string): string | undefined => { - return pluginReduxStore(state).activeCalls[channelID]?.ownerID; + return getPluginStore(state).activeCalls[channelID]?.ownerID; }; export const sipCallDetailsForCallInChannel = (state: GlobalState, channelID: string): SipCallDetails | undefined => { - return pluginReduxStore(state).sipDetails[channelID]; + return getPluginStore(state).sipDetails[channelID]; }; // isPhoneCall is true only for SIP/phone calls. The sipCallDetails slice holds an entry @@ -284,13 +275,13 @@ export const isPhoneCall = (state: GlobalState, channelID: string): boolean => { export const isPhoneCallForCurrentCall: (state: GlobalState) => boolean = createSelector( 'isPhoneCallForCurrentCall', - sipCallDetailsInPluginReduxStore, + sipCallDetailsIngetPluginStore, channelIDForCurrentCall, (sipStates, channelID) => Boolean(sipStates[channelID]), ); const hostsInCalls = (state: GlobalState): hostsState => { - return pluginReduxStore(state).hosts; + return getPluginStore(state).hosts; }; export const hostIDForCallInChannel = (state: GlobalState, channelID: string): string | undefined => { @@ -314,11 +305,11 @@ export const hostChangeAtForCurrentCall: (state: GlobalState) => number = ); export const callDismissedNotification = (state: GlobalState, channelID: string) => { - return Boolean(pluginReduxStore(state).dismissedCalls[channelID]); + return Boolean(getPluginStore(state).dismissedCalls[channelID]); }; -const screenSharingIDsForCalls = (state: GlobalState): RootState['screenSharingIDs'] => { - return pluginReduxStore(state).screenSharingIDs; +const screenSharingIDsForCalls = (state: GlobalState) => { + return getPluginStore(state).screenSharingIDs; }; export const screenSharingSessionForCurrentCall: (state: GlobalState) => UserSessionState | undefined = @@ -331,11 +322,11 @@ export const screenSharingSessionForCurrentCall: (state: GlobalState) => UserSes ); export const threadIDForCallInChannel = (state: GlobalState, channelID: string) => { - return pluginReduxStore(state).activeCalls[channelID]?.threadID || ''; + return getPluginStore(state).activeCalls[channelID]?.threadID || ''; }; const recordingsForCalls = (state: GlobalState): callsJobState => { - return pluginReduxStore(state).recordings; + return getPluginStore(state).recordings; }; export const recordingForCurrentCall: (state: GlobalState) => CallJobReduxState = @@ -347,7 +338,7 @@ export const recordingForCurrentCall: (state: GlobalState) => CallJobReduxState ); export const hostControlNoticesForCalls = (state: GlobalState): hostControlNoticeState => { - return pluginReduxStore(state).hostControlNotices; + return getPluginStore(state).hostControlNotices; }; export const hostControlNoticesForCurrentCall: (state: GlobalState) => HostControlNotice[] = @@ -359,7 +350,7 @@ export const hostControlNoticesForCurrentCall: (state: GlobalState) => HostContr ); const liveCaptionsStateForCalls = (state: GlobalState): callsJobState => { - return pluginReduxStore(state).callLiveCaptionsState; + return getPluginStore(state).callLiveCaptionsState; }; export const liveCaptionsStateForCurrentCall: (state: GlobalState) => CallJobReduxState = @@ -383,7 +374,7 @@ export const areLiveCaptionsAvailableInCurrentCall: (state: GlobalState) => bool ); const recentlyJoinedUsersInCalls = (state: GlobalState): recentlyJoinedUsersState => { - return pluginReduxStore(state).recentlyJoinedUsers; + return getPluginStore(state).recentlyJoinedUsers; }; export const recentlyJoinedUsersInCurrentCall: (state: GlobalState) => string[] = @@ -413,7 +404,7 @@ export const isRecordingInCurrentCall: (state: GlobalState) => boolean = ); export const incomingCalls = (state: GlobalState): IncomingCallNotification[] => - pluginReduxStore(state).incomingCalls; + getPluginStore(state).incomingCalls; export const sortedIncomingCalls: (state: GlobalState) => IncomingCallNotification[] = createSelector( @@ -423,7 +414,7 @@ export const sortedIncomingCalls: (state: GlobalState) => IncomingCallNotificati ); export const dismissedCalls = (state: GlobalState): { [callID: string]: boolean } => - pluginReduxStore(state).dismissedCalls; + getPluginStore(state).dismissedCalls; export const dismissedCallForCurrentChannel: (state: GlobalState) => boolean = createSelector( @@ -434,10 +425,10 @@ export const dismissedCallForCurrentChannel: (state: GlobalState) => boolean = ); export const ringingForCall = (state: GlobalState, callID: string): boolean => - pluginReduxStore(state).ringingForCalls[callID] || false; + getPluginStore(state).ringingForCalls[callID] || false; export const currentlyRinging = (state: GlobalState): boolean => { - for (const val of Object.values(pluginReduxStore(state).ringingForCalls)) { + for (const val of Object.values(getPluginStore(state).ringingForCalls)) { if (val) { return true; } @@ -446,16 +437,16 @@ export const currentlyRinging = (state: GlobalState): boolean => { }; export const didRingForCall = (state: GlobalState, callID: string): boolean => - pluginReduxStore(state).didRingForCalls[callID] || false; + getPluginStore(state).didRingForCalls[callID] || false; export const didNotifyForCall = (state: GlobalState, callID: string): boolean => - pluginReduxStore(state).didNotifyForCalls[callID] || false; + getPluginStore(state).didNotifyForCalls[callID] || false; // // Config logic // export const callsConfig = (state: GlobalState): CallsConfig => - pluginReduxStore(state).callsConfig; + getPluginStore(state).callsConfig; export const iceServers = (state: GlobalState): RTCIceServer[] => callsConfig(state).ICEServersConfigs || []; @@ -491,7 +482,7 @@ export const recordingMaxDuration = (state: GlobalState) => callsConfig(state).MaxRecordingDuration; export const rtcdEnabled = (state: GlobalState) => - pluginReduxStore(state).rtcdEnabled; + getPluginStore(state).rtcdEnabled; export const ringingEnabled = (state: GlobalState) => callsConfig(state).EnableRinging; @@ -500,32 +491,7 @@ export const transcribeAPI = (state: GlobalState) => callsConfig(state).TranscribeAPI; export const callsConfigEnvOverrides = (state: GlobalState): Record => - pluginReduxStore(state).callsConfigEnvOverrides; - -// -// Calls enabled/disabled logic -// -export const channelState = (state: GlobalState, channelId: string): ChannelState => - pluginReduxStore(state).channels[channelId]; - -export const callsExplicitlyEnabled = (state: GlobalState, channelId: string): boolean => - Boolean(channelState(state, channelId)?.enabled); - -export const callsExplicitlyDisabled = (state: GlobalState, channelId: string): boolean => { - const enabled = channelState(state, channelId)?.enabled; - return (typeof enabled !== 'undefined') && !enabled; -}; - -export const callsEnabledInCurrentChannel = (state: GlobalState): boolean => { - const channelId = getCurrentChannelId(state); - if (callsExplicitlyDisabled(state, channelId)) { - return false; - } - return callsExplicitlyEnabled(state, channelId) || defaultEnabled(state) || isCurrentUserSystemAdmin(state); -}; - -export const callsShowButton = (state: GlobalState, channelId?: string): boolean => - !callsExplicitlyDisabled(state, channelId || ''); + getPluginStore(state).callsConfigEnvOverrides; export const hasPermissionsToEnableCalls = (state: GlobalState, channelId: string): boolean => { if (isCurrentUserSystemAdmin(state)) { @@ -596,7 +562,7 @@ export const isCloudTrialNeverStarted = (state: GlobalState): boolean => getSubscription(state)?.trial_end_at === 0; export const callsUserPreferences = (state: GlobalState): CallsUserPreferences => - pluginReduxStore(state).callsUserPreferences; + getPluginStore(state).callsUserPreferences; export const shouldPlayJoinUserSound = (state: GlobalState): boolean => profilesInCurrentCall(state).length < callsUserPreferences(state).joinSoundParticipantsThreshold; @@ -657,20 +623,20 @@ export const getStatusForCurrentUser: (state: GlobalState) => string = // modals export const expandedView = (state: GlobalState) => { - return pluginReduxStore(state).expandedView; + return getPluginStore(state).expandedView; }; export const switchCallModal = (state: GlobalState) => { - return pluginReduxStore(state).switchCallModal; + return getPluginStore(state).switchCallModal; }; export const screenSourceModal = (state: GlobalState) => { - return pluginReduxStore(state).screenSourceModal; + return getPluginStore(state).screenSourceModal; }; export const clientConnecting = (state: GlobalState) => { - return pluginReduxStore(state).clientConnecting; + return getPluginStore(state).clientConnecting; }; export const callsVersionInfo = (state: GlobalState): CallsVersionInfo => - pluginReduxStore(state).callsVersionInfo; + getPluginStore(state).callsVersionInfo; diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts index 57f2d40ce..6adf6bb57 100644 --- a/webapp/src/state/active_calls/actions.ts +++ b/webapp/src/state/active_calls/actions.ts @@ -15,7 +15,6 @@ export const activeCallRegistered = (channelID: Channel['id'], activeCall: Omit< channelID, threadID: activeCall.threadID, ownerID: activeCall.ownerID, - hostID: activeCall.hostID, }, }); export type ActionActiveCallRegistered = ReturnType diff --git a/webapp/src/state/active_calls/reducer.ts b/webapp/src/state/active_calls/reducer.ts index 17f1d1156..d51c01ed5 100644 --- a/webapp/src/state/active_calls/reducer.ts +++ b/webapp/src/state/active_calls/reducer.ts @@ -17,7 +17,6 @@ export type State = { channelID: Channel['id']; threadID: UserThread['id']; ownerID: UserProfile['id']; - hostID: UserProfile['id']; }; } diff --git a/webapp/src/state/call_availability/action_types.ts b/webapp/src/state/call_availability/action_types.ts new file mode 100644 index 000000000..94100a9dc --- /dev/null +++ b/webapp/src/state/call_availability/action_types.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {pluginId} from 'src/manifest'; + +export const CHANNEL_CALLS_AVAILABILITY_UPDATED = `${pluginId}_channel_calls_availability_updated` as const; diff --git a/webapp/src/state/call_availability/actions.ts b/webapp/src/state/call_availability/actions.ts new file mode 100644 index 000000000..6c660885f --- /dev/null +++ b/webapp/src/state/call_availability/actions.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; +import {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; +import RestClient from 'src/clients/rest'; +import {logErr} from 'src/log'; +import {ActionUnInitialized} from 'src/state/common_actions'; +import {getPluginPath} from 'src/utils'; + +import {CHANNEL_CALLS_AVAILABILITY_UPDATED} from './action_types'; +import {callsNotAvailableInChannel} from './selectors'; + +export const channelCallsAvailabilityUpdated = (channelID: Channel['id'], enabled?: boolean) => { + return { + type: CHANNEL_CALLS_AVAILABILITY_UPDATED, + data: { + channelID, + enabled: enabled ?? true, + }, + }; +}; +export type ActionChannelCallsAvailabilityUpdated = ReturnType + +export const toggleCallsAvailabilityForChannel = () => { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const currentChannelID = getCurrentChannelId(getState()); + + try { + const data = await RestClient.fetch<{enabled: boolean}>(`${getPluginPath()}/${currentChannelID}`, { + method: 'post', + body: JSON.stringify({enabled: callsNotAvailableInChannel(getState(), currentChannelID)}), + }); + + dispatch(channelCallsAvailabilityUpdated(currentChannelID, data.enabled)); + } catch (err) { + logErr(err); + } + }; +}; + +export type Actions = + | ActionUnInitialized + | ActionChannelCallsAvailabilityUpdated; \ No newline at end of file diff --git a/webapp/src/state/call_availability/reducer.ts b/webapp/src/state/call_availability/reducer.ts new file mode 100644 index 000000000..9cf34f1e3 --- /dev/null +++ b/webapp/src/state/call_availability/reducer.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {Reducer} from 'redux'; +import {UN_INITIALIZED} from 'src/state/common_action_types'; + +import {CHANNEL_CALLS_AVAILABILITY_UPDATED} from './action_types'; +import {type Actions} from './actions'; + +type State = { + [channelID: Channel['id']]: { + channelID: Channel['id']; + enabled: boolean; + }; +}; + +const emptyState: State = {}; + +export const reducer: Reducer = (initialState = emptyState, action) => { + switch (action.type) { + case UN_INITIALIZED: { + return emptyState; + } + + case CHANNEL_CALLS_AVAILABILITY_UPDATED: { + return { + ...initialState, + [action.data.channelID]: { + channelID: action.data.channelID, + enabled: action.data.enabled, + }, + }; + } + + default: + return initialState; + } +}; diff --git a/webapp/src/state/call_availability/selectors.ts b/webapp/src/state/call_availability/selectors.ts new file mode 100644 index 000000000..821e1e185 --- /dev/null +++ b/webapp/src/state/call_availability/selectors.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {GlobalState} from '@mattermost/types/store'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; +import {getPluginStore} from 'src/state/common_selectors'; + +export const callsAvailableInChannel = (state: GlobalState, channelID: Channel['id']) => + getPluginStore(state).callAvailability?.[channelID]?.enabled === true; + +export const callsNotAvailableInChannel = (state: GlobalState, channelID: Channel['id']) => + getPluginStore(state).callAvailability?.[channelID]?.enabled === false; + +export const callsAvailableInChannelWithDefault = (state: GlobalState, channelID: Channel['id']): boolean => { + if (callsNotAvailableInChannel(state, channelID)) { + return false; + } + + const callsDefaultEnabled = getPluginStore(state).callsConfig?.DefaultEnabled === true; + return callsAvailableInChannel(state, channelID) || callsDefaultEnabled; +}; + +export const callsAvailableInCurrentChannelWithDefault = (state: GlobalState): boolean => { + const currentChannelID = getCurrentChannelId(state); + return callsAvailableInChannelWithDefault(state, currentChannelID); +}; + +/** + * Shows the calls button unless the channel has been explicitly disabled. + * Channels enabled by the default config may not have a per-channel availability record in Store. + */ +export const shouldShowCallsButtonInChannelHeader = (state: GlobalState, channelId?: Channel['id']) => + !callsNotAvailableInChannel(state, channelId || ''); diff --git a/webapp/src/state/common_selectors.ts b/webapp/src/state/common_selectors.ts new file mode 100644 index 000000000..313a1439d --- /dev/null +++ b/webapp/src/state/common_selectors.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GlobalState as MattermostStore} from '@mattermost/types/store'; +import {pluginId} from 'src/manifest'; +import {emptyRootReducer, RootReducer} from 'src/reducers'; + +const PLUGIN_REDUX_STATE_KEY = `plugins-${pluginId}`; + +// The plugin attaches its store to the Mattermost global store +// via the PLUGIN_REDUX_STATE_KEY at root level. +type State = MattermostStore & Record; + +// Although this selector reads from `State`, callers receive the Mattermost +// global store type, which does not declare plugin reducer keys. +export const getPluginStore = (state: MattermostStore): State[typeof PLUGIN_REDUX_STATE_KEY] => + (state as State)[PLUGIN_REDUX_STATE_KEY] ?? emptyRootReducer; diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index dd2bb3c3e..f64eb7b4f 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -331,12 +331,17 @@ export async function getProfilesByIds(state: GlobalState, ids: string[]): Promi return profiles; } -export function getUserIDsForSessions(sessions: SessionState[]) { - const idsMap: {[id: string]: boolean} = {}; +/** + * Retrieves a deduplicated list of user IDs from call sessions. + * Since a user can have multiple sessions (e.g., desktop and mobile), duplicate user IDs are removed. + */ +export function getUserIDsFromSessions(sessions: SessionState[]): Array { + const userIDsSet = new Set(); for (const session of sessions) { - idsMap[session.user_id] = true; + userIDsSet.add(session.user_id); } - return Object.keys(idsMap); + + return Array.from(userIDsSet); } export function getUserIdFromDM(dmName: string, currentUserId: string) { diff --git a/webapp/src/websocket_handlers.ts b/webapp/src/websocket_handlers.ts index 242610bd4..77f016ad2 100644 --- a/webapp/src/websocket_handlers.ts +++ b/webapp/src/websocket_handlers.ts @@ -123,7 +123,6 @@ export function handleCallStart(store: Store, ev: WebSocketMessage Date: Wed, 24 Jun 2026 23:59:54 +0530 Subject: [PATCH 06/18] revert sip thing --- webapp/src/actions.ts | 7 -- webapp/src/reducers.ts | 2 - webapp/src/selectors.ts | 23 ------- webapp/src/state/sip_details/action_types.ts | 6 -- webapp/src/state/sip_details/actions.ts | 22 ------- webapp/src/state/sip_details/reducer.ts | 68 -------------------- webapp/src/state/sip_details/selector.ts | 3 - 7 files changed, 131 deletions(-) delete mode 100644 webapp/src/state/sip_details/action_types.ts delete mode 100644 webapp/src/state/sip_details/actions.ts delete mode 100644 webapp/src/state/sip_details/reducer.ts delete mode 100644 webapp/src/state/sip_details/selector.ts diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 64b80f551..be5f3c8cd 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -43,12 +43,10 @@ import {channelCallsAvailabilityUpdated} from 'src/state/call_availability/actio import {callEnded} from 'src/state/common_actions'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; -import {sipCallDetailsReceived} from 'src/state/sip_details/actions'; import {CallsStats, ChannelType} from 'src/types/types'; import { getCallsClientSessionID, getPluginPath, - getSipCallDetailsFromCallState, getUserIDsFromSessions, isDMChannel, isGMChannel, @@ -663,11 +661,6 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: }), ); - const sipCallDetails = getSipCallDetailsFromCallState(call); - if (sipCallDetails) { - actions.push(sipCallDetailsReceived(channelID, sipCallDetails)); - } - actions.push({ type: CALL_RECORDING_STATE, data: { diff --git a/webapp/src/reducers.ts b/webapp/src/reducers.ts index 31cac6afe..0e144ddb9 100644 --- a/webapp/src/reducers.ts +++ b/webapp/src/reducers.ts @@ -15,7 +15,6 @@ import { import {reducer as screenSharingIDs} from 'src/state/screen_sharing_ids/reducer'; import {USER_JOINED, USER_LEFT, USER_REACTED, USER_REACTED_TIMEOUT} from 'src/state/sessions/action_types'; import {reducer as sessions} from 'src/state/sessions/reducer'; -import {reducer as sipDetails} from 'src/state/sip_details/reducer'; import { CallsConfigDefault, CallsUserPreferences, @@ -709,7 +708,6 @@ const rootReducer = combineReducers({ activeCalls, hosts, screenSharingIDs, - sipDetails, expandedView, switchCallModal, screenSourceModal, diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 241384216..f1e8e9478 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -37,7 +37,6 @@ import { usersReactionsState, } from 'src/reducers'; import {getPluginStore} from 'src/state/common_selectors'; -import {SipCallDetails} from 'src/state/sip_details/reducer'; import { CallJobReduxState, CallsUserPreferences, @@ -50,9 +49,6 @@ import {getCallsClientChannelID, getCallsClientInitTime, getCallsClientSessionID const activeCallsIngetPluginStore = (state: GlobalState): { [channelID: string]: callState } => getPluginStore(state).activeCalls; -const sipCallDetailsIngetPluginStore = (state: GlobalState): { [channelID: string]: SipCallDetails } => - getPluginStore(state).sipDetails; - export const channelIDForCurrentCall: (state: GlobalState) => string = createSelector( 'channelIDForCurrentCall', @@ -261,25 +257,6 @@ export const callOwnerIDForCallInChannel = (state: GlobalState, channelID: strin return getPluginStore(state).activeCalls[channelID]?.ownerID; }; -export const sipCallDetailsForCallInChannel = (state: GlobalState, channelID: string): SipCallDetails | undefined => { - return getPluginStore(state).sipDetails[channelID]; -}; - -// isPhoneCall is true only for SIP/phone calls. The sipCallDetails slice holds an entry -// only for such calls (populated from the server's call props), so presence of -// an entry is the signal. Regular WebRTC calls have no entry and read as false. -export const isPhoneCall = (state: GlobalState, channelID: string): boolean => { - return Boolean(sipCallDetailsForCallInChannel(state, channelID)); -}; - -export const isPhoneCallForCurrentCall: (state: GlobalState) => boolean = - createSelector( - 'isPhoneCallForCurrentCall', - sipCallDetailsIngetPluginStore, - channelIDForCurrentCall, - (sipStates, channelID) => Boolean(sipStates[channelID]), - ); - const hostsInCalls = (state: GlobalState): hostsState => { return getPluginStore(state).hosts; }; diff --git a/webapp/src/state/sip_details/action_types.ts b/webapp/src/state/sip_details/action_types.ts deleted file mode 100644 index 20f0a9a2c..000000000 --- a/webapp/src/state/sip_details/action_types.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {pluginId} from 'src/manifest'; - -export const SIP_CALL_DETAILS = `${pluginId}_sip_call_details` as const; diff --git a/webapp/src/state/sip_details/actions.ts b/webapp/src/state/sip_details/actions.ts deleted file mode 100644 index 8a60e53fe..000000000 --- a/webapp/src/state/sip_details/actions.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Channel} from '@mattermost/types/channels'; -import {ActionCallEnded, ActionUnInitialized} from 'src/state/common_actions'; - -import {SIP_CALL_DETAILS} from './action_types'; -import {SipCallDetails} from './reducer'; - -export const sipCallDetailsReceived = (channelID: Channel['id'], details: SipCallDetails) => ({ - type: SIP_CALL_DETAILS, - data: { - channelID, - details, - }, -}); -export type ActionSipCallDetailsReceived = ReturnType - -export type Actions = - | ActionUnInitialized - | ActionCallEnded - | ActionSipCallDetailsReceived; diff --git a/webapp/src/state/sip_details/reducer.ts b/webapp/src/state/sip_details/reducer.ts deleted file mode 100644 index 93214b8c1..000000000 --- a/webapp/src/state/sip_details/reducer.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {UserProfile} from '@mattermost/types/users'; -import {Reducer} from 'redux'; -import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; - -import {SIP_CALL_DETAILS} from './action_types'; -import {Actions} from './actions'; - -// PHONE_CALL_TYPE is the wire value of `call.props.type` that marks a call as a -// SIP/phone call. It is the only signal the server sends to discriminate phone -// calls from regular WebRTC calls; the client stores presence, not the value. -export const PHONE_CALL_TYPE = 'phone'; - -// CallDirection distinguishes inbound from outbound SIP/phone calls. Today only -// outbound calls are placed; the inbound value is reserved for incoming-SIP -// support and is read from the wire defensively (see getSipCallDetailsFromCallState). -export enum CallDirection { - Outbound = 'outbound', - Inbound = 'inbound', -} - -// SipCallDetails holds the contact metadata for a SIP/phone call, derived from the -// server's `call.props`. The presence of an entry in this slice IS the -// "is this a phone/SIP call" signal — there is no separate `type` flag. Kept -// plugin-local: the calls-common CallState type does not declare these props -// yet (server proposal P1), so they are read defensively from the wire. -export type SipCallDetails = { - direction: CallDirection; - phone_number: string; - display_number: string; - label: string; - user_id: UserProfile['id']; -} - -// State is keyed by channelID and only holds entries for SIP/phone calls. -// Regular WebRTC calls have no entry here, mirroring how hosts/screenSharingIDs -// only hold entries for the channels they apply to. -type State = { - [channelID: string]: SipCallDetails; -} - -const emptyState: State = {}; - -export const reducer: Reducer = (initialState = emptyState, action): State => { - switch (action.type) { - case UN_INITIALIZED: { - return emptyState; - } - - case SIP_CALL_DETAILS: { - return { - ...initialState, - [action.data.channelID]: action.data.details, - }; - } - - case CALL_ENDED: { - const nextState = {...initialState}; - delete nextState[action.data.channelID]; - return nextState; - } - - default: - return initialState; - } -}; diff --git a/webapp/src/state/sip_details/selector.ts b/webapp/src/state/sip_details/selector.ts deleted file mode 100644 index e888d7581..000000000 --- a/webapp/src/state/sip_details/selector.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - From fe3028383533dca1b3a0a58e9072a4989074a4fd Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 00:01:33 +0530 Subject: [PATCH 07/18] rev --- webapp/src/components/call_widget/index.ts | 2 -- webapp/src/utils.ts | 26 +++------------------- 2 files changed, 3 insertions(+), 25 deletions(-) diff --git a/webapp/src/components/call_widget/index.ts b/webapp/src/components/call_widget/index.ts index 7eac59913..a99d05e72 100644 --- a/webapp/src/components/call_widget/index.ts +++ b/webapp/src/components/call_widget/index.ts @@ -26,7 +26,6 @@ import { hostChangeAtForCurrentCall, hostControlNoticesForCurrentCall, hostIDForCurrentCall, - isPhoneCallForCurrentCall, isRecordingInCurrentCall, profilesInCurrentCallMap, recentlyJoinedUsersInCurrentCall, @@ -99,7 +98,6 @@ const mapStateToProps = (state: GlobalState) => { recordingsEnabled: recordingsEnabled(state), connectedDMUser, otherSessions: sessionsForOtherUsersInCall(state), - isPhoneCall: isPhoneCallForCurrentCall(state), }; }; diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index f64eb7b4f..140afeb11 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -1,8 +1,10 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls-common'; -import {CallJobMetadata, CallPostProps, CallRecordingPostProps, CallState, SessionState, UserSessionState} from '@mattermost/calls-common/lib/types'; +import {CallJobMetadata, CallPostProps, CallRecordingPostProps, SessionState, UserSessionState} from '@mattermost/calls-common/lib/types'; import {Channel} from '@mattermost/types/channels'; import {ClientConfig} from '@mattermost/types/config'; import {Post} from '@mattermost/types/posts'; @@ -21,7 +23,6 @@ import {parseSemVer} from 'semver-parser'; import type CallClient from 'src/clients/call'; import RestClient from 'src/clients/rest'; import {STORAGE_CALLS_SHARE_AUDIO_WITH_SCREEN} from 'src/constants'; -import {CallDirection, PHONE_CALL_TYPE, SipCallDetails} from 'src/state/sip_details/reducer'; import {DesktopMessage} from 'src/types/types'; import {notificationSounds} from 'src/webapp_globals'; @@ -609,27 +610,6 @@ export function getCallPropsFromPost(post: Post): CallPostProps { }; } -// getSipCallDetailsFromCallState defensively reads the server's `call.props` off a -// CallState and projects the SIP/phone contact metadata. The calls-common -// CallState type does not declare `props` yet (server proposal P1), so we read -// it through a narrow local view and guard every field. Returns undefined for -// any non-SIP call (no props, or props whose `type` is not the 'phone' wire -// value) — its presence is what marks a call as phone/SIP for the -// sipCallDetails slice and the isPhoneCall selector. -export function getSipCallDetailsFromCallState(call: CallState): SipCallDetails | undefined { - const props = (call as {props?: Record}).props; - if (!props || !isValidObject(props) || props.type !== PHONE_CALL_TYPE) { - return undefined; - } - return { - direction: props.direction === CallDirection.Inbound ? CallDirection.Inbound : CallDirection.Outbound, - phone_number: typeof props.phone_number === 'string' ? props.phone_number : '', - display_number: typeof props.display_number === 'string' ? props.display_number : '', - label: typeof props.label === 'string' ? props.label : '', - user_id: typeof props.user_id === 'string' ? props.user_id : '', - }; -} - export function getCallRecordingPropsFromPost(post: Post): CallRecordingPostProps { return { call_post_id: typeof post.props?.call_post_id === 'string' ? post.props.call_post_id : '', From f14289174ce18215e1722b983175dc1f6e3042de Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 00:03:02 +0530 Subject: [PATCH 08/18] rev3 --- .../components/call_widget/component.test.tsx | 1 - .../src/components/call_widget/component.tsx | 1 - webapp/src/utils.test.ts | 57 ------------------- 3 files changed, 59 deletions(-) diff --git a/webapp/src/components/call_widget/component.test.tsx b/webapp/src/components/call_widget/component.test.tsx index a08694bf7..a4feb3e57 100644 --- a/webapp/src/components/call_widget/component.test.tsx +++ b/webapp/src/components/call_widget/component.test.tsx @@ -80,7 +80,6 @@ const props: Props = { openModal: jest.fn(), openCallsUserSettings: jest.fn(), connectedDMUser: undefined, - isPhoneCall: false, }; describe('CallWidget', () => { diff --git a/webapp/src/components/call_widget/component.tsx b/webapp/src/components/call_widget/component.tsx index 62535017b..cb8088168 100644 --- a/webapp/src/components/call_widget/component.tsx +++ b/webapp/src/components/call_widget/component.tsx @@ -140,7 +140,6 @@ interface Props { openModal:

(modalData: ModalData

) => void; openCallsUserSettings: () => void; connectedDMUser: UserProfile | undefined, - isPhoneCall: boolean, } interface DraggingState { diff --git a/webapp/src/utils.test.ts b/webapp/src/utils.test.ts index b0d05cfc6..1ad05e57b 100644 --- a/webapp/src/utils.test.ts +++ b/webapp/src/utils.test.ts @@ -1,12 +1,10 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {CallState} from '@mattermost/calls-common/lib/types'; import {Post} from '@mattermost/types/posts'; import {Duration} from 'luxon'; import {createIntl} from 'react-intl'; import type CallClient from 'src/clients/call'; -import {CallDirection} from 'src/state/sip_details/reducer'; import {pluginId} from './manifest'; import { @@ -16,7 +14,6 @@ import { getCallsClient, getCallsWindow, getPlatformInfo, - getSipCallDetailsFromCallState, getWebappUtils, getWSConnectionURL, maxAttemptsReachedErr, @@ -692,59 +689,5 @@ describe('utils', () => { }, ); }); - - describe('getSipCallDetailsFromCallState', () => { - // The calls-common CallState type does not declare `props`, so cast - // when injecting fixture props (this mirrors the real wire payload). - const withProps = (props: unknown) => ({props} as unknown as CallState); - - it('returns sip details for a phone call, defaulting direction to outbound', () => { - const call = withProps({ - type: 'phone', - phone_number: '+15551234567', - display_number: '(555) 123-4567', - label: 'DSN', - user_id: 'user1', - }); - expect(getSipCallDetailsFromCallState(call)).toEqual({ - direction: CallDirection.Outbound, - phone_number: '+15551234567', - display_number: '(555) 123-4567', - label: 'DSN', - user_id: 'user1', - }); - }); - - it('reads an inbound direction from the wire', () => { - const call = withProps({type: 'phone', direction: 'inbound'}); - expect(getSipCallDetailsFromCallState(call)?.direction).toBe(CallDirection.Inbound); - }); - - it('returns undefined for any non-phone call', () => { - expect(getSipCallDetailsFromCallState({} as CallState)).toBeUndefined(); - expect(getSipCallDetailsFromCallState(withProps(undefined))).toBeUndefined(); - expect(getSipCallDetailsFromCallState(withProps(null))).toBeUndefined(); - - // Regular WebRTC calls carry props (hosts, screen sharing, etc.) but - // no phone type, so they must not produce sip details. - expect(getSipCallDetailsFromCallState(withProps({type: 'something-else'}))).toBeUndefined(); - expect(getSipCallDetailsFromCallState(withProps({hosts: ['user1']}))).toBeUndefined(); - }); - - it('coerces non-string fields to empty strings for a phone call', () => { - expect(getSipCallDetailsFromCallState(withProps({ - type: 'phone', - phone_number: {}, - display_number: null, - label: 123, - }))).toEqual({ - direction: CallDirection.Outbound, - phone_number: '', - display_number: '', - label: '', - user_id: '', - }); - }); - }); }); From a407ec221acdd08db67e123a4ff59b9674872a49 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 00:15:38 +0530 Subject: [PATCH 09/18] Refactor active call management and channel availability handling. Updated action types and action creators to improve consistency, renaming `activeCallRegistered` to `activeCallAdded`. Adjusted import paths for call availability actions and selectors, enhancing code organization. Introduced new state management for channel call availability, including actions and reducers, to streamline functionality. --- webapp/src/actions.ts | 8 ++++---- webapp/src/components/channel_header_button.tsx | 2 +- .../channel_header_dropdown_button/index.ts | 2 +- .../components/channel_header_menu_item/index.tsx | 2 +- webapp/src/index.tsx | 4 ++-- webapp/src/reducers.ts | 4 ++-- webapp/src/state/active_calls/action_types.ts | 2 +- webapp/src/state/active_calls/actions.ts | 12 ++++++------ webapp/src/state/active_calls/reducer.ts | 10 +++++----- .../action_types.ts | 0 .../actions.ts | 0 .../reducer.ts | 6 +++--- .../selectors.ts | 4 ++-- webapp/src/state/screen_sharing_ids/reducer.ts | 6 +++--- webapp/src/state/sessions/reducer.ts | 8 ++++---- webapp/src/websocket_handlers.ts | 4 ++-- 16 files changed, 37 insertions(+), 37 deletions(-) rename webapp/src/state/{call_availability => calls_availability}/action_types.ts (100%) rename webapp/src/state/{call_availability => calls_availability}/actions.ts (100%) rename webapp/src/state/{call_availability => calls_availability}/reducer.ts (83%) rename webapp/src/state/{call_availability => calls_availability}/selectors.ts (90%) diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index be5f3c8cd..15dab10e0 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -38,8 +38,8 @@ import { ringingForCall, shouldPlayJoinUserSound, } from 'src/selectors'; -import {activeCallRegistered} from 'src/state/active_calls/actions'; -import {channelCallsAvailabilityUpdated} from 'src/state/call_availability/actions'; +import {activeCallAdded} from 'src/state/active_calls/actions'; +import {channelCallsAvailabilityUpdated} from 'src/state/calls_availability/actions'; import {callEnded} from 'src/state/common_actions'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; @@ -600,7 +600,7 @@ export const hydradeCallsAndChannelStatesExcept = (skipChannelID?: string) => { if (!callStartAtForCallInChannel(getState(), callAndChannelState.channel_id)) { actions.push( - activeCallRegistered(callAndChannelState.channel_id, { + activeCallAdded(callAndChannelState.channel_id, { callID: callAndChannelState.call.id, startAt: callAndChannelState.call.start_at, ownerID: callAndChannelState.call.owner_id, @@ -653,7 +653,7 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: const actions: AnyAction[] = []; actions.push( - activeCallRegistered(channelID, { + activeCallAdded(channelID, { callID: call.id, startAt: call.start_at, threadID: call.thread_id, diff --git a/webapp/src/components/channel_header_button.tsx b/webapp/src/components/channel_header_button.tsx index dcaa8dfda..d08958f0a 100644 --- a/webapp/src/components/channel_header_button.tsx +++ b/webapp/src/components/channel_header_button.tsx @@ -20,7 +20,7 @@ import { isLimitRestricted, maxParticipants, } from 'src/selectors'; -import {shouldShowCallsButtonInChannelHeader} from 'src/state/call_availability/selectors'; +import {shouldShowCallsButtonInChannelHeader} from 'src/state/calls_availability/selectors'; import {getUserIdFromDM, isDMChannel} from 'src/utils'; import styled, {css} from 'styled-components'; diff --git a/webapp/src/components/channel_header_dropdown_button/index.ts b/webapp/src/components/channel_header_dropdown_button/index.ts index ca28a941d..206f4ca21 100644 --- a/webapp/src/components/channel_header_dropdown_button/index.ts +++ b/webapp/src/components/channel_header_dropdown_button/index.ts @@ -13,7 +13,7 @@ import { isLimitRestricted, maxParticipants, } from 'src/selectors'; -import {shouldShowCallsButtonInChannelHeader} from 'src/state/call_availability/selectors'; +import {shouldShowCallsButtonInChannelHeader} from 'src/state/calls_availability/selectors'; import ChannelHeaderDropdownButton from './component'; diff --git a/webapp/src/components/channel_header_menu_item/index.tsx b/webapp/src/components/channel_header_menu_item/index.tsx index 760f71a23..0d2377928 100644 --- a/webapp/src/components/channel_header_menu_item/index.tsx +++ b/webapp/src/components/channel_header_menu_item/index.tsx @@ -5,7 +5,7 @@ import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/user import React from 'react'; import {FormattedMessage} from 'react-intl'; import {useSelector} from 'react-redux'; -import {callsAvailableInCurrentChannelWithDefault} from 'src/state/call_availability/selectors'; +import {callsAvailableInCurrentChannelWithDefault} from 'src/state/calls_availability/selectors'; export default function ChannelHeaderMenuItem() { const isEnabled = useSelector(callsAvailableInCurrentChannelWithDefault); diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 4351f05fe..140d86e4a 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -103,8 +103,8 @@ import VideoDevicesSettingsSection from 'src/components/user_settings/video_devi import {CALL_RECORDING_POST_TYPE, CALL_START_POST_TYPE, CALL_TRANSCRIPTION_POST_TYPE, DisabledCallsErr} from 'src/constants'; import {desktopNotificationHandler} from 'src/desktop_notifications'; import slashCommandsHandler from 'src/slash_commands'; -import {channelCallsAvailabilityUpdated, toggleCallsAvailabilityForChannel} from 'src/state/call_availability/actions'; -import {callsAvailableInChannelWithDefault, callsNotAvailableInChannel} from 'src/state/call_availability/selectors'; +import {channelCallsAvailabilityUpdated, toggleCallsAvailabilityForChannel} from 'src/state/calls_availability/actions'; +import {callsAvailableInChannelWithDefault, callsNotAvailableInChannel} from 'src/state/calls_availability/selectors'; import {unInitialized} from 'src/state/common_actions'; import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; import {CurrentCallDataDefault, DesktopMessageType} from 'src/types/types'; diff --git a/webapp/src/reducers.ts b/webapp/src/reducers.ts index 0e144ddb9..04c15777a 100644 --- a/webapp/src/reducers.ts +++ b/webapp/src/reducers.ts @@ -7,7 +7,7 @@ import {CallJobState, CallsConfig, CallsVersionInfo, LiveCaption, Reaction, Tran import {combineReducers} from 'redux'; import {MAX_NUM_REACTIONS_IN_REACTION_STREAM} from 'src/constants'; import {reducer as activeCalls} from 'src/state/active_calls/reducer'; -import {reducer as callAvailability} from 'src/state/call_availability/reducer'; +import {reducer as callsAvailability} from 'src/state/calls_availability/reducer'; import { CALL_ENDED, UN_INITIALIZED, @@ -701,7 +701,7 @@ const hostControlNotices = (state: hostControlNoticeState = {}, }; const rootReducer = combineReducers({ - callAvailability, + callsAvailability, clientStateReducer, reactions, sessions, diff --git a/webapp/src/state/active_calls/action_types.ts b/webapp/src/state/active_calls/action_types.ts index da813e4ef..7adf386e1 100644 --- a/webapp/src/state/active_calls/action_types.ts +++ b/webapp/src/state/active_calls/action_types.ts @@ -3,4 +3,4 @@ import {pluginId} from 'src/manifest'; -export const ACTIVE_CALL_REGISTERED = `${pluginId}_active_call_registered` as const; +export const ACTIVE_CALL_ADDED = `${pluginId}_active_call_added` as const; diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts index 6adf6bb57..18c927921 100644 --- a/webapp/src/state/active_calls/actions.ts +++ b/webapp/src/state/active_calls/actions.ts @@ -4,11 +4,11 @@ import {type Channel} from '@mattermost/types/channels'; import {type ActionCallEnded, type ActionUnInitialized} from '../common_actions'; -import {ACTIVE_CALL_REGISTERED} from './action_types'; -import {type State as ActiveCall} from './reducer'; +import {ACTIVE_CALL_ADDED} from './action_types'; +import {type ActiveCalls} from './reducer'; -export const activeCallRegistered = (channelID: Channel['id'], activeCall: Omit) => ({ - type: ACTIVE_CALL_REGISTERED, +export const activeCallAdded = (channelID: Channel['id'], activeCall: Omit) => ({ + type: ACTIVE_CALL_ADDED, data: { callID: activeCall.callID, startAt: activeCall.startAt, @@ -17,9 +17,9 @@ export const activeCallRegistered = (channelID: Channel['id'], activeCall: Omit< ownerID: activeCall.ownerID, }, }); -export type ActionActiveCallRegistered = ReturnType +export type ActionActiveCallAdded = ReturnType export type Actions = | ActionUnInitialized | ActionCallEnded -| ActionActiveCallRegistered \ No newline at end of file +| ActionActiveCallAdded \ No newline at end of file diff --git a/webapp/src/state/active_calls/reducer.ts b/webapp/src/state/active_calls/reducer.ts index d51c01ed5..8aad707a7 100644 --- a/webapp/src/state/active_calls/reducer.ts +++ b/webapp/src/state/active_calls/reducer.ts @@ -7,10 +7,10 @@ import {type UserProfile} from '@mattermost/types/users'; import {type Reducer} from 'redux'; import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; -import {ACTIVE_CALL_REGISTERED} from './action_types'; +import {ACTIVE_CALL_ADDED} from './action_types'; import {type Actions} from './actions'; -export type State = { +export type ActiveCalls = { [channelID: string]: { callID: string; startAt: number; @@ -20,15 +20,15 @@ export type State = { }; } -const emptyState: State = {}; +const emptyState: ActiveCalls = {}; -export const reducer: Reducer = (initialState = emptyState, action) : State => { +export const reducer: Reducer = (initialState = emptyState, action) : ActiveCalls => { switch (action.type) { case UN_INITIALIZED:{ return emptyState; } - case ACTIVE_CALL_REGISTERED: { + case ACTIVE_CALL_ADDED: { return { ...initialState, [action.data.channelID]: { diff --git a/webapp/src/state/call_availability/action_types.ts b/webapp/src/state/calls_availability/action_types.ts similarity index 100% rename from webapp/src/state/call_availability/action_types.ts rename to webapp/src/state/calls_availability/action_types.ts diff --git a/webapp/src/state/call_availability/actions.ts b/webapp/src/state/calls_availability/actions.ts similarity index 100% rename from webapp/src/state/call_availability/actions.ts rename to webapp/src/state/calls_availability/actions.ts diff --git a/webapp/src/state/call_availability/reducer.ts b/webapp/src/state/calls_availability/reducer.ts similarity index 83% rename from webapp/src/state/call_availability/reducer.ts rename to webapp/src/state/calls_availability/reducer.ts index 9cf34f1e3..ca43fe71a 100644 --- a/webapp/src/state/call_availability/reducer.ts +++ b/webapp/src/state/calls_availability/reducer.ts @@ -8,16 +8,16 @@ import {UN_INITIALIZED} from 'src/state/common_action_types'; import {CHANNEL_CALLS_AVAILABILITY_UPDATED} from './action_types'; import {type Actions} from './actions'; -type State = { +type CallsAvailability = { [channelID: Channel['id']]: { channelID: Channel['id']; enabled: boolean; }; }; -const emptyState: State = {}; +const emptyState: CallsAvailability = {}; -export const reducer: Reducer = (initialState = emptyState, action) => { +export const reducer: Reducer = (initialState = emptyState, action) => { switch (action.type) { case UN_INITIALIZED: { return emptyState; diff --git a/webapp/src/state/call_availability/selectors.ts b/webapp/src/state/calls_availability/selectors.ts similarity index 90% rename from webapp/src/state/call_availability/selectors.ts rename to webapp/src/state/calls_availability/selectors.ts index 821e1e185..4c4236ad7 100644 --- a/webapp/src/state/call_availability/selectors.ts +++ b/webapp/src/state/calls_availability/selectors.ts @@ -7,10 +7,10 @@ import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; import {getPluginStore} from 'src/state/common_selectors'; export const callsAvailableInChannel = (state: GlobalState, channelID: Channel['id']) => - getPluginStore(state).callAvailability?.[channelID]?.enabled === true; + getPluginStore(state).callsAvailability?.[channelID]?.enabled === true; export const callsNotAvailableInChannel = (state: GlobalState, channelID: Channel['id']) => - getPluginStore(state).callAvailability?.[channelID]?.enabled === false; + getPluginStore(state).callsAvailability?.[channelID]?.enabled === false; export const callsAvailableInChannelWithDefault = (state: GlobalState, channelID: Channel['id']): boolean => { if (callsNotAvailableInChannel(state, channelID)) { diff --git a/webapp/src/state/screen_sharing_ids/reducer.ts b/webapp/src/state/screen_sharing_ids/reducer.ts index bfbd5657e..2f9623dae 100644 --- a/webapp/src/state/screen_sharing_ids/reducer.ts +++ b/webapp/src/state/screen_sharing_ids/reducer.ts @@ -9,13 +9,13 @@ import {USER_LEFT} from 'src/state/sessions/action_types'; import {USER_SCREEN_OFF, USER_SCREEN_ON} from './action_types'; import {type Actions} from './actions'; -type State = { +type ScreenSharingIDs = { [channelID: string]: UserSessionState['session_id']; } -const emptyState: State = {}; +const emptyState: ScreenSharingIDs = {}; -export const reducer: Reducer = (initialState = emptyState, action) : State => { +export const reducer: Reducer = (initialState = emptyState, action) : ScreenSharingIDs => { switch (action.type) { case UN_INITIALIZED:{ return emptyState; diff --git a/webapp/src/state/sessions/reducer.ts b/webapp/src/state/sessions/reducer.ts index 7cb3ac5e9..df5b0c5be 100644 --- a/webapp/src/state/sessions/reducer.ts +++ b/webapp/src/state/sessions/reducer.ts @@ -20,15 +20,15 @@ import { } from './action_types'; import {type Actions} from './actions'; -type State = { +type Sessions = { [channelID: Channel['id']]: { [session_id: UserSessionState['session_id']]: UserSessionState; } } -const emptyState: State = {}; +const emptyState: Sessions = {}; -export const reducer: Reducer = (initialState = emptyState, action) : State => { +export const reducer: Reducer = (initialState = emptyState, action) : Sessions => { switch (action.type) { case UN_INITIALIZED: { return emptyState; @@ -68,7 +68,7 @@ export const reducer: Reducer = (initialState = emptyState, acti // With this flag we avoid creating a new state object if no changes are made let stateChanged = false; - const nextState: State[Channel['id']] = {}; + const nextState: Sessions[Channel['id']] = {}; // Walk every session in the channel — sessions present in the active-speakers list // get voice: true, sessions absent from it get voice: false. diff --git a/webapp/src/websocket_handlers.ts b/webapp/src/websocket_handlers.ts index 77f016ad2..5162fb07c 100644 --- a/webapp/src/websocket_handlers.ts +++ b/webapp/src/websocket_handlers.ts @@ -71,7 +71,7 @@ import { profilesInCurrentCallMap, ringingEnabled, } from './selectors'; -import {activeCallRegistered} from './state/active_calls/actions'; +import {activeCallAdded} from './state/active_calls/actions'; import {Store} from './types/mattermost-webapp'; import { followThread, @@ -119,7 +119,7 @@ export function handleCallStart(store: Store, ev: WebSocketMessage Date: Thu, 25 Jun 2026 00:22:56 +0530 Subject: [PATCH 10/18] Remove admin check from ChannelHeaderMenuItem component. The component now only renders the disable calls message based on channel availability, simplifying the logic and improving clarity. --- webapp/src/components/channel_header_menu_item/index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/webapp/src/components/channel_header_menu_item/index.tsx b/webapp/src/components/channel_header_menu_item/index.tsx index 0d2377928..c41e9ad3c 100644 --- a/webapp/src/components/channel_header_menu_item/index.tsx +++ b/webapp/src/components/channel_header_menu_item/index.tsx @@ -1,7 +1,6 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import React from 'react'; import {FormattedMessage} from 'react-intl'; import {useSelector} from 'react-redux'; @@ -10,9 +9,7 @@ import {callsAvailableInCurrentChannelWithDefault} from 'src/state/calls_availab export default function ChannelHeaderMenuItem() { const isEnabled = useSelector(callsAvailableInCurrentChannelWithDefault); - const isAdmin = useSelector(isCurrentUserSystemAdmin); - - if (isEnabled || isAdmin) { + if (isEnabled) { return ( ); From 3e8ae3b8abd6e0ab462652de3588c3ad283618b2 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 00:29:13 +0530 Subject: [PATCH 11/18] Update action types and exports for call management. Ensure consistency in action type definitions across files, maintaining clarity in the state management structure. --- webapp/src/state/active_calls/actions.ts | 2 +- webapp/src/state/calls_availability/actions.ts | 2 +- webapp/src/state/common_action_types.ts | 2 +- webapp/src/state/common_actions.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts index 18c927921..35ea43a4c 100644 --- a/webapp/src/state/active_calls/actions.ts +++ b/webapp/src/state/active_calls/actions.ts @@ -22,4 +22,4 @@ export type ActionActiveCallAdded = ReturnType export type Actions = | ActionUnInitialized | ActionCallEnded -| ActionActiveCallAdded \ No newline at end of file +| ActionActiveCallAdded diff --git a/webapp/src/state/calls_availability/actions.ts b/webapp/src/state/calls_availability/actions.ts index 6c660885f..0937873b5 100644 --- a/webapp/src/state/calls_availability/actions.ts +++ b/webapp/src/state/calls_availability/actions.ts @@ -42,4 +42,4 @@ export const toggleCallsAvailabilityForChannel = () => { export type Actions = | ActionUnInitialized - | ActionChannelCallsAvailabilityUpdated; \ No newline at end of file + | ActionChannelCallsAvailabilityUpdated; diff --git a/webapp/src/state/common_action_types.ts b/webapp/src/state/common_action_types.ts index 377ce0dad..e3d3b884c 100644 --- a/webapp/src/state/common_action_types.ts +++ b/webapp/src/state/common_action_types.ts @@ -4,4 +4,4 @@ import {pluginId} from 'src/manifest'; export const UN_INITIALIZED = `${pluginId}_un_initialized` as const; -export const CALL_ENDED = `${pluginId}_call_ended` as const; \ No newline at end of file +export const CALL_ENDED = `${pluginId}_call_ended` as const; diff --git a/webapp/src/state/common_actions.ts b/webapp/src/state/common_actions.ts index fd3a4268b..6c64c03af 100644 --- a/webapp/src/state/common_actions.ts +++ b/webapp/src/state/common_actions.ts @@ -17,4 +17,4 @@ export const callEnded = (channelID: Channel['id'], callID: string) => ({ callID, }, }); -export type ActionCallEnded = ReturnType \ No newline at end of file +export type ActionCallEnded = ReturnType From d42a892e834c38fbfebe5111dd69392b35d1d45c Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 06:31:33 +0530 Subject: [PATCH 12/18] Implement host management in call functionality. Introduced new action types and actions for host changes, replacing the previous `CALL_HOST` action. Updated selectors and reducers to support the new host management structure, enhancing clarity and maintainability in call state management. --- webapp/src/action_types.ts | 1 - webapp/src/actions.ts | 26 +++------- webapp/src/components/call_widget/index.ts | 7 ++- .../custom_post_types/post_type/index.ts | 4 +- webapp/src/components/expanded_view/index.ts | 7 ++- webapp/src/index.tsx | 6 +-- webapp/src/reducers.ts | 52 +------------------ webapp/src/selectors.ts | 32 ++---------- webapp/src/slash_commands.tsx | 7 ++- webapp/src/state/hosts/action_types.ts | 6 +++ webapp/src/state/hosts/actions.ts | 25 +++++++++ webapp/src/state/hosts/reducer.ts | 46 ++++++++++++++++ webapp/src/state/hosts/selectors.ts | 28 ++++++++++ webapp/src/state/sessions/selectors.ts | 15 ++++++ webapp/src/utils.ts | 13 ----- webapp/src/websocket_handlers.ts | 21 ++------ 16 files changed, 150 insertions(+), 146 deletions(-) create mode 100644 webapp/src/state/hosts/action_types.ts create mode 100644 webapp/src/state/hosts/actions.ts create mode 100644 webapp/src/state/hosts/reducer.ts create mode 100644 webapp/src/state/hosts/selectors.ts diff --git a/webapp/src/action_types.ts b/webapp/src/action_types.ts index c02149710..487e0fa03 100644 --- a/webapp/src/action_types.ts +++ b/webapp/src/action_types.ts @@ -3,7 +3,6 @@ import {pluginId} from './manifest'; -export const CALL_HOST = pluginId + '_call_host'; export const CALL_RECORDING_STATE = pluginId + '_call_recording_state'; export const CALL_LIVE_CAPTIONS_STATE = pluginId + '_call_live_captions_state'; export const CALL_REC_PROMPT_DISMISSED = pluginId + '_call_rec_prompt_dismissed'; diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 15dab10e0..9428da970 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. /* eslint-disable max-lines */ + import {CallChannelState, CallsConfig, CallState, CallsVersionInfo} from '@mattermost/calls-common/lib/types'; import {ClientError} from '@mattermost/client'; import {Channel} from '@mattermost/types/channels'; @@ -31,7 +32,6 @@ import { callStartAtForCallInChannel, getCallIDForChannel, getCallIDForCurrentCall, - hostChangeAtForCurrentCall, incomingCalls, numSessionsInCallInChannel, ringingEnabled, @@ -41,13 +41,15 @@ import { import {activeCallAdded} from 'src/state/active_calls/actions'; import {channelCallsAvailabilityUpdated} from 'src/state/calls_availability/actions'; import {callEnded} from 'src/state/common_actions'; +import {hostChanged} from 'src/state/hosts/actions'; +import {getHostChangeAt} from 'src/state/hosts/selectors'; import {userScreenShared} from 'src/state/screen_sharing_ids/actions'; import {getSessionsMapFromSessions, sessionsReceived, userJoined, userLeft} from 'src/state/sessions/actions'; +import {getUserIDsFromSessions} from 'src/state/sessions/selectors'; import {CallsStats, ChannelType} from 'src/types/types'; import { getCallsClientSessionID, getPluginPath, - getUserIDsFromSessions, isDMChannel, isGMChannel, notificationsStopRinging, @@ -57,7 +59,6 @@ import {modals, notificationSounds, openPricingModal} from 'src/webapp_globals'; import { ADD_INCOMING_CALL, - CALL_HOST, CALL_LIVE_CAPTIONS_STATE, CALL_REC_PROMPT_DISMISSED, CALL_RECORDING_STATE, @@ -608,14 +609,7 @@ export const hydradeCallsAndChannelStatesExcept = (skipChannelID?: string) => { }), ); - actions.push({ - type: CALL_HOST, - data: { - channelID: callAndChannelState.channel_id, - hostID: callAndChannelState.call.host_id, - hostChangeAt: callAndChannelState.call.start_at, - }, - }); + actions.push(hostChanged(callAndChannelState.channel_id, callAndChannelState.call.host_id, callAndChannelState.call.start_at)); actions.push(sessionsReceived(callAndChannelState.channel_id, getSessionsMapFromSessions(callAndChannelState.call.sessions))); @@ -684,14 +678,8 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: } } - actions.push({ - type: CALL_HOST, - data: { - channelID, - hostID: call.host_id, - hostChangeAt: hostChangeAtForCurrentCall(getState()) || call.start_at, - }, - }); + const hostChangeAt = getHostChangeAt(getState(), channelID) ?? call.start_at; + actions.push(hostChanged(channelID, call.host_id, hostChangeAt)); const dismissed = call.dismissed_notification; if (dismissed) { diff --git a/webapp/src/components/call_widget/index.ts b/webapp/src/components/call_widget/index.ts index a99d05e72..c17602180 100644 --- a/webapp/src/components/call_widget/index.ts +++ b/webapp/src/components/call_widget/index.ts @@ -23,9 +23,7 @@ import { clientConnecting, expandedView, getChannelUrlAndDisplayName, - hostChangeAtForCurrentCall, hostControlNoticesForCurrentCall, - hostIDForCurrentCall, isRecordingInCurrentCall, profilesInCurrentCallMap, recentlyJoinedUsersInCurrentCall, @@ -40,6 +38,7 @@ import { threadIDForCallInChannel, transcriptionsEnabled, } from 'src/selectors'; +import {getHostChangeAtForCurrentChannel, getHostIDForCurrentChannel} from 'src/state/hosts/selectors'; import {alphaSortSessions, getUserIdFromDM, isDMChannel, stateSortSessions} from 'src/utils'; import {modals} from 'src/webapp_globals'; @@ -81,8 +80,8 @@ const mapStateToProps = (state: GlobalState) => { currentSession: sessionForCurrentCall(state), profiles, callStartAt: callStartAtForCurrentCall(state), - callHostID: hostIDForCurrentCall(state), - callHostChangeAt: hostChangeAtForCurrentCall(state), + callHostID: getHostIDForCurrentChannel(state), + callHostChangeAt: getHostChangeAtForCurrentChannel(state), callRecording: recordingForCurrentCall(state), isRecording: isRecordingInCurrentCall(state), screenSharingSession, diff --git a/webapp/src/components/custom_post_types/post_type/index.ts b/webapp/src/components/custom_post_types/post_type/index.ts index d32a06294..ee1412b66 100644 --- a/webapp/src/components/custom_post_types/post_type/index.ts +++ b/webapp/src/components/custom_post_types/post_type/index.ts @@ -11,11 +11,11 @@ import PostType from 'src/components/custom_post_types/post_type/component'; import {MESSAGE_DISPLAY, MESSAGE_DISPLAY_COMPACT, MESSAGE_DISPLAY_DEFAULT} from 'src/constants'; import { channelIDForCurrentCall, - hostIDForCallInChannel, isCloudProfessionalOrEnterpriseorEnterpriseAdvanceOrTrial, maxParticipants, profilesInCallInChannel, } from 'src/selectors'; +import {getHostID} from 'src/state/hosts/selectors'; interface OwnProps { post: Post, @@ -31,7 +31,7 @@ const mapStateToProps = (state: GlobalState, ownProps: OwnProps) => { maxParticipants: maxParticipants(state), militaryTime: getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false), compactDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, MESSAGE_DISPLAY, MESSAGE_DISPLAY_DEFAULT) === MESSAGE_DISPLAY_COMPACT, - isHost: hostIDForCallInChannel(state, ownProps.post.channel_id) === getCurrentUserId(state), + isHost: getHostID(state, ownProps.post.channel_id) === getCurrentUserId(state), }; }; diff --git a/webapp/src/components/expanded_view/index.ts b/webapp/src/components/expanded_view/index.ts index 367bbc5ed..15994ae62 100644 --- a/webapp/src/components/expanded_view/index.ts +++ b/webapp/src/components/expanded_view/index.ts @@ -24,8 +24,6 @@ import { channelForCurrentCall, expandedView, getChannelUrlAndDisplayName, - hostChangeAtForCurrentCall, - hostIDForCurrentCall, isRecordingInCurrentCall, profilesInCurrentCallMap, recordingForCurrentCall, @@ -39,6 +37,7 @@ import { threadIDForCallInChannel, transcriptionsEnabled, } from 'src/selectors'; +import {getHostChangeAtForCurrentChannel, getHostIDForCurrentChannel} from 'src/state/hosts/selectors'; import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; import {alphaSortSessions, getUserIdFromDM, isDMChannel, stateSortSessions} from 'src/utils'; import {closeRhs, getIsRhsOpen, getRhsSelectedPostId, modals, selectRhsPost} from 'src/webapp_globals'; @@ -77,8 +76,8 @@ const mapStateToProps = (state: GlobalState) => { sessionsMap: sessionsInCurrentCallMap(state), currentSession: sessionForCurrentCall(state), callStartAt: callStartAtForCurrentCall(state), - callHostID: hostIDForCurrentCall(state), - callHostChangeAt: hostChangeAtForCurrentCall(state), + callHostID: getHostIDForCurrentChannel(state), + callHostChangeAt: getHostChangeAtForCurrentChannel(state), callRecording: recordingForCurrentCall(state), isRecording: isRecordingInCurrentCall(state), screenSharingSession, diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 140d86e4a..ec7aba6d8 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -107,6 +107,7 @@ import {channelCallsAvailabilityUpdated, toggleCallsAvailabilityForChannel} from import {callsAvailableInChannelWithDefault, callsNotAvailableInChannel} from 'src/state/calls_availability/selectors'; import {unInitialized} from 'src/state/common_actions'; import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; +import {getUserIDsFromSessions} from 'src/state/sessions/selectors'; import {CurrentCallDataDefault, DesktopMessageType} from 'src/types/types'; import {getWSConnectionURL} from 'src/utils'; import {modals} from 'src/webapp_globals'; @@ -133,12 +134,12 @@ import { channelHasCall, channelIDForCurrentCall, hasPermissionsToEnableCalls, - hostIDForCallInChannel, isCloudStarter, isLimitRestricted, sessionsInCurrentCall, } from './selectors'; import {JOIN_CALL, keyToAction} from './shortcuts'; +import {getHostID} from './state/hosts/selectors'; import {convertStatsToPanels} from './stats'; import {PluginRegistry, Store} from './types/mattermost-webapp'; import { @@ -146,7 +147,6 @@ import { getCallsClient, getChannelURL, getTranslations, - getUserIDsFromSessions, isCallsPopOut, playSound, sendDesktopEvent, @@ -293,7 +293,7 @@ export default class Plugin { window.e2eCallStateLoaded = (channelID?: string) => { const state = store.getState(); const cid = channelID || channelIDForCurrentCall(state); - return Boolean(cid && hostIDForCallInChannel(state, cid)); + return Boolean(cid && getHostID(state, cid)); }; const theme = getTheme(store.getState()); diff --git a/webapp/src/reducers.ts b/webapp/src/reducers.ts index 04c15777a..7e9ae458f 100644 --- a/webapp/src/reducers.ts +++ b/webapp/src/reducers.ts @@ -12,6 +12,7 @@ import { CALL_ENDED, UN_INITIALIZED, } from 'src/state/common_action_types'; +import {reducer as hosts} from 'src/state/hosts/reducer'; import {reducer as screenSharingIDs} from 'src/state/screen_sharing_ids/reducer'; import {USER_JOINED, USER_LEFT, USER_REACTED, USER_REACTED_TIMEOUT} from 'src/state/sessions/action_types'; import {reducer as sessions} from 'src/state/sessions/reducer'; @@ -28,7 +29,6 @@ import { import { ADD_INCOMING_CALL, - CALL_HOST, CALL_LIVE_CAPTIONS_STATE, CALL_REC_PROMPT_DISMISSED, CALL_RECORDING_STATE, @@ -345,56 +345,6 @@ const callLiveCaptionsState = (state: callsJobState = {}, action: jobStateAction } }; -// callState should only hold immutable data, meaning those -// fields that don't change for the whole duration of a call. -export type callState = { - callID: string; - startAt: number; - channelID: string; - threadID: string; - ownerID: string; -} - -export type hostsState = { - [channelID: string]: { - hostID: string; - hostChangeAt?: number; - }; -} - -type hostsStateAction = { - type: string; - data: { - channelID: string; - hostID: string; - hostChangeAt: number; - }; -} - -const hosts = (state: hostsState = {}, action: hostsStateAction) => { - switch (action.type) { - case UN_INITIALIZED: { - return {}; - } - case CALL_HOST: { - return { - ...state, - [action.data.channelID]: { - hostID: action.data.hostID, - hostChangeAt: action.data.hostChangeAt, - }, - }; - } - case CALL_ENDED: { - const nextState = {...state}; - delete nextState[action.data.channelID]; - return nextState; - } - default: - return state; - } -}; - const expandedView = (state = false, action: { type: string }) => { switch (action.type) { case UN_INITIALIZED: diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index f1e8e9478..9e7d22297 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -29,9 +29,7 @@ import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {createSelector} from 'reselect'; import { callsJobState, - callState, hostControlNoticeState, - hostsState, liveCaptionState, recentlyJoinedUsersState, usersReactionsState, @@ -46,7 +44,9 @@ import { } from 'src/types/types'; import {getCallsClientChannelID, getCallsClientInitTime, getCallsClientSessionID, getChannelURL} from 'src/utils'; -const activeCallsIngetPluginStore = (state: GlobalState): { [channelID: string]: callState } => +import {ActiveCalls} from './state/active_calls/reducer'; + +const activeCallsIngetPluginStore = (state: GlobalState) => getPluginStore(state).activeCalls; export const channelIDForCurrentCall: (state: GlobalState) => string = @@ -241,7 +241,7 @@ export const callStartAtForCurrentCall: (state: GlobalState) => number = (callsStates, channelID, initTime) => callsStates[channelID]?.startAt || initTime || 0, ); -export const callInCurrentChannel: (state: GlobalState) => callState | undefined = +export const callInCurrentChannel: (state: GlobalState) => ActiveCalls[Channel['id']] | undefined = createSelector( 'callInCurrentChannel', activeCallsIngetPluginStore, @@ -257,30 +257,6 @@ export const callOwnerIDForCallInChannel = (state: GlobalState, channelID: strin return getPluginStore(state).activeCalls[channelID]?.ownerID; }; -const hostsInCalls = (state: GlobalState): hostsState => { - return getPluginStore(state).hosts; -}; - -export const hostIDForCallInChannel = (state: GlobalState, channelID: string): string | undefined => { - return hostsInCalls(state)[channelID]?.hostID; -}; - -export const hostIDForCurrentCall: (state: GlobalState) => string = - createSelector( - 'hostIDForCurrentCall', - hostsInCalls, - channelIDForCurrentCall, - (hosts, channelID) => hosts[channelID]?.hostID || '', - ); - -export const hostChangeAtForCurrentCall: (state: GlobalState) => number = - createSelector( - 'hostChangeAtForCurrentCall', - hostsInCalls, - channelIDForCurrentCall, - (hosts, channelID) => hosts[channelID]?.hostChangeAt || 0, - ); - export const callDismissedNotification = (state: GlobalState, channelID: string) => { return Boolean(getPluginStore(state).dismissedCalls[channelID]); }; diff --git a/webapp/src/slash_commands.tsx b/webapp/src/slash_commands.tsx index 7706145a5..ffb58170d 100644 --- a/webapp/src/slash_commands.tsx +++ b/webapp/src/slash_commands.tsx @@ -28,10 +28,9 @@ import { areGroupCallsAllowed, channelHasCall, channelIDForCurrentCall, - hostIDForCallInChannel, - hostIDForCurrentCall, isRecordingInCurrentCall, } from './selectors'; +import {getHostID, getHostIDForCurrentChannel} from './state/hosts/selectors'; import {Store} from './types/mattermost-webapp'; import {getCallsClient, getCallsWindow, getPersistentStorage, getPluginPath, isDMChannel, sendDesktopEvent, shouldRenderDesktopWidget} from './utils'; @@ -144,7 +143,7 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC } if (!isCurrentUserSystemAdmin(store.getState()) && - getCurrentUserId(store.getState()) !== hostIDForCallInChannel(store.getState(), args.channel_id)) { + getCurrentUserId(store.getState()) !== getHostID(store.getState(), args.channel_id)) { store.dispatch(displayGenericErrorModal( defineMessage({defaultMessage: 'Unable to end the call'}), defineMessage({defaultMessage: 'You don\'t have permission to end the call. Please ask the call owner to end call.'}), @@ -217,7 +216,7 @@ export default async function slashCommandsHandler(store: Store, joinCall: joinC } const state = store.getState(); - const isHost = hostIDForCurrentCall(state) === getCurrentUserId(state); + const isHost = getHostIDForCurrentChannel(state) === getCurrentUserId(state); if (fields[2] === 'start') { if (!isHost) { diff --git a/webapp/src/state/hosts/action_types.ts b/webapp/src/state/hosts/action_types.ts new file mode 100644 index 000000000..dd37c9177 --- /dev/null +++ b/webapp/src/state/hosts/action_types.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {pluginId} from 'src/manifest'; + +export const HOST_CHANGED = `${pluginId}_host_changed` as const; diff --git a/webapp/src/state/hosts/actions.ts b/webapp/src/state/hosts/actions.ts new file mode 100644 index 000000000..4f8ce4da2 --- /dev/null +++ b/webapp/src/state/hosts/actions.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {type Channel} from '@mattermost/types/channels'; +import {type UserProfile} from '@mattermost/types/users'; +import {ActionCallEnded, ActionUnInitialized} from 'src/state/common_actions'; + +import {HOST_CHANGED} from './action_types'; + +export const hostChanged = (channelID: Channel['id'], hostID: UserProfile['id'], hostChangeAt: number) => { + return { + type: HOST_CHANGED, + data: { + channelID, + hostID, + hostChangeAt, + }, + }; +}; +export type HostChangedAction = ReturnType; + +export type Actions = +| ActionUnInitialized +| HostChangedAction +| ActionCallEnded; diff --git a/webapp/src/state/hosts/reducer.ts b/webapp/src/state/hosts/reducer.ts new file mode 100644 index 000000000..31737d2e4 --- /dev/null +++ b/webapp/src/state/hosts/reducer.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {UserProfile} from '@mattermost/types/users'; +import {Reducer} from 'redux'; +import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; + +import {HOST_CHANGED} from './action_types'; +import {Actions} from './actions'; + +type Hosts = { + [channelID: Channel['id']]: { + hostID: UserProfile['id']; + hostChangeAt: number; + } +} + +const emptyState: Hosts = {}; + +export const reducer: Reducer = (initialState = emptyState, action) => { + switch (action.type) { + case UN_INITIALIZED: { + return emptyState; + } + + case HOST_CHANGED: { + return { + ...initialState, + [action.data.channelID]: { + hostID: action.data.hostID, + hostChangeAt: action.data.hostChangeAt, + }, + }; + } + + case CALL_ENDED: { + const nextState = {...initialState}; + delete nextState[action.data.channelID]; + return nextState; + } + + default: + return initialState; + } +}; \ No newline at end of file diff --git a/webapp/src/state/hosts/selectors.ts b/webapp/src/state/hosts/selectors.ts new file mode 100644 index 000000000..e25ae4ac0 --- /dev/null +++ b/webapp/src/state/hosts/selectors.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Channel} from '@mattermost/types/channels'; +import {GlobalState} from '@mattermost/types/store'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; +import {getPluginStore} from 'src/state/common_selectors'; + +export const getHostID = (state: GlobalState, channelID: Channel['id']) => { + return getPluginStore(state).hosts[channelID]?.hostID; +}; + +export const getHostChangeAt = (state: GlobalState, channelID: Channel['id']) => { + return getPluginStore(state).hosts[channelID]?.hostChangeAt; +}; + +export const getHostForCurrentCall = (state: GlobalState) => { + const currentChannelID = getCurrentChannelId(state); + return getPluginStore(state).hosts[currentChannelID]; +}; + +export const getHostIDForCurrentChannel = (state: GlobalState) => { + return getHostForCurrentCall(state)?.hostID; +}; + +export const getHostChangeAtForCurrentChannel = (state: GlobalState) => { + return getHostForCurrentCall(state)?.hostChangeAt; +}; diff --git a/webapp/src/state/sessions/selectors.ts b/webapp/src/state/sessions/selectors.ts index e888d7581..790e9bd33 100644 --- a/webapp/src/state/sessions/selectors.ts +++ b/webapp/src/state/sessions/selectors.ts @@ -1,3 +1,18 @@ // Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {type SessionState} from '@mattermost/calls-common/lib/types/types'; +import {type UserProfile} from '@mattermost/types/users'; + +/** + * Retrieves a deduplicated list of user IDs from call sessions. + * Since a user can have multiple sessions (e.g., desktop and mobile), duplicate user IDs are removed. + */ +export function getUserIDsFromSessions(sessions: SessionState[]): Array { + const userIDsSet = new Set(); + for (const session of sessions) { + userIDsSet.add(session.user_id); + } + + return Array.from(userIDsSet); +} \ No newline at end of file diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index 140afeb11..ff0775a97 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -332,19 +332,6 @@ export async function getProfilesByIds(state: GlobalState, ids: string[]): Promi return profiles; } -/** - * Retrieves a deduplicated list of user IDs from call sessions. - * Since a user can have multiple sessions (e.g., desktop and mobile), duplicate user IDs are removed. - */ -export function getUserIDsFromSessions(sessions: SessionState[]): Array { - const userIDsSet = new Set(); - for (const session of sessions) { - userIDsSet.add(session.user_id); - } - - return Array.from(userIDsSet); -} - export function getUserIdFromDM(dmName: string, currentUserId: string) { const ids = dmName.split('__'); let otherUserId = ''; diff --git a/webapp/src/websocket_handlers.ts b/webapp/src/websocket_handlers.ts index 5162fb07c..178b901f6 100644 --- a/webapp/src/websocket_handlers.ts +++ b/webapp/src/websocket_handlers.ts @@ -48,6 +48,7 @@ import { LIVE_CAPTION_TIMEOUT, REACTION_TIMEOUT_IN_REACTION_STREAM, } from 'src/constants'; +import {hostChanged} from 'src/state/hosts/actions'; import {userScreenShared, userScreenUnshared} from 'src/state/screen_sharing_ids/actions'; import {userLoweredHand, userMuted, userRaisedHand, userReacted, userReactedTimeout, userUnmuted} from 'src/state/sessions/actions'; import { @@ -56,7 +57,6 @@ import { } from 'src/types/types'; import { - CALL_HOST, CALL_LIVE_CAPTIONS_STATE, CALL_RECORDING_STATE, DISMISS_CALL, @@ -125,14 +125,8 @@ export function handleCallStart(store: Store, ev: WebSocketMessage) { const channelID = ev.data.channelID || ev.broadcast.channel_id; - store.dispatch({ - type: CALL_HOST, - data: { - channelID, - hostID: ev.data.hostID, - hostChangeAt: Date.now(), - }, - }); + store.dispatch(hostChanged(channelID, ev.data.hostID, Date.now())); const hostProfile = profilesInCurrentCallMap(store.getState())[ev.data.hostID] || getUser(store.getState(), ev.data.hostID); From d97d3219b1889c04c339bd613f3801e5a7db8d28 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 06:31:41 +0530 Subject: [PATCH 13/18] Remove unused import from utils.ts to streamline code and improve clarity. --- webapp/src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/utils.ts b/webapp/src/utils.ts index ff0775a97..2cbb13a0f 100644 --- a/webapp/src/utils.ts +++ b/webapp/src/utils.ts @@ -4,7 +4,7 @@ /* eslint-disable max-lines */ import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls-common'; -import {CallJobMetadata, CallPostProps, CallRecordingPostProps, SessionState, UserSessionState} from '@mattermost/calls-common/lib/types'; +import {CallJobMetadata, CallPostProps, CallRecordingPostProps, UserSessionState} from '@mattermost/calls-common/lib/types'; import {Channel} from '@mattermost/types/channels'; import {ClientConfig} from '@mattermost/types/config'; import {Post} from '@mattermost/types/posts'; From ed0fa2289268f2a77d02ac5bf5c7ffe39216bc20 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 07:04:52 +0530 Subject: [PATCH 14/18] Refactor host management actions and selectors for improved clarity and functionality. Introduced new actions for participant management, including muting and removing participants, while updating selectors to utilize the new ActiveCall type. Removed deprecated host actions to streamline the codebase. --- webapp/src/actions.ts | 71 +-------------- .../src/components/call_widget/component.tsx | 4 +- .../call_widget/participants_list.tsx | 10 ++- .../components/expanded_view/component.tsx | 23 ++--- webapp/src/components/host_controls_menu.tsx | 67 +++++++------- webapp/src/selectors.ts | 5 +- webapp/src/state/active_calls/actions.ts | 4 +- webapp/src/state/active_calls/reducer.ts | 18 ++-- webapp/src/state/hosts/actions.ts | 90 ++++++++++++++++++- 9 files changed, 166 insertions(+), 126 deletions(-) diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 9428da970..2fb4eb80f 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -655,6 +655,9 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: }), ); + const hostChangeAt = getHostChangeAt(getState(), channelID) ?? call.start_at; + actions.push(hostChanged(channelID, call.host_id, hostChangeAt)); + actions.push({ type: CALL_RECORDING_STATE, data: { @@ -678,9 +681,6 @@ export const loadCallState = (channelID: string, call: CallState) => (dispatch: } } - const hostChangeAt = getHostChangeAt(getState(), channelID) ?? call.start_at; - actions.push(hostChanged(channelID, call.host_id, hostChangeAt)); - const dismissed = call.dismissed_notification; if (dismissed) { const currentUserID = getCurrentUserId(getState()); @@ -712,71 +712,6 @@ export const setClientConnecting = (value: boolean) => (dispatch: Dispatch) => { }); }; -export const hostMake = async (callID: string, newHostID: string) => { - return RestClient.fetch( - `${getPluginPath()}/calls/${callID}/host/make`, - { - method: 'post', - body: JSON.stringify({new_host_id: newHostID}), - }, - ); -}; - -export const hostMute = async (callID: string, sessionID: string) => { - return RestClient.fetch( - `${getPluginPath()}/calls/${callID}/host/mute`, - { - method: 'post', - body: JSON.stringify({session_id: sessionID}), - }, - ); -}; - -export const hostScreenOff = async (callID: string, sessionID: string) => { - return RestClient.fetch( - `${getPluginPath()}/calls/${callID}/host/screen-off`, - { - method: 'post', - body: JSON.stringify({session_id: sessionID}), - }, - ); -}; - -export const hostLowerHand = async (callID: string, sessionID: string) => { - return RestClient.fetch( - `${getPluginPath()}/calls/${callID}/host/lower-hand`, - { - method: 'post', - body: JSON.stringify({session_id: sessionID}), - }, - ); -}; - -export const hostRemove = async (callID?: string, sessionID?: string) => { - if (!callID || !sessionID) { - return {}; - } - - return RestClient.fetch( - `${getPluginPath()}/calls/${callID}/host/remove`, - { - method: 'post', - body: JSON.stringify({session_id: sessionID}), - }, - ); -}; - -export const hostMuteOthers = async (callID?: string) => { - if (!callID) { - return {}; - } - - return RestClient.fetch( - `${getPluginPath()}/calls/${callID}/host/mute-others`, - {method: 'post'}, - ); -}; - export const getCallsStats = async () => { return RestClient.fetch(`${getPluginPath()}/stats`, {method: 'get'}); }; diff --git a/webapp/src/components/call_widget/component.tsx b/webapp/src/components/call_widget/component.tsx index cb8088168..a37d177d8 100644 --- a/webapp/src/components/call_widget/component.tsx +++ b/webapp/src/components/call_widget/component.tsx @@ -14,7 +14,6 @@ import {Client4} from 'mattermost-redux/client'; import React, {CSSProperties, useEffect, useState} from 'react'; import {FormattedMessage, IntlShape} from 'react-intl'; import {compareSemVer} from 'semver-parser'; -import {hostRemove} from 'src/actions'; import {navigateToURL} from 'src/browser_routing'; import {CALL_EVENT, CONNECTION_QUALITY} from 'src/clients/call'; import {VideoInputPermissionsError} from 'src/clients/calls'; @@ -68,6 +67,7 @@ import { reverseKeyMappings, SHARE_UNSHARE_SCREEN, } from 'src/shortcuts'; +import {hostRemoveParticipant} from 'src/state/hosts/actions'; import {ModalData} from 'src/types/mattermost-webapp'; import { CallAlertStates, @@ -999,7 +999,7 @@ export default class CallWidget extends React.PureComponent { onRemoveConfirm = () => { logDebug(`CallWidget.onRemoveConfirm: host removing session ${this.state.removeConfirmation?.sessionID}`); - hostRemove(this.props.channel?.id, this.state.removeConfirmation?.sessionID); + hostRemoveParticipant(this.props.channel?.id, this.state.removeConfirmation?.sessionID); this.setState({ removeConfirmation: null, }); diff --git a/webapp/src/components/call_widget/participants_list.tsx b/webapp/src/components/call_widget/participants_list.tsx index 871dd9f9e..f561a60ca 100644 --- a/webapp/src/components/call_widget/participants_list.tsx +++ b/webapp/src/components/call_widget/participants_list.tsx @@ -8,10 +8,11 @@ import {UserProfile} from '@mattermost/types/users'; import {IDMappedObjects} from '@mattermost/types/utilities'; import React from 'react'; import {useIntl} from 'react-intl'; -import {hostMuteOthers} from 'src/actions'; import {Participant} from 'src/components/call_widget/participant'; import {useHostControls} from 'src/components/expanded_view/hooks'; import MutedIcon from 'src/components/icons/muted_icon'; +import {logDebug} from 'src/log'; +import {hostMuteAllParticipants} from 'src/state/hosts/actions'; import styled from 'styled-components'; type Props = { @@ -54,6 +55,11 @@ export const ParticipantsList = ({ )); }; + function handleMuteOthers() { + logDebug('ParticipantsList: mute others'); + hostMuteAllParticipants(callID); + } + return (

{formatMessage({defaultMessage: 'Participants'})} {showMuteOthers && - hostMuteOthers(callID)}> + { } }; + handleMuteOthers = () => { + if (!this.props.channel) { + logErr('ExpandedView: host muting other failed, channel should be defined'); + return; + } + + logDebug('ExpandedView: host muting others'); + hostMuteAllParticipants(this.props.channel.id); + }; + handleKBShortcuts = (ev: KeyboardEvent) => { if ((!this.props.show || !window.callsClient) && !window.opener) { return; @@ -890,7 +900,7 @@ export default class ExpandedView extends React.PureComponent { onRemoveConfirm = () => { logDebug(`ExpandedView.onRemoveConfirm: host removing session ${this.state.removeConfirmation?.sessionID}`); - hostRemove(this.props.channel?.id, this.state.removeConfirmation?.sessionID); + hostRemoveParticipant(this.props.channel?.id, this.state.removeConfirmation?.sessionID); this.setState({ removeConfirmation: null, }); @@ -1540,14 +1550,7 @@ export default class ExpandedView extends React.PureComponent { {showMuteOthers && { - if (!this.props.channel) { - logErr('channel should be defined'); - return; - } - logDebug('ExpandedView: host muting all other participants'); - hostMuteOthers(this.props.channel.id); - }} + onClick={this.handleMuteOthers} > { - logDebug(`HostControlsMenu: mute participant ${sessionID}`); - hostMute(callID, sessionID); - }} - > - - {formatMessage({defaultMessage: 'Mute participant'})} - - ); + function handlehostMuteParticipant() { + logDebug(`HostControlsMenu: mute participant ${sessionID}`); + hostMuteParticipant(callID, sessionID); + } + + function handlehostMakeParticipantHost() { + logDebug(`HostControlsMenu: make host ${userID}`); + hostMakeParticipantHost(callID, userID); + } + + function handleStopParticipantScreenShare() { + logDebug(`HostControlsMenu: stop screen share for ${sessionID}`); + hostSwitchParticipantScreenOff(callID, sessionID); + } + + function handleLowerParticipantHand() { + logDebug(`HostControlsMenu: lower hand for ${sessionID}`); + hostLowerParticipantHand(callID, sessionID); + } const showingAtLeastOne = !isMuted || isSharingScreen || isHandRaised || !isHost; return ( <> - {muteUnmute} + {!isMuted && ( + + + {formatMessage({defaultMessage: 'Mute participant'})} + + )} {isSharingScreen && { - logDebug(`HostControlsMenu: stop screen share for ${sessionID}`); - hostScreenOff(callID, sessionID); - }} + onClick={handleStopParticipantScreenShare} > { - logDebug(`HostControlsMenu: lower hand for ${sessionID}`); - hostLowerHand(callID, sessionID); - }} + onClick={handleLowerParticipantHand} > { - logDebug(`HostControlsMenu: make host ${userID}`); - hostMake(callID, userID); - }} + onClick={handlehostMakeParticipantHost} > getPluginStore(state).activeCalls; @@ -241,7 +240,7 @@ export const callStartAtForCurrentCall: (state: GlobalState) => number = (callsStates, channelID, initTime) => callsStates[channelID]?.startAt || initTime || 0, ); -export const callInCurrentChannel: (state: GlobalState) => ActiveCalls[Channel['id']] | undefined = +export const callInCurrentChannel: (state: GlobalState) => ActiveCall | undefined = createSelector( 'callInCurrentChannel', activeCallsIngetPluginStore, diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts index 35ea43a4c..63f9253d8 100644 --- a/webapp/src/state/active_calls/actions.ts +++ b/webapp/src/state/active_calls/actions.ts @@ -5,9 +5,9 @@ import {type Channel} from '@mattermost/types/channels'; import {type ActionCallEnded, type ActionUnInitialized} from '../common_actions'; import {ACTIVE_CALL_ADDED} from './action_types'; -import {type ActiveCalls} from './reducer'; +import {type ActiveCall} from './reducer'; -export const activeCallAdded = (channelID: Channel['id'], activeCall: Omit) => ({ +export const activeCallAdded = (channelID: Channel['id'], activeCall: Omit) => ({ type: ACTIVE_CALL_ADDED, data: { callID: activeCall.callID, diff --git a/webapp/src/state/active_calls/reducer.ts b/webapp/src/state/active_calls/reducer.ts index 8aad707a7..025d90e4d 100644 --- a/webapp/src/state/active_calls/reducer.ts +++ b/webapp/src/state/active_calls/reducer.ts @@ -10,19 +10,21 @@ import {CALL_ENDED, UN_INITIALIZED} from 'src/state/common_action_types'; import {ACTIVE_CALL_ADDED} from './action_types'; import {type Actions} from './actions'; +export type ActiveCall = { + callID: string; + startAt: number; + channelID: Channel['id']; + threadID: UserThread['id']; + ownerID: UserProfile['id']; +} + export type ActiveCalls = { - [channelID: string]: { - callID: string; - startAt: number; - channelID: Channel['id']; - threadID: UserThread['id']; - ownerID: UserProfile['id']; - }; + [channelID: string]: ActiveCall; } const emptyState: ActiveCalls = {}; -export const reducer: Reducer = (initialState = emptyState, action) : ActiveCalls => { +export const reducer: Reducer = (initialState = emptyState, action) => { switch (action.type) { case UN_INITIALIZED:{ return emptyState; diff --git a/webapp/src/state/hosts/actions.ts b/webapp/src/state/hosts/actions.ts index 4f8ce4da2..db87e38ba 100644 --- a/webapp/src/state/hosts/actions.ts +++ b/webapp/src/state/hosts/actions.ts @@ -2,8 +2,13 @@ // See LICENSE.txt for license information. import {type Channel} from '@mattermost/types/channels'; +import {Session} from '@mattermost/types/sessions'; import {type UserProfile} from '@mattermost/types/users'; -import {ActionCallEnded, ActionUnInitialized} from 'src/state/common_actions'; +import RestClient from 'src/clients/rest'; +import {logErr} from 'src/log'; +import {type ActiveCall} from 'src/state/active_calls/reducer'; +import {type ActionCallEnded, type ActionUnInitialized} from 'src/state/common_actions'; +import {getPluginPath} from 'src/utils'; import {HOST_CHANGED} from './action_types'; @@ -19,6 +24,89 @@ export const hostChanged = (channelID: Channel['id'], hostID: UserProfile['id'], }; export type HostChangedAction = ReturnType; +export const hostMakeParticipantHost = async (callID: ActiveCall['callID'], newHostID: UserProfile['id']) => { + try { + await RestClient.fetch(`${getPluginPath()}/calls/${callID}/host/make`, + { + method: 'post', + body: JSON.stringify({new_host_id: newHostID}), + }, + ); + } catch (error) { + logErr(error); + } +}; + +export const hostMuteParticipant = async (callID: ActiveCall['callID'], sessionID: Session['id']) => { + try { + await RestClient.fetch(`${getPluginPath()}/calls/${callID}/host/mute`, + { + method: 'post', + body: JSON.stringify({session_id: sessionID}), + }, + ); + } catch (error) { + logErr(error); + } +}; + +export const hostSwitchParticipantScreenOff = async (callID: ActiveCall['callID'], sessionID: Session['id']) => { + try { + await RestClient.fetch(`${getPluginPath()}/calls/${callID}/host/screen-off`, + { + method: 'post', + body: JSON.stringify({session_id: sessionID}), + }, + ); + } catch (error) { + logErr(error); + } +}; + +export const hostLowerParticipantHand = async (callID: ActiveCall['callID'], sessionID: Session['id']) => { + try { + await RestClient.fetch(`${getPluginPath()}/calls/${callID}/host/lower-hand`, + { + method: 'post', + body: JSON.stringify({session_id: sessionID}), + }, + ); + } catch (error) { + logErr(error); + } +}; + +export const hostRemoveParticipant = async (callID?: ActiveCall['callID'], sessionID?: Session['id']) => { + try { + if (!callID || !sessionID) { + return {}; + } + + await RestClient.fetch(`${getPluginPath()}/calls/${callID}/host/remove`, + { + method: 'post', + body: JSON.stringify({session_id: sessionID}), + }, + ); + } catch (error) { + logErr(error); + } +}; + +export const hostMuteAllParticipants = async (callID?: ActiveCall['callID']) => { + if (!callID) { + return {}; + } + + try { + await RestClient.fetch(`${getPluginPath()}/calls/${callID}/host/mute-others`, + {method: 'post'}, + ); + } catch (error) { + logErr(error); + } +}; + export type Actions = | ActionUnInitialized | HostChangedAction From 0703d818cc6ad23dc955c3653ba377b9df928bc6 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 07:09:08 +0530 Subject: [PATCH 15/18] Update call component props to use ActiveCall type for callID. Refactor selectors and components to improve type safety and maintainability. --- webapp/src/components/call_widget/participant.tsx | 3 ++- webapp/src/components/call_widget/participants_list.tsx | 3 ++- webapp/src/components/expanded_view/call_participant.tsx | 3 ++- webapp/src/components/expanded_view/call_participant_rhs.tsx | 3 ++- webapp/src/components/expanded_view/component.tsx | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/webapp/src/components/call_widget/participant.tsx b/webapp/src/components/call_widget/participant.tsx index dd07c364f..e9eee97eb 100644 --- a/webapp/src/components/call_widget/participant.tsx +++ b/webapp/src/components/call_widget/participant.tsx @@ -19,6 +19,7 @@ import ScreenIcon from 'src/components/icons/screen_icon'; import {ThreeDotsButton} from 'src/components/icons/three_dots'; import UnmutedIcon from 'src/components/icons/unmuted_icon'; import VideoOnIcon from 'src/components/icons/video_on'; +import {type ActiveCall} from 'src/state/active_calls/reducer'; import {getUserDisplayName} from 'src/utils'; import styled, {css} from 'styled-components'; @@ -30,7 +31,7 @@ type Props = { iAmHost: boolean, isSharingScreen: boolean; onRemove: () => void; - callID?: string; + callID: ActiveCall['callID']; }; export const Participant = ({session, profile, isYou, isHost, iAmHost, isSharingScreen, onRemove, callID}: Props) => { diff --git a/webapp/src/components/call_widget/participants_list.tsx b/webapp/src/components/call_widget/participants_list.tsx index f561a60ca..b84f7816f 100644 --- a/webapp/src/components/call_widget/participants_list.tsx +++ b/webapp/src/components/call_widget/participants_list.tsx @@ -12,6 +12,7 @@ import {Participant} from 'src/components/call_widget/participant'; import {useHostControls} from 'src/components/expanded_view/hooks'; import MutedIcon from 'src/components/icons/muted_icon'; import {logDebug} from 'src/log'; +import {type ActiveCall} from 'src/state/active_calls/reducer'; import {hostMuteAllParticipants} from 'src/state/hosts/actions'; import styled from 'styled-components'; @@ -22,7 +23,7 @@ type Props = { onRemove: (sessionID: string, userID: string) => void; currentSession?: UserSessionState; screenSharingSession?: UserSessionState; - callID?: string; + callID: ActiveCall['callID']; }; export const ParticipantsList = ({ diff --git a/webapp/src/components/expanded_view/call_participant.tsx b/webapp/src/components/expanded_view/call_participant.tsx index 46374bb01..aae8db48f 100644 --- a/webapp/src/components/expanded_view/call_participant.tsx +++ b/webapp/src/components/expanded_view/call_participant.tsx @@ -15,6 +15,7 @@ import HandEmoji from 'src/components/icons/hand'; import MutedIcon from 'src/components/icons/muted_icon'; import {ThreeDotsButton} from 'src/components/icons/three_dots'; import UnmutedIcon from 'src/components/icons/unmuted_icon'; +import {type ActiveCall} from 'src/state/active_calls/reducer'; import styled, {css} from 'styled-components'; export enum TileSize { @@ -35,7 +36,7 @@ export type Props = { isYou: boolean, isHost: boolean, iAmHost: boolean, - callID?: string, + callID: ActiveCall['callID'], userID: string, sessionID: string, onRemove: () => void, diff --git a/webapp/src/components/expanded_view/call_participant_rhs.tsx b/webapp/src/components/expanded_view/call_participant_rhs.tsx index 3b305c73e..d6b172352 100644 --- a/webapp/src/components/expanded_view/call_participant_rhs.tsx +++ b/webapp/src/components/expanded_view/call_participant_rhs.tsx @@ -19,6 +19,7 @@ import ScreenIcon from 'src/components/icons/screen_icon'; import {ThreeDotsButton} from 'src/components/icons/three_dots'; import UnmutedIcon from 'src/components/icons/unmuted_icon'; import VideoOnIcon from 'src/components/icons/video_on'; +import {type ActiveCall} from 'src/state/active_calls/reducer'; import {getUserDisplayName} from 'src/utils'; import styled, {css} from 'styled-components'; @@ -30,7 +31,7 @@ type Props = { iAmHost: boolean, isSharingScreen: boolean; onRemove: () => void; - callID?: string; + callID: ActiveCall['callID']; }; const CallParticipantRHS = ({session, profile, isYou, isHost, iAmHost, isSharingScreen, onRemove, callID}: Props) => { diff --git a/webapp/src/components/expanded_view/component.tsx b/webapp/src/components/expanded_view/component.tsx index 46fd14283..8e68acda9 100644 --- a/webapp/src/components/expanded_view/component.tsx +++ b/webapp/src/components/expanded_view/component.tsx @@ -1164,7 +1164,7 @@ export default class ExpandedView extends React.PureComponent { isHost={this.props.callHostID === session.user_id} isSharingScreen={this.props.screenSharingSession?.session_id === session.session_id} iAmHost={this.props.currentSession?.user_id === this.props.callHostID} - callID={this.props.channel?.id} + callID={this.props.channel?.id ?? ''} onRemove={() => this.onRemove(session.session_id, session.user_id)} /> )); From 51b15c9848323047b77629a8ef2315d7e5cc888d Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Thu, 25 Jun 2026 16:33:54 +0530 Subject: [PATCH 16/18] Refactor Jest configuration to use new i18n mock file and remove outdated setup file. Updated paths for improved clarity and maintainability in test setup. --- build/sync/plan.yml | 1 - webapp/jest.config.js | 2 +- webapp/{tests/i18n_mock.json => src/i18n.mock.json} | 0 webapp/tests/setup.js | 3 --- 4 files changed, 1 insertion(+), 5 deletions(-) rename webapp/{tests/i18n_mock.json => src/i18n.mock.json} (100%) delete mode 100644 webapp/tests/setup.js diff --git a/build/sync/plan.yml b/build/sync/plan.yml index c31bfb063..2833c9a21 100644 --- a/build/sync/plan.yml +++ b/build/sync/plan.yml @@ -35,7 +35,6 @@ actions: - webapp/tsconfig.json - webapp/webpack.config.js - webapp/src/manifest.test.tsx - - webapp/tests/setup.tsx actions: - type: overwrite_file params: diff --git a/webapp/jest.config.js b/webapp/jest.config.js index 80f61b1b9..7916e8829 100644 --- a/webapp/jest.config.js +++ b/webapp/jest.config.js @@ -21,7 +21,7 @@ const config = { moduleNameMapper: { '^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'identity-obj-proxy', '^.+\\.(css|less|scss)$': 'identity-obj-proxy', - '^.*i18n.*\\.(json)$': '/tests/i18n_mock.json', + '^.*i18n.*\\.(json)$': '/src/i18n.mock.json', '^bundle-loader\\?lazy\\!(.*)$': '$1', '^@mattermost/types/(.*)$': '/mattermost-webapp/webapp/platform/types/src/$1', '^@mattermost/client$': '/mattermost-webapp/webapp/platform/client/src/index', diff --git a/webapp/tests/i18n_mock.json b/webapp/src/i18n.mock.json similarity index 100% rename from webapp/tests/i18n_mock.json rename to webapp/src/i18n.mock.json diff --git a/webapp/tests/setup.js b/webapp/tests/setup.js deleted file mode 100644 index e888d7581..000000000 --- a/webapp/tests/setup.js +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - From fa449dc6eb9b0f5c5df72da59df263423467cb41 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Mon, 29 Jun 2026 14:02:15 +0530 Subject: [PATCH 17/18] Refactor call management by replacing deprecated `getCallActive` with `fetchIsCallActiveInChannel` for improved clarity and functionality. Update selectors and imports to enhance maintainability, including the addition of `hasPermissionToRenderCallsButtonInChannelHeader` for better permission handling in channel headers. --- standalone/src/index.ts | 7 +- .../components/recording_view/index.tsx | 4 +- standalone/src/recording/index.tsx | 2 +- webapp/src/actions.ts | 12 - webapp/src/index.tsx | 5 +- webapp/src/selectors.ts | 29 +-- webapp/src/state/README.md | 16 ++ webapp/src/state/active_calls/actions.test.ts | 33 +++ webapp/src/state/active_calls/actions.ts | 14 ++ webapp/src/state/active_calls/reducer.test.ts | 84 +++++++ .../state/calls_availability/actions.test.ts | 104 +++++++++ .../state/calls_availability/reducer.test.ts | 46 ++++ .../src/state/calls_availability/selectors.ts | 30 ++- webapp/src/state/common_actions.test.ts | 23 ++ webapp/src/state/hosts/actions.test.ts | 140 +++++++++++ webapp/src/state/hosts/reducer.test.ts | 67 ++++++ .../state/screen_sharing_ids/actions.test.ts | 31 +++ .../state/screen_sharing_ids/reducer.test.ts | 85 +++++++ webapp/src/state/sessions/actions.test.ts | 147 ++++++++++++ webapp/src/state/sessions/reducer.test.ts | 220 ++++++++++++++++++ 20 files changed, 1050 insertions(+), 49 deletions(-) create mode 100644 webapp/src/state/active_calls/actions.test.ts create mode 100644 webapp/src/state/active_calls/reducer.test.ts create mode 100644 webapp/src/state/calls_availability/actions.test.ts create mode 100644 webapp/src/state/calls_availability/reducer.test.ts create mode 100644 webapp/src/state/common_actions.test.ts create mode 100644 webapp/src/state/hosts/actions.test.ts create mode 100644 webapp/src/state/hosts/reducer.test.ts create mode 100644 webapp/src/state/screen_sharing_ids/actions.test.ts create mode 100644 webapp/src/state/screen_sharing_ids/reducer.test.ts create mode 100644 webapp/src/state/sessions/actions.test.ts create mode 100644 webapp/src/state/sessions/reducer.test.ts diff --git a/standalone/src/index.ts b/standalone/src/index.ts index c49598a87..9d8f84104 100644 --- a/standalone/src/index.ts +++ b/standalone/src/index.ts @@ -35,7 +35,7 @@ import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getTheme, Theme} from 'mattermost-redux/selectors/entities/preferences'; import configureStore from 'mattermost-redux/store'; -import {getCallActive, getCallsConfig, getCallsVersionInfo, localSessionClose, setClientConnecting} from 'plugin/actions'; +import {getCallsConfig, getCallsVersionInfo, localSessionClose, setClientConnecting} from 'plugin/actions'; import CallClient, {CALL_EVENT, ConnectPayload, DisconnectReason} from 'plugin/clients/call'; import RestClient from 'plugin/clients/rest'; import { @@ -44,6 +44,7 @@ import { } from 'plugin/log'; import {pluginId} from 'plugin/manifest'; import reducer from 'plugin/reducers'; +import {fetchIsCallActiveInChannel} from 'plugin/state/active_calls/actions'; import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'plugin/state/sessions/actions'; import {Store} from 'plugin/types/mattermost-webapp'; import { @@ -273,10 +274,10 @@ export default async function initialiseEmbedApp(cfg: InitConfig) { let active = false; try { - [, active] = await Promise.all([ + [, , active] = await Promise.all([ store.dispatch(getCallsConfig()), store.dispatch(getCallsVersionInfo()), - getCallActive(channelID), + fetchIsCallActiveInChannel(channelID), ]); } catch (e) { throw new Error(`failed to fetch channel data: ${e}`); diff --git a/standalone/src/recording/components/recording_view/index.tsx b/standalone/src/recording/components/recording_view/index.tsx index 99050a7da..d65f15309 100644 --- a/standalone/src/recording/components/recording_view/index.tsx +++ b/standalone/src/recording/components/recording_view/index.tsx @@ -16,11 +16,11 @@ import {ReactionStream} from 'src/components/reaction_stream/reaction_stream'; import Timestamp from 'src/components/timestamp'; import {callProfileImages} from 'src/recording/selectors'; import { - hostIDForCurrentCall, profilesInCurrentCallMap, screenSharingSessionForCurrentCall, sessionsInCurrentCall, } from 'src/selectors'; +import {getHostIDForCurrentChannel} from 'src/state/hosts/selectors'; const RecordingView = () => { const {formatMessage} = useIntl(); @@ -35,7 +35,7 @@ const RecordingView = () => { .sort(stateSortSessions(screenSharingSession?.session_id || '', true))); const profileImages = useSelector((state: GlobalState) => callProfileImages(state, callsClient?.channelID || '')); - const hostID = useSelector((state: GlobalState) => hostIDForCurrentCall(state)); + const hostID = useSelector(getHostIDForCurrentChannel); const attachVoiceTracks = (tracks: MediaStreamTrack[]) => { for (const track of tracks) { diff --git a/standalone/src/recording/index.tsx b/standalone/src/recording/index.tsx index bfbafd705..08e77ba4a 100644 --- a/standalone/src/recording/index.tsx +++ b/standalone/src/recording/index.tsx @@ -11,7 +11,6 @@ import {Store} from 'plugin/types/mattermost-webapp'; import { getPluginPath, getTranslations, - getUserIDsFromSessions, runWithRetry, setCallsGlobalCSSVars, } from 'plugin/utils'; @@ -22,6 +21,7 @@ import {Provider} from 'react-redux'; import RestClient from 'src/clients/rest'; import {getJobID} from 'src/common'; import recordingReducer from 'src/recording/reducers'; +import {getUserIDsFromSessions} from 'src/state/sessions/selectors'; import initialiseEmbedApp, {InitCbProps} from '../index'; import { diff --git a/webapp/src/actions.ts b/webapp/src/actions.ts index 2fb4eb80f..67d448811 100644 --- a/webapp/src/actions.ts +++ b/webapp/src/actions.ts @@ -154,18 +154,6 @@ export const getCallsVersionInfo = (): ActionFuncAsync => { }); }; -export const getCallActive = async (channelID: string) => { - try { - const res = await RestClient.fetch<{ active: boolean }>( - `${getPluginPath()}/calls/${channelID}/active`, - {method: 'get'}, - ); - return res.active; - } catch (e) { - return false; - } -}; - export const setRecordingsEnabled = (enabled: boolean) => (dispatch: Dispatch) => { dispatch({ type: RECORDINGS_ENABLED, diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index ec7aba6d8..42a571355 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -104,7 +104,7 @@ import {CALL_RECORDING_POST_TYPE, CALL_START_POST_TYPE, CALL_TRANSCRIPTION_POST_ import {desktopNotificationHandler} from 'src/desktop_notifications'; import slashCommandsHandler from 'src/slash_commands'; import {channelCallsAvailabilityUpdated, toggleCallsAvailabilityForChannel} from 'src/state/calls_availability/actions'; -import {callsAvailableInChannelWithDefault, callsNotAvailableInChannel} from 'src/state/calls_availability/selectors'; +import {callsAvailableInChannelWithDefault, callsNotAvailableInChannel, hasPermissionToRenderCallsButtonInChannelHeader} from 'src/state/calls_availability/selectors'; import {unInitialized} from 'src/state/common_actions'; import {userLoweredHand, userMuted, userRaisedHand, usersVoiceActivityChanged, userUnmuted} from 'src/state/sessions/actions'; import {getUserIDsFromSessions} from 'src/state/sessions/selectors'; @@ -133,7 +133,6 @@ import { callsConfig, channelHasCall, channelIDForCurrentCall, - hasPermissionsToEnableCalls, isCloudStarter, isLimitRestricted, sessionsInCurrentCall, @@ -839,7 +838,7 @@ export default class Plugin { const registerHeaderMenuComponentIfNeeded = async (channelID: string) => { try { registry.unregisterComponent(channelHeaderMenuID); - if (hasPermissionsToEnableCalls(store.getState(), channelID)) { + if (hasPermissionToRenderCallsButtonInChannelHeader(store.getState(), channelID)) { registerChannelHeaderMenuAction(); } } catch (err) { diff --git a/webapp/src/selectors.ts b/webapp/src/selectors.ts index 7fce971df..482fcf68b 100644 --- a/webapp/src/selectors.ts +++ b/webapp/src/selectors.ts @@ -6,18 +6,15 @@ import {Channel} from '@mattermost/types/channels'; import {GlobalState} from '@mattermost/types/store'; import {Team} from '@mattermost/types/teams'; import {UserProfile} from '@mattermost/types/users'; -import {getAllChannels, getChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; -import {getMyChannelMemberships} from 'mattermost-redux/selectors/entities/common'; +import {getAllChannels, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; -import {getMyChannelRoles, getMyTeamRoles} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentTeamId, getTeams} from 'mattermost-redux/selectors/entities/teams'; import { getCurrentUserId, getUserIdsInChannels, getUsers, getUserStatuses, - isCurrentUserSystemAdmin, } from 'mattermost-redux/selectors/entities/users'; import { getGroupDisplayNameFromUserIds, @@ -48,6 +45,7 @@ import {getCallsClientChannelID, getCallsClientInitTime, getCallsClientSessionID const activeCallsIngetPluginStore = (state: GlobalState) => getPluginStore(state).activeCalls; +// TODO: this should be a selector for the ActiveCall type moved to active_calls/selectors.ts export const channelIDForCurrentCall: (state: GlobalState) => string = createSelector( 'channelIDForCurrentCall', @@ -445,29 +443,6 @@ export const transcribeAPI = (state: GlobalState) => export const callsConfigEnvOverrides = (state: GlobalState): Record => getPluginStore(state).callsConfigEnvOverrides; -export const hasPermissionsToEnableCalls = (state: GlobalState, channelId: string): boolean => { - if (isCurrentUserSystemAdmin(state)) { - return true; - } - if (!defaultEnabled(state)) { - return false; - } - - const channelRoles = getMyChannelRoles(state); - const channel = getChannel(state, channelId); - if (!channel) { - return false; - } - - const teamRoles = getMyTeamRoles(state)[channel.team_id]; - const cm = getMyChannelMemberships(state)[channelId]; - - return (isDirectChannel(channel) || isGroupChannel(channel)) || - cm?.scheme_admin === true || - channelRoles[channel.id]?.has('channel_admin') || - teamRoles.has('team_admin'); -}; - // // Selectors for Cloud and beta limits: // diff --git a/webapp/src/state/README.md b/webapp/src/state/README.md index f82bc85dd..daeb90ee3 100644 --- a/webapp/src/state/README.md +++ b/webapp/src/state/README.md @@ -24,9 +24,25 @@ Each slice lives in its own folder: 1. Similar to Action types, convention for naming them is also past tense verbs but in camelCase eg. `userJoined`, `userMuted` etc. 1. Each slice exports a union of its action types named `Actions` from its `actions.ts`, used to type the reducer (`Reducer`). 1. Actions used by more than one slice stay in the root in `common_actions.ts`. +1. It is highly recommended to write tests for actions. +1. When writing tests for actions test the logic, not the shape. Spend the effort on testing actions with real behavior: defaulting and async/thunk actions where the request URL, method, payload, guard clauses, and error handling all matter. ## Reducers 1. Reducers are pure: derive the next state only from the current state and the action. No side effects, no reading other slices. 1. Read state through selectors rather than reaching into the store shape directly, so slice internals can change without breaking call sites. +1. It is highly recommended to write tests for reducers. +1. When writing tests for reducers test the highest-value tests in a slice. Cover every action type plus the edge branches: no-op cases that return the *same state reference, immutability, and any object-reuse optimizations. Drive the reducer through the real action creators or actions rather than hand-writing action objects with hardcoded `type` strings — this exercises the action + reducer together and survives renames. + +## Selectors + +1. Selectors read from the slice's state and derive the values components need; shared ones live in the root `common_selectors.ts`. +1. It is highly recommended to write tests for selectors. +1. When writing tests for selectors test the derivation logic, especially memoized selectors and any defaulting/fallback behavior. + +## Testing conventions + +1. Colocate tests next to the source as `*.test.ts` (e.g. `reducer.test.ts`, `actions.test.ts`, `selectors.test.ts`). +1. Use `test(...)`, not `it(...)`. +1. Try to keep a flat `describe` structure — one `describe` per function under test, with no nested `describe` blocks. In `actions.ts`/`selectors.ts` that means one `describe` per exported function (e.g. `describe('userScreenShared', ...)`). A reducer is a single function, so it gets one `describe` with each case named after its action type (e.g. `test('USER_LEFT clears the channel when the sharer leaves', ...)`). diff --git a/webapp/src/state/active_calls/actions.test.ts b/webapp/src/state/active_calls/actions.test.ts new file mode 100644 index 000000000..76b389570 --- /dev/null +++ b/webapp/src/state/active_calls/actions.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ACTIVE_CALL_ADDED} from './action_types'; +import {activeCallAdded} from './actions'; +import {type ActiveCall} from './reducer'; + +const call: Omit = { + callID: 'call1', + startAt: 100, + threadID: 'thread1', + ownerID: 'owner1', +}; + +describe('activeCallAdded', () => { + test('builds the ACTIVE_CALL_ADDED action with the channel id merged in', () => { + expect(activeCallAdded('channel1', call)).toEqual({ + type: ACTIVE_CALL_ADDED, + data: { + callID: 'call1', + startAt: 100, + channelID: 'channel1', + threadID: 'thread1', + ownerID: 'owner1', + }, + }); + }); + + test('uses the channel id argument, not any channel id on the call object', () => { + const action = activeCallAdded('channel1', {...call, channelID: 'other'} as Omit); + expect(action.data.channelID).toBe('channel1'); + }); +}); diff --git a/webapp/src/state/active_calls/actions.ts b/webapp/src/state/active_calls/actions.ts index 63f9253d8..bd06d7f5f 100644 --- a/webapp/src/state/active_calls/actions.ts +++ b/webapp/src/state/active_calls/actions.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import {type Channel} from '@mattermost/types/channels'; +import RestClient from 'src/clients/rest'; +import {getPluginPath} from 'src/utils'; import {type ActionCallEnded, type ActionUnInitialized} from '../common_actions'; import {ACTIVE_CALL_ADDED} from './action_types'; @@ -19,6 +21,18 @@ export const activeCallAdded = (channelID: Channel['id'], activeCall: Omit +export const fetchIsCallActiveInChannel = async (channelID: Channel['id']): Promise => { + try { + const data = await RestClient.fetch<{active: boolean}>(`${getPluginPath()}/calls/${channelID}/active`, { + method: 'get', + }); + + return data.active; + } catch (e) { + return false; + } +}; + export type Actions = | ActionUnInitialized | ActionCallEnded diff --git a/webapp/src/state/active_calls/reducer.test.ts b/webapp/src/state/active_calls/reducer.test.ts new file mode 100644 index 000000000..004449756 --- /dev/null +++ b/webapp/src/state/active_calls/reducer.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {callEnded, unInitialized} from 'src/state/common_actions'; + +import {activeCallAdded} from './actions'; +import {type ActiveCall, type ActiveCalls, reducer} from './reducer'; + +const callOne: Omit = { + callID: 'call1', + startAt: 100, + threadID: 'thread1', + ownerID: 'owner1', +}; +const callTwo: Omit = { + callID: 'call2', + startAt: 200, + threadID: 'thread2', + ownerID: 'owner2', +}; + +describe('active_calls reducer', () => { + test('returns the initial state for an unknown action', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + expect(reducer(state, {type: 'unhandled'} as never)).toBe(state); + }); + + test('defaults to an empty state', () => { + expect(reducer(undefined, {type: 'unhandled'} as never)).toEqual({}); + }); + + test('UN_INITIALIZED clears all calls', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + expect(reducer(state, unInitialized())).toEqual({}); + }); + + test('ACTIVE_CALL_ADDED adds a call keyed by channel id', () => { + expect(reducer({}, activeCallAdded('channel1', callOne))).toEqual({ + channel1: {channelID: 'channel1', ...callOne}, + }); + }); + + test('ACTIVE_CALL_ADDED keeps calls in other channels', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + expect(reducer(state, activeCallAdded('channel2', callTwo))).toEqual({ + channel1: {channelID: 'channel1', ...callOne}, + channel2: {channelID: 'channel2', ...callTwo}, + }); + }); + + test('ACTIVE_CALL_ADDED overwrites an existing call for the same channel', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + expect(reducer(state, activeCallAdded('channel1', callTwo))).toEqual({ + channel1: {channelID: 'channel1', ...callTwo}, + }); + }); + + test('ACTIVE_CALL_ADDED does not mutate the previous state', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + reducer(state, activeCallAdded('channel2', callTwo)); + expect(state).toEqual({channel1: {channelID: 'channel1', ...callOne}}); + }); + + test('CALL_ENDED removes the call for the channel and leaves others untouched', () => { + const state: ActiveCalls = { + channel1: {channelID: 'channel1', ...callOne}, + channel2: {channelID: 'channel2', ...callTwo}, + }; + expect(reducer(state, callEnded('channel1', 'call1'))).toEqual({ + channel2: {channelID: 'channel2', ...callTwo}, + }); + }); + + test('CALL_ENDED is a no-op for a channel without a call', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + expect(reducer(state, callEnded('channel2', 'call2'))).toEqual(state); + }); + + test('CALL_ENDED does not mutate the previous state', () => { + const state: ActiveCalls = {channel1: {channelID: 'channel1', ...callOne}}; + reducer(state, callEnded('channel1', 'call1')); + expect(state).toEqual({channel1: {channelID: 'channel1', ...callOne}}); + }); +}); diff --git a/webapp/src/state/calls_availability/actions.test.ts b/webapp/src/state/calls_availability/actions.test.ts new file mode 100644 index 000000000..991abbdee --- /dev/null +++ b/webapp/src/state/calls_availability/actions.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {type GlobalState} from '@mattermost/types/store'; +import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; +import RestClient from 'src/clients/rest'; +import {logErr} from 'src/log'; +import {getPluginPath} from 'src/utils'; + +import {CHANNEL_CALLS_AVAILABILITY_UPDATED} from './action_types'; +import {channelCallsAvailabilityUpdated, toggleCallsAvailabilityForChannel} from './actions'; +import {callsNotAvailableInChannel} from './selectors'; + +jest.mock('src/clients/rest', () => ({ + __esModule: true, + default: { + fetch: jest.fn(), + }, +})); + +jest.mock('src/log', () => ({ + logErr: jest.fn(), +})); + +jest.mock('mattermost-redux/selectors/entities/common', () => ({ + ...jest.requireActual('mattermost-redux/selectors/entities/common'), + getCurrentChannelId: jest.fn(), +})); + +jest.mock('./selectors', () => ({ + callsNotAvailableInChannel: jest.fn(), +})); + +const mockedFetch = RestClient.fetch as jest.Mock; +const mockedLogErr = logErr as jest.Mock; +const mockedGetCurrentChannelId = getCurrentChannelId as jest.Mock; +const mockedCallsNotAvailableInChannel = callsNotAvailableInChannel as jest.Mock; + +const dispatch = jest.fn(); +const getState = jest.fn(() => ({} as GlobalState)); + +beforeEach(() => { + mockedGetCurrentChannelId.mockReturnValue('channel1'); +}); + +describe('channelCallsAvailabilityUpdated', () => { + test('defaults enabled to true when omitted', () => { + expect(channelCallsAvailabilityUpdated('channel1')).toEqual({ + type: CHANNEL_CALLS_AVAILABILITY_UPDATED, + data: {channelID: 'channel1', enabled: true}, + }); + }); + + test('preserves an explicit false', () => { + expect(channelCallsAvailabilityUpdated('channel1', false)).toEqual({ + type: CHANNEL_CALLS_AVAILABILITY_UPDATED, + data: {channelID: 'channel1', enabled: false}, + }); + }); + + test('preserves an explicit true', () => { + expect(channelCallsAvailabilityUpdated('channel1', true).data.enabled).toBe(true); + }); +}); + +describe('toggleCallsAvailabilityForChannel', () => { + test('posts the negated current availability and dispatches the server response', async () => { + // Calls are currently unavailable, so toggling should request enabled: true. + mockedCallsNotAvailableInChannel.mockReturnValue(true); + mockedFetch.mockResolvedValue({enabled: true}); + + await toggleCallsAvailabilityForChannel()(dispatch, getState); + + expect(mockedFetch).toHaveBeenCalledWith(`${getPluginPath()}/channel1`, { + method: 'post', + body: JSON.stringify({enabled: true}), + }); + expect(dispatch).toHaveBeenCalledWith(channelCallsAvailabilityUpdated('channel1', true)); + }); + + test('dispatches the enabled value returned by the server, not the requested one', async () => { + mockedCallsNotAvailableInChannel.mockReturnValue(false); + mockedFetch.mockResolvedValue({enabled: false}); + + await toggleCallsAvailabilityForChannel()(dispatch, getState); + + expect(mockedFetch).toHaveBeenCalledWith(`${getPluginPath()}/channel1`, { + method: 'post', + body: JSON.stringify({enabled: false}), + }); + expect(dispatch).toHaveBeenCalledWith(channelCallsAvailabilityUpdated('channel1', false)); + }); + + test('logs and swallows request errors without dispatching', async () => { + const err = new Error('boom'); + mockedCallsNotAvailableInChannel.mockReturnValue(true); + mockedFetch.mockRejectedValue(err); + + await toggleCallsAvailabilityForChannel()(dispatch, getState); + + expect(mockedLogErr).toHaveBeenCalledWith(err); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/webapp/src/state/calls_availability/reducer.test.ts b/webapp/src/state/calls_availability/reducer.test.ts new file mode 100644 index 000000000..072f8fe95 --- /dev/null +++ b/webapp/src/state/calls_availability/reducer.test.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {unInitialized} from 'src/state/common_actions'; + +import {channelCallsAvailabilityUpdated} from './actions'; +import {reducer} from './reducer'; + +type CallsAvailabilityState = ReturnType; + +describe('calls_availability reducer', () => { + test('returns the initial state for an unknown action', () => { + const state: CallsAvailabilityState = {channel1: {channelID: 'channel1', enabled: true}}; + expect(reducer(state, {type: 'unhandled'} as never)).toBe(state); + }); + + test('defaults to an empty state', () => { + expect(reducer(undefined, {type: 'unhandled'} as never)).toEqual({}); + }); + + test('UN_INITIALIZED clears availability for all channels', () => { + const state: CallsAvailabilityState = {channel1: {channelID: 'channel1', enabled: true}}; + expect(reducer(state, unInitialized())).toEqual({}); + }); + + test('CHANNEL_CALLS_AVAILABILITY_UPDATED stores availability keyed by channel', () => { + expect(reducer({}, channelCallsAvailabilityUpdated('channel1', false))).toEqual({ + channel1: {channelID: 'channel1', enabled: false}, + }); + }); + + test('CHANNEL_CALLS_AVAILABILITY_UPDATED keeps availability for other channels', () => { + const state: CallsAvailabilityState = {channel1: {channelID: 'channel1', enabled: true}}; + expect(reducer(state, channelCallsAvailabilityUpdated('channel2', false))).toEqual({ + channel1: {channelID: 'channel1', enabled: true}, + channel2: {channelID: 'channel2', enabled: false}, + }); + }); + + test('CHANNEL_CALLS_AVAILABILITY_UPDATED overwrites availability for the same channel', () => { + const state: CallsAvailabilityState = {channel1: {channelID: 'channel1', enabled: true}}; + expect(reducer(state, channelCallsAvailabilityUpdated('channel1', false))).toEqual({ + channel1: {channelID: 'channel1', enabled: false}, + }); + }); +}); diff --git a/webapp/src/state/calls_availability/selectors.ts b/webapp/src/state/calls_availability/selectors.ts index 4c4236ad7..d242ae764 100644 --- a/webapp/src/state/calls_availability/selectors.ts +++ b/webapp/src/state/calls_availability/selectors.ts @@ -3,7 +3,11 @@ import {Channel} from '@mattermost/types/channels'; import {GlobalState} from '@mattermost/types/store'; -import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common'; +import {getChannel, getCurrentChannelId, getMyChannelMemberships} from 'mattermost-redux/selectors/entities/channels'; +import {getMyChannelRoles, getMyTeamRoles} from 'mattermost-redux/selectors/entities/roles'; +import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; +import {isDirectChannel, isGroupChannel} from 'mattermost-redux/utils/channel_utils'; +import {defaultEnabled} from 'src/selectors'; import {getPluginStore} from 'src/state/common_selectors'; export const callsAvailableInChannel = (state: GlobalState, channelID: Channel['id']) => @@ -32,3 +36,27 @@ export const callsAvailableInCurrentChannelWithDefault = (state: GlobalState): b */ export const shouldShowCallsButtonInChannelHeader = (state: GlobalState, channelId?: Channel['id']) => !callsNotAvailableInChannel(state, channelId || ''); + +export const hasPermissionToRenderCallsButtonInChannelHeader = (state: GlobalState, channelId: Channel['id']) => { + if (isCurrentUserSystemAdmin(state)) { + return true; + } + if (!defaultEnabled(state)) { + return false; + } + + const channelRoles = getMyChannelRoles(state); + const channel = getChannel(state, channelId); + if (!channel) { + return false; + } + + const teamRoles = getMyTeamRoles(state)[channel.team_id]; + const channelMemberships = getMyChannelMemberships(state)[channelId]; + + return (isDirectChannel(channel) || isGroupChannel(channel)) || + channelMemberships?.scheme_admin === true || + channelRoles[channel.id]?.has('channel_admin') || + teamRoles.has('team_admin'); +}; + diff --git a/webapp/src/state/common_actions.test.ts b/webapp/src/state/common_actions.test.ts new file mode 100644 index 000000000..a14a29586 --- /dev/null +++ b/webapp/src/state/common_actions.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {CALL_ENDED, UN_INITIALIZED} from './common_action_types'; +import {callEnded, unInitialized} from './common_actions'; + +describe('unInitialized', () => { + test('returns the UN_INITIALIZED action', () => { + expect(unInitialized()).toEqual({type: UN_INITIALIZED}); + }); +}); + +describe('callEnded', () => { + test('carries the channel and call ids', () => { + expect(callEnded('channel1', 'call1')).toEqual({ + type: CALL_ENDED, + data: { + channelID: 'channel1', + callID: 'call1', + }, + }); + }); +}); diff --git a/webapp/src/state/hosts/actions.test.ts b/webapp/src/state/hosts/actions.test.ts new file mode 100644 index 000000000..9d56b51d4 --- /dev/null +++ b/webapp/src/state/hosts/actions.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import RestClient from 'src/clients/rest'; +import {logErr} from 'src/log'; +import {getPluginPath} from 'src/utils'; + +import {HOST_CHANGED} from './action_types'; +import { + hostChanged, + hostLowerParticipantHand, + hostMakeParticipantHost, + hostMuteAllParticipants, + hostMuteParticipant, + hostRemoveParticipant, + hostSwitchParticipantScreenOff, +} from './actions'; + +jest.mock('src/clients/rest', () => ({ + __esModule: true, + default: { + fetch: jest.fn(), + }, +})); + +jest.mock('src/log', () => ({ + logErr: jest.fn(), +})); + +const mockedFetch = RestClient.fetch as jest.Mock; +const mockedLogErr = logErr as jest.Mock; + +const callBase = `${getPluginPath()}/calls/call1`; + +beforeEach(() => { + mockedFetch.mockResolvedValue({}); +}); + +describe('hostChanged', () => { + test('builds the HOST_CHANGED action', () => { + expect(hostChanged('channel1', 'host1', 1000)).toEqual({ + type: HOST_CHANGED, + data: { + channelID: 'channel1', + hostID: 'host1', + hostChangeAt: 1000, + }, + }); + }); +}); + +describe('hostMakeParticipantHost', () => { + test('posts the new host id to /host/make', async () => { + await hostMakeParticipantHost('call1', 'host2'); + expect(mockedFetch).toHaveBeenCalledWith(`${callBase}/host/make`, { + method: 'post', + body: JSON.stringify({new_host_id: 'host2'}), + }); + }); +}); + +describe('hostMuteParticipant', () => { + test('posts the session id to /host/mute', async () => { + await hostMuteParticipant('call1', 'session1'); + expect(mockedFetch).toHaveBeenCalledWith(`${callBase}/host/mute`, { + method: 'post', + body: JSON.stringify({session_id: 'session1'}), + }); + }); + + test('swallows request errors and logs them', async () => { + const err = new Error('boom'); + mockedFetch.mockRejectedValue(err); + + await expect(hostMuteParticipant('call1', 'session1')).resolves.toBeUndefined(); + expect(mockedLogErr).toHaveBeenCalledWith(err); + }); +}); + +describe('hostSwitchParticipantScreenOff', () => { + test('posts the session id to /host/screen-off', async () => { + await hostSwitchParticipantScreenOff('call1', 'session1'); + expect(mockedFetch).toHaveBeenCalledWith(`${callBase}/host/screen-off`, { + method: 'post', + body: JSON.stringify({session_id: 'session1'}), + }); + }); +}); + +describe('hostLowerParticipantHand', () => { + test('posts the session id to /host/lower-hand', async () => { + await hostLowerParticipantHand('call1', 'session1'); + expect(mockedFetch).toHaveBeenCalledWith(`${callBase}/host/lower-hand`, { + method: 'post', + body: JSON.stringify({session_id: 'session1'}), + }); + }); +}); + +describe('hostRemoveParticipant', () => { + test('posts the session id to /host/remove', async () => { + await hostRemoveParticipant('call1', 'session1'); + expect(mockedFetch).toHaveBeenCalledWith(`${callBase}/host/remove`, { + method: 'post', + body: JSON.stringify({session_id: 'session1'}), + }); + }); + + test('does not call the server when the call id is missing', async () => { + await expect(hostRemoveParticipant(undefined, 'session1')).resolves.toEqual({}); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test('does not call the server when the session id is missing', async () => { + await expect(hostRemoveParticipant('call1', undefined)).resolves.toEqual({}); + expect(mockedFetch).not.toHaveBeenCalled(); + }); +}); + +describe('hostMuteAllParticipants', () => { + test('posts to /host/mute-others without a body', async () => { + await hostMuteAllParticipants('call1'); + expect(mockedFetch).toHaveBeenCalledWith(`${callBase}/host/mute-others`, { + method: 'post', + }); + }); + + test('does not call the server when the call id is missing', async () => { + await expect(hostMuteAllParticipants(undefined)).resolves.toEqual({}); + expect(mockedFetch).not.toHaveBeenCalled(); + }); + + test('swallows request errors and logs them', async () => { + const err = new Error('boom'); + mockedFetch.mockRejectedValue(err); + + await expect(hostMuteAllParticipants('call1')).resolves.toBeUndefined(); + expect(mockedLogErr).toHaveBeenCalledWith(err); + }); +}); diff --git a/webapp/src/state/hosts/reducer.test.ts b/webapp/src/state/hosts/reducer.test.ts new file mode 100644 index 000000000..0ee81d486 --- /dev/null +++ b/webapp/src/state/hosts/reducer.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {callEnded, unInitialized} from 'src/state/common_actions'; + +import {hostChanged} from './actions'; +import {reducer} from './reducer'; + +type HostsState = ReturnType; + +describe('hosts reducer', () => { + test('returns the initial state for an unknown action', () => { + const state: HostsState = {channel1: {hostID: 'host1', hostChangeAt: 1}}; + expect(reducer(state, {type: 'unhandled'} as never)).toBe(state); + }); + + test('defaults to an empty state', () => { + expect(reducer(undefined, {type: 'unhandled'} as never)).toEqual({}); + }); + + test('UN_INITIALIZED clears all hosts', () => { + const state: HostsState = {channel1: {hostID: 'host1', hostChangeAt: 1}}; + expect(reducer(state, unInitialized())).toEqual({}); + }); + + test('HOST_CHANGED records the host and the change timestamp keyed by channel', () => { + expect(reducer({}, hostChanged('channel1', 'host1', 1000))).toEqual({ + channel1: {hostID: 'host1', hostChangeAt: 1000}, + }); + }); + + test('HOST_CHANGED keeps hosts for other channels', () => { + const state: HostsState = {channel1: {hostID: 'host1', hostChangeAt: 1}}; + expect(reducer(state, hostChanged('channel2', 'host2', 2))).toEqual({ + channel1: {hostID: 'host1', hostChangeAt: 1}, + channel2: {hostID: 'host2', hostChangeAt: 2}, + }); + }); + + test('HOST_CHANGED overwrites the host for the same channel', () => { + const state: HostsState = {channel1: {hostID: 'host1', hostChangeAt: 1}}; + expect(reducer(state, hostChanged('channel1', 'host2', 5))).toEqual({ + channel1: {hostID: 'host2', hostChangeAt: 5}, + }); + }); + + test('HOST_CHANGED only persists the hostID and hostChangeAt fields', () => { + const result = reducer({}, hostChanged('channel1', 'host1', 1000)); + expect(Object.keys(result.channel1)).toEqual(['hostID', 'hostChangeAt']); + }); + + test('CALL_ENDED removes the host for the channel and leaves others untouched', () => { + const state: HostsState = { + channel1: {hostID: 'host1', hostChangeAt: 1}, + channel2: {hostID: 'host2', hostChangeAt: 2}, + }; + expect(reducer(state, callEnded('channel1', 'call1'))).toEqual({ + channel2: {hostID: 'host2', hostChangeAt: 2}, + }); + }); + + test('CALL_ENDED does not mutate the previous state', () => { + const state: HostsState = {channel1: {hostID: 'host1', hostChangeAt: 1}}; + reducer(state, callEnded('channel1', 'call1')); + expect(state).toEqual({channel1: {hostID: 'host1', hostChangeAt: 1}}); + }); +}); diff --git a/webapp/src/state/screen_sharing_ids/actions.test.ts b/webapp/src/state/screen_sharing_ids/actions.test.ts new file mode 100644 index 000000000..4c91d7862 --- /dev/null +++ b/webapp/src/state/screen_sharing_ids/actions.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {USER_SCREEN_OFF, USER_SCREEN_ON} from './action_types'; +import {userScreenShared, userScreenUnshared} from './actions'; + +describe('userScreenShared', () => { + test('builds the USER_SCREEN_ON action', () => { + expect(userScreenShared('channel1', 'session1', 'user1')).toEqual({ + type: USER_SCREEN_ON, + data: { + channelID: 'channel1', + session_id: 'session1', + userID: 'user1', + }, + }); + }); +}); + +describe('userScreenUnshared', () => { + test('builds the USER_SCREEN_OFF action', () => { + expect(userScreenUnshared('channel1', 'session1', 'user1')).toEqual({ + type: USER_SCREEN_OFF, + data: { + channelID: 'channel1', + session_id: 'session1', + userID: 'user1', + }, + }); + }); +}); diff --git a/webapp/src/state/screen_sharing_ids/reducer.test.ts b/webapp/src/state/screen_sharing_ids/reducer.test.ts new file mode 100644 index 000000000..8213dcf62 --- /dev/null +++ b/webapp/src/state/screen_sharing_ids/reducer.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {callEnded, unInitialized} from 'src/state/common_actions'; +import {userLeft} from 'src/state/sessions/actions'; + +import {userScreenShared, userScreenUnshared} from './actions'; +import {reducer} from './reducer'; + +type ScreenSharingState = ReturnType; + +describe('screen_sharing_ids reducer', () => { + test('returns the initial state for an unknown action', () => { + const state: ScreenSharingState = {channel1: 'session1'}; + expect(reducer(state, {type: 'unhandled'} as never)).toBe(state); + }); + + test('defaults to an empty state', () => { + expect(reducer(undefined, {type: 'unhandled'} as never)).toEqual({}); + }); + + test('UN_INITIALIZED clears all sharers', () => { + const state: ScreenSharingState = {channel1: 'session1'}; + expect(reducer(state, unInitialized())).toEqual({}); + }); + + test('USER_SCREEN_ON records the sharing session for the channel', () => { + expect(reducer({}, userScreenShared('channel1', 'session1', 'user1'))).toEqual({ + channel1: 'session1', + }); + }); + + test('USER_SCREEN_ON keeps sharers in other channels and overwrites the same channel', () => { + const state: ScreenSharingState = {channel1: 'session1', channel2: 'session2'}; + expect(reducer(state, userScreenShared('channel1', 'sessionX', 'userX'))).toEqual({ + channel1: 'sessionX', + channel2: 'session2', + }); + }); + + test('USER_SCREEN_OFF clears the channel when the session matches the current sharer', () => { + const state: ScreenSharingState = {channel1: 'session1', channel2: 'session2'}; + expect(reducer(state, userScreenUnshared('channel1', 'session1', 'user1'))).toEqual({ + channel2: 'session2', + }); + }); + + test('USER_SCREEN_OFF is a no-op when there is no current sharer', () => { + const state: ScreenSharingState = {channel2: 'session2'}; + expect(reducer(state, userScreenUnshared('channel1', 'session1', 'user1'))).toBe(state); + }); + + test('USER_SCREEN_OFF is a no-op when a different session stops sharing', () => { + const state: ScreenSharingState = {channel1: 'session1'}; + expect(reducer(state, userScreenUnshared('channel1', 'otherSession', 'user1'))).toBe(state); + }); + + test('USER_LEFT clears the channel when the sharer leaves', () => { + const state: ScreenSharingState = {channel1: 'session1'}; + expect(reducer(state, userLeft('channel1', 'session1', 'user1'))).toEqual({}); + }); + + test('USER_LEFT is a no-op when there is no current sharer', () => { + const state: ScreenSharingState = {channel2: 'session2'}; + expect(reducer(state, userLeft('channel1', 'session1', 'user1'))).toBe(state); + }); + + test('USER_LEFT is a no-op when a non-sharing user leaves', () => { + const state: ScreenSharingState = {channel1: 'session1'}; + expect(reducer(state, userLeft('channel1', 'otherSession', 'user2'))).toBe(state); + }); + + test('CALL_ENDED removes the sharer for the channel and leaves others untouched', () => { + const state: ScreenSharingState = {channel1: 'session1', channel2: 'session2'}; + expect(reducer(state, callEnded('channel1', 'call1'))).toEqual({ + channel2: 'session2', + }); + }); + + test('CALL_ENDED does not mutate the previous state', () => { + const state: ScreenSharingState = {channel1: 'session1'}; + reducer(state, callEnded('channel1', 'call1')); + expect(state).toEqual({channel1: 'session1'}); + }); +}); diff --git a/webapp/src/state/sessions/actions.test.ts b/webapp/src/state/sessions/actions.test.ts new file mode 100644 index 000000000..94811e75d --- /dev/null +++ b/webapp/src/state/sessions/actions.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {type Reaction, type UserSessionState} from '@mattermost/calls-common/lib/types'; + +import { + SESSIONS_RECEIVED, + USER_HAND_LOWERED, + USER_HAND_RAISED, + USER_JOINED, + USER_LEFT, + USER_MUTED, + USER_REACTED, + USER_REACTED_TIMEOUT, + USER_UNMUTED, + USERS_VOICE_ACTIVITY_CHANGED, +} from './action_types'; +import { + getSessionsMapFromSessions, + sessionsReceived, + userJoined, + userLeft, + userLoweredHand, + userMuted, + userRaisedHand, + userReacted, + userReactedTimeout, + usersVoiceActivityChanged, + userUnmuted, +} from './actions'; + +const reaction: Reaction = { + user_id: 'user1', + session_id: 'session1', + emoji: {name: 'smile', unified: '1f604'}, + timestamp: 1000, + displayName: 'User One', +}; + +describe('sessionsReceived', () => { + test('builds the SESSIONS_RECEIVED action', () => { + const sessions = {session1: {session_id: 'session1', user_id: 'user1'} as UserSessionState}; + expect(sessionsReceived('channel1', sessions)).toEqual({ + type: SESSIONS_RECEIVED, + data: {channelID: 'channel1', sessions}, + }); + }); +}); + +describe('userJoined', () => { + test('builds the USER_JOINED action', () => { + expect(userJoined('channel1', 'session1', 'user1', 'me')).toEqual({ + type: USER_JOINED, + data: {channelID: 'channel1', session_id: 'session1', userID: 'user1', currentUserID: 'me'}, + }); + }); +}); + +describe('usersVoiceActivityChanged', () => { + test('builds the USERS_VOICE_ACTIVITY_CHANGED action', () => { + expect(usersVoiceActivityChanged('channel1', ['session1'], ['user1'])).toEqual({ + type: USERS_VOICE_ACTIVITY_CHANGED, + data: {channelID: 'channel1', session_ids: ['session1'], userIDs: ['user1']}, + }); + }); +}); + +describe('userMuted', () => { + test('builds the USER_MUTED action', () => { + expect(userMuted('channel1', 'session1', 'user1')).toEqual({ + type: USER_MUTED, + data: {channelID: 'channel1', session_id: 'session1', userID: 'user1'}, + }); + }); +}); + +describe('userUnmuted', () => { + test('builds the USER_UNMUTED action', () => { + expect(userUnmuted('channel1', 'session1', 'user1')).toEqual({ + type: USER_UNMUTED, + data: {channelID: 'channel1', session_id: 'session1', userID: 'user1'}, + }); + }); +}); + +describe('userRaisedHand', () => { + test('carries the raised-hand timestamp', () => { + expect(userRaisedHand('channel1', 'session1', 'user1', 1234)).toEqual({ + type: USER_HAND_RAISED, + data: {channelID: 'channel1', session_id: 'session1', userID: 'user1', raised_hand: 1234}, + }); + }); +}); + +describe('userLoweredHand', () => { + test('sets raised_hand to 0', () => { + expect(userLoweredHand('channel1', 'session1', 'user1')).toEqual({ + type: USER_HAND_LOWERED, + data: {channelID: 'channel1', session_id: 'session1', userID: 'user1', raised_hand: 0}, + }); + }); +}); + +describe('userReacted', () => { + test('builds the USER_REACTED action', () => { + expect(userReacted('channel1', 'user1', 'session1', reaction)).toEqual({ + type: USER_REACTED, + data: {channelID: 'channel1', userID: 'user1', session_id: 'session1', reaction}, + }); + }); +}); + +describe('userReactedTimeout', () => { + test('builds the USER_REACTED_TIMEOUT action', () => { + expect(userReactedTimeout('channel1', 'user1', 'session1', reaction)).toEqual({ + type: USER_REACTED_TIMEOUT, + data: {channelID: 'channel1', userID: 'user1', session_id: 'session1', reaction}, + }); + }); +}); + +describe('userLeft', () => { + test('builds the USER_LEFT action', () => { + expect(userLeft('channel1', 'session1', 'user1')).toEqual({ + type: USER_LEFT, + data: {channelID: 'channel1', userID: 'user1', session_id: 'session1'}, + }); + }); +}); + +describe('getSessionsMapFromSessions', () => { + test('keys sessions by their session_id', () => { + const sessions = [ + {session_id: 'a', user_id: 'user1'}, + {session_id: 'b', user_id: 'user2'}, + ] as UserSessionState[]; + + expect(getSessionsMapFromSessions(sessions)).toEqual({ + a: {session_id: 'a', user_id: 'user1'}, + b: {session_id: 'b', user_id: 'user2'}, + }); + }); + + test('returns an empty map for no sessions', () => { + expect(getSessionsMapFromSessions([])).toEqual({}); + }); +}); diff --git a/webapp/src/state/sessions/reducer.test.ts b/webapp/src/state/sessions/reducer.test.ts new file mode 100644 index 000000000..c2cf65d66 --- /dev/null +++ b/webapp/src/state/sessions/reducer.test.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {type Reaction, type UserSessionState} from '@mattermost/calls-common/lib/types'; +import {callEnded, unInitialized} from 'src/state/common_actions'; + +import { + sessionsReceived, + userJoined, + userLeft, + userLoweredHand, + userMuted, + userRaisedHand, + userReacted, + userReactedTimeout, + usersVoiceActivityChanged, + userUnmuted, +} from './actions'; +import {reducer} from './reducer'; + +type SessionsState = ReturnType; + +const makeSession = (overrides: Partial = {}): UserSessionState => ({ + session_id: 'session1', + user_id: 'user1', + unmuted: false, + raised_hand: 0, + voice: false, + video: false, + ...overrides, +}); + +const makeReaction = (timestamp: number): Reaction => ({ + user_id: 'user1', + session_id: 'session1', + emoji: {name: 'smile', unified: '1f604'}, + timestamp, + displayName: 'User One', +}); + +describe('sessions reducer', () => { + test('returns the initial state for an unknown action', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, {type: 'unhandled'} as never)).toBe(state); + }); + + test('defaults to an empty state', () => { + expect(reducer(undefined, {type: 'unhandled'} as never)).toEqual({}); + }); + + test('UN_INITIALIZED clears every channel', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, unInitialized())).toEqual({}); + }); + + test('SESSIONS_RECEIVED replaces the sessions map for the channel and keeps other channels', () => { + const state: SessionsState = {channel2: {session2: makeSession({session_id: 'session2'})}}; + const sessions = {session1: makeSession()}; + expect(reducer(state, sessionsReceived('channel1', sessions))).toEqual({ + channel1: sessions, + channel2: {session2: makeSession({session_id: 'session2'})}, + }); + }); + + test('USER_JOINED adds a session with default flags', () => { + const result = reducer({}, userJoined('channel1', 'session1', 'user1', 'me')); + expect(result.channel1.session1).toEqual({ + session_id: 'session1', + user_id: 'user1', + unmuted: false, + voice: false, + video: false, + raised_hand: 0, + }); + }); + + test('USER_JOINED merges into the channel without dropping existing sessions', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + const result = reducer(state, userJoined('channel1', 'session2', 'user2', 'me')); + expect(Object.keys(result.channel1)).toEqual(['session1', 'session2']); + }); + + test('USERS_VOICE_ACTIVITY_CHANGED is a no-op when the channel is unknown', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, usersVoiceActivityChanged('other', ['x'], ['y']))).toBe(state); + }); + + test('USERS_VOICE_ACTIVITY_CHANGED sets voice true for active speakers and false for everyone else', () => { + const state: SessionsState = { + channel1: { + session1: makeSession({session_id: 'session1', voice: false}), + session2: makeSession({session_id: 'session2', voice: true}), + }, + }; + const result = reducer(state, usersVoiceActivityChanged('channel1', ['session1'], ['user1'])); + expect(result.channel1.session1.voice).toBe(true); + expect(result.channel1.session2.voice).toBe(false); + }); + + test('USERS_VOICE_ACTIVITY_CHANGED returns the same state reference when no voice flag actually changes', () => { + const state: SessionsState = { + channel1: { + session1: makeSession({session_id: 'session1', voice: true}), + session2: makeSession({session_id: 'session2', voice: false}), + }, + }; + expect(reducer(state, usersVoiceActivityChanged('channel1', ['session1'], ['user1']))).toBe(state); + }); + + test('USERS_VOICE_ACTIVITY_CHANGED reuses unchanged session object references', () => { + const session2 = makeSession({session_id: 'session2', voice: false}); + const state: SessionsState = { + channel1: { + session1: makeSession({session_id: 'session1', voice: false}), + session2, + }, + }; + const result = reducer(state, usersVoiceActivityChanged('channel1', ['session1'], ['user1'])); + expect(result.channel1.session2).toBe(session2); + }); + + test('USER_MUTED sets unmuted to false', () => { + const state: SessionsState = {channel1: {session1: makeSession({unmuted: true})}}; + expect(reducer(state, userMuted('channel1', 'session1', 'user1')).channel1.session1.unmuted).toBe(false); + }); + + test('USER_UNMUTED sets unmuted to true', () => { + const state: SessionsState = {channel1: {session1: makeSession({unmuted: false})}}; + expect(reducer(state, userUnmuted('channel1', 'session1', 'user1')).channel1.session1.unmuted).toBe(true); + }); + + test('USER_MUTED / USER_UNMUTED are no-ops when the session is unknown', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, userMuted('channel1', 'unknown', 'user1'))).toBe(state); + expect(reducer(state, userUnmuted('channel2', 'session1', 'user1'))).toBe(state); + }); + + test('USER_HAND_RAISED stores the timestamp', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, userRaisedHand('channel1', 'session1', 'user1', 555)).channel1.session1.raised_hand).toBe(555); + }); + + test('USER_HAND_LOWERED resets the timestamp to 0', () => { + const state: SessionsState = {channel1: {session1: makeSession({raised_hand: 555})}}; + expect(reducer(state, userLoweredHand('channel1', 'session1', 'user1')).channel1.session1.raised_hand).toBe(0); + }); + + test('USER_HAND_RAISED / USER_HAND_LOWERED are no-ops when the session is unknown', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, userRaisedHand('channel1', 'unknown', 'user1', 5))).toBe(state); + expect(reducer(state, userLoweredHand('channel1', 'unknown', 'user1'))).toBe(state); + }); + + test('USER_REACTED stores the reaction on the session', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + const reaction = makeReaction(1000); + expect(reducer(state, userReacted('channel1', 'user1', 'session1', reaction)).channel1.session1.reaction).toEqual(reaction); + }); + + test('USER_REACTED is a no-op when the session is unknown', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, userReacted('channel1', 'user1', 'unknown', makeReaction(1000)))).toBe(state); + }); + + test('USER_REACTED_TIMEOUT clears the reaction when the timing-out reaction is still displayed', () => { + const state: SessionsState = {channel1: {session1: makeSession({reaction: makeReaction(1000)})}}; + const result = reducer(state, userReactedTimeout('channel1', 'user1', 'session1', makeReaction(1000))); + expect(result.channel1.session1.reaction).toBeUndefined(); + }); + + test('USER_REACTED_TIMEOUT keeps the reaction when a newer reaction has replaced it', () => { + const state: SessionsState = {channel1: {session1: makeSession({reaction: makeReaction(2000)})}}; + + // The timeout fires for the older reaction (1000), but a newer one (2000) is showing. + expect(reducer(state, userReactedTimeout('channel1', 'user1', 'session1', makeReaction(1000)))).toBe(state); + }); + + test('USER_REACTED_TIMEOUT is a no-op when there is no reaction', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, userReactedTimeout('channel1', 'user1', 'session1', makeReaction(1000)))).toBe(state); + }); + + test('USER_LEFT removes the session and keeps the rest of the channel', () => { + const state: SessionsState = { + channel1: { + session1: makeSession({session_id: 'session1'}), + session2: makeSession({session_id: 'session2'}), + }, + }; + const result = reducer(state, userLeft('channel1', 'session1', 'user1')); + expect(Object.keys(result.channel1)).toEqual(['session2']); + }); + + test('USER_LEFT is a no-op when the session is unknown', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + expect(reducer(state, userLeft('channel1', 'unknown', 'user1'))).toBe(state); + }); + + test('USER_LEFT does not mutate the previous state', () => { + const state: SessionsState = {channel1: {session1: makeSession(), session2: makeSession({session_id: 'session2'})}}; + reducer(state, userLeft('channel1', 'session1', 'user1')); + expect(Object.keys(state.channel1)).toEqual(['session1', 'session2']); + }); + + test('CALL_ENDED removes the channel and leaves others untouched', () => { + const state: SessionsState = { + channel1: {session1: makeSession()}, + channel2: {session2: makeSession({session_id: 'session2'})}, + }; + expect(reducer(state, callEnded('channel1', 'call1'))).toEqual({ + channel2: {session2: makeSession({session_id: 'session2'})}, + }); + }); + + test('CALL_ENDED does not mutate the previous state', () => { + const state: SessionsState = {channel1: {session1: makeSession()}}; + reducer(state, callEnded('channel1', 'call1')); + expect(state).toEqual({channel1: {session1: makeSession()}}); + }); +}); From 4546346da10806253bf7227a81394607b9538f57 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Tue, 30 Jun 2026 01:47:57 +0530 Subject: [PATCH 18/18] Fix phone-call SIP tests: set botID so isPhoneCallChannel resolves The new phone-call tests initialized only botSession, but getBotID() reads the independently-cached botID field. With botID empty, isPhoneCallChannel short-circuits to false, so the SIP-hangup path never runs: - TestRemoveUserSessionPhoneCall: lingering SIP session not dropped - TestHandleLiveKitSIPParticipant/last_SIP_participant_left...: call never ended (EndAt stayed 0) Set botID alongside botSession (matching logs_test.go / limits_test.go), including the non-bot-DM subtest so it exercises the channel-type check rather than short-circuiting on the empty bot ID. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/api_livekit_webhook_test.go | 2 ++ server/session_test.go | 1 + 2 files changed, 3 insertions(+) diff --git a/server/api_livekit_webhook_test.go b/server/api_livekit_webhook_test.go index f6f0e50c4..371ab5c27 100644 --- a/server/api_livekit_webhook_test.go +++ b/server/api_livekit_webhook_test.go @@ -411,6 +411,7 @@ func TestHandleLiveKitSIPParticipant(t *testing.T) { defer ResetTestStore(t, p.store) botID := model.NewId() + p.botID = botID p.botSession = &model.Session{UserId: botID} channelID := model.NewId() @@ -462,6 +463,7 @@ func TestHandleLiveKitSIPParticipant(t *testing.T) { defer ResetTestStore(t, p.store) botID := model.NewId() + p.botID = botID p.botSession = &model.Session{UserId: botID} channelID := model.NewId() diff --git a/server/session_test.go b/server/session_test.go index 209b044dd..d99f76040 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -181,6 +181,7 @@ func TestRemoveUserSessionPhoneCall(t *testing.T) { callsClusterLocks: map[string]*cluster.Mutex{}, metrics: mockMetrics, configuration: &configuration{}, // no LiveKitURL: livekitDeleteRoom is a no-op + botID: botID, botSession: &model.Session{UserId: botID}, sessions: map[string]*session{}, }