From ac9c8e1f2aefc24e19c7b76ac34134453f11a552 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Thu, 18 Jun 2026 22:09:51 +0530 Subject: [PATCH] fix(store,samples): support multi-login hydrate and WebRTC gating Add task:multiLoginHydrate handling with task-class compatibility so engaged state is hydrated reliably in mirrored sessions, and update the React sample to pass disableWebRTCRegistration while unchecking WebRTC-dependent widgets before locking their toggles. Co-authored-by: Cursor --- .../store/src/storeEventsWrapper.ts | 46 ++++++- .../store/tests/storeEventsWrapper.ts | 58 +++++++++ .../cc/samples-cc-react-app/src/App.tsx | 114 +++++++++++++++++- 3 files changed, 213 insertions(+), 5 deletions(-) diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 9eb586ee5..2ded1f3cd 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -33,6 +33,8 @@ import { import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; +const TASK_MULTI_LOGIN_HYDRATE = 'task:multiLoginHydrate'; + class StoreWrapper implements IStoreWrapper { store: IStore; onIncomingTask: ({task}: {task: ITask}) => void; @@ -238,7 +240,7 @@ class StoreWrapper implements IStoreWrapper { // Determine if the new task is the same as the current task let isSameTask = false; if (task && this.currentTask) { - isSameTask = task.data.interactionId === this.currentTask.data.interactionId; + isSameTask = this.getTaskInteractionId(task) === this.getTaskInteractionId(this.currentTask); } // Update the current task @@ -694,6 +696,46 @@ class StoreWrapper implements IStoreWrapper { this.refreshTaskList(); }; + private getTaskInteractionId = (task: ITask | null | undefined): string | undefined => { + return ( + task?.data?.interactionId ?? + // SDK task-class mode compatibility + (task as ITask & {getInteractionId?: () => string})?.getInteractionId?.() ?? + (task as ITask & {getInteraction?: () => {id?: string}})?.getInteraction?.()?.id + ); + }; + + private getTaskInteractionState = (task: ITask | null | undefined): string | undefined => { + return ( + task?.data?.interaction?.state ?? + // SDK task-class mode compatibility + (task as ITask & {getInteractionState?: () => string})?.getInteractionState?.() ?? + (task as ITask & {getInteraction?: () => {state?: string}})?.getInteraction?.()?.state + ); + }; + + handleMultiLoginHydrate = (event) => { + const task = event as ITask; + if (!task) { + this.store.logger.warn('CC-Widgets: handleMultiLoginHydrate(): task payload missing', { + module: 'storeEventsWrapper.ts', + method: 'handleMultiLoginHydrate', + }); + return; + } + + const interactionId = this.getTaskInteractionId(task); + const interactionState = this.getTaskInteractionState(task); + + if (interactionId && this.store.taskList[interactionId] && interactionState === 'new') { + return; + } + + this.registerTaskEventListeners(task); + this.refreshTaskList(); + this.handleTaskAssigned(task); + }; + handleTaskHydrate = (event) => { const task = event; @@ -866,6 +908,7 @@ class StoreWrapper implements IStoreWrapper { method: 'setupIncomingTaskHandler#addEventListeners', }); ccSDK.on(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + ccSDK.on(TASK_MULTI_LOGIN_HYDRATE, this.handleMultiLoginHydrate); ccSDK.on(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); ccSDK.on(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); ccSDK.on(TASK_EVENTS.TASK_MERGED, this.handleTaskMerged); @@ -879,6 +922,7 @@ class StoreWrapper implements IStoreWrapper { method: 'setupIncomingTaskHandler#removeEventListeners', }); ccSDK.off(TASK_EVENTS.TASK_HYDRATE, this.handleTaskHydrate); + ccSDK.off(TASK_MULTI_LOGIN_HYDRATE, this.handleMultiLoginHydrate); ccSDK.off(CC_EVENTS.AGENT_STATE_CHANGE, this.handleStateChange); ccSDK.off(TASK_EVENTS.TASK_INCOMING, this.handleIncomingTask); ccSDK.off(TASK_EVENTS.TASK_MERGED, this.handleTaskMerged); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index a06479d5d..fd7eb2655 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -33,6 +33,8 @@ import { mockQueueDetails, } from '@webex/test-fixtures'; +const TASK_MULTI_LOGIN_HYDRATE = 'task:multiLoginHydrate'; + jest.mock('../src/store', () => ({ getInstance: jest.fn().mockReturnValue({ teams: 'mockTeams', @@ -1093,6 +1095,7 @@ describe('storeEventsWrapper', () => { }); expect(onSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, expect.any(Function)); + expect(onSpy).toHaveBeenCalledWith(TASK_MULTI_LOGIN_HYDRATE, expect.any(Function)); expect(onSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); expect(onSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, expect.any(Function)); expect(onSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATE_CHANGE, expect.any(Function)); @@ -1518,6 +1521,60 @@ describe('storeEventsWrapper', () => { }); }); + it('should skip multiLoginHydrate when interaction already exists in taskList with new state', () => { + const interactionId = 'multi-hydrate-skip-1'; + const task = { + data: { + interactionId, + interaction: {state: 'new'}, + }, + on: jest.fn(), + off: jest.fn(), + } as unknown as ITask; + + storeWrapper['store'].taskList = {[interactionId]: task}; + + const registerSpy = jest.spyOn( + storeWrapper as unknown as {registerTaskEventListeners: (task: ITask) => void}, + 'registerTaskEventListeners' + ); + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + const assignedSpy = jest.spyOn(storeWrapper, 'handleTaskAssigned'); + + storeWrapper.handleMultiLoginHydrate(task); + + expect(registerSpy).not.toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(assignedSpy).not.toHaveBeenCalled(); + }); + + it('should process multiLoginHydrate when interaction state is connected', () => { + const interactionId = 'multi-hydrate-connected-1'; + const task = { + data: { + interactionId, + interaction: {state: 'connected'}, + }, + on: jest.fn(), + off: jest.fn(), + } as unknown as ITask; + + storeWrapper['store'].taskList = {[interactionId]: task}; + + const registerSpy = jest.spyOn( + storeWrapper as unknown as {registerTaskEventListeners: (task: ITask) => void}, + 'registerTaskEventListeners' + ); + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + const assignedSpy = jest.spyOn(storeWrapper, 'handleTaskAssigned'); + + storeWrapper.handleMultiLoginHydrate(task); + + expect(registerSpy).toHaveBeenCalledWith(task); + expect(refreshSpy).toHaveBeenCalled(); + expect(assignedSpy).toHaveBeenCalledWith(task); + }); + it('should handle hydrating the store with correct data', async () => { const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); const refreshTaskListSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); @@ -1582,6 +1639,7 @@ describe('storeEventsWrapper', () => { }); expect(storeWrapper['cc'].off).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, expect.any(Function)); + expect(storeWrapper['cc'].off).toHaveBeenCalledWith(TASK_MULTI_LOGIN_HYDRATE, expect.any(Function)); expect(storeWrapper['cc'].off).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.any(Function)); expect(storeWrapper['cc'].off).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, expect.any(Function)); expect(storeWrapper['cc'].off).toHaveBeenCalledWith(CC_EVENTS.AGENT_STATE_CHANGE, expect.any(Function)); diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 7583180a3..07b0237f1 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -88,6 +88,18 @@ function App() { const savedAllowInternationalDn = window.localStorage.getItem('allowInternationalDn'); return savedAllowInternationalDn === 'true'; }); + const [disableWebRTCRegistration, setDisableWebRTCRegistration] = useState(() => { + const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); + return savedDisableWebRTCRegistration === 'true'; + }); + const [isWebRTCWidgetSelectionLocked, setIsWebRTCWidgetSelectionLocked] = useState(() => { + const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); + return savedDisableWebRTCRegistration === 'true'; + }); + + const WEBRTC_DEPENDENT_WIDGETS = ['incomingTask', 'taskList', 'callControl', 'callControlCAD']; + const isWidgetDisabledByWebRTC = (widget: string) => + isWebRTCWidgetSelectionLocked && WEBRTC_DEPENDENT_WIDGETS.includes(widget); const handleSaveStart = () => { setShowLoader(true); @@ -136,6 +148,7 @@ function App() { }, cc: { allowMultiLogin: isMultiLoginEnabled, + disableWebRTCRegistration, }, ...(integrationEnv && { services: { @@ -222,6 +235,26 @@ function App() { } }; + const toggleDisableWebRTCRegistration = () => { + const newValue = !disableWebRTCRegistration; + + if (!newValue) { + setDisableWebRTCRegistration(false); + return; + } + + // Ensure dependent widgets are unchecked first, then lock their selection. + setIsWebRTCWidgetSelectionLocked(false); + setSelectedWidgets((prev) => { + const next = {...prev}; + WEBRTC_DEPENDENT_WIDGETS.forEach((widget) => { + next[widget] = false; + }); + return next; + }); + setDisableWebRTCRegistration(true); + }; + function playNotificationSound() { const ctx = new AudioContext(); const osc = ctx.createOscillator(); @@ -326,6 +359,9 @@ function App() { redirect_uri: redirectUri, scope: requestedScopes, }, + cc: { + disableWebRTCRegistration, + }, }, }; @@ -363,6 +399,36 @@ function App() { useEffect(() => { window.localStorage.setItem('hideDesktopLogin', JSON.stringify(hideDesktopLogin)); }, [hideDesktopLogin]); + useEffect(() => { + window.localStorage.setItem('disableWebRTCRegistration', JSON.stringify(disableWebRTCRegistration)); + }, [disableWebRTCRegistration]); + + useEffect(() => { + if (!disableWebRTCRegistration) { + setIsWebRTCWidgetSelectionLocked(false); + return; + } + + const hasDependentWidgetChecked = WEBRTC_DEPENDENT_WIDGETS.some((widget) => selectedWidgets[widget]); + if (hasDependentWidgetChecked) { + setSelectedWidgets((prev) => { + const next = {...prev}; + let changed = false; + + WEBRTC_DEPENDENT_WIDGETS.forEach((widget) => { + if (next[widget]) { + next[widget] = false; + changed = true; + } + }); + + return changed ? next : prev; + }); + return; + } + + setIsWebRTCWidgetSelectionLocked(true); + }, [disableWebRTCRegistration, selectedWidgets]); useEffect(() => { store.setIncomingTaskCb(onIncomingTaskCB); @@ -524,6 +590,7 @@ function App() { name={widget} checked={selectedWidgets[widget]} onChange={handleCheckboxChange} + disabled={isWidgetDisabledByWebRTC(widget)} data-testid={`samples:widget-${widget}`} />   @@ -542,7 +609,7 @@ function App() { style={{color: 'var(--mds-color-theme-text-error-normal)', marginBottom: '10px'}} > Note: When a number is dialed, the agent gets an incoming task to - accept via an Extension, Dial Number, or Browser. It's recommended to have the + accept via an Extension, Dial Number, or Browser. It is recommended to have the incoming task/task list widget and call controls widget according to your needs. @@ -631,11 +698,50 @@ function App() {
- Note: The "Enable Multi Login" option must be set before initializing the + Note: The Enable Multi Login option must be set before initializing the SDK. Changes to this setting after SDK initialization will not take effect. Please ensure - you configure this option before clicking the "Init Widgets" button. + you configure this option before clicking the Init Widgets button. +
+
+ + +