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 70791a63b..d9e65993d 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 @@ -1,7 +1,7 @@ -import React from 'react'; +import React, {useState} from 'react'; import {ButtonCircle, TooltipNext, Text} from '@momentum-ui/react-collaboration'; import {Avatar, Icon} from '@momentum-design/components/dist/react'; - +import {MUTE_CALL, UNMUTE_CALL} from '../../constants'; import TaskTimer from '../../TaskTimer'; import {CallControlConsultComponentsProps} from '../../task.types'; @@ -14,7 +14,12 @@ const CallControlConsultComponent: React.FC = isAgentBeingConsulted, isEndConsultEnabled, logger, + muteUnmute, + isMuted, + onToggleConsultMute, }) => { + const [isMuteDisabled, setIsMuteDisabled] = useState(false); + const timerKey = `timer-${startTimeStamp}`; const handleTransfer = () => { @@ -51,7 +56,35 @@ const CallControlConsultComponent: React.FC = } }; + const handleConsultMuteToggle = () => { + setIsMuteDisabled(true); + + try { + onToggleConsultMute(); + } catch (error) { + logger.error('Mute toggle failed:', { + error, + module: 'call-control-consult.tsx', + method: 'handleConsultMuteToggle', + }); + } finally { + // Re-enable button after operation + setTimeout(() => { + setIsMuteDisabled(false); + }, 500); + } + }; + const buttons = [ + { + key: 'mute', + icon: isMuted ? 'microphone-muted-bold' : 'microphone-bold', + onClick: handleConsultMuteToggle, + tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, + className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, + disabled: isMuteDisabled, + shouldShow: muteUnmute, + }, { key: 'transfer', icon: 'next-bold', 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 c6e1e0405..c27d7c03b 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 @@ -137,6 +137,37 @@ --mdc-icon-fill-color: var(--mds-color-theme-button-cancel-normal); } +.md-button-circle-wrapper.call-control-button-muted { + width: 1rem; + height: 1rem; + min-width: 2rem; + min-height: 2rem; + padding: 0.5rem; + border-radius: 50%; + background-color: var(--mds-color-theme-button-secondary-normal); + border: 1px solid var(--mds-color-theme-outline-cancel-normal); + &:hover, + &.hover { + background-color: var(--mds-color-theme-button-secondary-hover); + } + .call-control-button-muted-icon { + --mdc-icon-fill-color: var(--mds-color-theme-text-error-normal); + } +} + +.md-button-circle-wrapper.call-control-button-muted[disabled], +.md-button-circle-wrapper.call-control-button-muted.shallowDisabled, +.md-button-circle-wrapper.call-control-button-muted:disabled { + outline-color: var(--mds-color-theme-button-primary-disabled); + border-color: var(--mds-color-theme-button-primary-disabled); + opacity: 0.5; + cursor: not-allowed; + .call-control-button-muted-icon { + --mdc-icon-fill-color: var(--mds--color-theme-button-secondary-normal); + } +} + + .wrapup-button { display: inline-flex; justify-content: center; 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 b8afefe27..629df947f 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 @@ -21,6 +21,8 @@ import { WRAP_UP_REASON, SELECT, SUBMIT_WRAP_UP, + MUTE_CALL, + UNMUTE_CALL, } from '../constants'; function CallControlComponent(props: CallControlComponentProps) { @@ -28,11 +30,14 @@ function CallControlComponent(props: CallControlComponentProps) { const [selectedWrapupId, setSelectedWrapupId] = useState(null); const [showAgentMenu, setShowAgentMenu] = useState(false); const [agentMenuType, setAgentMenuType] = useState(null); + const [isMuteButtonDisabled, setIsMuteButtonDisabled] = useState(false); const { currentTask, toggleHold, toggleRecording, + toggleMute, + isMuted, endCall, wrapupCall, wrapupCodes, @@ -82,6 +87,25 @@ function CallControlComponent(props: CallControlComponentProps) { setIsHeld(!isHeld); }; + const handleMuteToggle = () => { + setIsMuteButtonDisabled(true); + + try { + toggleMute(); + } catch (error) { + logger.error('Mute toggle failed:', { + error, + module: 'call-control.tsx', + method: 'handleMuteToggle', + }); + } finally { + // Re-enable button after operation + setTimeout(() => { + setIsMuteButtonDisabled(false); + }, 500); + } + }; + const handleWrapupCall = () => { logger.info('CC-Widgets: CallControl: wrap-up submitted', { module: 'call-control.tsx', @@ -151,6 +175,15 @@ function CallControlComponent(props: CallControlComponentProps) { const isTelephony = mediaType === 'telephony'; const buttons = [ + { + id: 'mute', + icon: isMuted ? 'microphone-muted-bold' : 'microphone-bold', + onClick: handleMuteToggle, + tooltip: isMuted ? UNMUTE_CALL : MUTE_CALL, + className: `${isMuted ? 'call-control-button-muted' : 'call-control-button'}`, + disabled: isMuteButtonDisabled, + isVisible: controlVisibility.muteUnmute, + }, { id: 'hold', icon: isHeld ? 'play-bold' : 'pause-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 fdacfd384..bdacb6e56 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 @@ -41,6 +41,8 @@ const CallControlCADComponent: React.FC = (props) => lastTargetType, controlVisibility, logger, + isMuted, + toggleMute, } = props; const formatTime = (time: number): string => { @@ -207,6 +209,9 @@ const CallControlCADComponent: React.FC = (props) => isAgentBeingConsulted={!consultAccepted} isEndConsultEnabled={isEndConsultEnabled} logger={logger} + muteUnmute={controlVisibility.muteUnmute} + isMuted={isMuted} + onToggleConsultMute={toggleMute} /> )} diff --git a/packages/contact-center/cc-components/src/components/task/constants.ts b/packages/contact-center/cc-components/src/components/task/constants.ts index 0fce18bff..166468754 100644 --- a/packages/contact-center/cc-components/src/components/task/constants.ts +++ b/packages/contact-center/cc-components/src/components/task/constants.ts @@ -13,6 +13,8 @@ export const WRAP_UP_INTERACTION = 'Wrap up interaction'; export const WRAP_UP_REASON = 'Wrap-up reason'; export const SELECT = 'Select'; export const SUBMIT_WRAP_UP = 'Submit & Wrap up'; +export const MUTE_CALL = 'Mute'; +export const UNMUTE_CALL = 'Unmute'; // CallControlCAD constants export const NO_CUSTOMER_NAME = 'No Customer Name'; 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 fc11255c8..bde8e9856 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 @@ -149,6 +149,14 @@ export interface ControlProps { */ onRecordingToggle?: ({isRecording, task}: {isRecording: boolean; task: ITask}) => void; + /** + * Function to handle mute/unmute toggle actions. + * @param isMuted - Boolean indicating whether the task is muted. + * @param task - The current task being handled. + * @returns void + */ + onToggleMute?: ({isMuted, task}: {isMuted: boolean; task: ITask}) => void; + /** * Function to handle ending the task. * @param task - The current task being handled. @@ -191,6 +199,11 @@ export interface ControlProps { */ toggleRecording: () => void; + /** + * Function to handle mute/unmute actions. + */ + toggleMute: () => void; + /** * Function to handle ending the call. */ @@ -230,6 +243,11 @@ export interface ControlProps { */ setIsRecording: (isRecording: boolean) => void; + /** + * Flag to determine if the task is muted. + */ + isMuted: boolean; + /** * List of buddy agents available for consult */ @@ -399,6 +417,8 @@ export type CallControlComponentProps = Pick< | 'wrapupCodes' | 'toggleHold' | 'toggleRecording' + | 'toggleMute' + | 'isMuted' | 'endCall' | 'wrapupCall' | 'isHeld' @@ -496,6 +516,9 @@ export interface CallControlConsultComponentsProps { isAgentBeingConsulted: boolean; isEndConsultEnabled: boolean; logger: ILogger; + muteUnmute: boolean; + isMuted: boolean; + onToggleConsultMute: () => void; } /** diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 453833e37..b534681a2 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -51,6 +51,7 @@ class Store implements IStore { isEndConsultEnabled: boolean = false; allowConsultToQueue: boolean = false; agentProfile: AgentLoginProfile = {}; + isMuted: boolean = false; constructor() { makeAutoObservable(this, { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 40cec0c03..8391cd8bb 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -69,6 +69,7 @@ interface IStore { isEndConsultEnabled: boolean; allowConsultToQueue: boolean; agentProfile: AgentLoginProfile; + isMuted: boolean; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; } @@ -93,6 +94,7 @@ interface IStoreWrapper extends IStore { setConsultStartTimeStamp(timestamp: number): void; setAgentProfile(profile: Profile): void; setTeamId(id: string): void; + setIsMuted(value: boolean): void; } interface IWrapupCode { @@ -125,6 +127,7 @@ enum TASK_EVENTS { AGENT_CONSULT_CREATED = 'AgentConsultCreated', TASK_RECORDING_PAUSED = 'task:recordingPaused', TASK_RECORDING_RESUMED = 'task:recordingResumed', + TASK_OFFER_CONSULT = 'task:offerConsult', } // TODO: remove this once cc sdk exports this enum // Events that are received on the contact center SDK diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 0e0a0ca6d..a6263d3b9 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -149,6 +149,16 @@ class StoreWrapper implements IStoreWrapper { return this.store.agentProfile; } + get isMuted() { + return this.store.isMuted; + } + + setIsMuted = (value: boolean): void => { + runInAction(() => { + this.store.isMuted = value; + }); + }; + setCurrentTheme = (theme: string): void => { this.store.currentTheme = theme; }; @@ -356,7 +366,7 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_REJECT, (reason) => this.handleTaskReject(taskToRemove, reason)); taskToRemove.off(TASK_EVENTS.AGENT_WRAPPEDUP, this.handleTaskWrapUp); taskToRemove.off(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); - taskToRemove.off(CC_EVENTS.AGENT_OFFER_CONSULT, this.handleConsultOffer); + taskToRemove.off(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); taskToRemove.off(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); taskToRemove.off(TASK_EVENTS.TASK_CONSULT_ACCEPTED, this.handleConsultAccepted); taskToRemove.off(TASK_EVENTS.AGENT_CONSULT_CREATED, this.handleConsultCreated); @@ -386,6 +396,16 @@ class StoreWrapper implements IStoreWrapper { }); }; + handleTaskMuteState = (task: ITask): void => { + const isBrowser = this.deviceType === 'BROWSER'; + const webRtcEnabled = this.featureFlags?.webRtcEnabled; + const isTelephony = task?.data?.interaction?.mediaType === 'telephony'; + + if (isBrowser && isTelephony && webRtcEnabled) { + this.setIsMuted(false); + } + }; + handleTaskEnd = () => { this.refreshTaskList(); }; @@ -497,7 +517,7 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); task.on(TASK_EVENTS.TASK_CONSULT_ACCEPTED, this.handleConsultAccepted); - task.on(CC_EVENTS.AGENT_OFFER_CONSULT, this.handleConsultOffer); + task.on(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); task.on(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); task.on(TASK_EVENTS.TASK_HOLD, this.refreshTaskList); task.on(TASK_EVENTS.TASK_UNHOLD, this.refreshTaskList); @@ -506,6 +526,7 @@ class StoreWrapper implements IStoreWrapper { // If it is, we dont have to send the incoming task callback if (this.onIncomingTask && !this.taskList[task.data.interactionId]) { this.onIncomingTask({task}); + this.handleTaskMuteState(task); } // We should update the task list in the store after sending the incoming task callback @@ -555,7 +576,7 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.AGENT_WRAPPEDUP, this.handleTaskWrapUp); task.on(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); - task.on(CC_EVENTS.AGENT_OFFER_CONSULT, this.handleConsultOffer); + task.on(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); task.on(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); if (this.deviceType === 'BROWSER') { diff --git a/packages/contact-center/task/src/CallControl/index.tsx b/packages/contact-center/task/src/CallControl/index.tsx index 82929a599..d4db7ad42 100644 --- a/packages/contact-center/task/src/CallControl/index.tsx +++ b/packages/contact-center/task/src/CallControl/index.tsx @@ -7,7 +7,7 @@ import {CallControlProps} from '../task.types'; import {CallControlComponent} from '@webex/cc-components'; const CallControl: React.FunctionComponent = observer( - ({onHoldResume, onEnd, onWrapUp, onRecordingToggle}) => { + ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, onToggleMute}) => { const { logger, currentTask, @@ -21,6 +21,7 @@ const CallControl: React.FunctionComponent = observer( featureFlags, isEndConsultEnabled, allowConsultToQueue, + isMuted, } = store; const result = { @@ -30,10 +31,12 @@ const CallControl: React.FunctionComponent = observer( onEnd, onWrapUp, onRecordingToggle, + onToggleMute, logger, consultInitiated, deviceType, featureFlags, + isMuted, }), wrapupCodes, consultInitiated, diff --git a/packages/contact-center/task/src/CallControlCAD/index.tsx b/packages/contact-center/task/src/CallControlCAD/index.tsx index 29d395a1f..25fdf0137 100644 --- a/packages/contact-center/task/src/CallControlCAD/index.tsx +++ b/packages/contact-center/task/src/CallControlCAD/index.tsx @@ -7,7 +7,15 @@ import {CallControlProps} from '../task.types'; import {CallControlCADComponent} from '@webex/cc-components'; const CallControlCAD: React.FunctionComponent = observer( - ({onHoldResume, onEnd, onWrapUp, onRecordingToggle, callControlClassName, callControlConsultClassName}) => { + ({ + onHoldResume, + onEnd, + onWrapUp, + onRecordingToggle, + onToggleMute, + callControlClassName, + callControlConsultClassName, + }) => { const { logger, currentTask, @@ -21,6 +29,7 @@ const CallControlCAD: React.FunctionComponent = observer( allowConsultToQueue, featureFlags, deviceType, + isMuted, } = store; const result = { ...useCallControl({ @@ -29,10 +38,12 @@ const CallControlCAD: React.FunctionComponent = observer( onEnd, onWrapUp, onRecordingToggle, + onToggleMute, logger, consultInitiated, deviceType, featureFlags, + isMuted, }), wrapupCodes, consultInitiated, diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index e84ca82b4..1d0de2309 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -164,10 +164,12 @@ export const useCallControl = (props: useCallControlProps) => { onEnd, onWrapUp, onRecordingToggle, + onToggleMute, logger, consultInitiated, deviceType, featureFlags, + isMuted, } = props; const [isHeld, setIsHeld] = useState(undefined); const [isRecording, setIsRecording] = useState(true); @@ -426,6 +428,43 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const toggleMute = async () => { + if (!controlVisibility?.muteUnmute) { + logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); + return; + } + + logger.info('toggleMute() called', {module: 'useCallControl', method: 'toggleMute'}); + + // Store the intended new state + const intendedMuteState = !isMuted; + + try { + await currentTask.toggleMute(); + + // Only update state after successful SDK call + store.setIsMuted(intendedMuteState); + + if (onToggleMute) { + onToggleMute({ + isMuted: intendedMuteState, + task: currentTask, + }); + } + + logger.info(`Mute state toggled to: ${intendedMuteState}`, {module: 'useCallControl', method: 'toggleMute'}); + } catch (error) { + logger.error(`toggleMute failed: ${error}`, {module: 'useCallControl', method: 'toggleMute'}); + + if (onToggleMute) { + onToggleMute({ + isMuted: isMuted, + task: currentTask, + }); + } + } + }; + const endCall = () => { logger.info('endCall() called', {module: 'useCallControl', method: 'endCall'}); currentTask.end().catch((e) => logger.error(`endCall failed: ${e}`, {module: 'useCallControl', method: 'endCall'})); @@ -573,6 +612,8 @@ export const useCallControl = (props: useCallControlProps) => { endCall, toggleHold, toggleRecording, + toggleMute, + isMuted, wrapupCall, isHeld, setIsHeld, diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index f9491e514..7f27c9638 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -10,7 +10,13 @@ export type TaskListProps = Pick; export type useCallControlProps = Pick< @@ -20,10 +26,12 @@ export type useCallControlProps = Pick< | 'onEnd' | 'onWrapUp' | 'onRecordingToggle' + | 'onToggleMute' | 'logger' | 'consultInitiated' | 'deviceType' | 'featureFlags' + | 'isMuted' >; export type Participant = { diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index f1accc31d..4151abd59 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,9 +1,21 @@ import {renderHook, act, waitFor} from '@testing-library/react'; import {useIncomingTask, useTaskList, useCallControl, useOutdialCall} from '../src/helper'; +import {getControlsVisibility} from '../src/Utils/task-util'; import {TASK_EVENTS} from '@webex/cc-store'; import store from '@webex/cc-store'; import React from 'react'; +jest.mock('../src/Utils/task-util', () => { + const actual = jest.requireActual('../src/Utils/task-util'); + return { + ...actual, + getControlsVisibility: jest.fn(), + }; +}); + +// Cast it to a mock +const mockGetControlsVisibility = getControlsVisibility as jest.MockedFunction; + // Mock webex instance and task const ccMock = { on: jest.fn(), @@ -578,6 +590,24 @@ describe('useCallControl', () => { // Mock URL.createObjectURL global.URL.createObjectURL = jest.fn().mockImplementation(() => 'mocked-worker-url'); jest.clearAllMocks(); + + mockGetControlsVisibility.mockClear(); + + const mockControlVisibility = { + muteUnmute: true, + holdResume: true, + transfer: true, + consult: true, + end: true, + accept: true, + decline: true, + pauseResumeRecording: true, + recordingIndicator: true, + wrapup: false, + endConsult: false, + conference: false, + }; + mockGetControlsVisibility.mockReturnValue(mockControlVisibility); }); afterEach(() => { @@ -1866,6 +1896,233 @@ describe('useCallControl', () => { method: 'useCallControl#cancelAutoWrapup', }); }); + + describe('toggleMute functionality', () => { + const mockOnToggleMute = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockCurrentTask.toggleMute = jest.fn(() => Promise.resolve()); + + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + mockOnToggleMute.mockClear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should successfully toggle mute from unmuted to muted', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockLogger.info).toHaveBeenCalledWith('toggleMute() called', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); + expect(store.setIsMuted).toHaveBeenCalledWith(true); + expect(mockOnToggleMute).toHaveBeenCalledWith({ + isMuted: true, + task: mockCurrentTask, + }); + expect(mockLogger.info).toHaveBeenCalledWith('Mute state toggled to: true', { + module: 'useCallControl', + method: 'toggleMute', + }); + }); + + it('should successfully toggle mute from muted to unmuted', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: true, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockLogger.info).toHaveBeenCalledWith('toggleMute() called', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); + expect(store.setIsMuted).toHaveBeenCalledWith(false); + expect(mockOnToggleMute).toHaveBeenCalledWith({ + isMuted: false, + task: mockCurrentTask, + }); + expect(mockLogger.info).toHaveBeenCalledWith('Mute state toggled to: false', { + module: 'useCallControl', + method: 'toggleMute', + }); + }); + + it('should handle multiple rapid toggleMute calls correctly', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + }) + ); + + await act(async () => { + await Promise.all([result.current.toggleMute(), result.current.toggleMute(), result.current.toggleMute()]); + }); + + expect(mockCurrentTask.toggleMute).toHaveBeenCalledTimes(3); + expect(store.setIsMuted).toHaveBeenCalledTimes(3); + expect(mockOnToggleMute).toHaveBeenCalledTimes(3); + }); + + it('should not call onToggleMute callback if not provided', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); + expect(store.setIsMuted).toHaveBeenCalledWith(true); + expect(mockOnToggleMute).not.toHaveBeenCalled(); + }); + + it('should not call onToggleMute callback on error if not provided', async () => { + const toggleMuteError = new Error('Toggle mute failed'); + mockCurrentTask.toggleMute = jest.fn().mockRejectedValue(toggleMuteError); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); + expect(store.setIsMuted).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith('toggleMute failed: Error: Toggle mute failed', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockOnToggleMute).not.toHaveBeenCalled(); + }); + + it('should handle errors when toggleMute SDK call fails and call onToggleMute with current state', async () => { + const toggleMuteError = new Error('SDK Toggle mute failed'); + mockCurrentTask.toggleMute = jest.fn().mockRejectedValue(toggleMuteError); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: true, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockLogger.info).toHaveBeenCalledWith('toggleMute() called', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); + expect(store.setIsMuted).not.toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith('toggleMute failed: Error: SDK Toggle mute failed', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockOnToggleMute).toHaveBeenCalledWith({ + isMuted: true, + task: mockCurrentTask, + }); + }); + + it('should return toggleMute function and isMuted state in hook result', () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + }) + ); + + expect(typeof result.current.toggleMute).toBe('function'); + expect(typeof result.current.isMuted).toBe('boolean'); + }); + + it('should handle controlVisibility being undefined', async () => { + mockGetControlsVisibility.mockReturnValue(undefined); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockLogger.warn).toHaveBeenCalledWith('Mute control not available', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockCurrentTask.toggleMute).not.toHaveBeenCalled(); + }); + }); }); describe('useOutdialCall', () => { 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 5d0ea298d..4e83aacf8 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -185,6 +185,10 @@ const onTaskDeclined = (task,reason) => { if (params && params.wrapUpReason) console.log(`onWrapup invoked with reason : ${params.wrapUpReason}`); }; +const onToggleMute = ({isMuted, task}) => { + console.log('onToggleMute invoked', {isMuted, task}); +}; + const enableDisableMultiLogin = () => { if (isMultiLoginEnabled) { setIsMultiLoginEnabled(false); @@ -739,7 +743,7 @@ const onTaskDeclined = (task,reason) => {
Call Control - +
@@ -756,6 +760,7 @@ const onTaskDeclined = (task,reason) => { onRecordingToggle={onRecordingToggle} callControlClassName={'call-control-outer'} callControlConsultClassName={'call-control-consult-outer'} + onToggleMute={onToggleMute} />