diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx index 9785df737..ca0938501 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-consult.tsx @@ -18,6 +18,8 @@ const CallControlConsultComponent: React.FC = controls, toggleConsultMute, conferenceEnabled = true, + enableWxBetterTogether = false, + currentTask = null, }) => { // Use the label and timestamp calculated in helper.ts // Stable key based on timestamp to prevent timer resets @@ -35,7 +37,9 @@ const CallControlConsultComponent: React.FC = consultConference, switchToMainCall, logger, - conferenceEnabled + conferenceEnabled, + enableWxBetterTogether, + currentTask ); // Filter buttons that should be shown, then map them diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts index 1bc2f21f9..daebb7381 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/CallControlCustom/call-control-custom.utils.ts @@ -1,6 +1,7 @@ -import {BuddyDetails, ContactServiceQueue, ILogger, TaskUIControls} from '@webex/cc-store'; +import {BuddyDetails, ContactServiceQueue, ILogger, ITask, TaskUIControls} from '@webex/cc-store'; import {MUTE_CALL, UNMUTE_CALL} from '../../constants'; import {ButtonConfig} from '../../task.types'; +import {shouldShowWxAppTelephonyControls} from '../call-control.utils'; /** * Interface for list item data @@ -22,7 +23,9 @@ export const createConsultButtons = ( consultConference: () => void, switchToMainCall: () => void, logger?, - conferenceEnabled = true + conferenceEnabled = true, + enableWxBetterTogether = false, + task: ITask | null = null ): ButtonConfig[] => { try { const consultCtrl = controls?.consult; @@ -44,7 +47,9 @@ export const createConsultButtons = ( className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, // Consult mute should only be interactive while consult leg is active. disabled: !isConsultLegActive || !(consultCtrl?.mute?.isEnabled ?? false), - isVisible: consultCtrl?.mute?.isVisible ?? false, + isVisible: shouldShowWxAppTelephonyControls(enableWxBetterTogether, task) + ? false + : (consultCtrl?.mute?.isVisible ?? false), }, { key: 'switchToMainCall', 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..f99bd791d --- /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 task.transmitDtmf(). + */ +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 ( +
    + {KEY_LIST.map((key) => ( +
  • + +
  • + ))} +
+ ); +}; + +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..beeba7f5a 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'; @@ -24,6 +25,7 @@ import { filterButtonsForConsultation, getConsultFilterPhase, updateCallStateFromTask, + applyWxAppTelephonyControlVisibility, } from './call-control.utils'; import {withMetrics} from '@webex/cc-ui-logging'; @@ -40,6 +42,7 @@ function CallControlComponent(props: CallControlComponentProps) { toggleHold, toggleRecording, toggleMute, + sendDtmf, isMuted, endCall, wrapupCall, @@ -68,6 +71,8 @@ function CallControlComponent(props: CallControlComponentProps) { getQueuesFetcher, consultTransferOptions, conferenceEnabled = true, + enableWxBetterTogether = false, + agentDeviceType, } = props; useEffect(() => { @@ -145,8 +150,17 @@ function CallControlComponent(props: CallControlComponentProps) { conferenceEnabled ); + const wxAppGatedButtons = applyWxAppTelephonyControlVisibility( + buttons, + currentTask, + controls, + isTelephony, + enableWxBetterTogether, + agentDeviceType + ); + const consultFilterPhase = getConsultFilterPhase(currentTask, controls); - const filteredButtons = filterButtonsForConsultation(buttons, consultFilterPhase, isTelephony, logger); + const filteredButtons = filterButtonsForConsultation(wxAppGatedButtons, consultFilterPhase, isTelephony, logger); if (!currentTask) return null; @@ -168,13 +182,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 +235,9 @@ function CallControlComponent(props: CallControlComponentProps) { } > - {showAgentMenu && agentMenuType === button.menuType ? ( + {showAgentMenu && agentMenuType === button.menuType && button.menuType === 'Keypad' ? ( + + ) : showAgentMenu && agentMenuType === button.menuType ? ( string | null | undefined; +}; + +export const isWxAppEngagedCall = (task: ITask | null | undefined): boolean => { + const wxTask = task as WxAppTelephonyTaskForVisibility | null | undefined; + return typeof wxTask?.getWebexCallingCallId === 'function' && !!wxTask.getWebexCallingCallId(); +}; + +/** Thick-client main-bar Mute/Keypad visibility gate — visibility only; not mute API routing. */ +export const shouldShowWxAppTelephonyControls = ( + enableWxBetterTogether: boolean, + task: ITask | null | undefined +): boolean => enableWxBetterTogether === true && isWxAppEngagedCall(task); + +/** + * Thin defense layer for thick-client flag ON: suppresses visible+disabled ghost controls. + * SDK owns isVisible/isEnabled for wxApp engaged calls; widgets pass through when SDK is correct. + */ +export const applyWxAppTelephonyControlVisibility = ( + buttons: CallControlButton[], + task: ITask | null | undefined, + controls: TaskUIControls | undefined, + isTelephony: boolean, + enableWxBetterTogether?: boolean, + agentDeviceType?: string +): CallControlButton[] => { + if (enableWxBetterTogether !== true) { + return buttons; + } + + const mainCtrl = controls?.main as TaskMainControlsWithKeypad | undefined; + const wxAppEngaged = isTelephony && isWxAppEngagedCall(task); + const isExtensionAgent = agentDeviceType != null && agentDeviceType !== 'BROWSER'; + + return buttons.map((button) => { + if (button.id !== 'mute' && button.id !== 'keypad') { + return button; + } + + const ctrl = button.id === 'mute' ? mainCtrl?.mute : mainCtrl?.keypad; + + if (!ctrl?.isVisible || ctrl.isEnabled) { + return button; + } + + // Extension ghost controls when flag ON but wxApp telephony not active. + if (isExtensionAgent && !wxAppEngaged) { + return {...button, isVisible: false}; + } + + // wxApp engaged: hide stale visible+disabled if SDK payload briefly desyncs. + if (wxAppEngaged) { + return {...button, isVisible: false}; + } + + return button; + }); +}; + /** * Checks if the media type is telephony */ @@ -204,7 +271,7 @@ export const buildCallControlButtons = ( conferenceEnabled = true ): CallControlButton[] => { 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 +296,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/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index a841f5cb1..6e4f2fce9 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx @@ -49,6 +49,7 @@ const CallControlCADComponent: React.FC = (props) => conferenceParticipants, conferenceEnabled = true, isCampaignCall = false, + enableWxBetterTogether = false, } = props; const formatTime = (time: number): string => { @@ -322,6 +323,8 @@ const CallControlCADComponent: React.FC = (props) => controls={controls} toggleConsultMute={toggleMute} conferenceEnabled={conferenceEnabled} + enableWxBetterTogether={enableWxBetterTogether} + currentTask={currentTask} /> )} diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx index bcf4e07fd..1dd8263e2 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx @@ -5,8 +5,17 @@ import {withMetrics} from '@webex/cc-ui-logging'; import {extractIncomingTaskData} from './incoming-task.utils'; const IncomingTaskComponent: React.FunctionComponent = (props) => { - const {incomingTask, accept, reject, logger, acceptControl, declineControl, isDeclineButtonEnabled, isBrowser} = - props; + const { + incomingTask, + accept, + reject, + logger, + acceptControl, + declineControl, + isDeclineButtonEnabled, + isBrowser, + offerActionError, + } = props; if (!incomingTask) { return <>; // hidden component } @@ -46,6 +55,7 @@ const IncomingTaskComponent: React.FunctionComponent styles="task-list-hover" mediaType={taskData.mediaType as MEDIA_CHANNEL} mediaChannel={taskData.mediaChannel as MEDIA_CHANNEL} + actionError={offerActionError} /> ); }; diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx index f747193dc..4b9ab9f5b 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.utils.tsx @@ -63,7 +63,14 @@ export const extractIncomingTaskData = ( // Desktop/WebRTC outdial: accept visible but disabled → show "Accept" (auto-answer handles it) // Desktop/WebRTC inbound: accept visible and enabled → show "Accept" const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); - const acceptText = accept.isVisible ? (showRinging ? 'Ringing...' : 'Accept') : undefined; + const showCalling = isTelephony && isOutdial && accept.isVisible && !accept.isEnabled && decline.isVisible; + const acceptText = accept.isVisible + ? showCalling + ? 'Calling...' + : showRinging + ? 'Ringing...' + : 'Accept' + : undefined; const declineText = decline.isVisible ? 'Decline' : undefined; diff --git a/packages/contact-center/cc-components/src/components/task/Task/index.tsx b/packages/contact-center/cc-components/src/components/task/Task/index.tsx index d7402fae2..37f8f2555 100644 --- a/packages/contact-center/cc-components/src/components/task/Task/index.tsx +++ b/packages/contact-center/cc-components/src/components/task/Task/index.tsx @@ -3,7 +3,8 @@ import {ButtonPill, ListItemBase, ListItemBaseSection, Text} from '@momentum-ui/ import {Avatar, Brandvisual, Tooltip} from '@momentum-design/components/dist/react'; import {PressEvent} from '@react-types/shared'; import TaskTimer from '../TaskTimer'; -import type {MEDIA_CHANNEL as MediaChannelType} from '../task.types'; +import type {MEDIA_CHANNEL as MediaChannelType, WxAppTelephonyErrorDisplay} from '../task.types'; +import WxAppOfferActionError from '../WxAppOfferActionError/wxapp-offer-action-error'; import {extractTaskComponentData, getTaskListItemClasses} from './task.utils'; import './styles.scss'; @@ -26,6 +27,7 @@ export interface TaskProps { styles?: string; mediaType?: MediaChannelType; mediaChannel?: MediaChannelType; + actionError?: WxAppTelephonyErrorDisplay | null; } const Task: React.FC = ({ @@ -47,6 +49,7 @@ const Task: React.FC = ({ declineText, mediaType, mediaChannel, + actionError, }) => { // Extract all computed data using the utility function const taskData = extractTaskComponentData({ @@ -99,94 +102,97 @@ const Task: React.FC = ({ }; return ( - - - {taskData.currentMediaType.isBrandVisual ? ( -
- -
- ) : ( - - )} -
- - -
- {renderTitle()} - {taskData.shouldShowState && ( - - {taskData.capitalizedState} - + <> + + + {taskData.currentMediaType.isBrandVisual ? ( +
+ +
+ ) : ( + )} +
- {taskData.shouldShowQueue && ( - - {taskData.capitalizedQueue} - - )} + +
+ {renderTitle()} + {taskData.shouldShowState && ( + + {taskData.capitalizedState} + + )} - {/* Handle Time should render if it's an incoming call without ronaTimeout OR if it's not an incoming call */} - {taskData.shouldShowHandleTime && ( - - Handle Time: {' '} - - - )} + {taskData.shouldShowQueue && ( + + {taskData.capitalizedQueue} + + )} - {/* Time Left should render if it's an incoming call with ronaTimeout */} - {taskData.shouldShowTimeLeft && ( - - Time Left: {' '} - - - )} -
-
+ {/* Handle Time should render if it's an incoming call without ronaTimeout OR if it's not an incoming call */} + {taskData.shouldShowHandleTime && ( + + Handle Time: {' '} + + + )} - -
- {acceptText ? ( - - {acceptText} - - ) : null} - {declineText ? ( - - {declineText} - - ) : null} -
-
-
+ {/* Time Left should render if it's an incoming call with ronaTimeout */} + {taskData.shouldShowTimeLeft && ( + + Time Left: {' '} + + + )} +
+
+ + +
+ {acceptText ? ( + + {acceptText} + + ) : null} + {declineText ? ( + + {declineText} + + ) : null} +
+
+
+ {actionError ? : null} + ); }; diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx index 08a50d375..333d7b776 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.tsx @@ -30,6 +30,7 @@ const TaskListComponent: React.FunctionComponent = (prop cc, hasCampaignPreviewEnabled = true, acceptedCampaignIds, + taskActionErrors, } = props; // Early return for empty task list @@ -93,9 +94,12 @@ const TaskListComponent: React.FunctionComponent = (prop ); } + const interactionId = task.data.interactionId; + const actionError = taskActionErrors?.[interactionId] ?? null; + return ( = (prop declineText={taskData.declineText} mediaType={taskData.mediaType as MEDIA_CHANNEL} mediaChannel={taskData.mediaChannel as MEDIA_CHANNEL} + actionError={actionError} /> ); })} diff --git a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts index abcf39de0..857c8767c 100644 --- a/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/TaskList/task-list.utils.ts @@ -141,7 +141,15 @@ export const extractTaskListItemData = ( // Desktop/WebRTC outdial: accept visible but disabled → show "Accept" (auto-answer handles it) // Desktop/WebRTC inbound: accept visible and enabled → show "Accept" const showRinging = isTelephony && !accept.isEnabled && !(isBrowser && isOutdial); - const acceptText = accept.isVisible && isTaskIncoming ? (showRinging ? 'Ringing...' : 'Accept') : undefined; + const showCalling = isTelephony && isOutdial && accept.isVisible && !accept.isEnabled && decline.isVisible; + const acceptText = + accept.isVisible && isTaskIncoming + ? showCalling + ? 'Calling...' + : showRinging + ? 'Ringing...' + : 'Accept' + : undefined; const declineText = decline.isVisible && isTaskIncoming ? 'Decline' : undefined; diff --git a/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss new file mode 100644 index 000000000..44c4cb462 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.style.scss @@ -0,0 +1,27 @@ +.telephony-action-toast-anchor { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + + .telephony-action-toast-icon-wrap { + display: inline-flex; + flex-shrink: 0; + line-height: 0; + } + + .telephony-action-toast-icon { + display: inline-block; + width: 1.5rem; + height: 1.5rem; + background-color: var(--mds-color-theme-button-cancel-normal, #c9190b); + mask-image: var(--telephony-toast-icon-url); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + -webkit-mask-image: var(--telephony-toast-icon-url); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + } +} diff --git a/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx new file mode 100644 index 000000000..c658ea077 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/TelephonyActionToast/telephony-action-toast.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {Text, Toast} from '@momentum-design/components/dist/react'; +import errorLegacyBoldIcon from '@momentum-design/icons/dist/svg/error-legacy-bold.svg'; +import {WxAppTelephonyErrorDisplay} from '../task.types'; +import './telephony-action-toast.style.scss'; + +export type TelephonyActionToastProps = { + error: WxAppTelephonyErrorDisplay; + onDismiss: () => void; +}; + +const TelephonyActionToast: React.FunctionComponent = ({error, onDismiss}) => { + return ( +
+ + +
+ ); +}; + +export default TelephonyActionToast; diff --git a/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss new file mode 100644 index 000000000..1983fead3 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.style.scss @@ -0,0 +1,11 @@ +.wxapp-offer-action-error { + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background-color: var(--alertbg-error, #fce8e6); + color: var(--label-error-text, #a12b2f); +} + +.wxapp-offer-action-error-message { + margin: 0; +} diff --git a/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx new file mode 100644 index 000000000..cb004e3b3 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/WxAppOfferActionError/wxapp-offer-action-error.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import {Text} from '@momentum-design/components/dist/react'; +import {WxAppTelephonyErrorDisplay} from '../task.types'; +import './wxapp-offer-action-error.style.scss'; + +export type WxAppOfferActionErrorProps = { + error: WxAppTelephonyErrorDisplay; +}; + +const WxAppOfferActionError: React.FunctionComponent = ({error}) => { + return ( +
+ + {error.message} + +
+ ); +}; + +export default WxAppOfferActionError; 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..1defbabfa 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 @@ -180,8 +180,17 @@ export type IncomingTaskComponentProps = Pick void; }; +export type WxAppTelephonyErrorDisplay = { + message: string; + trackingId?: string; + status?: number | string; + isWxAppTelephonyError: boolean; +}; + export type TaskListComponentProps = Pick< TaskProps, 'acceptTask' | 'declineTask' | 'onTaskSelect' | 'logger' | 'agentId' | 'cc' @@ -189,6 +198,8 @@ export type TaskListComponentProps = Pick< Partial> & { isDeclineButtonEnabled?: boolean; isBrowser?: boolean; + taskActionErrors?: Record; + clearTaskActionError?: (interactionId: string) => void; }; export interface RealTimeTranscriptEntry { @@ -288,6 +299,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. */ @@ -515,6 +531,16 @@ export interface ControlProps { * Agent ID of the logged-in user */ agentId: string; + + /** + * Host init flag for wxApp thick-client main-bar Mute/Keypad visibility gating only. + */ + enableWxBetterTogether?: boolean; + + /** + * Logged-in agent device type (BROWSER, EXTENSION, AGENT_DN) for Extension-only ghost control suppression. + */ + agentDeviceType?: string; } export type CallControlComponentProps = Pick< @@ -525,6 +551,7 @@ export type CallControlComponentProps = Pick< | 'toggleHold' | 'toggleRecording' | 'toggleMute' + | 'sendDtmf' | 'isMuted' | 'endCall' | 'wrapupCall' @@ -572,6 +599,16 @@ export type CallControlComponentProps = Pick< * "Campaign call" label instead of the standard media type. */ isCampaignCall?: boolean; + + /** + * Host init flag for wxApp thick-client main-bar Mute/Keypad visibility gating only. + */ + enableWxBetterTogether?: boolean; + + /** + * Logged-in agent device type for Extension-only ghost control suppression. + */ + agentDeviceType?: string; }; export type OutdialAniEntry = { @@ -704,12 +741,14 @@ export interface CallControlConsultComponentsProps { controls: TaskUIControls; toggleConsultMute: () => void; conferenceEnabled: boolean; + enableWxBetterTogether?: boolean; + currentTask?: ITask | null; } /** * 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/src/index.ts b/packages/contact-center/cc-components/src/index.ts index 4a38e62a4..e504bec1b 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -11,6 +11,8 @@ import CampaignTaskComponent from './components/task/CampaignTask/campaign-task' import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript'; import E911Modal from './components/StationLogin/E911Modal/e911-modal'; import AIAssistantComponent from './components/AIAssistant/ai-assistant'; +import TelephonyActionToast from './components/task/TelephonyActionToast/telephony-action-toast'; +import WxAppOfferActionError from './components/task/WxAppOfferActionError/wxapp-offer-action-error'; export { UserStateComponent, @@ -26,6 +28,8 @@ export { RealTimeTranscriptComponent, E911Modal, AIAssistantComponent, + TelephonyActionToast, + WxAppOfferActionError, }; export * from './components/StationLogin/constants'; export * from './components/StationLogin/E911Modal/e911-modal.types'; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx index cfd9ec4cc..8da6d7b12 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-custom.util.tsx @@ -112,6 +112,65 @@ describe('Call Control Custom Utils', () => { expect(muteButton?.tooltip).toBe('Unmute'); }); + it('hides consult mute when wxApp is engaged and enableWxBetterTogether is true', () => { + const wxAppTask = { + getWebexCallingCallId: () => 'call-123', + }; + + const buttons = createConsultButtons( + false, + mockControls, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + loggerMock, + true, + true, + wxAppTask as never + ); + + const muteButton = buttons.find((b) => b.key === 'mute'); + expect(muteButton?.isVisible).toBe(false); + }); + + it('uses SDK consult mute visibility when enableWxBetterTogether is true but wxApp is not engaged', () => { + const buttons = createConsultButtons( + false, + mockControls, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + loggerMock, + true, + true + ); + + const muteButton = buttons.find((b) => b.key === 'mute'); + expect(muteButton?.isVisible).toBe(true); + }); + + it('uses SDK consult mute visibility when enableWxBetterTogether is false', () => { + const buttons = createConsultButtons( + false, + mockControls, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + loggerMock, + true, + false + ); + + const muteButton = buttons.find((b) => b.key === 'mute'); + expect(muteButton?.isVisible).toBe(true); + }); + it('should configure mute button correctly when not muted', () => { const buttons = createConsultButtons( false, // isMuted 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..5f69c2075 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 @@ -18,6 +18,7 @@ import { handleAudioRef, onInputDialNumber, handleButtonPress, + applyWxAppTelephonyControlVisibility, } from '../../../../src/components/task/CallControl/call-control.utils'; import * as utils from '../../../../src/utils'; @@ -445,7 +446,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 +475,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, @@ -801,6 +872,196 @@ describe('CallControl Utils', () => { }); }); + describe('applyWxAppTelephonyControlVisibility', () => { + const wxAppTask = { + getWebexCallingCallId: () => 'call-123', + } as ITask; + + const baseButtons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: true}, + keypad: {isVisible: true, isEnabled: true}, + }, + }, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + it('passes through SDK visibility when enableWxBetterTogether is false', () => { + const controlsWithDisabledKeypad = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabledKeypad, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility(buttons, wxAppTask, controlsWithDisabledKeypad, true, false); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('hides mute and keypad when flag is on, wxApp not engaged, and SDK shows disabled controls', () => { + const controlsWithDisabled = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabled, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility( + buttons, + {} as ITask, + controlsWithDisabled, + true, + true, + 'EXTENSION' + ); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(false); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(false); + }); + + it('passes through BROWSER mute when flag is on, wxApp not engaged, and SDK shows disabled controls', () => { + const controlsWithDisabled = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabled, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility( + buttons, + {} as ITask, + controlsWithDisabled, + true, + true, + 'BROWSER' + ); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('passes through SDK visibility when flag is on but wxApp is not engaged and controls are enabled', () => { + const result = applyWxAppTelephonyControlVisibility(baseButtons, {} as ITask, mockControls, true, true); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('passes through SDK visibility when wxApp is engaged and SDK enables controls', () => { + const result = applyWxAppTelephonyControlVisibility(baseButtons, wxAppTask, mockControls, true, true); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(true); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(true); + }); + + it('hides mute and keypad when wxApp is engaged but SDK disables controls during consult/hold', () => { + const controlsWithDisabled = { + ...mockControls, + main: { + ...mockControls.main, + mute: {isVisible: true, isEnabled: false}, + keypad: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + controlsWithDisabled, + false, + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn(), + jest.fn() + ); + + const result = applyWxAppTelephonyControlVisibility(buttons, wxAppTask, controlsWithDisabled, true, true); + + expect(result.find((b) => b.id === 'mute')?.isVisible).toBe(false); + expect(result.find((b) => b.id === 'keypad')?.isVisible).toBe(false); + }); + }); + describe('getConsultFilterPhase', () => { it('returns none when endConsult is not visible', () => { expect(getConsultFilterPhase(null, {main: {}, consult: {}} as never)).toBe('none'); 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/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx index dc3e24e3e..8e1bae85d 100644 --- a/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/IncomingTask/incoming-task.utils.tsx @@ -249,6 +249,21 @@ describe('incoming-task.utils', () => { mockTask.data.interaction.outboundType = originalOutboundType; }); + it('should show Calling... for wxApp outdial while answer is pending', () => { + const originalOutboundType = mockTask.data.interaction.outboundType; + const originalMediaType = mockTask.data.interaction.mediaType; + + mockTask.data.interaction.mediaType = MEDIA_CHANNEL.TELEPHONY; + mockTask.data.interaction.outboundType = OUTBOUND_TYPE.OUTDIAL; + + const result = extractIncomingTaskData(mockTask, logger, visibleDisabledAccept, enabledControl, false, false); + + expect(result.acceptText).toBe('Calling...'); + + mockTask.data.interaction.outboundType = originalOutboundType; + mockTask.data.interaction.mediaType = originalMediaType; + }); + it.skip('should extract correct button states for outdial telephony on non-browser', () => { const originalMediaType = mockTask.data.interaction.mediaType; const originalCallAssociatedDetails = mockTask.data.interaction.callAssociatedDetails; diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 75a882569..47ecc2b00 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,15 @@ 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 | +| `STORE-R-023` | **`setCurrentTask`** calls **`seedWxAppMuteFromTask`** only when the promoted task **changes** (`!isSameTask`): resolves canonical SDK task from `taskManager.getAllTasks()`, awaits **`syncWxAppMuteFromCallDetails()`**, then applies **`getWxAppMuted()`** to `store.isMuted` when still current (refresh/hydrate backfill; skips re-seed on uiControls-driven list refresh) | Page refresh must restore mute icon from telephony GET even if Mercury event was missed; avoid duplicate GETs on answer | `src/storeEventsWrapper.ts` (`seedWxAppMuteFromTask`, `setCurrentTask`) | `tests/storeEventsWrapper.ts` (`seed isMuted on setCurrentTask`, dedupe tests) | SDK Voice must implement sync + getter + in-flight dedupe | 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/store.ts b/packages/contact-center/store/src/store.ts index 2fd5a8b5f..01b34ce05 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -52,6 +52,8 @@ class Store implements IStore { allowConsultToQueue: boolean = false; agentProfile: AgentLoginProfile = {}; isMuted: boolean = false; + /** Host init flag for wxApp thick-client UI gating only — not used for mute API routing. */ + enableWxBetterTogether: boolean = false; isDigitalChannelsInitialized: boolean = false; dataCenter: string = ''; realtimeTranscriptionData: Partial[] = []; @@ -135,6 +137,12 @@ class Store implements IStore { } init(options: InitParams, setupEventListeners): Promise { + if ('webexConfig' in options) { + this.enableWxBetterTogether = options.webexConfig?.cc?.enableWxBetterTogether === true; + } else { + this.enableWxBetterTogether = false; + } + if ('webex' in options) { // If devs decide to go with webex, they will have to listen to the ready event before calling init // This has to be documented diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index c0bf9fc45..116830ba9 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -199,6 +199,7 @@ interface IStore { allowConsultToQueue: boolean; agentProfile: AgentLoginProfile; isMuted: boolean; + enableWxBetterTogether: boolean; isAddressBookEnabled: boolean; isDigitalChannelsInitialized: boolean; dataCenter: string; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 4f1f35f60..8fde131cd 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(); @@ -173,6 +174,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.isEmergencyModalAlreadyDisplayed; } + get enableWxBetterTogether() { + return this.store.enableWxBetterTogether; + } + get realTimeAssist() { return this.store.realTimeAssist; } @@ -263,6 +268,45 @@ class StoreWrapper implements IStoreWrapper { this.store.isAgentLoggedIn = value; }; + private getCanonicalTask(task: ITask): ITask { + const interactionId = task.data?.interactionId; + if (!interactionId) { + return task; + } + + const tasks = this.store.cc?.taskManager?.getAllTasks?.(); + return tasks?.[interactionId] ?? task; + } + + private seedWxAppMuteFromTask(task: ITask): void { + const interactionId = task.data?.interactionId; + if (!interactionId) { + return; + } + + const canonicalTask = this.getCanonicalTask(task) as ITask & { + syncWxAppMuteFromCallDetails?: () => Promise; + getWxAppMuted?: () => boolean; + }; + const sync = canonicalTask.syncWxAppMuteFromCallDetails?.bind(canonicalTask); + if (typeof sync !== 'function') { + return; + } + + void sync() + .then(() => { + if (this.currentTask?.data?.interactionId !== interactionId) { + return; + } + + const muted = canonicalTask.getWxAppMuted?.(); + if (typeof muted === 'boolean') { + this.setIsMuted(muted); + } + }) + .catch(() => undefined); + } + setCurrentTask = (task: ITask | null, isClicked: boolean = false): void => { // Don't assign the task as current task is incoming if (isIncomingTask(task, this.agentId)) return; @@ -296,6 +340,10 @@ class StoreWrapper implements IStoreWrapper { // Update the current task this.store.currentTask = task ? Object.assign(Object.create(Object.getPrototypeOf(task)), task) : null; + if (task && !isSameTask) { + this.seedWxAppMuteFromTask(task); + } + if (this.onTaskSelected && !isSameTask && typeof isClicked !== 'undefined') { this.onTaskSelected(task, isClicked); } @@ -503,6 +551,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 +610,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 +621,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 +853,7 @@ class StoreWrapper implements IStoreWrapper { handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); + this.setIsMuted(false); this.refreshTaskList(); }; @@ -931,6 +986,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 +1043,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/store.ts b/packages/contact-center/store/tests/store.ts index b57924e0c..b6eb24c70 100644 --- a/packages/contact-center/store/tests/store.ts +++ b/packages/contact-center/store/tests/store.ts @@ -188,6 +188,38 @@ describe('Store', () => { expect(storeInstance.registerCC).toHaveBeenCalledWith(mockWebex); }); + it('sets enableWxBetterTogether from webexConfig.cc at init', async () => { + //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 + webexInitSpy = jest.spyOn(Webex, 'init').mockReturnValue(mockWebex); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); + + await storeInstance.init( + { + webexConfig: {cc: {enableWxBetterTogether: true}}, + access_token: 'fake_token', + }, + jest.fn() + ); + + expect(storeInstance.enableWxBetterTogether).toBe(true); + }); + + it('defaults enableWxBetterTogether to false when webexConfig.cc flag is absent', async () => { + //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 + webexInitSpy = jest.spyOn(Webex, 'init').mockReturnValue(mockWebex); + jest.spyOn(storeInstance, 'registerCC').mockResolvedValue(); + + await storeInstance.init( + { + webexConfig: {}, + access_token: 'fake_token', + }, + jest.fn() + ); + + expect(storeInstance.enableWxBetterTogether).toBe(false); + }); + it('should log an error and reject the promise if registerCC fails in init method', async () => { const initParams = { webexConfig: {anyConfig: true}, diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index e41c51e94..68465c07d 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -603,6 +603,10 @@ describe('storeEventsWrapper', () => { storeWrapper['store'].taskList = {interaction2: mockTaskWithJoined}; storeWrapper.setCurrentTask(mockTaskWithJoined); + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [mockTaskWithJoined.data.interactionId]: mockTaskWithJoined, + [mockTask2.data.interactionId]: mockTask2, + }); // Call the method under test storeWrapper.handleIncomingTask(mockTask2); @@ -688,6 +692,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 +979,161 @@ 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 seed isMuted from syncWxAppMuteFromCallDetails on setCurrentTask', async () => { + storeWrapper['store'].agentId = 'mockAgentId'; + const interactionId = 'interaction-wxapp-mute-seed'; + const task = makeMockTask({ + data: { + interactionId, + agentId: 'mockAgentId', + interaction: { + state: 'connected', + participants: { + mockAgentId: {hasJoined: true}, + }, + }, + }, + }) as ITask & { + syncWxAppMuteFromCallDetails: jest.Mock; + getWxAppMuted: jest.Mock; + }; + task.syncWxAppMuteFromCallDetails = jest.fn().mockResolvedValue(true); + task.getWxAppMuted = jest.fn().mockReturnValue(true); + + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [interactionId]: task, + }); + + storeWrapper.setCurrentTask(task); + await waitFor(() => { + expect(task.syncWxAppMuteFromCallDetails).toHaveBeenCalled(); + }); + expect(storeWrapper.isMuted).toBe(true); + }); + + it('should not re-seed isMuted when setCurrentTask is called with the same interactionId', async () => { + const interactionId = 'interaction-wxapp-mute-dedupe'; + storeWrapper['store'].agentId = 'mockAgentId'; + const task = makeMockTask({ + data: { + interactionId, + agentId: 'mockAgentId', + interaction: { + state: 'connected', + participants: { + mockAgentId: {hasJoined: true}, + }, + }, + }, + }) as ITask & { + syncWxAppMuteFromCallDetails: jest.Mock; + getWxAppMuted: jest.Mock; + }; + task.syncWxAppMuteFromCallDetails = jest.fn().mockResolvedValue(true); + task.getWxAppMuted = jest.fn().mockReturnValue(true); + + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [interactionId]: task, + }); + + storeWrapper.setCurrentTask(task); + await waitFor(() => { + expect(task.syncWxAppMuteFromCallDetails).toHaveBeenCalledTimes(1); + }); + + task.syncWxAppMuteFromCallDetails.mockClear(); + storeWrapper.setCurrentTask(task); + expect(task.syncWxAppMuteFromCallDetails).not.toHaveBeenCalled(); + }); + + it('should not trigger additional mute sync when refreshTaskList re-promotes the same current task', async () => { + storeWrapper['store'].agentId = 'mockAgentId'; + const interactionId = 'interaction-wxapp-mute-refresh'; + const task = makeMockTask({ + data: { + interactionId, + agentId: 'mockAgentId', + interaction: { + state: 'connected', + participants: { + mockAgentId: {hasJoined: true}, + }, + }, + }, + }) as ITask & { + syncWxAppMuteFromCallDetails: jest.Mock; + getWxAppMuted: jest.Mock; + }; + task.syncWxAppMuteFromCallDetails = jest.fn().mockResolvedValue(true); + task.getWxAppMuted = jest.fn().mockReturnValue(false); + + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({ + [interactionId]: task, + }); + + storeWrapper.setCurrentTask(task); + await waitFor(() => { + expect(task.syncWxAppMuteFromCallDetails).toHaveBeenCalledTimes(1); + }); + + task.syncWxAppMuteFromCallDetails.mockClear(); + storeWrapper.refreshTaskList(); + expect(task.syncWxAppMuteFromCallDetails).not.toHaveBeenCalled(); + }); + 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..9d5f9ae23 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,24 @@ 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()`; persisted on store for **UI visibility gating only** | +| **IncomingTask** | Calls SDK `task.accept()` / `task.decline()` — wxApp routing is internal to SDK `Voice` | +| **CallControl** | Engaged wxApp → `task.toggleMute({ muted })` / `task.transmitDtmf({ dtmf })`; widget force-visible only when wxApp engaged **and** SDK `isEnabled`; hide SDK visible+disabled ghosts; Desktop WebRTC SDK passthrough; CAD consult sub-bar mute hidden only when wxApp engaged; `toggleMute` guard includes `consult.mute.isVisible` | +| **TaskList** | Inline Accept / Decline — same unified `task.accept()` / `task.decline()` as IncomingTask | +| **wxapp-task.utils.ts** | UI visibility helpers only: `isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` | + +**SDK follow-up (uiControls):** SDK must enable `main.mute/keypad` through consult/hold/conference when wxApp engaged; BROWSER login ignores init flag for uiControls. See [intake.md §7.6–§7.7](../../ai-docs/features/thick-client-answer/intake.md). + +**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 +122,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 mute controls are unavailable; wxApp engaged calls use **`currentTask.toggleMute({ muted: intendedMuteState })`**; WebRTC uses parameterless toggle; 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 hooks), `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/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 443072076..d1ad8cbc2 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -5,7 +5,7 @@ import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; -import {CallControlComponent} from '@webex/cc-components'; +import {CallControlComponent, TelephonyActionToast} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; const CallControlInternal: React.FunctionComponent = observer( @@ -20,6 +20,8 @@ const CallControlInternal: React.FunctionComponent = observer( isMuted, agentId, acceptedCampaignIds, + enableWxBetterTogether, + deviceType, } = store; // Hide call control when the current task is a campaign preview that @@ -29,7 +31,7 @@ const CallControlInternal: React.FunctionComponent = observer( return <>; } - const callControlProps = useCallControl({ + const {telephonyToast, dismissTelephonyToast, ...callControlHookProps} = useCallControl({ currentTask, onHoldResume, onEnd, @@ -40,23 +42,33 @@ const CallControlInternal: React.FunctionComponent = observer( isMuted, conferenceEnabled, agentId, + enableWxBetterTogether, }); const result = { - ...callControlProps, + ...callControlHookProps, wrapupCodes, consultStartTimeStamp, callControlAudio, allowConsultToQueue, logger, consultTransferOptions, + enableWxBetterTogether, + agentDeviceType: deviceType, }; if (!currentTask) { return <>; } - return ; + return ( + <> + + {telephonyToast ? ( + + ) : null} + + ); } ); diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index 1426d19da..c50f7bd5b 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -5,7 +5,7 @@ import {ErrorBoundary} from 'react-error-boundary'; import store from '@webex/cc-store'; import {useCallControl} from '../helper'; import {CallControlProps} from '../task.types'; -import {CallControlCADComponent} from '@webex/cc-components'; +import {CallControlCADComponent, TelephonyActionToast} from '@webex/cc-components'; import {isUnacceptedCampaignPreview} from '../Utils/task-util'; const CallControlCADInternal: React.FunctionComponent = observer( @@ -30,13 +30,15 @@ const CallControlCADInternal: React.FunctionComponent = observ isMuted, agentId, acceptedCampaignIds, + enableWxBetterTogether, + deviceType, } = store; if (currentTask && isUnacceptedCampaignPreview(currentTask, acceptedCampaignIds)) { return <>; } - const callControlProps = useCallControl({ + const {telephonyToast, dismissTelephonyToast, ...callControlHookProps} = useCallControl({ currentTask, onHoldResume, onEnd, @@ -47,10 +49,11 @@ const CallControlCADInternal: React.FunctionComponent = observ isMuted, conferenceEnabled, agentId, + enableWxBetterTogether, }); const result = { - ...callControlProps, + ...callControlHookProps, wrapupCodes, consultStartTimeStamp, callControlAudio, @@ -59,13 +62,22 @@ const CallControlCADInternal: React.FunctionComponent = observ allowConsultToQueue, logger, consultTransferOptions, + enableWxBetterTogether, + agentDeviceType: deviceType, }; if (!currentTask) { return <>; } - return ; + return ( + <> + + {telephonyToast ? ( + + ) : null} + + ); } ); 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..5ec15d861 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -27,6 +27,14 @@ import store, { MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, } from '@webex/cc-store'; +import {shouldShowWxAppTelephonyControls} from './wxapp-task.utils'; +import { + getTelephonyToastDisplay, + reportWxAppTelephonyFailure, + TelephonyToastAction, + withOfferActionUserMessage, + WxAppTelephonyErrorDisplay, +} from './wxapp-error.utils'; import { TIMER_LABEL_CONSULTING, TIMER_LABEL_CONSULT_REQUESTED, @@ -72,6 +80,24 @@ const mapTranscriptLineToEntry = ( // Hook for managing the task list export const useTaskList = (props: UseTaskListProps) => { const {onTaskAccepted, onTaskDeclined, onTaskSelected, logger, taskList} = props; + const [taskActionErrors, setTaskActionErrors] = useState>({}); + + const clearTaskActionError = useCallback((interactionId: string) => { + setTaskActionErrors((prev) => { + if (!prev[interactionId]) return prev; + const next = {...prev}; + delete next[interactionId]; + return next; + }); + }, []); + + const setTaskActionError = useCallback( + (interactionId: string, error: unknown, action: string) => { + const parsed = reportWxAppTelephonyFailure(error, {widget: 'TaskList', action}, logger, store.onErrorCallback); + setTaskActionErrors((prev) => ({...prev, [interactionId]: withOfferActionUserMessage(parsed, action)})); + }, + [logger] + ); const logError = (message: string, method: string) => { logger.error(message, { @@ -143,6 +169,7 @@ export const useTaskList = (props: UseTaskListProps) => { method: 'acceptTask', }); task.accept().catch((error) => { + setTaskActionError(task.data.interactionId, error, 'acceptTask'); logError(`CC-Widgets: Error accepting task: ${error}`, 'acceptTask'); }); } catch (error) { @@ -160,6 +187,7 @@ export const useTaskList = (props: UseTaskListProps) => { method: 'declineTask', }); task.decline().catch((error) => { + setTaskActionError(task.data.interactionId, error, 'declineTask'); logError(`CC-Widgets: Error declining task: ${error}`, 'declineTask'); }); logger.log(`CC-Widgets: incoming task declined for ${task.data.interactionId}`, { @@ -184,7 +212,7 @@ export const useTaskList = (props: UseTaskListProps) => { } }; - return {taskList, acceptTask, declineTask, onTaskSelect}; + return {taskList, acceptTask, declineTask, onTaskSelect, taskActionErrors, clearTaskActionError}; }; export const useRealTimeTranscript = (props: UseRealTimeTranscriptInternalProps) => { @@ -208,9 +236,26 @@ export const useRealTimeTranscript = (props: UseRealTimeTranscriptInternalProps) export const useIncomingTask = (props: UseTaskProps) => { const {onAccepted, onRejected, incomingTask, logger} = props; + const [offerActionError, setOfferActionError] = useState(null); + + const clearOfferActionError = useCallback(() => { + setOfferActionError(null); + }, []); + + useEffect(() => { + setOfferActionError(null); + }, [incomingTask?.data?.interactionId]); 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, @@ -294,6 +339,13 @@ export const useIncomingTask = (props: UseTaskProps) => { }); if (!incomingTask?.data.interactionId) return; incomingTask.accept().catch((error) => { + const parsed = reportWxAppTelephonyFailure( + error, + {widget: 'IncomingTask', action: 'accept'}, + logger, + store.onErrorCallback + ); + setOfferActionError(withOfferActionUserMessage(parsed, 'accept')); logError(`CC-Widgets: Error accepting incoming task: ${error}`, 'accept'); }); logger.log(`CC-Widgets: incomingTask accepted`, { @@ -316,6 +368,13 @@ export const useIncomingTask = (props: UseTaskProps) => { }); if (!incomingTask?.data.interactionId) return; incomingTask.decline().catch((error) => { + const parsed = reportWxAppTelephonyFailure( + error, + {widget: 'IncomingTask', action: 'reject'}, + logger, + store.onErrorCallback + ); + setOfferActionError(withOfferActionUserMessage(parsed, 'reject')); logError(`CC-Widgets: Error rejecting incoming task: ${error}`, 'reject'); }); logger.log(`CC-Widgets: incomingTask rejected`, { @@ -336,6 +395,8 @@ export const useIncomingTask = (props: UseTaskProps) => { reject, acceptControl, declineControl, + offerActionError, + clearOfferActionError, }; }; @@ -351,6 +412,7 @@ export const useCallControl = (props: useCallControlProps) => { isMuted, agentId, conferenceEnabled = true, + enableWxBetterTogether = false, } = props; const [isRecording, setIsRecording] = useState(true); const [controls, setControls] = useState(currentTask?.uiControls ?? getDefaultUIControls()); @@ -360,6 +422,22 @@ export const useCallControl = (props: useCallControlProps) => { const [consultAgentName, setConsultAgentName] = useState('Consult Agent'); const [startTimestamp, setStartTimestamp] = useState(0); const [secondsUntilAutoWrapup, setsecondsUntilAutoWrapup] = useState(null); + const [telephonyToast, setTelephonyToast] = useState<{ + error: WxAppTelephonyErrorDisplay; + action: TelephonyToastAction; + } | null>(null); + + const showTelephonyToast = useCallback( + (error: unknown, action: TelephonyToastAction) => { + const parsed = reportWxAppTelephonyFailure(error, {widget: 'CallControl', action}, logger, store.onErrorCallback); + setTelephonyToast({error: getTelephonyToastDisplay(parsed, action), action}); + }, + [logger] + ); + + const dismissTelephonyToast = useCallback(() => { + setTelephonyToast(null); + }, []); // State timer labels and timestamps const [stateTimerLabel, setStateTimerLabel] = useState(null); @@ -815,7 +893,11 @@ export const useCallControl = (props: useCallControlProps) => { const toggleMute = async () => { try { - if (!controls?.main?.mute?.isVisible) { + if ( + !controls?.main?.mute?.isVisible && + !controls?.consult?.mute?.isVisible && + !shouldShowWxAppTelephonyControls(enableWxBetterTogether, currentTask) + ) { logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); return; } @@ -826,7 +908,7 @@ export const useCallControl = (props: useCallControlProps) => { const intendedMuteState = !isMuted; try { - await currentTask.toggleMute(); + await currentTask.toggleMute({muted: intendedMuteState}); // Only update state after successful SDK call store.setIsMuted(intendedMuteState); @@ -841,6 +923,7 @@ export const useCallControl = (props: useCallControlProps) => { logger.info(`Mute state toggled to: ${intendedMuteState}`, {module: 'useCallControl', method: 'toggleMute'}); } catch (error) { logger.error(`toggleMute failed: ${error}`, {module: 'useCallControl', method: 'toggleMute'}); + showTelephonyToast(error, intendedMuteState ? 'mute' : 'unmute'); if (onToggleMute) { onToggleMute({ @@ -857,6 +940,25 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const sendDtmf = async (digit: string) => { + try { + if ( + !controls?.main?.keypad?.isVisible && + !shouldShowWxAppTelephonyControls(enableWxBetterTogether, currentTask) + ) { + logger.warn('Keypad control not available', {module: 'useCallControl', method: 'sendDtmf'}); + return; + } + + logger.info(`sendDtmf(${digit}) called`, {module: 'useCallControl', method: 'sendDtmf'}); + + await currentTask.transmitDtmf({dtmf: digit}); + } catch (error) { + logger.error(`sendDtmf failed: ${error}`, {module: 'useCallControl', method: 'sendDtmf'}); + showTelephonyToast(error, 'dtmf'); + } + }; + const endCall = () => { try { logger.info('endCall() called', {module: 'useCallControl', method: 'endCall'}); @@ -1231,6 +1333,7 @@ export const useCallControl = (props: useCallControlProps) => { toggleHold, toggleRecording, toggleMute, + sendDtmf, isMuted, wrapupCall, isRecording, @@ -1265,6 +1368,8 @@ export const useCallControl = (props: useCallControlProps) => { getEntryPoints, getQueuesFetcher, isCampaignCall, + telephonyToast, + dismissTelephonyToast, }; }; diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 94c1affc6..43bdfbb9f 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -50,7 +50,12 @@ export type useCallControlProps = Pick< ControlProps, 'currentTask' | 'logger' | 'isMuted' | 'conferenceEnabled' | 'agentId' > & - Partial>; + Partial< + Pick< + ControlProps, + 'onHoldResume' | 'onEnd' | 'onWrapUp' | 'onRecordingToggle' | 'onToggleMute' | 'enableWxBetterTogether' + > + >; export type useOutdialCallProps = Pick; diff --git a/packages/contact-center/task/src/wxapp-error.utils.ts b/packages/contact-center/task/src/wxapp-error.utils.ts new file mode 100644 index 000000000..5795dd522 --- /dev/null +++ b/packages/contact-center/task/src/wxapp-error.utils.ts @@ -0,0 +1,111 @@ +import {ILogger} from '@webex/cc-store'; + +export type WxAppTelephonyErrorDisplay = { + message: string; + trackingId?: string; + status?: number | string; + isWxAppTelephonyError: boolean; +}; + +export const OFFER_ACTION_ACCEPT_MESSAGE = 'Unable to answer the Call. Please try again'; +export const OFFER_ACTION_DECLINE_MESSAGE = 'Unable to decline the Call. Please try again'; + +export const getOfferActionUserMessage = (action: string): string => { + if (action === 'accept' || action === 'acceptTask') { + return OFFER_ACTION_ACCEPT_MESSAGE; + } + if (action === 'reject' || action === 'declineTask') { + return OFFER_ACTION_DECLINE_MESSAGE; + } + return OFFER_ACTION_ACCEPT_MESSAGE; +}; + +export const withOfferActionUserMessage = ( + display: WxAppTelephonyErrorDisplay, + action: string +): WxAppTelephonyErrorDisplay => ({ + ...display, + message: getOfferActionUserMessage(action), +}); + +export type TelephonyToastAction = 'mute' | 'unmute' | 'dtmf'; + +export const TELEPHONY_MUTE_MESSAGE = "Couldn't mute call. Please try again."; +export const TELEPHONY_UNMUTE_MESSAGE = "Couldn't unmute call. Please try again."; +export const TELEPHONY_DTMF_MESSAGE = "Action didn't work. Please try again."; + +export const getTelephonyToastUserMessage = (action: TelephonyToastAction): string => { + if (action === 'mute') { + return TELEPHONY_MUTE_MESSAGE; + } + if (action === 'unmute') { + return TELEPHONY_UNMUTE_MESSAGE; + } + return TELEPHONY_DTMF_MESSAGE; +}; + +export const getTelephonyToastDisplay = ( + display: WxAppTelephonyErrorDisplay, + action: TelephonyToastAction +): WxAppTelephonyErrorDisplay => ({ + ...display, + message: getTelephonyToastUserMessage(action), +}); + +type WxAppTelephonyErrorLike = Error & { + isWxAppTelephonyError?: boolean; + trackingId?: string; + status?: number | string; + statusCode?: number; +}; + +export const parseWxAppTelephonyError = (error: unknown): WxAppTelephonyErrorDisplay => { + if (error instanceof Error) { + const wxError = error as WxAppTelephonyErrorLike; + return { + message: error.message || 'Telephony request failed', + trackingId: wxError.trackingId, + status: wxError.status ?? wxError.statusCode, + isWxAppTelephonyError: !!wxError.isWxAppTelephonyError, + }; + } + + return { + message: typeof error === 'string' ? error : 'Telephony request failed', + isWxAppTelephonyError: false, + }; +}; + +export const toWxAppTelephonyError = (display: WxAppTelephonyErrorDisplay): WxAppTelephonyErrorLike => { + const err = new Error(display.message) as WxAppTelephonyErrorLike; + err.isWxAppTelephonyError = display.isWxAppTelephonyError; + if (display.trackingId) { + err.trackingId = display.trackingId; + } + if (display.status !== undefined) { + err.status = display.status; + } + return err; +}; + +export const reportWxAppTelephonyFailure = ( + error: unknown, + context: {widget: string; action: string}, + logger: ILogger, + onErrorCallback?: (widgetName: string, error: Error) => void +): WxAppTelephonyErrorDisplay => { + const parsed = parseWxAppTelephonyError(error); + + logger.error(`CC-Widgets: ${context.action} failed: ${parsed.message}`, { + module: 'wxapp-error.utils', + method: context.action, + trackingId: parsed.trackingId, + status: parsed.status, + }); + + if (onErrorCallback) { + onErrorCallback(context.widget, toWxAppTelephonyError(parsed)); + } + + return parsed; +}; 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..2c95d28da --- /dev/null +++ b/packages/contact-center/task/src/wxapp-task.utils.ts @@ -0,0 +1,16 @@ +import {ITask} from '@webex/contact-center'; + +/** + * UI visibility helpers for wxApp thick-client telephony (WXCC-6026). + * Telephony routing is owned by the SDK via task.accept/decline/toggleMute/transmitDtmf. + */ +export const isWxAppEngagedCall = (task: ITask | null | undefined): boolean => { + const voiceTask = task as ITask & {getWebexCallingCallId?: () => string | null | undefined}; + return typeof voiceTask?.getWebexCallingCallId === 'function' && !!voiceTask.getWebexCallingCallId(); +}; + +/** Thick-client main-bar Mute/Keypad visibility gate — does not affect mute API routing. */ +export const shouldShowWxAppTelephonyControls = ( + enableWxBetterTogether: boolean, + task: ITask | null | undefined +): boolean => enableWxBetterTogether === true && isWxAppEngagedCall(task); diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 3e7bae023..da2461b38 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(), @@ -65,6 +66,8 @@ describe('CallControl Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render( @@ -88,6 +91,7 @@ describe('CallControl Component', () => { isMuted: false, onToggleMute: undefined, agentId: store.agentId, + enableWxBetterTogether: false, }); }); diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index b30905ba9..3e28170fa 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(), @@ -68,6 +69,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render( @@ -94,6 +97,7 @@ describe('CallControlCAD Component', () => { isMuted: false, conferenceEnabled: undefined, agentId: store.agentId, + enableWxBetterTogether: false, }); }); @@ -127,6 +131,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -139,6 +144,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render(); @@ -180,6 +187,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -192,6 +200,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); render( @@ -236,6 +246,7 @@ describe('CallControlCAD Component', () => { secondsUntilAutoWrapup: 0, cancelAutoWrapup: jest.fn(), toggleMute: jest.fn(), + sendDtmf: jest.fn(), isMuted: false, consultConference: jest.fn(), exitConference: jest.fn(), @@ -248,6 +259,8 @@ describe('CallControlCAD Component', () => { consultTimerLabel: 'Consulting', consultTimerTimestamp: 0, isCampaignCall: false, + telephonyToast: null, + dismissTelephonyToast: jest.fn(), }); const {container} = render( diff --git a/packages/contact-center/task/tests/IncomingTask/index.tsx b/packages/contact-center/task/tests/IncomingTask/index.tsx index e15667071..c4a829609 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,31 @@ 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}, + offerActionError: null, + clearOfferActionError: jest.fn(), + }); + + 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'); @@ -36,6 +68,8 @@ describe('IncomingTask Component', () => { reject: jest.fn(), acceptControl: {isVisible: true, isEnabled: true}, declineControl: {isVisible: true, isEnabled: true}, + offerActionError: null, + clearOfferActionError: jest.fn(), }); render(); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..a577c7b94 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -7076,3 +7076,508 @@ describe('Task Hook Error Handling and Logging', () => { }); }); }); + +describe('WXCC-6026 wxApp thick-client hooks', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('useIncomingTask accept calls task.accept()', async () => { + const accept = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + 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(accept).toHaveBeenCalled(); + }); + + it('useIncomingTask reject calls task.decline()', async () => { + const decline = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...taskMock, + accept: jest.fn(), + decline, + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.reject(); + }); + + expect(decline).toHaveBeenCalled(); + }); + + it('useCallControl toggleMute calls task.toggleMute with target state', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + 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(toggleMute).toHaveBeenCalledWith({muted: true}); + }); + + it('useCallControl sendDtmf calls task.transmitDtmf for engaged wxApp calls', async () => { + const transmitDtmf = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + transmitDtmf, + 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(transmitDtmf).toHaveBeenCalledWith({dtmf: '5'}); + }); + + it('useTaskList acceptTask calls task.accept()', async () => { + const accept = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + }; + const mockTaskList = {mockId1: wxAppTask}; + + const {result} = renderHook(() => useTaskList({cc: mockCC, onTaskAccepted, logger, taskList: mockTaskList})); + + act(() => { + result.current.acceptTask(wxAppTask); + }); + + await waitFor(() => { + expect(accept).toHaveBeenCalled(); + }); + }); + + it('useTaskList declineTask calls task.decline()', async () => { + const decline = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...taskMock, + accept: jest.fn(), + decline, + }; + const mockTaskList = {mockId1: wxAppTask}; + + const {result} = renderHook(() => useTaskList({cc: mockCC, onTaskDeclined, logger, taskList: mockTaskList})); + + act(() => { + result.current.declineTask(wxAppTask); + }); + + await waitFor(() => { + expect(decline).toHaveBeenCalled(); + }); + }); + + it('useIncomingTask accept uses task.accept for non-wxApp offers', async () => { + const accept = jest.fn().mockResolvedValue(undefined); + const legacyTask = { + ...taskMock, + accept, + decline: jest.fn(), + 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(); + }); + + it('useCallControl toggleMute calls task.toggleMute for non-wxApp calls', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const legacyTask = { + ...mockTask, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue(null), + 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).toHaveBeenCalledWith({muted: true}); + }); + + it('useCallControl sendDtmf no-ops when keypad control is not visible', async () => { + const transmitDtmf = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + transmitDtmf, + 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(transmitDtmf).not.toHaveBeenCalled(); + expect(mockCC.LoggerProxy.warn).toHaveBeenCalledWith('Keypad control not available', { + module: 'useCallControl', + method: 'sendDtmf', + }); + }); + + it('useIncomingTask accept failure surfaces offerActionError', async () => { + const telephonyError = Object.assign(new Error('Answer failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-accept', + status: 500, + }); + const accept = jest.fn().mockRejectedValue(telephonyError); + const onErrorCallback = jest.fn(); + store.onErrorCallback = onErrorCallback; + const wxAppTask = { + ...taskMock, + accept, + decline: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }; + + const {result} = renderHook(() => + useIncomingTask({ + incomingTask: wxAppTask, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + await act(async () => { + await result.current.accept(); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.offerActionError).toMatchObject({ + message: 'Unable to answer the Call. Please try again', + trackingId: 'track-accept', + status: 500, + }); + expect(onErrorCallback).toHaveBeenCalledWith('IncomingTask', expect.objectContaining({message: 'Answer failed'})); + }); + + it('useCallControl toggleMute failure surfaces telephonyToast', async () => { + const telephonyError = Object.assign(new Error('Mute failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-mute', + }); + const toggleMute = jest.fn().mockRejectedValue(telephonyError); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + 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(result.current.telephonyToast).toMatchObject({ + action: 'mute', + error: expect.objectContaining({ + message: "Couldn't mute call. Please try again.", + trackingId: 'track-mute', + }), + }); + }); + + it('useCallControl toggleMute failure while muted surfaces unmute telephonyToast', async () => { + const telephonyError = Object.assign(new Error('Unmute failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-unmute', + }); + const toggleMute = jest.fn().mockRejectedValue(telephonyError); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: createEnabledMainTaskUIControls(), + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => true); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: true, + conferenceEnabled: false, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(result.current.telephonyToast).toMatchObject({ + action: 'unmute', + error: expect.objectContaining({ + message: "Couldn't unmute call. Please try again.", + trackingId: 'track-unmute', + }), + }); + }); + + it('useCallControl toggleMute allows wxApp path when SDK hides mute but enableWxBetterTogether is true', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + mute: {isVisible: false, isEnabled: false}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + + const {result} = renderHook(() => + useCallControl({ + currentTask: wxAppTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + enableWxBetterTogether: true, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).toHaveBeenCalledWith({muted: true}); + }); + + it('useCallControl toggleMute no-ops when SDK hides mute and enableWxBetterTogether is false despite wxApp call id', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const wxAppTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'wxapp-interaction'}, + toggleMute, + getWebexCallingCallId: jest.fn().mockReturnValue('call-123'), + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + mute: {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', + enableWxBetterTogether: false, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).not.toHaveBeenCalled(); + expect(mockCC.LoggerProxy.warn).toHaveBeenCalledWith('Mute control not available', { + module: 'useCallControl', + method: 'toggleMute', + }); + }); + + it('useCallControl toggleMute routes via task.toggleMute when only consult.mute is visible', async () => { + const toggleMute = jest.fn().mockResolvedValue(undefined); + const consultTask = { + ...mockTask, + data: {...mockTask.data, interactionId: 'consult-interaction'}, + toggleMute, + uiControls: { + ...createEnabledMainTaskUIControls(), + main: { + ...createEnabledMainTaskUIControls().main, + mute: {isVisible: false, isEnabled: false}, + }, + consult: { + ...createEnabledMainTaskUIControls().consult, + mute: {isVisible: true, isEnabled: true}, + }, + }, + on: jest.fn(), + off: jest.fn(), + }; + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + + const {result} = renderHook(() => + useCallControl({ + currentTask: consultTask, + logger: mockCC.LoggerProxy, + isMuted: false, + conferenceEnabled: false, + agentId: 'agent1', + enableWxBetterTogether: true, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(toggleMute).toHaveBeenCalled(); + }); +}); diff --git a/packages/contact-center/task/tests/wxapp-error.utils.test.ts b/packages/contact-center/task/tests/wxapp-error.utils.test.ts new file mode 100644 index 000000000..1fbfa2470 --- /dev/null +++ b/packages/contact-center/task/tests/wxapp-error.utils.test.ts @@ -0,0 +1,144 @@ +import { + getOfferActionUserMessage, + getTelephonyToastDisplay, + getTelephonyToastUserMessage, + OFFER_ACTION_ACCEPT_MESSAGE, + OFFER_ACTION_DECLINE_MESSAGE, + parseWxAppTelephonyError, + reportWxAppTelephonyFailure, + TELEPHONY_DTMF_MESSAGE, + TELEPHONY_MUTE_MESSAGE, + TELEPHONY_UNMUTE_MESSAGE, + toWxAppTelephonyError, + withOfferActionUserMessage, +} from '../src/wxapp-error.utils'; + +const logger = { + error: jest.fn(), + info: jest.fn(), + log: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), +}; + +describe('wxapp-error.utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('parseWxAppTelephonyError', () => { + it('extracts structured wxApp telephony fields from Error', () => { + const error = Object.assign(new Error('TELEPHONY_ERROR'), { + isWxAppTelephonyError: true, + trackingId: 'track-123', + status: 400, + }); + + expect(parseWxAppTelephonyError(error)).toEqual({ + message: 'TELEPHONY_ERROR', + trackingId: 'track-123', + status: 400, + isWxAppTelephonyError: true, + }); + }); + + it('returns generic message for unknown errors', () => { + expect(parseWxAppTelephonyError('network down')).toEqual({ + message: 'network down', + isWxAppTelephonyError: false, + }); + }); + }); + + describe('reportWxAppTelephonyFailure', () => { + it('logs, invokes onErrorCallback, and returns parsed display', () => { + const onErrorCallback = jest.fn(); + const error = Object.assign(new Error('Mute failed'), { + isWxAppTelephonyError: true, + trackingId: 'track-mute', + status: 503, + }); + + const parsed = reportWxAppTelephonyFailure( + error, + {widget: 'CallControl', action: 'Mute'}, + logger, + onErrorCallback + ); + + expect(parsed.trackingId).toBe('track-mute'); + expect(logger.error).toHaveBeenCalled(); + expect(onErrorCallback).toHaveBeenCalledWith('CallControl', expect.objectContaining({message: 'Mute failed'})); + }); + }); + + describe('getOfferActionUserMessage', () => { + it('returns accept message for accept actions', () => { + expect(getOfferActionUserMessage('accept')).toBe(OFFER_ACTION_ACCEPT_MESSAGE); + expect(getOfferActionUserMessage('acceptTask')).toBe(OFFER_ACTION_ACCEPT_MESSAGE); + }); + + it('returns decline message for decline actions', () => { + expect(getOfferActionUserMessage('reject')).toBe(OFFER_ACTION_DECLINE_MESSAGE); + expect(getOfferActionUserMessage('declineTask')).toBe(OFFER_ACTION_DECLINE_MESSAGE); + }); + }); + + describe('withOfferActionUserMessage', () => { + it('replaces SDK message with user-facing offer action text', () => { + const display = withOfferActionUserMessage( + { + message: 'Answer failed', + trackingId: 'track-accept', + status: 500, + isWxAppTelephonyError: true, + }, + 'accept' + ); + + expect(display.message).toBe(OFFER_ACTION_ACCEPT_MESSAGE); + expect(display.trackingId).toBe('track-accept'); + }); + }); + + describe('getTelephonyToastUserMessage', () => { + it('returns user-facing messages for mute, unmute, and dtmf actions', () => { + expect(getTelephonyToastUserMessage('mute')).toBe(TELEPHONY_MUTE_MESSAGE); + expect(getTelephonyToastUserMessage('unmute')).toBe(TELEPHONY_UNMUTE_MESSAGE); + expect(getTelephonyToastUserMessage('dtmf')).toBe(TELEPHONY_DTMF_MESSAGE); + }); + }); + + describe('getTelephonyToastDisplay', () => { + it('replaces SDK message with user-facing telephony toast text', () => { + const display = getTelephonyToastDisplay( + { + message: 'Mute failed', + trackingId: 'track-mute', + status: 503, + isWxAppTelephonyError: true, + }, + 'mute' + ); + + expect(display.message).toBe(TELEPHONY_MUTE_MESSAGE); + expect(display.trackingId).toBe('track-mute'); + }); + }); + + describe('toWxAppTelephonyError', () => { + it('builds Error with wxApp telephony metadata', () => { + const err = toWxAppTelephonyError({ + message: 'Reject failed', + trackingId: 'track-reject', + status: 404, + isWxAppTelephonyError: true, + }); + + expect(err.message).toBe('Reject failed'); + expect(err.trackingId).toBe('track-reject'); + expect(err.status).toBe(404); + expect(err.isWxAppTelephonyError).toBe(true); + }); + }); +}); 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..d8b995c77 --- /dev/null +++ b/packages/contact-center/task/tests/wxapp-task.utils.test.ts @@ -0,0 +1,54 @@ +import {ITask} from '@webex/contact-center'; +import {isWxAppEngagedCall, shouldShowWxAppTelephonyControls} 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', () => { + 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); + }); + + it('returns false when helper is missing', () => { + expect(isWxAppEngagedCall(baseTask)).toBe(false); + }); + }); + + describe('shouldShowWxAppTelephonyControls', () => { + it('returns true when flag is on and wxApp call id is set', () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + } as ITask; + expect(shouldShowWxAppTelephonyControls(true, task)).toBe(true); + }); + + it('returns false when flag is off even with wxApp call id', () => { + const task = { + ...baseTask, + getWebexCallingCallId: () => 'call-123', + } as ITask; + expect(shouldShowWxAppTelephonyControls(false, task)).toBe(false); + }); + + it('returns false when flag is on but wxApp is not engaged', () => { + expect(shouldShowWxAppTelephonyControls(true, baseTask)).toBe(false); + }); + }); +}); 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..765d1999e 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -74,6 +74,11 @@ function App() { const [collapsedTasks, setCollapsedTasks] = React.useState([]); const [showLoader, setShowLoader] = useState(false); const [toast, setToast] = useState<{type: 'success' | 'error'} | null>(null); + const [telephonyError, setTelephonyError] = useState<{ + widgetName: string; + message: string; + trackingId?: string; + } | null>(null); const [integrationEnv, setintegrationEnv] = useState(() => { const savedintegrationEnv = window.localStorage.getItem('integrationEnv'); return savedintegrationEnv === 'true'; @@ -94,6 +99,9 @@ function App() { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; }); + const [enableWxBetterTogether, setEnableWxBetterTogether] = useState(() => { + return window.localStorage.getItem('enableWxBetterTogether') === 'true'; + }); const [isWebRTCWidgetSelectionLocked, setIsWebRTCWidgetSelectionLocked] = useState(() => { const savedDisableWebRTCRegistration = window.localStorage.getItem('disableWebRTCRegistration'); return savedDisableWebRTCRegistration === 'true'; @@ -151,6 +159,7 @@ function App() { cc: { allowMultiLogin: isMultiLoginEnabled, disableWebRTCRegistration, + enableWxBetterTogether, }, ...(integrationEnv && { services: { @@ -235,6 +244,12 @@ function App() { } }; + const handleEnableWxBetterTogetherChange = () => { + const next = !enableWxBetterTogether; + setEnableWxBetterTogether(next); + window.localStorage.setItem('enableWxBetterTogether', next ? 'true' : 'false'); + }; + const toggleDisableWebRTCRegistration = () => { const newValue = !disableWebRTCRegistration; @@ -361,6 +376,7 @@ function App() { }, cc: { disableWebRTCRegistration, + enableWxBetterTogether, }, }, }; @@ -396,6 +412,10 @@ function App() { window.localStorage.setItem('disableWebRTCRegistration', JSON.stringify(disableWebRTCRegistration)); }, [disableWebRTCRegistration]); + useEffect(() => { + window.localStorage.setItem('enableWxBetterTogether', enableWxBetterTogether ? 'true' : 'false'); + }, [enableWxBetterTogether]); + useEffect(() => { if (!disableWebRTCRegistration) { setIsWebRTCWidgetSelectionLocked(false); @@ -436,6 +456,21 @@ function App() { }; }, []); + useEffect(() => { + const handlePageHide = () => { + if (!store.isAgentLoggedIn && !isLoggedIn) { + return; + } + + void store.cc + ?.stationLogout({logoutReason: 'Page unload'}) + .catch(() => undefined); + }; + + window.addEventListener('pagehide', handlePageHide); + return () => window.removeEventListener('pagehide', handlePageHide); + }, [isLoggedIn]); + useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && showOutdialFailedModal) { @@ -453,6 +488,20 @@ function App() { }, [showOutdialFailedModal]); const onError = (widgetName: string, error: Error) => { + const wxError = error as Error & {isWxAppTelephonyError?: boolean; trackingId?: string}; + if (wxError.isWxAppTelephonyError) { + // CallControl / CallControlCAD show TelephonyActionToast — avoid duplicate host toast. + if (widgetName === 'CallControl' || widgetName === 'CallControlCAD') { + console.log('WxApp telephony error (widget toast):', widgetName, error.message, wxError.trackingId); + return; + } + setTelephonyError({ + widgetName, + message: error.message, + trackingId: wxError.trackingId, + }); + return; + } console.log('Error in widgets:', widgetName, error); }; @@ -531,6 +580,28 @@ function App() { )} + {telephonyError && ( +
+
+
Telephony error ({telephonyError.widgetName})
+
{telephonyError.message}
+ {telephonyError.trackingId ?
Tracking ID: {telephonyError.trackingId}
: null} +
+
+ )} +
@@ -736,6 +807,42 @@ function App() { +