diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx new file mode 100644 index 000000000..57665362f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control-dtmf-keypad.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {Button} from '@momentum-design/components/dist/react'; +import {KEY_LIST} from '../OutdialCall/constants'; +import type {ILogger} from '@webex/cc-store'; + +export type CallControlDtmfKeypadProps = { + onDigitPress: (digit: string) => void; + logger?: ILogger; +}; + +/** + * In-call DTMF keypad for wxApp telephony sessions (Extension login). + * Each key press sends a single tone via SDK transmitDtmfOnWebex(). + */ +const CallControlDtmfKeypad: React.FunctionComponent = ({onDigitPress, logger}) => { + const handleDigitPress = (digit: string) => { + logger?.info(`CC-Widgets: CallControl: DTMF digit pressed`, { + module: 'call-control-dtmf-keypad.tsx', + method: 'handleDigitPress', + }); + onDigitPress(digit); + }; + + return ( + + ); +}; + +export default CallControlDtmfKeypad; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss index 58b02d01f..12e5d006f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.styles.scss @@ -27,6 +27,22 @@ margin-top: 1rem; } +.call-control-dtmf-keys { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + list-style: none; + padding: 0.5rem; + margin: 0; + min-width: 12rem; +} + +.call-control-dtmf-key { + width: 100%; + min-height: 2.5rem; +} + + .wrapup-group { display: flex; flex-direction: column; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index eb902e4b9..43d1b388e 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -5,6 +5,7 @@ import './call-control.styles.scss'; import {PopoverNext, TooltipNext, Text, ButtonCircle} from '@momentum-ui/react-collaboration'; import {Icon, Button, Select, Option} from '@momentum-design/components/dist/react'; import ConsultTransferPopoverComponent from './CallControlCustom/consult-transfer-popover'; +import CallControlDtmfKeypad from './call-control-dtmf-keypad'; import AutoWrapupTimer from '../AutoWrapupTimer/AutoWrapupTimer'; import type {MEDIA_CHANNEL as MediaChannelType} from '../task.types'; import {DestinationType} from '@webex/cc-store'; @@ -40,6 +41,7 @@ function CallControlComponent(props: CallControlComponentProps) { toggleHold, toggleRecording, toggleMute, + sendDtmf, isMuted, endCall, wrapupCall, @@ -168,13 +170,15 @@ function CallControlComponent(props: CallControlComponentProps) { { - logger.info(`CC-Widgets: CallControl: showing consult-transfer popover`, { + logger.info(`CC-Widgets: CallControl: showing ${button.menuType} popover`, { module: 'call-control.tsx', method: 'onShowPopover', }); setShowAgentMenu(true); setAgentMenuType(button.menuType as CallControlMenuType); - loadBuddyAgents(); + if (button.menuType !== 'Keypad') { + loadBuddyAgents(); + } }} onHide={() => { setShowAgentMenu(false); @@ -219,7 +223,9 @@ function CallControlComponent(props: CallControlComponentProps) { } > - {showAgentMenu && agentMenuType === button.menuType ? ( + {showAgentMenu && agentMenuType === button.menuType && button.menuType === 'Keypad' ? ( + + ) : showAgentMenu && agentMenuType === button.menuType ? ( { try { - const mainCtrl = controls?.main; + const mainCtrl = controls?.main as TaskMainControlsWithKeypad | undefined; const isTransferConferenceVisible = mainCtrl?.transferConference?.isVisible ?? false; const isTransferConferenceEnabled = mainCtrl?.transferConference?.isEnabled ?? false; const isTransferVisible = mainCtrl?.transfer?.isVisible ?? false; @@ -229,6 +235,16 @@ export const buildCallControlButtons = ( isVisible: mainCtrl?.mute?.isVisible ?? false, dataTestId: 'call-control:mute-toggle', }, + { + id: 'keypad', + icon: 'dialpad-bold', + tooltip: 'Keypad', + className: 'call-control-button', + disabled: !(mainCtrl?.keypad?.isEnabled ?? false), + isVisible: mainCtrl?.keypad?.isVisible ?? false, + menuType: 'Keypad', + dataTestId: 'call-control:keypad', + }, { id: 'switchToConsult', icon: 'call-swap-bold', diff --git a/packages/contact-center/cc-components/src/components/task/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 6192837be..e95b2a165 100644 --- a/packages/contact-center/cc-components/src/components/task/task.types.ts +++ b/packages/contact-center/cc-components/src/components/task/task.types.ts @@ -288,6 +288,11 @@ export interface ControlProps { */ toggleMute: () => void; + /** + * Sends a DTMF tone on wxApp engaged telephony calls. + */ + sendDtmf: (digit: string) => void; + /** * Function to handle ending the call. */ @@ -525,6 +530,7 @@ export type CallControlComponentProps = Pick< | 'toggleHold' | 'toggleRecording' | 'toggleMute' + | 'sendDtmf' | 'isMuted' | 'endCall' | 'wrapupCall' @@ -709,7 +715,7 @@ export interface CallControlConsultComponentsProps { /** * Type representing the possible menu types in call control. */ -export type CallControlMenuType = 'Consult' | 'Transfer' | 'ExitConference'; +export type CallControlMenuType = 'Consult' | 'Transfer' | 'ExitConference' | 'Keypad'; export const MEDIA_CHANNEL = { EMAIL: 'email', diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx new file mode 100644 index 000000000..ce2e3db9e --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control-dtmf-keypad.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import CallControlDtmfKeypad from '../../../../src/components/task/CallControl/call-control-dtmf-keypad'; +import {KEY_LIST} from '../../../../src/components/task/OutdialCall/constants'; +import {mockCC} from '@webex/test-fixtures'; + +describe('CallControlDtmfKeypad', () => { + const onDigitPress = jest.fn(); + const logger = mockCC.LoggerProxy; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all DTMF keys', async () => { + render(); + + const keypad = await screen.findByTestId('call-control-keypad-keys'); + await waitFor(() => { + expect(keypad.querySelectorAll('.call-control-dtmf-key')).toHaveLength(KEY_LIST.length); + }); + + KEY_LIST.forEach((key) => { + expect(within(keypad).getByText(key)).toBeInTheDocument(); + }); + }); + + it('calls onDigitPress and logs when a digit is pressed', async () => { + render(); + + const keypad = await screen.findByTestId('call-control-keypad-keys'); + fireEvent.click(within(keypad).getByText('5')); + + expect(onDigitPress).toHaveBeenCalledWith('5'); + expect(logger.info).toHaveBeenCalledWith('CC-Widgets: CallControl: DTMF digit pressed', { + module: 'call-control-dtmf-keypad.tsx', + method: 'handleDigitPress', + }); + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx index 2309338ab..b2da61945 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.snapshot.tsx @@ -87,6 +87,7 @@ describe('CallControlComponent Snapshots', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 8363fb2ec..38864c073 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -70,6 +70,7 @@ describe('CallControlComponent', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index 789ac36f6..1bce6cb97 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -445,7 +445,7 @@ describe('CallControl Utils', () => { jest.fn() // mergeConference ); - expect(buttons).toHaveLength(10); // Updated to 10 to include switchToConsult, transferConsult, and conference buttons + expect(buttons).toHaveLength(11); // Includes keypad (WXCC-6026), switchToConsult, transferConsult, and conference buttons // Check mute button const muteButton = buttons.find((b) => b.id === 'mute'); @@ -474,6 +474,76 @@ describe('CallControl Utils', () => { }); }); + it('includes visible keypad button when main keypad control is enabled (WXCC-6026)', () => { + const controlsWithKeypad = { + ...mockControls, + main: { + ...mockControls.main, + keypad: {isVisible: true, isEnabled: true}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithKeypad, + false, + mockFunctions.handleMuteToggleFunc, + mockFunctions.handleToggleHoldFunc, + mockFunctions.toggleRecording, + mockFunctions.endCall, + mockFunctions.exitConference, + mockFunctions.switchToConsult, + jest.fn(), + jest.fn() + ); + + const keypadButton = buttons.find((b) => b.id === 'keypad'); + expect(keypadButton).toEqual({ + id: 'keypad', + icon: 'dialpad-bold', + tooltip: 'Keypad', + className: 'call-control-button', + disabled: false, + isVisible: true, + menuType: 'Keypad', + dataTestId: 'call-control:keypad', + }); + }); + + it('hides keypad button when main keypad control is not visible (WXCC-6026)', () => { + const controlsWithoutKeypad = { + ...mockControls, + main: { + ...mockControls.main, + keypad: {isVisible: false, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithoutKeypad, + false, + mockFunctions.handleMuteToggleFunc, + mockFunctions.handleToggleHoldFunc, + mockFunctions.toggleRecording, + mockFunctions.endCall, + mockFunctions.exitConference, + mockFunctions.switchToConsult, + jest.fn(), + jest.fn() + ); + + const keypadButton = buttons.find((b) => b.id === 'keypad'); + expect(keypadButton?.isVisible).toBe(false); + expect(keypadButton?.disabled).toBe(true); + }); + it('should build buttons with correct configuration when not muted and held', () => { const heldControls = createEnabledMainTaskUIControls({ wrapup: enabledControl, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx index 6d6fc2d42..3271e118e 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.snapshot.tsx @@ -127,6 +127,7 @@ describe('CallControlCADComponent Snapshots', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index dd4888597..bf845645c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -96,6 +96,7 @@ describe('CallControlCADComponent', () => { toggleHold: jest.fn(), toggleRecording: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, endCall: jest.fn(), wrapupCall: jest.fn(), diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 75a882569..09e7c09ab 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -31,9 +31,8 @@ as approved unknowns only when the human explicitly defers or does not know. |---|---|---|---| | `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/AGENTS.md` | overview / API / usage | migrated | Overview, Purpose, Public Surface, Use Cases; usage snippets condensed to behavior. | | `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/ARCHITECTURE.md` | architecture / sequence diagrams | reconciled | Design Overview, Data Flow, Sequence Diagram(s), Pitfalls. Diagrams re-derived from current `store.ts` / `storeEventsWrapper.ts`; see Conflicts note below for drift corrected. | +| `packages/contact-center/ai-docs/features/thick-client-answer/intake.md` | wxApp thick-client answer (WXCC-6026) | reference-only (implemented) | Mercury mute sync → `TASK_WXAPP_MUTE_STATE_UPDATED`; see Design Overview event handling | | `@webex/contact-center` package types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | SDK API reference (installed `.d.ts`) | reference-only | Linked as the authoritative source for SDK-shaped types/methods consumed via `store.cc.*`. | - -## Overview `@webex/cc-store` is the single shared MobX store for every Webex Contact Center widget. It is the sole boundary between widgets and the `@webex/contact-center` SDK: widgets never import the SDK directly — they read observables and call methods on the store, which proxies to `store.cc.*`. The package is structured in two layers. `Store` (`src/store.ts`) is a `makeAutoObservable` singleton (`Store.getInstance()`) that holds raw observable state and owns initialization/registration with the SDK. `StoreWrapper` (`src/storeEventsWrapper.ts`) is the default export — it wraps the singleton, getter-proxies every observable, owns all SDK event wiring (CC + task events), exposes mutators (all writes funnel through `runInAction`), list-fetch helpers, callback registration, and task-lifecycle handling. `src/index.ts` re-exports the `StoreWrapper` instance as the default export plus everything from `store.types.ts` (types, the `CC_EVENTS` / `TASK_EVENTS` enums, login/consult/campaign constants) and `task-utils.ts` (pure selectors over SDK `ITask` objects). `util.ts` extracts a fixed allow-list of feature flags from the agent `Profile` at registration time. @@ -111,13 +110,14 @@ Compatibility notes: | `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | | `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | | `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | +| `STORE-R-022` | Per-task listener on **`TASK_WXAPP_MUTE_STATE_UPDATED`** (guarded by `wxAppMuteStateListeners` map) calls **`handleWxAppMuteStateUpdated`** → `setIsMuted(payload.muted)` only when the task matches `currentTask`; detached in **`handleTaskRemove`** | Webex App mute/unmute must sync embed UI without widgets calling Mercury; prevent duplicate listeners | `src/storeEventsWrapper.ts:507-509,942-946,999-1003` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | SDK must emit event; store does not call telephony REST | PRESENT | ## Design Overview The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`). With a host-supplied `webex`, the wrapper wires event listeners and registers synchronously. Without one, the store calls `Webex.init()`, arms a 6000ms timeout, and waits for the `ready` event before wiring listeners and registering; the timeout guards against an SDK that never becomes ready. Registration maps the agent `Profile` into observables once. -Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. +Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed into `init` and attaches CC-level listeners (`stationLoginSuccess`, `dnRegistered`/`reloginSuccess`, `multiLogin`, `stateChange`, `logoutSuccess`, task incoming/hydrate/merged/campaign-preview). Per-task listeners are attached in `registerTaskEventListeners` when a task arrives and symmetrically detached in `handleTaskRemove`. Most task events simply call `refreshTaskList()`, which re-reads the SDK's authoritative task map and reconciles `currentTask`. **WxApp mute sync:** when the SDK emits **`TASK_WXAPP_MUTE_STATE_UPDATED`** (Mercury path in SDK), the store updates **`isMuted`** for the current task via **`handleWxAppMuteStateUpdated`** — widgets read `store.isMuted`; they never subscribe to Mercury directly. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`. diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4f1f35f60..8288b51fa 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -54,6 +54,7 @@ class StoreWrapper implements IStoreWrapper { // replacement task object (task:hydrate / task:merged) gets rebound. private realTimeAssistListeners: Record void}> = {}; + private wxAppMuteStateListeners: Record void> = {}; constructor() { this.store = Store.getInstance(); @@ -503,6 +504,10 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_REJECT, (reason) => this.handleTaskReject(taskToRemove, reason)); taskToRemove.off(TASK_EVENTS.TASK_OUTDIAL_FAILED, (reason) => this.handleOutdialFailed(reason)); taskToRemove.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, this.handleUIControlsUpdated); + if (taskId && this.wxAppMuteStateListeners[taskId]) { + taskToRemove.off(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, this.wxAppMuteStateListeners[taskId]); + delete this.wxAppMuteStateListeners[taskId]; + } taskToRemove.off(TASK_EVENTS.TASK_WRAPPEDUP, this.refreshTaskList); taskToRemove.off(TASK_EVENTS.TASK_CONSULT_CREATED, this.handleConsultCreated); taskToRemove.off(TASK_EVENTS.TASK_OFFER_CONTACT, this.refreshTaskList); @@ -558,6 +563,7 @@ class StoreWrapper implements IStoreWrapper { } if (taskToRemove && this.store.currentTask?.data.interactionId === taskToRemove.data.interactionId) { this.setCurrentTask(null); + this.setIsMuted(false); } this.setState({ @@ -568,11 +574,12 @@ class StoreWrapper implements IStoreWrapper { }; handleTaskMuteState = (task: ITask): void => { - const isBrowser = this.deviceType === DEVICE_TYPE_BROWSER; - const webRtcEnabled = this.featureFlags?.webRtcEnabled; const isTelephony = task?.data?.interaction?.mediaType === MEDIA_TYPE_TELEPHONY_LOWER; - if (isBrowser && isTelephony && webRtcEnabled) { + // Each new telephony offer starts unmuted on Webex App / WebRTC media. + // Widgets track mute locally in store.isMuted — reset so a prior call's mute + // state does not leak into the next interaction (WXCC-6026 wxApp thick-client). + if (isTelephony) { this.setIsMuted(false); } }; @@ -799,6 +806,7 @@ class StoreWrapper implements IStoreWrapper { handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); + this.setIsMuted(false); this.refreshTaskList(); }; @@ -931,6 +939,12 @@ class StoreWrapper implements IStoreWrapper { this.refreshTaskList(); }; + handleWxAppMuteStateUpdated = (payload: {muted: boolean}, task: ITask) => { + if (this.currentTask?.data?.interactionId === task.data?.interactionId) { + this.setIsMuted(payload.muted); + } + }; + handleSwitchCall = () => { this.refreshTaskList(); }; @@ -982,6 +996,11 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.TASK_CAMPAIGN_CONTACT_UPDATED, this.refreshTaskList); const taskId = task.data?.interactionId; + if (taskId && !this.wxAppMuteStateListeners[taskId]) { + const wxAppMuteListener = (payload: {muted: boolean}) => this.handleWxAppMuteStateUpdated(payload, task); + this.wxAppMuteStateListeners[taskId] = wxAppMuteListener; + task.on(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, wxAppMuteListener); + } if (taskId && !this.realtimeTranscriptionListeners[taskId]) { this.realtimeTranscriptionListeners[taskId] = (payload: RealTimeTranscriptionEventPayload) => this.handleRealtimeTranscription(payload); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index e41c51e94..bd0d461a7 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -688,6 +688,34 @@ describe('storeEventsWrapper', () => { expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_CREATED, expect.any(Function)); }); + describe('handleTaskMuteState', () => { + it('resets isMuted on new incoming telephony task for Extension login', () => { + storeWrapper['store'].deviceType = 'EXTENSION'; + storeWrapper['store'].isMuted = true; + + storeWrapper.handleTaskMuteState(mockTask); + + expect(storeWrapper.isMuted).toBe(false); + }); + + it('resets isMuted when current task is removed after ending muted', () => { + storeWrapper['store'].isMuted = true; + storeWrapper['store'].currentTask = mockTask; + + storeWrapper.handleTaskRemove(mockTask); + + expect(storeWrapper.isMuted).toBe(false); + }); + + it('resets isMuted on task end', () => { + storeWrapper['store'].isMuted = true; + + storeWrapper.handleTaskEnd(); + + expect(storeWrapper.isMuted).toBe(false); + }); + }); + it('should call onErrorCallback and rethrow when store.init rejects with an Error', async () => { const cc = storeWrapper['store'].cc; const logger = storeWrapper['store'].logger; @@ -947,6 +975,59 @@ describe('storeEventsWrapper', () => { expect(storeWrapper.realTimeAssist[interactionId]).toBeUndefined(); }); + it('should update isMuted for current task on TASK_WXAPP_MUTE_STATE_UPDATED', () => { + const interactionId = 'interaction-wxapp-mute'; + const task = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + storeWrapper['store'].currentTask = task; + const setIsMutedSpy = jest.spyOn(storeWrapper, 'setIsMuted'); + + storeWrapper.handleWxAppMuteStateUpdated({muted: true}, task); + + expect(setIsMutedSpy).toHaveBeenCalledWith(true); + }); + + it('should ignore TASK_WXAPP_MUTE_STATE_UPDATED for non-current task', () => { + const task = makeMockTask({ + data: {interactionId: 'interaction-wxapp-mute', interaction: {state: 'connected'}}, + }); + storeWrapper['store'].currentTask = makeMockTask({ + data: {interactionId: 'other-interaction', interaction: {state: 'connected'}}, + }); + const setIsMutedSpy = jest.spyOn(storeWrapper, 'setIsMuted'); + + storeWrapper.handleWxAppMuteStateUpdated({muted: true}, task); + + expect(setIsMutedSpy).not.toHaveBeenCalled(); + }); + + it('should register one wxApp mute listener and remove it with the task', () => { + const interactionId = 'interaction-wxapp-mute-listener'; + const task = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + const registerTaskEventListeners = storeWrapper as unknown as { + registerTaskEventListeners: (taskToRegister: ITask) => void; + }; + + registerTaskEventListeners.registerTaskEventListeners(task); + registerTaskEventListeners.registerTaskEventListeners(task); + + const listenerCalls = (task.on as jest.Mock).mock.calls.filter( + ([event]) => event === TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED + ); + expect(listenerCalls).toHaveLength(1); + + storeWrapper['store'].currentTask = task; + const setIsMutedSpy = jest.spyOn(storeWrapper, 'setIsMuted'); + listenerCalls[0][1]({muted: false}); + expect(setIsMutedSpy).toHaveBeenCalledWith(false); + + storeWrapper.handleTaskRemove(task); + expect(task.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, listenerCalls[0][1]); + }); + it('should handle task removal', () => { const refreshTaskListSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); const setCurrentTaskSpy = jest.spyOn(storeWrapper, 'setCurrentTask'); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..b35e57552 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -27,7 +27,7 @@ Every generated requirement below must cite concrete source evidence using `file | `ai-docs/_archive/.../task/ai-docs/widgets/OutdialCall/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Outdial + ANI flow → Sequence Diagram(s); login-mode behavior → Use Cases / Pitfalls. | | `ai-docs/_archive/.../task/ai-docs/widgets/TaskList/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Task selection / accept / decline flow → Sequence Diagram(s). | | `packages/contact-center/ai-docs/migration/*.md` (7 files) | architecture (planned refactor) | reference-only | Describes a planned SDK `task.uiControls` migration that is NOT in current code. Used only to mark conflicts; current behavior documented as-is. | -| `packages/contact-center/task/src/` | source of truth | migrated | All requirements, flows, state, and error tables derive from real code here. | +| `packages/contact-center/ai-docs/features/thick-client-answer/intake.md` | feature intake (WXCC-6026) | reference-only (implemented) | wxApp Answer/Decline/Mute + Mercury mute sync — see § Feature: Accept on Webex thick client | ## Overview `task` is the largest CC widget bundle: it exports six React/Web-Component widgets that together cover the full agent interaction lifecycle — being offered a task, accepting/declining it, controlling an active call (hold, mute, record, consult, transfer, conference, wrap-up), placing outbound calls, listing concurrent tasks, and rendering a live transcript. Each widget follows the repo-standard layering: a thin `observer()` widget wraps an `ErrorBoundary`, reads MobX state from `@webex/cc-store`, delegates business logic to a custom hook in `helper.ts`, and renders a presentational component from `@webex/cc-components`. The hook is the only place that touches the SDK (`task.*` / `store.cc.*`) and registers/unregisters store task-event callbacks. @@ -87,6 +87,22 @@ Compatibility notes: - Adding an optional prop/callback is additive (minor); removing or renaming one, or changing a callback payload shape, is breaking (major) — these widgets are consumed via r2wc Web Components in `@webex/cc-widgets`. - `conferenceEnabled` is normalized to `true` when undefined inside the `CallControl`/`CallControlCAD` wrappers; consumers relying on `undefined` getting `false` would break. +### Feature: Accept on Webex thick client (implemented — WXCC-6026) + +Canonical spec: [`intake.md`](../../ai-docs/features/thick-client-answer/intake.md). + +| Surface | Change | +|---|---| +| **Host init** | `webexConfig.cc.enableAnswerOnWebex: boolean` (default `false`) — set **before** `store.init()` | +| **IncomingTask** | When SDK `isWebexAppCallingOffer()` → `acceptOnWebex()` / `rejectOnWebex()` instead of `accept()` / `decline()` | +| **CallControl** | Engaged wxApp → **`toggleMuteForTask(task, intendedMuteState)`** → **`toggleMuteOnWebex({ muted })`**; **`transmitDtmfOnWebex()`** for keypad; visibility from SDK `uiControls` | +| **TaskList** | Inline Accept / Decline on wxApp offers — same branch as IncomingTask | +| **wxapp-task.utils.ts** | Shared wxApp helpers: `isWxAppEngagedCall`, `toggleMuteForTask`, accept/decline routing | + +**SDK scope:** telephony REST, uiControls, usersub publish, **Mercury mute sync** (`TASK_WXAPP_MUTE_STATE_UPDATED`). + +**Store scope:** `storeEventsWrapper` listens for **`TASK_WXAPP_MUTE_STATE_UPDATED`** per task → `handleWxAppMuteStateUpdated` → `setIsMuted()` when task is `currentTask`. Widgets never call Mercury directly. + ## Requires (dependencies) - `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask`, `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names: `packages/contact-center/store/src/store.types.ts`. - `@webex/cc-components` (internal): presentational components (`IncomingTaskComponent`, `TaskListComponent`, `CallControlComponent`, `CallControlCADComponent`, `OutdialCallComponent`, `RealTimeTranscriptComponent`) and types (`ControlProps`, `TaskProps`, `OutdialCallProps`, `Visibility`, `ControlVisibility`, `RealTimeTranscriptComponentProps`, `CampaignCallProcessingDetails`). @@ -104,7 +120,7 @@ Compatibility notes: | `TASK-R-005` | `useTaskList` wires `store.setTaskAssigned`/`setTaskRejected`/`setTaskSelected` only when the matching consumer callback (`onTaskAccepted`/`onTaskDeclined`/`onTaskSelected`) is provided; each wrapped callback is try/caught. | Avoid registering no-op store callbacks and isolate consumer-thrown errors. | `src/helper.ts` (`useTaskList` `useEffect`) | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided", "should handle errors in taskAssigned callback", "should handle errors in taskSelected callback") | none | PRESENT | | `TASK-R-006` | `CallControl.toggleHold(true/false)` calls `currentTask.hold()`/`currentTask.resume()`; `TASK_HOLD`/`TASK_RESUME` events fire `onHoldResume({isHeld, task})`. | Hold/resume must reflect real SDK state to the consumer. | `src/helper.ts` (`useCallControl.toggleHold`, `holdCallback`, `resumeCallback`) | `tests/helper.ts` ("should call onHoldResume with hold=true and handle success", "...hold=false...", "should log an error if hold fails", "should log an error if resume fails") | none | PRESENT | | `TASK-R-007` | `toggleRecording` calls `pauseRecording()` when `isRecording` else `resumeRecording({autoResumed:false})`; `TASK_RECORDING_PAUSED`/`TASK_RECORDING_RESUMED` callbacks set `isRecording` and fire `onRecordingToggle`. | Recording UI state must track SDK events, not just the click. | `src/helper.ts` (`useCallControl.toggleRecording`, `pauseRecordingCallback`, `resumeRecordingCallback`) | `tests/helper.ts` ("should pause the recording when pauseResume is called with true", "should fail and log error if pause failed", "should resume the recording when pauseResume is called with false") | Subscription uses `TASK_RECORDING_PAUSED/RESUMED`; cleanup removes `CONTACT_RECORDING_PAUSED/RESUMED` (mismatch — see Pitfalls) | PRESENT | -| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; otherwise `await currentTask.toggleMute()`, then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure it reports the prior `isMuted`. | Mute state must reflect SDK truth even under rapid toggles or failure. | `src/helper.ts` (`useCallControl.toggleMute`) | `tests/helper.ts` ("should successfully toggle mute from unmuted to muted", "should handle multiple rapid toggleMute calls correctly", "should not call onToggleMute callback on error if not provided") | none | PRESENT | +| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; wxApp engaged calls use **`toggleMuteForTask(currentTask, intendedMuteState)`** → **`toggleMuteOnWebex({ muted })`**; WebRTC uses `toggleMute()`; then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure reports prior `isMuted`. | Mute state must reflect SDK/store truth; wxApp must pass UI intent to avoid Mercury desync. | `src/helper.ts` (`useCallControl.toggleMute`), `src/wxapp-task.utils.ts` | `tests/helper.ts` (mute + wxApp routing), `tests/wxapp-task.utils.test.ts` | none | PRESENT | | `TASK-R-009` | `wrapupCall(reason, auxCodeId)` calls `currentTask.wrapup(...)`; on resolve it promotes the first remaining task in `store.taskList` to `currentTask` and sets agent state to ENGAGED. | After wrap-up the agent should auto-focus the next task and return to an engaged state. | `src/helper.ts` (`useCallControl.wrapupCall`) | `tests/helper.ts` ("should call wrapupCall", "should log an error if wrapup fails") | ENGAGED label/username are local constants (`ENGAGED_LABEL`, `ENGAGED_USERNAME`) | PRESENT | | `TASK-R-010` | Auto-wrap-up: when `currentTask.autoWrapup` and `controlVisibility.wrapup` are present, a 1s interval counts `secondsUntilAutoWrapup` down from `getTimeLeftSeconds()`; `cancelAutoWrapup` calls `currentTask.cancelAutoWrapupTimer()`. | Show and allow cancellation of the auto-wrap-up countdown. | `src/helper.ts` (`useCallControl` auto-wrapup `useEffect`, `cancelAutoWrapup`) | `tests/helper.ts` ("should initialize secondsUntilAutoWrapup to null when auto wrap-up is not active", "should call cancelAutoWrapup successfully", "should handle cancelAutoWrapup when currentTask is missing") | none | PRESENT | | `TASK-R-011` | `consultCall(dest, type, allowParticipantsToInteract)` sends `holdParticipants: !allowParticipantsToInteract`; for `type==='queue'` it sets/clears `store.isQueueConsultInProgress` + `currentConsultQueueId` around the call, including on error. | Queue consult requires tracking the in-flight queue id so `endConsult` can pass it. | `src/helper.ts` (`useCallControl.consultCall`, `endConsultCall`) | `tests/helper.ts` ("should call consultCall successfully", "should call consultCall with allowParticipantsToInteract set to true", "should call endConsultCall with queue parameters when queue consult is in progress") | none | PRESENT | diff --git a/packages/contact-center/task/src/IncomingTask/index.tsx b/packages/contact-center/task/src/IncomingTask/index.tsx index ead1009e3..7f92b5192 100644 --- a/packages/contact-center/task/src/IncomingTask/index.tsx +++ b/packages/contact-center/task/src/IncomingTask/index.tsx @@ -9,8 +9,20 @@ import {IncomingTaskProps} from '../task.types'; const IncomingTaskInternal: React.FunctionComponent = observer( ({incomingTask, onAccepted, onRejected}) => { - const {logger, isDeclineButtonEnabled, deviceType} = store; - const result = useIncomingTask({incomingTask, onAccepted, onRejected, logger}); + const {logger, isDeclineButtonEnabled, deviceType, taskList} = store; + const interactionId = incomingTask?.data?.interactionId; + const liveIncomingTask = interactionId && taskList[interactionId] ? taskList[interactionId] : incomingTask; + + if (interactionId && liveIncomingTask !== incomingTask) { + logger?.info('CC-Widgets: IncomingTask using live task from store.taskList', { + module: 'IncomingTask', + method: 'render', + interactionId, + acceptEnabled: liveIncomingTask?.uiControls?.main?.accept?.isEnabled, + }); + } + + const result = useIncomingTask({incomingTask: liveIncomingTask, onAccepted, onRejected, logger}); const props = { ...result, diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..d67b456d4 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -27,6 +27,13 @@ import store, { MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, } from '@webex/cc-store'; +import { + acceptTaskForOffer, + getKeypadControl, + rejectTaskForOffer, + toggleMuteForTask, + transmitDtmfForTask, +} from './wxapp-task.utils'; import { TIMER_LABEL_CONSULTING, TIMER_LABEL_CONSULT_REQUESTED, @@ -142,7 +149,7 @@ export const useTaskList = (props: UseTaskListProps) => { module: 'useTaskList', method: 'acceptTask', }); - task.accept().catch((error) => { + acceptTaskForOffer(task).catch((error) => { logError(`CC-Widgets: Error accepting task: ${error}`, 'acceptTask'); }); } catch (error) { @@ -159,7 +166,7 @@ export const useTaskList = (props: UseTaskListProps) => { module: 'useTaskList', method: 'declineTask', }); - task.decline().catch((error) => { + rejectTaskForOffer(task).catch((error) => { logError(`CC-Widgets: Error declining task: ${error}`, 'declineTask'); }); logger.log(`CC-Widgets: incoming task declined for ${task.data.interactionId}`, { @@ -211,6 +218,14 @@ export const useIncomingTask = (props: UseTaskProps) => { const acceptControl = incomingTask?.uiControls?.main?.accept ?? {isVisible: false, isEnabled: false}; const sdkDeclineControl = incomingTask?.uiControls?.main?.decline ?? {isVisible: false, isEnabled: false}; + + logger?.info('CC-Widgets: IncomingTask uiControls snapshot', { + module: 'useIncomingTask', + method: 'render', + interactionId: incomingTask?.data?.interactionId, + accept: acceptControl, + decline: sdkDeclineControl, + }); const declineControl = { ...sdkDeclineControl, isEnabled: sdkDeclineControl.isEnabled || store.isDeclineButtonEnabled, @@ -293,7 +308,7 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'accept', }); if (!incomingTask?.data.interactionId) return; - incomingTask.accept().catch((error) => { + acceptTaskForOffer(incomingTask).catch((error) => { logError(`CC-Widgets: Error accepting incoming task: ${error}`, 'accept'); }); logger.log(`CC-Widgets: incomingTask accepted`, { @@ -315,7 +330,7 @@ export const useIncomingTask = (props: UseTaskProps) => { method: 'reject', }); if (!incomingTask?.data.interactionId) return; - incomingTask.decline().catch((error) => { + rejectTaskForOffer(incomingTask).catch((error) => { logError(`CC-Widgets: Error rejecting incoming task: ${error}`, 'reject'); }); logger.log(`CC-Widgets: incomingTask rejected`, { @@ -826,7 +841,7 @@ export const useCallControl = (props: useCallControlProps) => { const intendedMuteState = !isMuted; try { - await currentTask.toggleMute(); + await toggleMuteForTask(currentTask, intendedMuteState); // Only update state after successful SDK call store.setIsMuted(intendedMuteState); @@ -857,6 +872,21 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const sendDtmf = async (digit: string) => { + try { + if (!getKeypadControl(controls)?.isVisible) { + logger.warn('Keypad control not available', {module: 'useCallControl', method: 'sendDtmf'}); + return; + } + + logger.info(`sendDtmf(${digit}) called`, {module: 'useCallControl', method: 'sendDtmf'}); + + await transmitDtmfForTask(currentTask, digit); + } catch (error) { + logger.error(`sendDtmf failed: ${error}`, {module: 'useCallControl', method: 'sendDtmf'}); + } + }; + const endCall = () => { try { logger.info('endCall() called', {module: 'useCallControl', method: 'endCall'}); @@ -1231,6 +1261,7 @@ export const useCallControl = (props: useCallControlProps) => { toggleHold, toggleRecording, toggleMute, + sendDtmf, isMuted, wrapupCall, isRecording, diff --git a/packages/contact-center/task/src/wxapp-task.utils.ts b/packages/contact-center/task/src/wxapp-task.utils.ts new file mode 100644 index 000000000..35ca2a475 --- /dev/null +++ b/packages/contact-center/task/src/wxapp-task.utils.ts @@ -0,0 +1,64 @@ +import {ITask, TaskUIControls} from '@webex/contact-center'; + +/** + * WxApp thick-client telephony methods on ITask (SDK @webex/contact-center — WXCC-6026). + * Duck-typed until SDK types publish acceptOnWebex / transmitDtmfOnWebex on ITask. + */ +export type WxAppTelephonyTask = ITask & { + isWebexAppCallingOffer?: () => boolean; + acceptOnWebex?: () => Promise; + rejectOnWebex?: () => Promise; + toggleMuteOnWebex?: (options?: {lineOwnerId?: string; muted?: boolean}) => Promise; + transmitDtmfOnWebex?: (options: {dtmf: string; lineOwnerId?: string}) => Promise; + getWebexCallingCallId?: () => string | null | undefined; +}; + +/** SDK P0 keypad control — may exist on main leg before InteractionUIControls ships keypad. */ +export type TaskMainControlsWithKeypad = TaskUIControls['main'] & { + keypad?: {isVisible: boolean; isEnabled: boolean}; +}; + +export const getKeypadControl = (controls: TaskUIControls | undefined) => + (controls?.main as TaskMainControlsWithKeypad | undefined)?.keypad; + +export const isWxAppCallingOffer = (task: ITask | null | undefined): boolean => { + const wxTask = task as WxAppTelephonyTask | null | undefined; + return typeof wxTask?.isWebexAppCallingOffer === 'function' && !!wxTask.isWebexAppCallingOffer(); +}; + +export const isWxAppEngagedCall = (task: ITask | null | undefined): boolean => { + const wxTask = task as WxAppTelephonyTask | null | undefined; + return typeof wxTask?.getWebexCallingCallId === 'function' && !!wxTask.getWebexCallingCallId(); +}; + +export const acceptTaskForOffer = (task: ITask): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppCallingOffer(task) && typeof wxTask.acceptOnWebex === 'function') { + return wxTask.acceptOnWebex(); + } + return task.accept(); +}; + +export const rejectTaskForOffer = (task: ITask): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppCallingOffer(task) && typeof wxTask.rejectOnWebex === 'function') { + return wxTask.rejectOnWebex(); + } + return task.decline(); +}; + +export const toggleMuteForTask = (task: ITask, muted: boolean): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppEngagedCall(task) && typeof wxTask.toggleMuteOnWebex === 'function') { + return wxTask.toggleMuteOnWebex({muted}); + } + return task.toggleMute(); +}; + +export const transmitDtmfForTask = (task: ITask, dtmf: string): Promise => { + const wxTask = task as WxAppTelephonyTask; + if (isWxAppEngagedCall(task) && typeof wxTask.transmitDtmfOnWebex === 'function') { + return wxTask.transmitDtmfOnWebex({dtmf}); + } + return Promise.resolve(); +}; diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 3e7bae023..ff205bc04 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -53,6 +53,7 @@ describe('CallControl Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index b30905ba9..6999ccf70 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -56,6 +56,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -127,6 +128,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -180,6 +182,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -236,6 +239,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx index e15667071..c84fe65f0 100644 --- a/packages/contact-center/task/tests/IncomingTask/index.tsx +++ b/packages/contact-center/task/tests/IncomingTask/index.tsx @@ -8,9 +8,15 @@ import '@testing-library/jest-dom'; // Mock the store jest.mock('@webex/cc-store', () => ({ - cc: {}, - deviceType: 'BROWSER', - dialNumber: '12345', + __esModule: true, + default: { + cc: {}, + deviceType: 'BROWSER', + dialNumber: '12345', + taskList: {}, + isDeclineButtonEnabled: false, + logger: undefined, + }, })); const onAcceptedCb = jest.fn(); @@ -19,6 +25,7 @@ const onRejectedCb = jest.fn(); describe('IncomingTask Component', () => { beforeEach(() => { jest.clearAllMocks(); + store.taskList = {}; // Suppress console.error for error boundary tests jest.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -26,6 +33,29 @@ describe('IncomingTask Component', () => { jest.restoreAllMocks(); }); + it('prefers live task from store.taskList over incoming prop snapshot', () => { + const useIncomingTaskSpy = jest.spyOn(helper, 'useIncomingTask'); + useIncomingTaskSpy.mockReturnValue({ + incomingTask: mockTask, + accept: jest.fn(), + reject: jest.fn(), + acceptControl: {isVisible: true, isEnabled: true}, + declineControl: {isVisible: true, isEnabled: true}, + }); + + const staleTask = {...mockTask, uiControls: {main: {accept: {isVisible: true, isEnabled: false}}}}; + const liveTask = {...mockTask, uiControls: {main: {accept: {isVisible: true, isEnabled: true}}}}; + store.taskList = {[mockTask.data.interactionId]: liveTask as typeof mockTask}; + + render(); + + expect(useIncomingTaskSpy).toHaveBeenCalledWith( + expect.objectContaining({ + incomingTask: liveTask, + }) + ); + }); + it('renders IncomingTaskPresentational with correct props', () => { const useIncomingTaskSpy = jest.spyOn(helper, 'useIncomingTask'); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..6addc3364 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -7076,3 +7076,289 @@ describe('Task Hook Error Handling and Logging', () => { }); }); }); + +describe('WXCC-6026 wxApp thick-client routing', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('useIncomingTask accept routes to acceptOnWebex for wxApp offers', async () => { + const acceptOnWebex = jest.fn().mockResolvedValue(undefined); + const accept = jest.fn(); + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + acceptOnWebex, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.accept(); + }); + + expect(acceptOnWebex).toHaveBeenCalled(); + expect(accept).not.toHaveBeenCalled(); + }); + + it('useIncomingTask reject routes to rejectOnWebex for wxApp offers', async () => { + const rejectOnWebex = jest.fn().mockResolvedValue(undefined); + const decline = jest.fn(); + const wxAppTask = { + ...taskMock, + accept: jest.fn(), + decline, + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + rejectOnWebex, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.reject(); + }); + + expect(rejectOnWebex).toHaveBeenCalled(); + expect(decline).not.toHaveBeenCalled(); + }); + + it('useCallControl toggleMute routes to toggleMuteOnWebex for engaged wxApp calls', async () => { + const toggleMuteOnWebex = jest.fn().mockResolvedValue(undefined); + const toggleMute = jest.fn(); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + toggleMuteOnWebex, + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMuteOnWebex).toHaveBeenCalledWith({muted: true}); + expect(toggleMute).not.toHaveBeenCalled(); + }); + + it('useCallControl sendDtmf routes to transmitDtmfOnWebex for engaged wxApp calls', async () => { + const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + transmitDtmfOnWebex, + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + keypad: {isVisible: true, isEnabled: true}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.sendDtmf('5'); + }); + + expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); + }); + + it('useTaskList acceptTask routes to acceptOnWebex for wxApp offers', async () => { + const acceptOnWebex = jest.fn().mockResolvedValue(undefined); + const accept = jest.fn(); + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + acceptOnWebex, + }; + const mockTaskList = {mockId1: wxAppTask}; + + const {result} = renderHook(() => useTaskList({cc: mockCC, onTaskAccepted, logger, taskList: mockTaskList})); + + act(() => { + result.current.acceptTask(wxAppTask); + }); + + await waitFor(() => { + expect(acceptOnWebex).toHaveBeenCalled(); + }); + expect(accept).not.toHaveBeenCalled(); + }); + + it('useTaskList declineTask routes to rejectOnWebex for wxApp offers', async () => { + const rejectOnWebex = jest.fn().mockResolvedValue(undefined); + const decline = jest.fn(); + const wxAppTask = { + ...taskMock, + accept: jest.fn(), + decline, + isWebexAppCallingOffer: jest.fn().mockReturnValue(true), + rejectOnWebex, + }; + const mockTaskList = {mockId1: wxAppTask}; + + const {result} = renderHook(() => useTaskList({cc: mockCC, onTaskDeclined, logger, taskList: mockTaskList})); + + act(() => { + result.current.declineTask(wxAppTask); + }); + + await waitFor(() => { + expect(rejectOnWebex).toHaveBeenCalled(); + }); + expect(decline).not.toHaveBeenCalled(); + }); + + it('useIncomingTask accept uses legacy accept for non-wxApp offers', async () => { + const accept = jest.fn().mockResolvedValue(undefined); + const acceptOnWebex = jest.fn(); + const legacyTask = { + ...taskMock, + accept, + decline: jest.fn(), + isWebexAppCallingOffer: jest.fn().mockReturnValue(false), + acceptOnWebex, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: legacyTask, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.accept(); + }); + + expect(accept).toHaveBeenCalled(); + expect(acceptOnWebex).not.toHaveBeenCalled(); + }); + + it('useCallControl toggleMute uses legacy toggleMute for non-wxApp calls', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const toggleMuteOnWebex = jest.fn(); + const legacyTask = { + ...mockTask, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue(null), + toggleMuteOnWebex, + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + const {result} = renderHook(() => + useCallControl({ + currentTask: legacyTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).toHaveBeenCalled(); + expect(toggleMuteOnWebex).not.toHaveBeenCalled(); + }); + + it('useCallControl sendDtmf no-ops when keypad control is not visible', async () => { + const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + transmitDtmfOnWebex, + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + keypad: {isVisible: false, isEnabled: false}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.sendDtmf('5'); + }); + + expect(transmitDtmfOnWebex).not.toHaveBeenCalled(); + expect(mockCC.LoggerProxy.warn).toHaveBeenCalledWith('Keypad control not available', { + module: 'useCallControl', + method: 'sendDtmf', + }); + }); +}); diff --git a/packages/contact-center/task/tests/wxapp-task.utils.test.ts b/packages/contact-center/task/tests/wxapp-task.utils.test.ts new file mode 100644 index 000000000..d51165192 --- /dev/null +++ b/packages/contact-center/task/tests/wxapp-task.utils.test.ts @@ -0,0 +1,135 @@ +import {ITask} from '@webex/contact-center'; +import { + acceptTaskForOffer, + isWxAppCallingOffer, + isWxAppEngagedCall, + rejectTaskForOffer, + toggleMuteForTask, + transmitDtmfForTask, +} from '../src/wxapp-task.utils'; + +const baseTask = { + accept: jest.fn().mockResolvedValue(undefined), + decline: jest.fn().mockResolvedValue(undefined), + toggleMute: jest.fn().mockResolvedValue(undefined), +} as unknown as ITask; + +describe('wxapp-task.utils', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('isWxAppCallingOffer', () => { + it('returns true when isWebexAppCallingOffer is true', () => { + const task = { + ...baseTask, + isWebexAppCallingOffer: () => true, + } as ITask; + expect(isWxAppCallingOffer(task)).toBe(true); + }); + + it('returns false when helper is missing', () => { + expect(isWxAppCallingOffer(baseTask)).toBe(false); + }); + }); + + describe('isWxAppEngagedCall', () => { + it('returns true when getWebexCallingCallId returns a call id', () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + } as ITask; + expect(isWxAppEngagedCall(task)).toBe(true); + }); + + it('returns false when call id is empty', () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => '', + } as ITask; + expect(isWxAppEngagedCall(task)).toBe(false); + }); + }); + + describe('acceptTaskForOffer', () => { + it('calls acceptOnWebex for wxApp offers', async () => { + const acceptOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + isWebexAppCallingOffer: () => true, + acceptOnWebex, + } as ITask; + + await acceptTaskForOffer(task); + + expect(acceptOnWebex).toHaveBeenCalled(); + expect(baseTask.accept).not.toHaveBeenCalled(); + }); + + it('calls accept for non-wxApp offers', async () => { + await acceptTaskForOffer(baseTask); + expect(baseTask.accept).toHaveBeenCalled(); + }); + }); + + describe('rejectTaskForOffer', () => { + it('calls rejectOnWebex for wxApp offers', async () => { + const rejectOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + isWebexAppCallingOffer: () => true, + rejectOnWebex, + } as ITask; + + await rejectTaskForOffer(task); + + expect(rejectOnWebex).toHaveBeenCalled(); + expect(baseTask.decline).not.toHaveBeenCalled(); + }); + }); + + describe('toggleMuteForTask', () => { + it('calls toggleMuteOnWebex with muted target for engaged wxApp calls', async () => { + const toggleMuteOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + toggleMuteOnWebex, + } as ITask; + + await toggleMuteForTask(task, true); + + expect(toggleMuteOnWebex).toHaveBeenCalledWith({muted: true}); + expect(baseTask.toggleMute).not.toHaveBeenCalled(); + }); + + it('calls toggleMute for non-wxApp engaged calls', async () => { + await toggleMuteForTask(baseTask, true); + expect(baseTask.toggleMute).toHaveBeenCalled(); + }); + }); + + describe('transmitDtmfForTask', () => { + it('calls transmitDtmfOnWebex for engaged wxApp calls', async () => { + const transmitDtmfOnWebex = jest.fn().mockResolvedValue(undefined); + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + transmitDtmfOnWebex, + } as ITask; + + await transmitDtmfForTask(task, '5'); + + expect(transmitDtmfOnWebex).toHaveBeenCalledWith({dtmf: '5'}); + }); + + it('no-ops when transmitDtmfOnWebex is unavailable', async () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + } as ITask; + + await expect(transmitDtmfForTask(task, '1')).resolves.toBeUndefined(); + }); + }); +}); 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 38f2d975d..f4cf566a9 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect} from 'react'; +import React, {useState, useEffect, useRef} from 'react'; import { StationLogin, UserState, @@ -94,6 +94,8 @@ function App() { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; }); + const [enableAnswerOnWebex, setEnableAnswerOnWebex] = useState(false); + const wxAppPreferenceAppliedRef = useRef(false); const [isWebRTCWidgetSelectionLocked, setIsWebRTCWidgetSelectionLocked] = useState(() => { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; @@ -151,6 +153,7 @@ function App() { cc: { allowMultiLogin: isMultiLoginEnabled, disableWebRTCRegistration, + enableAnswerOnWebex: false, }, ...(integrationEnv && { services: { @@ -235,6 +238,25 @@ function App() { } }; + const handleEnableAnswerOnWebexChange = async () => { + const next = !enableAnswerOnWebex; + + setEnableAnswerOnWebex(next); + window.localStorage.setItem('enableAnswerOnWebex', next ? 'true' : 'false'); + + if (!store.isAgentLoggedIn) { + return; + } + + try { + await store.cc.setManageWebexCallingInWxcc(next); + } catch (error) { + console.error('setManageWebexCallingInWxcc failed:', error); + setEnableAnswerOnWebex(!next); + window.localStorage.setItem('enableAnswerOnWebex', !next ? 'true' : 'false'); + } + }; + const toggleDisableWebRTCRegistration = () => { const newValue = !disableWebRTCRegistration; @@ -396,6 +418,34 @@ function App() { window.localStorage.setItem('disableWebRTCRegistration', JSON.stringify(disableWebRTCRegistration)); }, [disableWebRTCRegistration]); + useEffect(() => { + if (!isSdkReady || !store.isAgentLoggedIn) { + if (!store.isAgentLoggedIn) { + wxAppPreferenceAppliedRef.current = false; + setEnableAnswerOnWebex(false); + } + + return; + } + + if (wxAppPreferenceAppliedRef.current) { + return; + } + + wxAppPreferenceAppliedRef.current = true; + + const savedPreference = window.localStorage.getItem('enableAnswerOnWebex') === 'true'; + if (!savedPreference) { + return; + } + + setEnableAnswerOnWebex(true); + store.cc.setManageWebexCallingInWxcc(true).catch((error) => { + console.error('setManageWebexCallingInWxcc failed:', error); + setEnableAnswerOnWebex(false); + }); + }, [isSdkReady, store.isAgentLoggedIn]); + useEffect(() => { if (!disableWebRTCRegistration) { setIsWebRTCWidgetSelectionLocked(false); @@ -950,6 +1000,53 @@ function App() { )} {(store.isAgentLoggedIn || isLoggedIn) && ( <> + {store.isAgentLoggedIn && ( +
+
+
+  Webex Calling (WxCC)  + +
+
+
+ )} + {selectedWidgets.userState && (