From 86464781e76a862edf32a0cbe72c061e36cd9aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkula?= Date: Tue, 8 Jul 2025 00:15:25 +0530 Subject: [PATCH 1/4] fix(call-control): implement-mute-unmute-actions --- .../task/CallControl/call-control.styles.scss | 18 ++ .../task/CallControl/call-control.tsx | 39 ++- .../src/components/task/constants.ts | 2 + .../src/components/task/task.types.ts | 20 ++ packages/contact-center/store/src/store.ts | 1 + .../contact-center/store/src/store.types.ts | 2 + .../store/src/storeEventsWrapper.ts | 21 ++ .../task/src/CallControl/index.tsx | 5 +- .../task/src/CallControlCAD/index.tsx | 13 +- packages/contact-center/task/src/helper.ts | 45 +++ .../contact-center/task/src/task.types.ts | 10 +- packages/contact-center/task/tests/helper.ts | 268 ++++++++++++++++++ .../cc/samples-cc-react-app/src/App.tsx | 8 +- 13 files changed, 446 insertions(+), 6 deletions(-) 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..1c015fa6f 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,24 @@ --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); + } +} + .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 08d5aeb91..602e420c8 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', @@ -293,11 +326,13 @@ function CallControlComponent(props: CallControlComponentProps) { 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..7c625bf13 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' 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..845cc9a6d 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 { diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 0e0a0ca6d..ac08ae2fb 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; }; @@ -386,12 +396,23 @@ class StoreWrapper implements IStoreWrapper { }); }; + handleMuteStateForWebRTCTask = (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(); }; handleTaskAssigned = (event) => { const task = event; + this.handleMuteStateForWebRTCTask(task); if (this.onTaskAssigned) { this.onTaskAssigned(task); } 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..de5e7d37c 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,47 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const toggleMute = async () => { + if (!controlVisibility?.muteUnmute) { + logger.warn('Mute only available for voice tasks', {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'}); + + // State remains unchanged (isMuted stays the same) + // UI will continue to show the actual current state + + // Optional: Show error feedback to user + if (onToggleMute) { + onToggleMute({ + isMuted: isMuted, // Current actual state + 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 +616,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..158c57520 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,9 +1,20 @@ +jest.mock('../src/Utils/task-util', () => { + const actual = jest.requireActual('../src/Utils/task-util'); + return { + ...actual, + getControlsVisibility: jest.fn(), + }; +}); 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'; +// Cast it to a mock +const mockGetControlsVisibility = getControlsVisibility as jest.MockedFunction; + // Mock webex instance and task const ccMock = { on: jest.fn(), @@ -578,6 +589,27 @@ describe('useCallControl', () => { // Mock URL.createObjectURL global.URL.createObjectURL = jest.fn().mockImplementation(() => 'mocked-worker-url'); jest.clearAllMocks(); + + // Clear the mock before each test + mockGetControlsVisibility.mockClear(); + + // Set up the default mock return value + 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, + }; + // Mock the function to return the control visibility object + mockGetControlsVisibility.mockReturnValue(mockControlVisibility); }); afterEach(() => { @@ -1866,6 +1898,242 @@ describe('useCallControl', () => { method: 'useCallControl#cancelAutoWrapup', }); }); + + describe('toggleMute functionality', () => { + const mockOnToggleMute = jest.fn(); + + beforeEach(() => { + // Clear all mocks first + jest.clearAllMocks(); + + // Add toggleMute mock to mockCurrentTask + mockCurrentTask.toggleMute = jest.fn(() => Promise.resolve()); + + // Mock store methods + jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); + jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); + + // Clear the callback mock + 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, // ✅ Add this prop + }) + ); + + 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, // ✅ Set to true for muted state + }) + ); + + 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); // Should unmute + 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, + }) + ); + + // Make multiple rapid calls + await act(async () => { + await Promise.all([result.current.toggleMute(), result.current.toggleMute(), result.current.toggleMute()]); + }); + + // Should have been called 3 times + 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, + // onToggleMute not provided + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, // ✅ Add this prop + }) + ); + + 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, + // onToggleMute not provided + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, // ✅ Add this prop + }) + ); + + 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, // ✅ Current state is muted + }) + ); + + 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(); // State should not be updated on error + expect(mockLogger.error).toHaveBeenCalledWith('toggleMute failed: Error: SDK Toggle mute failed', { + module: 'useCallControl', + method: 'toggleMute', + }); + expect(mockOnToggleMute).toHaveBeenCalledWith({ + isMuted: true, // Current actual state (unchanged) + 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, // ✅ Add this prop + }) + ); + + expect(typeof result.current.toggleMute).toBe('function'); + expect(typeof result.current.isMuted).toBe('boolean'); + }); + + it('should handle controlVisibility being undefined', async () => { + // Mock controlVisibility to return undefined + mockGetControlsVisibility.mockReturnValue(undefined); + + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onToggleMute: mockOnToggleMute, + logger: mockLogger, + featureFlags: store.featureFlags, + deviceType: store.deviceType, + isMuted: false, // ✅ Add this prop + }) + ); + + await act(async () => { + await result.current.toggleMute(); + }); + + expect(mockLogger.warn).toHaveBeenCalledWith('Mute only available for voice tasks', { + 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 c9044e744..0b53123fa 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -172,6 +172,11 @@ const onTaskDeclined = (task,reason) => { console.log('onWrapup invoked', params); }; +const onToggleMute = ({isMuted, task}) => { + console.log('onToggleMute invoked', {isMuted, task}); + +}; + const enableDisableMultiLogin = () => { if (isMultiLoginEnabled) { setIsMultiLoginEnabled(false); @@ -710,7 +715,7 @@ const onTaskDeclined = (task,reason) => {
Call Control - +
@@ -727,6 +732,7 @@ const onTaskDeclined = (task,reason) => { onRecordingToggle={onRecordingToggle} callControlClassName={'call-control-outer'} callControlConsultClassName={'call-control-consult-outer'} + onToggleMute={onToggleMute} /> From 5323dbcbb8badf8dc977d72afea79eafafda0bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkula?= Date: Fri, 11 Jul 2025 11:55:41 +0530 Subject: [PATCH 2/4] fix(call-control): implement-mute-during-consult --- .../call-control-consult.tsx | 37 ++++++++++++++++++- .../task/CallControl/call-control.styles.scss | 13 +++++++ .../task/CallControl/call-control.tsx | 6 +-- .../task/CallControlCAD/call-control-cad.tsx | 5 +++ .../src/components/task/task.types.ts | 3 ++ .../contact-center/store/src/store.types.ts | 1 + .../store/src/storeEventsWrapper.ts | 8 ++-- 7 files changed, 63 insertions(+), 10 deletions(-) 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..741fbe47a 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.tsx', + method: 'handleMuteToggle', + }); + } 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 1c015fa6f..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 @@ -155,6 +155,19 @@ } } +.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 602e420c8..1fbf87c15 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 @@ -326,13 +326,11 @@ function CallControlComponent(props: CallControlComponentProps) { 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/task.types.ts b/packages/contact-center/cc-components/src/components/task/task.types.ts index 7c625bf13..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 @@ -516,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.types.ts b/packages/contact-center/store/src/store.types.ts index 845cc9a6d..8391cd8bb 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -127,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 ac08ae2fb..eecf2a5da 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -366,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); @@ -412,7 +412,6 @@ class StoreWrapper implements IStoreWrapper { handleTaskAssigned = (event) => { const task = event; - this.handleMuteStateForWebRTCTask(task); if (this.onTaskAssigned) { this.onTaskAssigned(task); } @@ -518,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); @@ -527,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.handleMuteStateForWebRTCTask(task); } // We should update the task list in the store after sending the incoming task callback @@ -576,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') { From 01c6d756f9b23fb2abd4dba1d054869bd6b2665b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkula?= Date: Mon, 14 Jul 2025 14:36:56 +0530 Subject: [PATCH 3/4] fix(call-control-toggle-mute): address-review-comments --- .../CallControlCustom/call-control-consult.tsx | 4 ++-- .../store/src/storeEventsWrapper.ts | 4 ++-- packages/contact-center/task/src/helper.ts | 8 ++------ packages/contact-center/task/tests/helper.ts | 15 ++++++++------- .../cc/samples-cc-react-app/src/App.tsx | 1 - 5 files changed, 14 insertions(+), 18 deletions(-) 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 741fbe47a..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 @@ -64,8 +64,8 @@ const CallControlConsultComponent: React.FC = } catch (error) { logger.error('Mute toggle failed:', { error, - module: 'call-control.tsx', - method: 'handleMuteToggle', + module: 'call-control-consult.tsx', + method: 'handleConsultMuteToggle', }); } finally { // Re-enable button after operation diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index eecf2a5da..a6263d3b9 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -396,7 +396,7 @@ class StoreWrapper implements IStoreWrapper { }); }; - handleMuteStateForWebRTCTask = (task: ITask): void => { + handleTaskMuteState = (task: ITask): void => { const isBrowser = this.deviceType === 'BROWSER'; const webRtcEnabled = this.featureFlags?.webRtcEnabled; const isTelephony = task?.data?.interaction?.mediaType === 'telephony'; @@ -526,7 +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.handleMuteStateForWebRTCTask(task); + this.handleTaskMuteState(task); } // We should update the task list in the store after sending the incoming task callback diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index de5e7d37c..1d0de2309 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -430,7 +430,7 @@ export const useCallControl = (props: useCallControlProps) => { const toggleMute = async () => { if (!controlVisibility?.muteUnmute) { - logger.warn('Mute only available for voice tasks', {module: 'useCallControl', method: 'toggleMute'}); + logger.warn('Mute control not available', {module: 'useCallControl', method: 'toggleMute'}); return; } @@ -456,13 +456,9 @@ export const useCallControl = (props: useCallControlProps) => { } catch (error) { logger.error(`toggleMute failed: ${error}`, {module: 'useCallControl', method: 'toggleMute'}); - // State remains unchanged (isMuted stays the same) - // UI will continue to show the actual current state - - // Optional: Show error feedback to user if (onToggleMute) { onToggleMute({ - isMuted: isMuted, // Current actual state + isMuted: isMuted, task: currentTask, }); } diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 158c57520..d1f899187 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,3 +1,10 @@ +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 { @@ -5,12 +12,6 @@ jest.mock('../src/Utils/task-util', () => { getControlsVisibility: jest.fn(), }; }); -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'; // Cast it to a mock const mockGetControlsVisibility = getControlsVisibility as jest.MockedFunction; @@ -2127,7 +2128,7 @@ describe('useCallControl', () => { await result.current.toggleMute(); }); - expect(mockLogger.warn).toHaveBeenCalledWith('Mute only available for voice tasks', { + expect(mockLogger.warn).toHaveBeenCalledWith('Mute control not available', { module: 'useCallControl', method: 'toggleMute', }); 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 3d0941bbf..dcf7c6f30 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -185,7 +185,6 @@ const onTaskDeclined = (task,reason) => { const onToggleMute = ({isMuted, task}) => { console.log('onToggleMute invoked', {isMuted, task}); - }; const enableDisableMultiLogin = () => { From 7805d407a8f35d801ef4513a8041752214acd65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkula?= Date: Mon, 14 Jul 2025 14:48:46 +0530 Subject: [PATCH 4/4] fix(call-control-toggle-mute): remove-unncessary-comments --- packages/contact-center/task/tests/helper.ts | 32 ++++++-------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index d1f899187..4151abd59 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -591,10 +591,8 @@ describe('useCallControl', () => { global.URL.createObjectURL = jest.fn().mockImplementation(() => 'mocked-worker-url'); jest.clearAllMocks(); - // Clear the mock before each test mockGetControlsVisibility.mockClear(); - // Set up the default mock return value const mockControlVisibility = { muteUnmute: true, holdResume: true, @@ -609,7 +607,6 @@ describe('useCallControl', () => { endConsult: false, conference: false, }; - // Mock the function to return the control visibility object mockGetControlsVisibility.mockReturnValue(mockControlVisibility); }); @@ -1904,17 +1901,13 @@ describe('useCallControl', () => { const mockOnToggleMute = jest.fn(); beforeEach(() => { - // Clear all mocks first jest.clearAllMocks(); - // Add toggleMute mock to mockCurrentTask mockCurrentTask.toggleMute = jest.fn(() => Promise.resolve()); - // Mock store methods jest.spyOn(store, 'setIsMuted').mockImplementation(() => {}); jest.spyOn(store, 'isMuted', 'get').mockImplementation(() => false); - // Clear the callback mock mockOnToggleMute.mockClear(); }); @@ -1930,7 +1923,7 @@ describe('useCallControl', () => { logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: false, // ✅ Add this prop + isMuted: false, }) ); @@ -1962,7 +1955,7 @@ describe('useCallControl', () => { logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: true, // ✅ Set to true for muted state + isMuted: true, }) ); @@ -1975,7 +1968,7 @@ describe('useCallControl', () => { method: 'toggleMute', }); expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); - expect(store.setIsMuted).toHaveBeenCalledWith(false); // Should unmute + expect(store.setIsMuted).toHaveBeenCalledWith(false); expect(mockOnToggleMute).toHaveBeenCalledWith({ isMuted: false, task: mockCurrentTask, @@ -1998,12 +1991,10 @@ describe('useCallControl', () => { }) ); - // Make multiple rapid calls await act(async () => { await Promise.all([result.current.toggleMute(), result.current.toggleMute(), result.current.toggleMute()]); }); - // Should have been called 3 times expect(mockCurrentTask.toggleMute).toHaveBeenCalledTimes(3); expect(store.setIsMuted).toHaveBeenCalledTimes(3); expect(mockOnToggleMute).toHaveBeenCalledTimes(3); @@ -2013,11 +2004,10 @@ describe('useCallControl', () => { const {result} = renderHook(() => useCallControl({ currentTask: mockCurrentTask, - // onToggleMute not provided logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: false, // ✅ Add this prop + isMuted: false, }) ); @@ -2037,11 +2027,10 @@ describe('useCallControl', () => { const {result} = renderHook(() => useCallControl({ currentTask: mockCurrentTask, - // onToggleMute not provided logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: false, // ✅ Add this prop + isMuted: false, }) ); @@ -2069,7 +2058,7 @@ describe('useCallControl', () => { logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: true, // ✅ Current state is muted + isMuted: true, }) ); @@ -2082,13 +2071,13 @@ describe('useCallControl', () => { method: 'toggleMute', }); expect(mockCurrentTask.toggleMute).toHaveBeenCalled(); - expect(store.setIsMuted).not.toHaveBeenCalled(); // State should not be updated on error + 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, // Current actual state (unchanged) + isMuted: true, task: mockCurrentTask, }); }); @@ -2101,7 +2090,7 @@ describe('useCallControl', () => { logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: false, // ✅ Add this prop + isMuted: false, }) ); @@ -2110,7 +2099,6 @@ describe('useCallControl', () => { }); it('should handle controlVisibility being undefined', async () => { - // Mock controlVisibility to return undefined mockGetControlsVisibility.mockReturnValue(undefined); const {result} = renderHook(() => @@ -2120,7 +2108,7 @@ describe('useCallControl', () => { logger: mockLogger, featureFlags: store.featureFlags, deviceType: store.deviceType, - isMuted: false, // ✅ Add this prop + isMuted: false, }) );