diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 5226f7e86..0e10f2e0b 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -17,7 +17,7 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | cc-widgets.UserState | `@webex/cc-user-state` | `UserState` | React component; custom element `widget-cc-user-state`; prop `onStateChange` (function) | stable semver | `packages/contact-center/user-state/ai-docs/user-state-spec.md` | `packages/contact-center/user-state/src/index.ts` | | cc-widgets.IncomingTask | `@webex/cc-task` | `IncomingTask` | React component; custom element `widget-cc-incoming-task`; props `incomingTask` (json), `onAccepted`, `onRejected` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.CallControl | `@webex/cc-task` | `CallControl` | React component; custom element `widget-cc-call-control`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | -| cc-widgets.CallControlCAD | `@webex/cc-task` | `CallControlCAD` | React component; custom element `widget-cc-call-control-cad`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | +| cc-widgets.CallControlCAD | `@webex/cc-task` | `CallControlCAD` | React component; custom element `widget-cc-call-control-cad`; props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle` (functions); owner-aware participant Drop is store-driven and adds no prop/attribute | stable semver | `packages/contact-center/task/ai-docs/task-spec.md`; `ai-docs/features/participant-drop-intake.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.TaskList | `@webex/cc-task` | `TaskList` | React component; custom element `widget-cc-task-list`; props `onTaskAccepted`, `onTaskDeclined`, `onTaskSelected` (functions), `hasCampaignPreviewEnabled` (boolean) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.OutdialCall | `@webex/cc-task` | `OutdialCall` | React component; custom element `widget-cc-outdial-call`; no declared props | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | | cc-widgets.RealTimeTranscript | `@webex/cc-task` | `RealTimeTranscript` | React component; custom element `widget-cc-realtime-transcript`; props `liveTranscriptEntries` (json), `className` (string) | stable semver | `packages/contact-center/task/ai-docs/task-spec.md` | `packages/contact-center/task/src/index.ts` | @@ -26,14 +26,15 @@ The aggregator package `@webex/cc-widgets` re-exports every widget plus the `sto | store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | | cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action`, `availableDestinations`, and optional list-item `presence` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list/category order, passes buddy availability to Momentum Avatar as semantic presence, shows `AddressBookEntry.number` and `EntryPointRecord.number` subtitles, and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts; optional presentation prop is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md`; `ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | store.constants | `@webex/cc-store` | Value/enum re-exports (`CC_EVENTS`, `TASK_EVENTS`, `LoginOptions`, `ConsultStatus`, `CAMPAIGN_PREVIEW_OUTBOUND_TYPES`, `DESKTOP`, `EXTENSION`, etc.) | Exported consts/enums for event names and login/consult/campaign domain values | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts:368-403` | -| store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `isInteractionOnHold`, `findHoldStatus`, etc.) | `(task: ITask, agentId?: string) => boolean \| string \| number \| Participant[]` selectors over SDK task objects | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/task-utils.ts` | +| store.task-utils | `@webex/cc-store` | Pure task helpers (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantDropRoster`, `isInteractionOnHold`, `findHoldStatus`, etc.) | Read-only selectors over SDK task objects; the Drop selector returns an owner-aware main-leg roster plus the current Entry Point/EP-DN consult destination (dialed number while ringing, answering Agent name before merge), or `null` for Customer-only/terminal calls | stable semver | `packages/contact-center/store/ai-docs/store-spec.md`; `ai-docs/features/participant-drop-intake.md` | `packages/contact-center/store/src/task-utils.ts` | | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | | ui-logging.WidgetMetrics | `@webex/cc-ui-logging` | `WidgetMetrics` (type) | `{ widgetName; event: 'WIDGET_MOUNTED' \| 'ERROR' \| 'WIDGET_UNMOUNTED' \| 'PROPS_UPDATED'; props?; timestamp; additionalContext? }` | stable semver; narrowing the `event` union or removing a field is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| -| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established queue records and entry-point response wrapper with mapped `number`, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | `3.12.0-next.106`, pinned by the store package dependency | +| `@webex/contact-center` SDK | The entire CC runtime: `Webex.init()`, `webex.cc.*` methods, `ITask.dropConferenceParticipant({participantId})`, CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()` | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); consumed only via the store/task object | Host establishes the authenticated Webex session; SDK and routing backend are assumed reachable and authoritative | `Store.init()` rejects after a 6s init timeout; participant Drop rejects and `CallControlCAD` shows generic feedback | Pinned by the SDK dependency in each package's `package.json`; Participant Drop requires the published SDK version recorded in the feature intake | +| `@webex/contact-center` SDK | The CC runtime, including existing `getBuddyAgents`/`getQueues`/`getEntryPoints` methods, established queue records and entry-point response wrapper with mapped `number`, ordered Task destination controls, CC/task events, agent `Profile`, and host credentials | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`); operations are consumed via the store and Task controls arrive on SDK task objects | Host establishes the authenticated Webex session; SDK assumed reachable | Store methods log and rethrow SDK failures; widget hooks apply their existing empty/error UI fallbacks | `3.12.0-next.109`, pinned by the store package dependency | | `react` / `react-dom` (18) | Component runtime; consumer peer dependency | React docs | Provided by host or bundled | N/A (build-time/runtime peer) | React 18 | | `mobx` / `mobx-react-lite` | Store reactivity (`runInAction`, `observer`) | MobX docs | Bundled with store package | N/A | per `package.json` | | `@r2wc/react-to-web-component` | Wraps React widgets as custom elements (`packages/contact-center/cc-widgets/src/wc.ts:1`) | r2wc docs | Bundled with `cc-widgets` | N/A | per `package.json` | diff --git a/ai-docs/SECURITY.md b/ai-docs/SECURITY.md index bf164274d..0b1dd325c 100644 --- a/ai-docs/SECURITY.md +++ b/ai-docs/SECURITY.md @@ -18,7 +18,7 @@ This repo is a client-side React/Web-Component widget library. It hosts no netwo ## Authentication & Authorization Model - **Authentication:** Owned by the host app and the Webex SDK, not this repo. The host supplies either a live `webex` instance or `{webexConfig, access_token}`; the store passes the token to `Webex.init({credentials: {access_token}})` and otherwise treats identity as opaque (`packages/contact-center/store/src/store.ts:144-151`). Token retrieval for downstream SDK features delegates to the SDK: `getAccessToken()` calls `webex.credentials.getUserToken()` (`packages/contact-center/store/src/storeEventsWrapper.ts:988-998`). -- **Authorization:** Owned by the SDK / back end. The store surfaces the agent's capabilities as read-only feature flags derived from the SDK-provided `Profile` (`packages/contact-center/store/src/util.ts:3-36`); widgets use these only to show/hide UI. There is no access-decision logic enforced in this repo. +- **Authorization:** Owned by the SDK / back end. The store surfaces the agent's capabilities as read-only feature flags derived from the SDK-provided `Profile` (`packages/contact-center/store/src/util.ts:3-36`); widgets use these only to show/hide UI. Participant Drop additionally hides actions unless `interaction.owner === currentAgentId`, but that client rule is not an authorization boundary; the backend must authorize every request. - **Default posture:** Widgets are inert until the host completes `Store.init()`; with no valid host-supplied session the SDK never initializes (`Webex.init` rejects after a 6s timeout — `packages/contact-center/store/src/store.ts:140-142`), so no agent data flows. ## Secret & Credential Handling @@ -42,6 +42,7 @@ This repo is a client-side React/Web-Component widget library. It hosts no netwo |---|---|---|---| | `ui-logging` metrics props | Widget props are logged without sanitization — acknowledged in the `havePropsChanged` JSDoc `@remarks` (`metricsLogger.ts:73-76`) | Callers must not pass PII-bearing objects as metrics props; sanitization is noted as a future enhancement | cc-ui-logging maintainers | | `getAccessToken()` SDK gap | `webex.credentials.getUserToken()` is `@ts-expect-error`-typed (SDK API not yet typed) (`storeEventsWrapper.ts:990-992`) | Token value is returned to the caller and never logged; failures log only an error message | cc-store maintainers | +| Participant Drop identity | Target IDs can be agent identifiers or customer ANI/DNIS and therefore may be sensitive | Derive in memory, pass only to `task.dropConferenceParticipant`, render through React text escaping, and log only static context plus generic feedback | cc-store / cc-task maintainers | ## Reporting & Review - Security-relevant changes (anything touching the store init/credential path, the `@webex/contact-center` SDK boundary, logging, or the public export/custom-element surface) require review by the package CODEOWNERS on the `next`-targeted PR, following `.github/PULL_REQUEST_TEMPLATE.md` (FedRAMP/GAI sections). Suspected vulnerabilities: report through the Webex internal security channel, not a public issue. diff --git a/ai-docs/SPEC_INDEX.md b/ai-docs/SPEC_INDEX.md index e8124b429..681e8f99a 100644 --- a/ai-docs/SPEC_INDEX.md +++ b/ai-docs/SPEC_INDEX.md @@ -67,5 +67,6 @@ The intake record confirms scope/modules **against the code** and sets the chang | Decision records | `ai-docs/adr/` | Standing ADRs — why the architecture is the way it is | | Review catalog | `ai-docs/REVIEW_CHECKLIST.md` | 6-core + 4-coverage + 3-cross-cutting review checks | | SDK reference | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | installed SDK `.d.ts` surface — verify every SDK call | +| Participant Drop intake | `ai-docs/features/participant-drop-intake.md` | Cross-repository SDK/widget contract, behavior, delivery gates, and verification | _No `DATA_MODEL.md`: this repo owns no persistent datastore (all domain data comes from the SDK at runtime)._ diff --git a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md index b2674284c..51f05c1ec 100644 --- a/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +++ b/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md @@ -21,7 +21,7 @@ Related context: [repository architecture](../../../ARCHITECTURE.md) · [specifi | Work type | Defect | | Change class | Contract / UI | | Source/intake | Developer-approved consult/transfer behavior review and current code/tests | -| Last verified | 2026-08-24 with `@webex/contact-center` 3.12.0-next.106 | +| Last verified | 2026-08-25 with `@webex/contact-center` 3.12.0-next.109 | ## Applicability @@ -68,7 +68,7 @@ There are no open product decisions for this delta. - Load dial numbers through the generic SDK AddressBook service and rely on its default backend ordering. - Preserve the SDK's `data` order and pagination metadata without local sorting, channel filtering, or metadata reconstruction. - Keep loading, empty, and error behavior in the existing widget layers. -- Use the pinned `@webex/contact-center` 3.12.0-next.106 dependency containing the coordinated SDK change. +- Use the pinned `@webex/contact-center` 3.12.0-next.109 dependency containing the coordinated SDK change. ### Out of scope @@ -142,11 +142,11 @@ There are no open product decisions for this delta. - [x] Queue and entry-point fetchers delegate to existing SDK methods, while dial numbers use AddressBook, without local returned-data sort/filter/metadata logic (`MOD-001`, `WIDGET-LIST-R-001`, `WIDGET-LIST-R-004`). - [x] Params-only telephony queue requests rely on SDK defaults; active non-telephony requests supply a complete channel filter, and legacy media-plus-params calls preserve their channel scope and caller filter. Entry-point requests always delegate directly, and no list request contains widget-selected sorting, projection, or profile-view flags (`MOD-001`, `WIDGET-LIST-R-003`). - [x] Store errors are rethrown and task-hook errors retain the existing empty-result behavior (`WIDGET-LIST-R-005`). -- [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with `@webex/contact-center` 3.12.0-next.106 (`WIDGET-LIST-R-006`). +- [x] Store, task, test-fixtures, and cc-components build/type surfaces agree with `@webex/contact-center` 3.12.0-next.109 (`WIDGET-LIST-R-006`). - [x] Agent rows show active/away presence from buddy state, and dial-number/entry-point rows show their typed secondary identifiers (`WIDGET-LIST-R-008`). - [x] A late buddy-agent response from a previous Consult/Transfer action cannot overwrite the current action's list or loading state (`WIDGET-LIST-R-009`). -- [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with `@webex/contact-center` 3.12.0-next.106. -- [x] The complete cc-components unit suite and the focused consult/transfer suites pass with `@webex/contact-center` 3.12.0-next.106. +- [x] Store and task unit suites, focused consult/transfer cc-components tests, and touched package build/style checks pass with `@webex/contact-center` 3.12.0-next.109. +- [x] The complete cc-components unit suite and the focused consult/transfer suites pass with `@webex/contact-center` 3.12.0-next.109. ## Scenarios and applicable change views @@ -243,7 +243,7 @@ No event contract changes. | Risk or assumption | Evidence | Mitigation or decision owner | | --- | --- | --- | -| Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Pin the store to the compatible SDK release; currently `3.12.0-next.106`. | +| Widgets are run with an SDK lacking the corrected defaults and action-aware buddy policy. | `packages/contact-center/store/package.json` | Pin the store to the compatible SDK release; currently `3.12.0-next.109`. | | A future widget reintroduces local sorting/filtering. | `packages/contact-center/store/tests/storeEventsWrapper.ts` | Retain delegation and exact-response assertions. | | Transfer action is lost during reload. | `packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx` | Retain the action-specific reload assertion. | | A Consult buddy request completes after the user switches to Transfer. | `packages/contact-center/task/src/helper.ts` | Track the newest buddy request and ignore older data, error fallback, and loading-state updates. | diff --git a/ai-docs/features/participant-drop-intake.md b/ai-docs/features/participant-drop-intake.md new file mode 100644 index 000000000..b6645f14d --- /dev/null +++ b/ai-docs/features/participant-drop-intake.md @@ -0,0 +1,77 @@ +# Participant Drop Cross-Repository Intake + +Status: Implemented and verified locally on `feature/participant-drop`; live widget validation and SDK publication remain release gates. + +## Goal and delivery order + +Add owner-controlled participant removal to the Contact Center conference roster. Delivery is intentionally split into two sequential changes based on `next`: + +1. `webex-js-sdk` adds the SDK task API, AQM correlation, tests, and Contact Center sample behavior. +2. `widgets-1` consumes the published SDK API and adds Drop only to `CallControlCAD`. + +The remote routing/media backend and Agent Desktop are outside these repositories. No SDK initialization flag, widget flag, or new public failure event is introduced. + +## SDK contract + +```ts +export type DropConferenceParticipantPayload = { + participantId: string; +}; + +task.dropConferenceParticipant( + payload: DropConferenceParticipantPayload +): Promise; +``` + +The voice task resolves the latest main interaction ID and delegates to the existing AQM contact service: + +```http +POST /v1/tasks/{interactionId}/conference/participants/{encodeURIComponent(participantId)}/drop +Content-Type: application/json + +{} +``` + +Completion is event-correlated: `ParticipantLeftConference` resolves, `ParticipantDropConferenceFailed` rejects, and the existing 20-second AQM timeout applies. Existing participant-left task handling updates conference state and the participant roster; clients do not remove rows optimistically. Non-voice tasks reject as unsupported. + +For EP-DN cross-channel lifecycle ordering, `ContactMerged` replaces the child task with the main-interaction task and publishes `task:merged`. `ParticipantLeftConference` and `AgentConsultEnded` first use exact task correlation, then one unique `mainInteractionId`/`parentInteractionId` relationship. Updated `mainCall` membership is authoritative for whether the current Agent ended, independent of consult state or initiator role. Widgets consume the existing terminal events and defer their task-list read by one microtask so SDK final cleanup is visible; they never delete or terminate an SDK task locally. + +Primary-agent promotion follows Agent Desktop's two-event flow. `ContactOwnerChanged` carries the authoritative owner to the promoted Agent, while an owner-changing `ContactUpdated` carries it to the other surviving Agents. The SDK normalizes both paths into the existing `task:hydrate` notification. The widget Store replaces its cloned current task from that hydrate, and roster Primary status and Drop permissions recompute solely from `interaction.owner`; widgets never elect or infer the next owner locally. + +## Widget behavior + +`@webex/cc-store` derives a Drop-specific roster from the current main-call media leg without changing `getConferenceParticipants` for its existing consumers. The viewing agent must remain active, and any supported non-customer row keeps the roster visible. Main-leg membership remains authoritative when conference state, flags, controls, or wrap-up signals lag behind participant updates. + +- Exclude the viewing agent, departed/not-yet-joined participants, VVA, unsupported types, and consult-only participants. +- Include Agent and joined EP-DN rows; include Supervisor rows as read-only. +- Synthesize Customer only while an active main-leg Customer exists, using inbound ANI or outbound DNIS. +- After Customer leaves, retain Participants while at least one eligible Agent, EP-DN, or Supervisor remains with the viewing agent, even if backend conference state, flags, controls, or wrap-up signals downgrade. +- Customer-only calls use the original 1-to-1 UI. Dropping the final non-customer participant while Customer remains returns to that UI; Agent-to-Agent remains visible so the primary owner can Drop the other Agent. +- Merge only the current active Entry Point/EP-DN consult leg. Classify the destination from both SDK `pType` and `type` because Entry Point IDs can occupy `pType` while `type` carries `EpDn`. While ringing, display `dn` with participant/media ID fallback; once answered, replace the dialed number with the answering Agent name even before merge. Exclude stale legs and ordinary consult-only Agents, deduplicate after main-leg join, and keep the owner action disabled until merge. +- Show Drop only when `interaction.owner === currentAgentId`; Supervisor always remains read-only. +- Disable every Drop action during an active, non-held consult. +- Agent and joined EP-DN drop immediately; a ringing Entry Point/EP-DN or its pre-merge answering Agent is visible but disabled; Customer requires confirmation. +- Permit one request at a time, display `Dropping…` on only the selected row, and wait for SDK task hydration before a row disappears. +- On success announce `Participant removed from the conference.` +- On failure announce `Unable to drop participant from the call. Try again.` and invoke the existing `CallControlCAD` host error callback with a sanitized error. + +The `CallControlCAD` participant surface uses the installed Momentum Design primitives throughout: a compact tertiary `Button` with Momentum secondary-button background tokens triggers a focus-trapped `Popover`; `List`/`ListItem`, `Icon`, `Text`, and `Divider` render the roster; `primary-participant-regular` identifies the primary agent while `meet-regular` remains on other rows; compact secondary/negative `Button` components render Drop actions; and a controlled `Dialog` with Momentum footer actions handles Customer confirmation. The installed Dialog version forces its named footer-button slots to the default color, so its documented custom footer slot is used to preserve the destructive primary/negative Drop action; the remaining footer CSS controls layout only. The participant Popover closes before Customer confirmation so two focus traps never overlap. The Dialog is positioned against the viewport and focus is explicitly restored to the stable participant trigger, falling back to end-call when hydration removes the roster. Momentum owns overlay visuals, keyboard dismissal, and focus trapping. Other component CSS is restricted to compact layout, sizing, overflow/truncation, overlay positioning, and theme-token colors; visible live-region feedback is not duplicated with a screen-reader announcer. + +Backend authorization remains mandatory. Owner-based UI visibility is not an authorization boundary. + +## Public surfaces and compatibility + +Only the existing React `CallControlCAD` export and `widget-cc-call-control-cad` custom element gain behavior. Standard `CallControl` remains visually unchanged. No widget prop, custom-element attribute/property, initialization option, or callback is added. + +The widget consumes published `@webex/contact-center@3.12.0-next.109` from `@webex/cc-store`; the React sample uses `webex@3.12.0-next.184`, which contains that Contact Center version. Local links, absolute paths, portals, tarballs, and temporary resolutions must never be committed. + +## Verification + +- SDK: build, unit, and style tests plus the Contact Center sample on port 8001. +- Widgets: build, store selector tests, task hook/shell tests, component tests, cc-widgets tests, and style checks. +- Local samples: React on port 3000 and Web Component on port 4000. +- Manual coverage: owner/non-owner, Customer/Agent/EP-DN/Supervisor, confirmation and cancellation, consult gating, global pending lock, failure recovery, owner transfer, and final conference downgrade. + +## Security and privacy + +Participant IDs, names, ANI/DNIS, raw task/routing payloads, request URLs, credentials, and authorization headers must not enter widget logs or metrics. React text rendering is used for participant-derived display values. Failure reporting carries only the generic message and static module/method context. diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 9edaa7e52..46b26b40a 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -150,7 +150,8 @@ Compatibility notes: | `CC-COMPONENTS-R-014` | `useIntersectionObserver` reports element visibility for infinite-scroll/lazy paths (e.g. outdial address-book paging). | Paged lists must load more on scroll without per-component observer wiring. | `src/hooks/useIntersectionObserver.ts` | `tests/hooks/useIntersectionObserver.test.ts` | None | PRESENT | | `CC-COMPONENTS-R-015` | Each top-level exported component is wrapped with the `withMetrics` HOC so mount/usage metrics are tracked uniformly. | Consistent telemetry across all widgets without per-component instrumentation. | `withMetrics` import + wrap in `src/components/StationLogin/station-login.tsx`, `src/components/UserState/user-state.tsx`, `src/components/task/CallControl/call-control.tsx`, `src/components/task/RealTimeTranscript/real-time-transcript.tsx` | Covered indirectly by each component's render test | No test asserts the HOC wrapping itself | WEAK | | `CC-COMPONENTS-R-016` | `E911Modal` gates `Save & Continue` on the acknowledgment checkbox, disables both `Save & Continue` and `Cancel` while `onSaveAndContinue` is in flight (guarding against a double-click firing concurrent saves), shows a user-facing error and re-enables the buttons if the save rejects, and only `Cancel` (not the Dialog's built-in close button or Escape) dismisses the modal; checkbox/saving/error state resets when the modal closes. | An emergency-notification acknowledgment must not be skippable, must not double-submit against the preference API, and must give the agent visible recourse on failure. | `src/components/StationLogin/E911Modal/e911-modal.tsx` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` (checkbox gating, save-in-flight button disabling, save-error display, close-only-via-Cancel) | None | PRESENT | -| `CC-COMPONENTS-R-017` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render `ContactServiceQueue`, `EntryPointRecord`, and dial-number arrays in received order. | The component must preserve backend-selected order and established SDK entity records instead of introducing a second list policy or destination abstraction. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | +| `CC-COMPONENTS-R-017` | `CallControlCADComponent` renders Customer and Participants sections independently from the supplied Drop roster, so Customer departure removes only Customer while any supported non-customer row remains. Roster presence alone controls the trigger; `null` restores the Customer-only UI. It counts visible non-customer rows (including Supervisor and a ringing or answered pre-merge Entry Point destination), keeps the participant count and postfix chevron on one non-wrapping trigger row, removes participant-type sublabels, uses bold headings and aligned name/action rows, marks the primary agent with Momentum's `primary-participant-regular` icon while retaining `meet-regular` for other rows, keeps non-owner/Supervisor rows non-actionable, and shows a disabled owner action before Entry Point conference merge. The compact tertiary participant trigger uses Momentum secondary-button background tokens in normal, hover, pressed, and expanded states so it remains identifiable without custom chrome. The component receives request/confirmation state and handlers from `useCallControl`; it owns only presentation and focus fallback. The roster uses Momentum Design `Button`, `Popover`, `List`, `ListItem`, `Icon`, `Text`, and `Divider`, while Customer confirmation uses Momentum Design `Dialog` and footer `Button` components. Before Customer confirmation opens, the participant Popover closes so only one focus-trapped overlay is active; the Dialog is viewport-positioned and focus is explicitly restored to the stable participant trigger, with end-call fallback if hydration removes the roster. Styling is limited to compact layout, sizing, overflow/truncation, overlay positioning, and Momentum theme-token colors. Exact visible polite success/assertive failure feedback stays outside the roster, and no duplicate screen-reader announcer is added. Standard `CallControlComponent` does not render this roster. | Participant removal must be explicit, accessible, owner-aware, visually consistent with Momentum Design, and resilient when Customer or the final roster row disappears after hydration. | `src/components/task/CallControlCAD/call-control-cad.tsx`, `src/components/task/CallControlCAD/call-control-cad.types.ts`, `src/components/task/task.types.ts` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (`conference participant Drop`, visibility regression) | Eligibility and invocation are supplied by `@webex/cc-store`/`@webex/cc-task`; backend authorization remains mandatory. | PRESENT | +| `CC-COMPONENTS-R-018` | Consult/transfer paginated hooks must keep SDK response rows directly, append later pages without sorting/filtering/reprojection, and render `ContactServiceQueue`, `EntryPointRecord`, and dial-number arrays in received order. | The component must preserve backend-selected order and established SDK entity records instead of introducing a second list policy or destination abstraction. | `src/components/task/task.types.ts`, `src/components/task/CallControl/CallControlCustom/consult-transfer-popover-hooks.ts` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | Buddy-agent text search remains a presentational filter over the already ordered agent response. | PRESENT | ## Design Overview @@ -158,7 +159,7 @@ Every component follows the same shape: a typed function component destructures Logic that is non-trivial or shared is pulled out of the JSX: per-component utils (`station-login.utils.tsx`, `call-control.utils.ts`, `task-list.utils.ts`, etc.) and library-wide utils (`src/utils/index.ts`: `formatTime`, `getMediaTypeInfo`). `task.types.ts` is the shared type hub for the task family — the larger `ControlProps`/`TaskProps` interfaces describe the full data set, and each component's public prop type is a `Pick` of the keys it actually uses, which is why the public surface is intentionally narrower than the interfaces. -Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header + `GlobalVariablesPanel`; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes the same components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual registration into a host app happens in `cc-widgets`. +Composition is deliberate: `IncomingTaskComponent` and `TaskListComponent` both render the generic `Task` row; `CallControlCADComponent` wraps `CallControlComponent` and adds a CAD header, `GlobalVariablesPanel`, and the prop-driven participant Drop roster/dialog; `CallControlComponent` embeds the consult/transfer popover (`CallControlCustom/`) and `AutoWrapupTimer`; `CampaignTask` composes `CampaignTaskListItem`, `CampaignTaskPopover`, `CampaignCountdown`, and `CampaignErrorDialog`. The `wc.ts` module is a thin adapter that re-exposes components as `component-cc-*` custom elements with explicit r2wc prop type maps; actual widget registration into a host app happens in `cc-widgets`. ## Data Flow @@ -344,7 +345,8 @@ Each component is tested in isolation with React Testing Library: render from a | `CC-COMPONENTS-R-014` | `tests/hooks/useIntersectionObserver.test.ts` | None | | `CC-COMPONENTS-R-015` | None found (covered indirectly via render tests) | No explicit `withMetrics`-wrapping assertion | | `CC-COMPONENTS-R-016` | `tests/components/StationLogin/E911Modal/e911-modal.test.tsx` | None | -| `CC-COMPONENTS-R-017` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | +| `CC-COMPONENTS-R-017` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (Momentum structure/configuration, sections, immediate/confirmed Drop, pending/consult disabled state, read-only rows, focus fallback, live regions) | Momentum overlay keyboard behavior and visual placement receive final manual coverage | +| `CC-COMPONENTS-R-018` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | ## Traceability diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss index 19ec06a16..fefcaed5f 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.styles.scss @@ -112,59 +112,55 @@ } .vertical-divider { - width: 0.0625rem; height: 0.875rem; - background-color: var(--mds-color-theme-outline-secondary-normal); margin: 0 0.5rem; - opacity: 0.7; } .participants-section { display: flex; align-items: center; + flex: 0 0 auto; gap: 0.25rem; } -.participants-icon { - color: var(--mds-color-theme-text-primary-normal); -} +.participants-select-button { + --mdc-button-background: var(--mds-color-theme-button-secondary-hover); -.participants-count { - font-size: 0.875rem; - color: var(--mds-color-theme-text-secondary-normal); + flex: 0 0 auto; + width: max-content; white-space: nowrap; - font-weight: 500; } -.participants-select-button { - display: inline-flex; - align-items: center; - gap: 0.3rem; - padding: 0 0.4rem; - min-height: 1.5rem; - border-radius: 999px; - background: transparent !important; - border: 1px solid var(--mds-color-theme-outline-secondary-normal) !important; - color: var(--mds-color-theme-text-secondary-normal); - - &:hover, - &.hover { - background: var(--mds-color-theme-background-secondary-hover) !important; - } +.participants-select-button:hover { + --mdc-button-background: var(--mds-color-theme-button-secondary-pressed); +} + +.participants-select-button:active, +.participants-select-button.pressed { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-hover); } -.dropdown-arrow { - width: 0.75rem; - height: 0.75rem; - color: var(--mds-color-theme-text-secondary-normal); - margin-top: 0.0625rem; +.participants-select-button::part(postfix-icon) { transition: transform 0.15s ease; } -.participants-select-button[aria-expanded='true'] .dropdown-arrow { +.participants-select-button[aria-expanded='true']::part(postfix-icon) { transform: rotate(180deg); } +.participants-select-button[aria-expanded='true'] { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-normal); +} + +.participants-select-button[aria-expanded='true']:hover { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-hover); +} + +.participants-select-button[aria-expanded='true']:active, +.participants-select-button[aria-expanded='true'].pressed { + --mdc-button-background: var(--mds-color-theme-button-secondary-active-pressed); +} + .call-status { display: flex; align-items: center; @@ -300,59 +296,63 @@ } .participants-popover { - background: var(--mds-color-theme-background-solid-primary-normal, #ffffff) !important; - align-items: normal; - width: 100%; - padding: 0 !important; - border: var(--mds-color-theme-outline-secondary-normal); - - &::part(popover-content) { - background: var(--mds-color-theme-background-solid-primary-normal, #ffffff); - border-radius: 0.75rem; - border: 1px solid var(--mds-color-theme-outline-secondary-normal); - box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08); - padding: 0; - width: 16.125rem; /* 258px */ - overflow: hidden; - } + --mdc-popover-width: 16.125rem; + --mdc-popover-max-height: 18rem; .participants-menu { - padding: 0.125rem; - max-height: 12.5rem; - min-width: 16.125rem; - max-width: 16.125rem; + max-height: 16.5rem; overflow-y: auto; overflow-x: hidden; } - .participant-menu-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 0.6rem; - font-size: 0.875rem; - cursor: pointer; - color: var(--mds-color-theme-text-primary-normal, #000000f2); - border-radius: 0.5rem; - min-width: 0; - max-width: 100%; - white-space: nowrap; + .participant-roster-heading { + margin: 0 0 0.25rem 0.25rem; - &:hover { - background-color: var(--mds-color-theme-background-secondary-hover); + &::part(text) { + margin: 0; } + } - &:focus-visible { - background-color: var(--mds-color-theme-background-secondary-normal); - outline: 0.0625rem solid var(--mds-color-theme-outline-input-focus); - outline-offset: -0.0625rem; - } + .participant-roster-list { + width: 100%; } - .participant-menu-text { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + .participant-menu-item { + --mdc-listitem-padding-left-right: 0.25rem; + --mdc-listitem-padding-top-bottom: 0.5rem; + } + + .participant-roster-divider { + margin: 0.5rem 0; + } +} + +.participant-drop-dialog-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + width: 100%; +} + +// Momentum Dialog defaults to absolute positioning. Keep this modal centered in the viewport +// when CallControlCAD is rendered inside a relatively positioned or scrollable widget container. +.participant-drop-dialog { + position: fixed; +} + +.participant-drop-feedback { + margin-top: 0.5rem; + + mdc-text::part(text) { + margin: 0; + } + + .participant-drop-feedback-success { + color: var(--mds-color-theme-text-success-normal); + } + + .participant-drop-feedback-error { + color: var(--mds-color-theme-text-error-normal); } } 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..4a59068c9 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 @@ -1,13 +1,27 @@ -import React, {useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import CallControlComponent from '../CallControl/call-control'; -import {Text, PopoverNext} from '@momentum-ui/react-collaboration'; -import {Avatar, Brandvisual, Icon, Tooltip, Button} from '@momentum-design/components/dist/react'; +import {Text} from '@momentum-ui/react-collaboration'; +import { + Avatar, + Brandvisual, + Button, + Dialog, + Divider, + Icon, + List, + ListItem, + Popover, + Text as MomentumText, + Tooltip, +} from '@momentum-design/components/dist/react'; import './call-control-cad.styles.scss'; import TaskTimer from '../TaskTimer/index'; import CallControlConsultComponent from '../CallControl/CallControlCustom/call-control-consult'; import {MEDIA_CHANNEL as MediaChannelType, CallControlComponentProps, CallAssociatedDataMap} from '../task.types'; +import {ConferenceParticipantDropTarget} from '@webex/cc-store'; import {getAgentViewableGlobalVariables} from '../Task/task.utils'; import GlobalVariablesPanel from '../GlobalVariablesPanel/global-variables-panel'; +import {ParticipantRosterSectionProps} from './call-control-cad.types'; import {getMediaTypeInfo} from '../../../utils'; import { @@ -22,7 +36,65 @@ import { CAMPAIGN_CALL, } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; -import {isSecondaryAgent} from '@webex/cc-store'; + +const ParticipantRosterSection: React.FC = ({ + heading, + headingId, + targets, + pendingParticipantDropId, + participantDropIsPending, + rosterDropDisabled, + onParticipantDropRequest, +}) => ( +

+ + {heading} + + + {targets.map((target) => { + const dropIsDisabled = target.isDropDisabled || participantDropIsPending || rosterDropDisabled; + + return ( + + + + {target.displayName} + {target.isPrimary ? ' (Primary)' : ''} + + {!target.isReadOnly && ( + + )} + + ); + })} + +
+); const CallControlCADComponent: React.FC = (props) => { const { @@ -46,7 +118,14 @@ const CallControlCADComponent: React.FC = (props) => logger, isMuted, toggleMute, - conferenceParticipants, + conferenceParticipantDropRoster = null, + pendingParticipantDropId = null, + participantDropAnnouncement = null, + participantDropConfirmationTarget = null, + participantDropConfirmationDisabled = true, + requestParticipantDrop = async () => undefined, + confirmParticipantDrop = async () => undefined, + cancelParticipantDropConfirmation = () => undefined, conferenceEnabled = true, isCampaignCall = false, } = props; @@ -65,16 +144,17 @@ const CallControlCADComponent: React.FC = (props) => const mediaChannel = currentTask.data.interaction.mediaType as MediaChannelType; const isSocial = mediaChannel === MediaChannelType.SOCIAL; const isTelephony = mediaChannel === MediaChannelType.TELEPHONY; - const participantsCount = conferenceParticipants?.length || 1; + const participantsCount = conferenceParticipantDropRoster?.participants.length ?? 0; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; - const interactionState = currentTask?.data?.interaction?.state; - const isConferenceActive = - controls?.main?.exitConference?.isVisible || - currentTask?.data?.isConferenceInProgress === true || - interactionState === 'conference'; - const isConsultOnlyAgent = isSecondaryAgent(currentTask); - const shouldShowParticipantsList = - isConferenceActive && !isConsultOnlyAgent && (conferenceParticipants?.length ?? 0) > 0; + const shouldShowParticipantsList = Boolean(conferenceParticipantDropRoster); + const participantMenuTriggerRef = useRef(null); + const callControlContainerRef = useRef(null); + const customerDropDialogWasOpenRef = useRef(false); + const participantPopoverRef = useRef | null>(null); + const participantDropIsPending = pendingParticipantDropId !== null; + const interactionId = currentTask.data.interaction.interactionId; + const participantsTriggerId = `participants-trigger-${interactionId}`; + const participantsPopoverId = `participants-popover-${interactionId}`; const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; @@ -96,7 +176,6 @@ const CallControlCADComponent: React.FC = (props) => // variables. Variables are never cleared mid-call by the backend. // Reset when the interaction changes so stale CAD from a previous task // is never shown on a new call. - const interactionId = currentTask.data.interaction.interactionId; const globalVariablesRef = useRef(latestGlobalVariables); const prevInteractionIdRef = useRef(interactionId); if (prevInteractionIdRef.current !== interactionId) { @@ -107,6 +186,43 @@ const CallControlCADComponent: React.FC = (props) => } const globalVariables = globalVariablesRef.current; + const restoreCustomerDropFocusFallback = () => { + // Closing the roster before opening the Dialog makes its Customer button unavailable for + // focus restoration. Return focus to the stable participant trigger, or to end-call if + // hydration removed the entire roster while the confirmation was open. + participantPopoverRef.current?.hide(); + globalThis.setTimeout(() => { + const participantMenuTrigger = participantMenuTriggerRef.current; + const stableCallControl = callControlContainerRef.current?.querySelector( + '[data-testid="call-control:end-call"]' + ); + + (participantMenuTrigger?.isConnected ? participantMenuTrigger : stableCallControl)?.focus(); + }, 0); + }; + + useEffect(() => { + if (participantDropConfirmationTarget) { + customerDropDialogWasOpenRef.current = true; + return; + } + + if (customerDropDialogWasOpenRef.current) { + customerDropDialogWasOpenRef.current = false; + restoreCustomerDropFocusFallback(); + } + }, [participantDropConfirmationTarget]); + + const handleParticipantDropRequest = (target: ConferenceParticipantDropTarget) => { + if (target.requiresConfirmation) { + // Avoid stacking two focus-trapped Momentum overlays. The customer confirmation dialog + // becomes the only active overlay and returns focus to the stable participant trigger. + participantPopoverRef.current?.hide(); + } + + void requestParticipantDrop(target); + }; + // Create unique IDs for tooltips const customerNameTriggerId = `customer-name-trigger-${currentTask.data.interaction.interactionId}`; const customerNameTooltipId = `customer-name-tooltip-${currentTask.data.interaction.interactionId}`; @@ -199,7 +315,7 @@ const CallControlCADComponent: React.FC = (props) => return ( <> -
+
{/* Caller Information */}
@@ -226,51 +342,73 @@ const CallControlCADComponent: React.FC = (props) => )} - {shouldShowParticipantsList && !controls?.main?.wrapup?.isVisible && ( + {shouldShowParticipantsList && ( <> -
+
- { + participantMenuTriggerRef.current = element as HTMLElement | null; + }} + id={participantsTriggerId} + aria-label="Conference participants" + data-testid="call-control:participants-trigger" + className="participants-select-button" + color="default" + variant="tertiary" + size={24} + postfix-icon="arrow-down-bold" + > + +{participantsCount} {participantsLabel} + + - - +{participantsCount} {participantsLabel} - - - - } + data-testid="call-control:participants-popover" >
- {conferenceParticipants?.map((participant) => ( -
- - {participant.name} -
- ))} + {conferenceParticipantDropRoster?.customer && ( + + )} + {conferenceParticipantDropRoster?.customer && + conferenceParticipantDropRoster.participants.length > 0 && ( + + )} + {conferenceParticipantDropRoster?.participants.length ? ( + + ) : null}
-
+
)} @@ -306,6 +444,74 @@ const CallControlCADComponent: React.FC = (props) =>
+ +
+ + +
+
+ {participantDropAnnouncement && ( +
+ {participantDropAnnouncement.type === 'success' ? ( + + {participantDropAnnouncement.message} + + ) : ( + + {participantDropAnnouncement.message} + + )} +
+ )} {(controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) && !controls?.main?.wrapup?.isVisible && (
diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts new file mode 100644 index 000000000..946b4c88b --- /dev/null +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.types.ts @@ -0,0 +1,11 @@ +import {ConferenceParticipantDropTarget} from '@webex/cc-store'; + +export type ParticipantRosterSectionProps = { + heading: 'Customer' | 'Participants'; + headingId: string; + targets: ConferenceParticipantDropTarget[]; + pendingParticipantDropId: string | null; + participantDropIsPending: boolean; + rosterDropDisabled: boolean; + onParticipantDropRequest: (target: ConferenceParticipantDropTarget) => void; +}; 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 d3f6bf576..df24268ed 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 @@ -10,6 +10,8 @@ import { EntryPointRecord, FetchPaginatedList, Participant, + ConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, AddressBookEntrySearchParams, AddressBookEntriesResponse, TaskUIControls, @@ -53,6 +55,11 @@ export const TARGET_TYPE = { export type TargetType = (typeof TARGET_TYPE)[keyof typeof TARGET_TYPE]; +export type ParticipantDropAnnouncement = { + type: 'success' | 'error'; + message: string; +}; + /** * Interface representing the TaskProps of a user. */ @@ -497,6 +504,30 @@ export interface ControlProps { */ conferenceParticipants: Participant[]; + /** Owner-aware conference roster used by the CallControlCAD Drop menu. */ + conferenceParticipantDropRoster: ConferenceParticipantDropRoster | null; + + /** Drop target currently waiting for routing-event completion. */ + pendingParticipantDropId: string | null; + + /** Generic screen-reader and visible feedback for the most recent Drop request. */ + participantDropAnnouncement: ParticipantDropAnnouncement | null; + + /** Customer target currently awaiting explicit confirmation. */ + participantDropConfirmationTarget: ConferenceParticipantDropTarget | null; + + /** Whether the current Customer confirmation action is unavailable. */ + participantDropConfirmationDisabled: boolean; + + /** Validates a Drop target, opening confirmation for Customer or invoking Drop immediately. */ + requestParticipantDrop: (target: ConferenceParticipantDropTarget) => Promise; + + /** Confirms and invokes the currently selected Customer Drop. */ + confirmParticipantDrop: () => Promise; + + /** Cancels the currently selected Customer Drop. */ + cancelParticipantDropConfirmation: () => void; + /** Fetch paginated address book entries for dial numbers */ getAddressBookEntries?: FetchPaginatedList; @@ -565,14 +596,27 @@ export type CallControlComponentProps = Pick< | 'getQueuesFetcher' | 'consultTransferOptions' | 'conferenceEnabled' -> & { - /** - * Whether the current task is an accepted campaign preview call. - * When `true`, the header renders the campaign icon and - * "Campaign call" label instead of the standard media type. - */ - isCampaignCall?: boolean; -}; +> & + Partial< + Pick< + ControlProps, + | 'conferenceParticipantDropRoster' + | 'pendingParticipantDropId' + | 'participantDropAnnouncement' + | 'participantDropConfirmationTarget' + | 'participantDropConfirmationDisabled' + | 'requestParticipantDrop' + | 'confirmParticipantDrop' + | 'cancelParticipantDropConfirmation' + > + > & { + /** + * Whether the current task is an accepted campaign preview call. + * When `true`, the header renders the campaign icon and + * "Campaign call" label instead of the standard media type. + */ + isCampaignCall?: boolean; + }; export type OutdialAniEntry = { /** Unique identifier for the ANI entry */ 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 e7ef90f03..b409a82f1 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 @@ -161,6 +161,18 @@ describe('CallControlComponent', () => { jest.restoreAllMocks(); }); describe('Rendering', () => { + it('does not render the CallControlCAD participant roster surface', () => { + const screen = render( + + ); + + expect(screen.queryByTestId('call-control:participants-trigger')).not.toBeInTheDocument(); + }); + it('renders mute and hold buttons and responds to user interactions', async () => { const modifiedProps = { ...defaultProps, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap index a8cdf4aff..27ec18d9c 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/__snapshots__/call-control-cad.snapshot.tsx.snap @@ -93,6 +93,26 @@ exports[`CallControlCADComponent Snapshots should handle edge cases and control
+ +
+ + Cancel + + + Drop + +
+
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..e400ff194 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 @@ -1,5 +1,5 @@ import React from 'react'; -import {render} from '@testing-library/react'; +import {fireEvent, render, waitFor} from '@testing-library/react'; import CallControlCADComponent from '../../../../src/components/task/CallControlCAD/call-control-cad'; import {CallControlComponentProps, TARGET_TYPE, OUTBOUND_TYPE} from '../../../../src/components/task/task.types'; import { @@ -8,9 +8,37 @@ import { createMockTaskUIControls, enabledControl, } from '@webex/test-fixtures'; -import {BuddyDetails} from '@webex/cc-store'; +import {BuddyDetails, ConferenceParticipantDropRoster} from '@webex/cc-store'; import '@testing-library/jest-dom'; +type MomentumElement = HTMLElement & { + ariaLabelledby?: string; + color?: string; + delay?: string; + descriptionText?: string; + focusBackToTrigger?: boolean; + focusTrap?: boolean; + headerText?: string; + hideOnEscape?: boolean; + hideOnOutsideClick?: boolean; + interactive?: boolean; + name?: string; + offset?: number; + placement?: string; + showArrow?: boolean; + size?: number | string; + tagname?: string; + trigger?: string; + triggerID?: string; + type?: string; + variant?: string; + visible?: boolean; +}; + +type MomentumPopoverElement = MomentumElement & { + hide: () => void; +}; + // Mock MediaStream for testing Object.defineProperty(window, 'MediaStream', { writable: true, @@ -89,6 +117,29 @@ describe('CallControlCADComponent', () => { ]; const mockControls = createEnabledMainTaskUIControls(); + const ownerDropRoster: ConferenceParticipantDropRoster = { + customer: { + participantType: 'Customer', + displayName: '+15551234567', + dropTargetId: '+15551234567', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: true, + }, + participants: [ + { + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent-2', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }; const defaultProps: CallControlComponentProps = { currentTask: mockCurrentTask, @@ -415,6 +466,340 @@ describe('CallControlCADComponent', () => { }); }); + describe('conference participant Drop', () => { + const renderRoster = (overrides: Partial = {}) => { + const requestParticipantDrop = jest.fn().mockResolvedValue(undefined); + const confirmParticipantDrop = jest.fn().mockResolvedValue(undefined); + const cancelParticipantDropConfirmation = jest.fn(); + const renderProps = (nextOverrides: Partial = {}) => ({ + ...defaultProps, + controls: createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}), + conferenceParticipants: [{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}], + conferenceParticipantDropRoster: ownerDropRoster, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: false, + requestParticipantDrop, + confirmParticipantDrop, + cancelParticipantDropConfirmation, + ...overrides, + ...nextOverrides, + }); + const screen = render(); + + return { + screen, + requestParticipantDrop, + confirmParticipantDrop, + cancelParticipantDropConfirmation, + rerenderRoster: (nextOverrides: Partial) => + screen.rerender(), + }; + }; + + it('renders Customer and Participants sections and immediately drops an Agent target', () => { + const {screen, requestParticipantDrop} = renderRoster(); + const participantsTrigger = screen.getByTestId('call-control:participants-trigger') as MomentumElement; + const participantsPopover = screen.getByTestId('call-control:participants-popover') as MomentumElement; + const customerHeading = screen.getByText('Customer') as MomentumElement; + const participantsHeading = screen.getByText('Participants') as MomentumElement; + const agentDropButton = screen.getByLabelText('Drop agent Agent Two') as MomentumElement; + const participantIcons = Array.from(screen.container.querySelectorAll('.participant-menu-icon')); + + expect(participantsTrigger).toHaveAttribute('postfix-icon', 'arrow-down-bold'); + expect(participantsTrigger.variant).toBe('tertiary'); + expect(participantsTrigger.size).toBe(24); + expect(participantsPopover.triggerID).toBe(participantsTrigger.id); + expect(participantsPopover.trigger).toBe('click'); + expect(participantsPopover.placement).toBe('bottom-end'); + expect(participantsPopover.color).toBe('tonal'); + expect(participantsPopover.delay).toBe('0,0'); + expect(participantsPopover.offset).toBe(6); + expect(participantsPopover.showArrow).toBe(false); + expect(participantsPopover.interactive).toBe(true); + expect(participantsPopover.focusTrap).toBe(true); + expect(participantsPopover.hideOnEscape).toBe(true); + expect(participantsPopover.hideOnOutsideClick).toBe(true); + expect(participantsPopover.focusBackToTrigger).toBe(true); + expect(customerHeading.tagname).toBe('h4'); + expect(customerHeading.type).toBe('body-midsize-bold'); + expect(participantsHeading.tagname).toBe('h4'); + expect(participantsHeading.type).toBe('body-midsize-bold'); + expect(screen.container.querySelectorAll('mdc-list')).toHaveLength(2); + expect(screen.container.querySelectorAll('mdc-listitem')).toHaveLength(2); + expect(screen.container.querySelectorAll('mdc-divider')).toHaveLength(2); + expect(screen.getByText('+15551234567')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + expect(participantIcons.map((icon) => (icon as MomentumElement).name)).toEqual(['meet-regular', 'meet-regular']); + expect(agentDropButton.variant).toBe('secondary'); + expect(agentDropButton.color).toBe('negative'); + expect(agentDropButton.size).toBe(24); + expect(agentDropButton).toHaveAttribute('slot', 'trailing-controls'); + + fireEvent.click(agentDropButton); + + expect(requestParticipantDrop).toHaveBeenCalledWith(ownerDropRoster.participants[0]); + }); + + it('keeps the Participants section when the Customer row is removed', () => { + const {screen} = renderRoster({ + conferenceParticipantDropRoster: { + ...ownerDropRoster, + customer: null, + participants: [ + ...ownerDropRoster.participants, + { + participantType: 'Agent', + displayName: 'Agent Three', + dropTargetId: 'agent-3', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + }, + }); + + expect(screen.queryByText('Customer')).not.toBeInTheDocument(); + expect(screen.getByText('Participants')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + expect(screen.getByText('Agent Three')).toBeInTheDocument(); + expect(screen.getByLabelText('Drop agent Agent Two')).toBeEnabled(); + }); + + it('keeps a valid multiparty roster visible when wrap-up controls appear', () => { + const {screen} = renderRoster({ + controls: createEnabledMainTaskUIControls({wrapup: {isVisible: true, isEnabled: true}}), + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); + expect(screen.getByText('Customer')).toBeInTheDocument(); + expect(screen.getByText('Participants')).toBeInTheDocument(); + }); + + it('requires confirmation for Customer Drop and connects Momentum focus restoration through triggerID', async () => { + const {screen, requestParticipantDrop, cancelParticipantDropConfirmation, rerenderRoster} = renderRoster(); + const customerDropButton = screen.getByLabelText('Drop customer +15551234567'); + const participantsTrigger = screen.getByTestId('call-control:participants-trigger'); + const participantsPopover = screen.getByTestId('call-control:participants-popover') as MomentumPopoverElement; + const hideParticipantsPopover = jest.spyOn(participantsPopover, 'hide'); + + fireEvent.click(customerDropButton); + expect(requestParticipantDrop).toHaveBeenCalledWith(ownerDropRoster.customer); + expect(hideParticipantsPopover).toHaveBeenCalledTimes(1); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + + const dialog = screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement; + const cancelButton = screen.getByTestId('call-control:customer-drop-cancel') as MomentumElement; + const confirmButton = screen.getByTestId('call-control:customer-drop-confirm') as MomentumElement; + expect(dialog.visible).toBe(true); + expect(dialog.headerText).toBe('Drop customer from conference?'); + expect(dialog.descriptionText).toBe( + 'The customer will be removed from this conference. The remaining participants can continue the call.' + ); + expect(dialog.triggerID).toBe(participantsTrigger.id); + expect(dialog).toHaveClass('participant-drop-dialog'); + expect(cancelButton.parentElement).toHaveAttribute('slot', 'footer'); + await waitFor(() => { + expect(cancelButton.variant).toBe('secondary'); + expect(cancelButton.color).toBe('default'); + expect(confirmButton.variant).toBe('primary'); + expect(confirmButton.color).toBe('negative'); + }); + + fireEvent.click(cancelButton); + + expect(cancelParticipantDropConfirmation).toHaveBeenCalledTimes(1); + rerenderRoster({participantDropConfirmationTarget: null}); + expect((screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement).visible).toBe(false); + await waitFor(() => expect(participantsTrigger).toHaveFocus()); + }); + + it('restores focus to the roster trigger when the confirmed Customer row disappears', async () => { + const {screen, rerenderRoster} = renderRoster(); + + fireEvent.click(screen.getByLabelText('Drop customer +15551234567')); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + expect((screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement).visible).toBe(true); + + rerenderRoster({ + conferenceParticipantDropRoster: {...ownerDropRoster, customer: null}, + participantDropConfirmationTarget: null, + }); + + await waitFor(() => expect(screen.getByTestId('call-control:participants-trigger')).toHaveFocus()); + }); + + it('restores focus to a stable call control when the roster disappears after confirmation', async () => { + const {screen, rerenderRoster} = renderRoster(); + + fireEvent.click(screen.getByLabelText('Drop customer +15551234567')); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + expect((screen.getByTestId('call-control:customer-drop-dialog') as MomentumElement).visible).toBe(true); + + rerenderRoster({conferenceParticipantDropRoster: null, participantDropConfirmationTarget: null}); + + await waitFor(() => expect(screen.getByTestId('call-control:end-call')).toHaveFocus()); + }); + + it('confirms Customer Drop and routes Momentum close or Escape through cancellation', () => { + const {screen, confirmParticipantDrop, cancelParticipantDropConfirmation, rerenderRoster} = renderRoster(); + const customerDropButton = screen.getByLabelText('Drop customer +15551234567'); + + fireEvent.click(customerDropButton); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + fireEvent.click(screen.getByTestId('call-control:customer-drop-confirm')); + expect(confirmParticipantDrop).toHaveBeenCalledTimes(1); + + rerenderRoster({participantDropConfirmationTarget: null}); + fireEvent.click(customerDropButton); + rerenderRoster({participantDropConfirmationTarget: ownerDropRoster.customer}); + const dialog = screen.getByTestId('call-control:customer-drop-dialog'); + fireEvent(dialog, new CustomEvent('close')); + expect(cancelParticipantDropConfirmation).toHaveBeenCalledTimes(1); + }); + + it('globally disables Drop controls and shows loading only on the selected row', async () => { + const {screen} = renderRoster({pendingParticipantDropId: 'agent-2'}); + + expect(screen.getByLabelText('Drop agent Agent Two')).toHaveTextContent('Dropping…'); + await waitFor(() => { + expect(screen.getByLabelText('Drop agent Agent Two')).toBeDisabled(); + expect(screen.getByLabelText('Drop customer +15551234567')).toBeDisabled(); + }); + }); + + it('disables all owner Drop controls during an active non-held consult', async () => { + const {screen} = renderRoster({ + conferenceParticipantDropRoster: {...ownerDropRoster, isDropDisabled: true}, + }); + + await waitFor(() => { + expect(screen.getByLabelText('Drop agent Agent Two')).toBeDisabled(); + expect(screen.getByLabelText('Drop customer +15551234567')).toBeDisabled(); + }); + }); + + it('renders non-owner and Supervisor rows without Drop actions or read-only labels', () => { + const readOnlyRoster: ConferenceParticipantDropRoster = { + customer: ownerDropRoster.customer ? {...ownerDropRoster.customer, isReadOnly: true} : null, + participants: [ + {...ownerDropRoster.participants[0], isPrimary: true, isReadOnly: true}, + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor-1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }; + const {screen} = renderRoster({conferenceParticipantDropRoster: readOnlyRoster}); + const participantIcons = Array.from(screen.container.querySelectorAll('.participant-menu-icon')); + + expect(screen.getByText('Agent Two (Primary)')).toBeInTheDocument(); + expect(screen.getByText('Supervisor One')).toBeInTheDocument(); + expect(participantIcons.map((icon) => (icon as MomentumElement).name)).toEqual([ + 'meet-regular', + 'primary-participant-regular', + 'meet-regular', + ]); + expect(screen.queryByText('Read only')).not.toBeInTheDocument(); + expect(document.querySelector('.participant-menu-type')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Drop agent Agent Two')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Drop customer +15551234567')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Drop supervisor Supervisor One')).not.toBeInTheDocument(); + }); + + it('counts visible non-customer rows and disables a pending EP-DN action', async () => { + const pendingEpDn = { + participantType: 'EP-DN' as const, + displayName: '+15551230000', + dropTargetId: 'pending-epdn', + isPrimary: false, + isReadOnly: false, + isDropDisabled: true, + requiresConfirmation: false, + }; + const supervisor = { + participantType: 'Supervisor' as const, + displayName: 'Supervisor One', + dropTargetId: 'supervisor-1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }; + const {screen, requestParticipantDrop} = renderRoster({ + conferenceParticipantDropRoster: { + ...ownerDropRoster, + participants: [...ownerDropRoster.participants, pendingEpDn, supervisor], + }, + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+3 Participants'); + const pendingDrop = screen.getByLabelText('Drop ep-dn +15551230000'); + await waitFor(() => expect(pendingDrop).toBeDisabled()); + fireEvent.click(pendingDrop); + expect(requestParticipantDrop).not.toHaveBeenCalled(); + }); + + it('renders an answered Entry Point agent as disabled until conference merge', async () => { + const answeredEntryPointAgent = { + participantType: 'Agent' as const, + displayName: 'Support Agent', + dropTargetId: 'agent-3', + isPrimary: false, + isReadOnly: false, + isDropDisabled: true, + requiresConfirmation: false, + }; + const {screen, requestParticipantDrop} = renderRoster({ + conferenceParticipantDropRoster: { + customer: null, + participants: [answeredEntryPointAgent], + isDropDisabled: false, + }, + }); + + expect(screen.getByTestId('call-control:participants-trigger')).toHaveTextContent('+1 Participant'); + expect(screen.getByText('Support Agent')).toBeInTheDocument(); + const answeredAgentDrop = screen.getByLabelText('Drop agent Support Agent'); + await waitFor(() => expect(answeredAgentDrop).toBeDisabled()); + fireEvent.click(answeredAgentDrop); + expect(requestParticipantDrop).not.toHaveBeenCalled(); + }); + + it('keeps generic live feedback mounted when the roster disappears', () => { + const screen = render( + + ); + + expect(screen.getByRole('status')).toHaveTextContent('Participant removed from the conference.'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + + screen.rerender( + + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to drop participant from the call. Try again.'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + }); + describe('conference participants list visibility', () => { it('shows participants list when conference is active and other agents are present', () => { const screen = render( @@ -422,13 +807,14 @@ describe('CallControlCADComponent', () => { {...defaultProps} controls={createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}})} conferenceParticipants={[{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}]} + conferenceParticipantDropRoster={ownerDropRoster} /> ); expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); }); - it('hides participants list when exitConference is not visible and conference is not in progress', () => { + it('hides participants list when the validated Drop roster is null', () => { const screen = render( { {id: 'agent-2', name: 'Agent Two', pType: 'Agent'}, {id: 'agent-3', name: 'Agent Three', pType: 'Agent'}, ]} + conferenceParticipantDropRoster={null} /> ); @@ -462,6 +849,7 @@ describe('CallControlCADComponent', () => { currentTask={conferenceTask} controls={createEnabledMainTaskUIControls({exitConference: {isVisible: false, isEnabled: false}})} conferenceParticipants={[{id: 'agent-2', name: 'Agent Two', pType: 'Agent'}]} + conferenceParticipantDropRoster={ownerDropRoster} /> ); diff --git a/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md b/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md index 983dbce96..b0c4c4dbd 100644 --- a/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md +++ b/packages/contact-center/cc-widgets/ai-docs/cc-widgets-spec.md @@ -83,7 +83,7 @@ boundary (see `src/wc.ts`). | `cc-widgets.UserState` | SDK | React export `UserState`; tag `widget-cc-user-state` (prop `onStateChange`) | Agent state management UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.IncomingTask` | SDK | React export `IncomingTask`; tag `widget-cc-incoming-task` (props `incomingTask:json`, `onAccepted`, `onRejected`) | Incoming task notification UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.CallControl` | SDK | React export `CallControl`; tag `widget-cc-call-control` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`) | Active-call control buttons | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK | React export `CallControlCAD`; tag `widget-cc-call-control-cad` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`) | CAD-enabled call control | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK | React export `CallControlCAD`; tag `widget-cc-call-control-cad` (props `onHoldResume`, `onEnd`, `onWrapUp`, `onRecordingToggle`); participant Drop is inherited from the task widget | CAD-enabled call control with owner-aware conference participant removal | stable; no new property or attribute | `src/index.ts`, `src/wc.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.TaskList` | SDK | React export `TaskList`; tag `widget-cc-task-list` (props `onTaskAccepted`, `onTaskDeclined`, `onTaskSelected`, `hasCampaignPreviewEnabled:boolean`) | Active tasks list UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.OutdialCall` | SDK | React export `OutdialCall`; tag `widget-cc-outdial-call` (no mapped props; store-driven) | Outbound dialing UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.RealTimeTranscript` | SDK | React export `RealTimeTranscript`; tag `widget-cc-realtime-transcript` (props `liveTranscriptEntries:json`, `className:string`) | Live transcript UI | stable; export/tag change = major | `src/index.ts`, `src/wc.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -117,6 +117,7 @@ Compatibility notes: | `cc-widgets-R-004` | Map complex/callback props across the WC boundary with explicit r2wc prop types: `function` callbacks, `json` for object props (`incomingTask`, `liveTranscriptEntries`), `boolean` (`hasCampaignPreviewEnabled`), and `string` (`className`). | HTML attributes are strings only; functions and objects must be set as element properties with the right r2wc coercion or the widget won't receive them. | `packages/contact-center/cc-widgets/src/wc.ts` | None found | Prop maps are the WC public contract; no test asserts the type map. | WEAK | | `cc-widgets-R-005` | Import Momentum UI base CSS in the React entry so React consumers get widget styling without a separate import. | Avoids unstyled widgets in React hosts (a documented prior support issue). | `packages/contact-center/cc-widgets/src/index.ts` | None found | CSS side-effect import is untested. | WEAK | | `cc-widgets-R-006` | Treat React/ReactDOM as peer dependencies (`>=18.3.1`) rather than bundled runtime deps for the React export. | A single host React instance prevents "Invalid hook call" / duplicate-React failures. | `packages/contact-center/cc-widgets/package.json` | None found | Peer-dep enforcement is by package manager, not tested here. | WEAK | +| `cc-widgets-R-007` | The existing React `CallControlCAD` export and `widget-cc-call-control-cad` wrapper inherit participant Drop through `@webex/cc-task` without adding an r2wc prop mapping, attribute, property, event, or feature flag; `CallControl` remains unchanged. | Both consumption modes receive the same store-driven behavior without expanding or breaking the host contract. | `src/index.ts`, `src/wc.ts`, `packages/contact-center/task/src/CallControlCAD/index.tsx` | `@webex/cc-task` and `@webex/cc-components` tests; this package currently has no tests | Wrapper registration itself is unchanged. | PRESENT | ## Design Overview The module is a pure composition/distribution layer with two entry points and no internal state. The React @@ -354,6 +355,7 @@ tested by the upstream widget packages, not here. | `cc-widgets-R-004` (r2wc prop-type map) | None found | No test asserts function/json/boolean/string prop mapping. | | `cc-widgets-R-005` (Momentum CSS imported) | None found | No test for the CSS side-effect import. | | `cc-widgets-R-006` (React/ReactDOM peers) | None found | Enforced by package manager only; no automated check here. | +| `cc-widgets-R-007` (CallControlCAD inherits Drop without a new WC surface) | Covered by upstream task/component tests | No package-local test; the wrapper and prop map are unchanged. | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 01614e428..111148b0d 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -44,7 +44,7 @@ A maintainer should start at `src/store.ts` to understand the observable shape a Owns Contact Center client-side state and the SDK boundary: initialize/register with `@webex/contact-center`, subscribe to CC and task events, expose reactive observables and mutators, fetch domain lists (buddy agents, queues, entry points, address book), and centralize the error callback. It does NOT own UI rendering, business validation, or any direct network protocol beyond delegating to the SDK. ## Stack -TypeScript 5.6.3, MobX 6.13.5 (`makeAutoObservable`, `observable.ref`, `runInAction`). Consumed in React 18 via `mobx-react-lite` `observer()` in downstream packages (not a dependency of this package itself). SDK dependency `@webex/contact-center` 3.12.0-next.106. Tests: Jest 29 + ts compile (`tsc --project tsconfig.test.json && jest --coverage`). Build target: `dist/index.js` (Webpack). Evidence: `packages/contact-center/store/package.json`. +TypeScript 5.6.3, MobX 6.13.5 (`makeAutoObservable`, `observable.ref`, `runInAction`). Consumed in React 18 via `mobx-react-lite` `observer()` in downstream packages (not a dependency of this package itself). SDK dependency `@webex/contact-center` 3.12.0-next.109. Tests: Jest 29 + ts compile (`tsc --project tsconfig.test.json && jest --coverage`). Build target: `dist/index.js` (Webpack). Evidence: `packages/contact-center/store/package.json`. ## Folder / Package Structure ``` @@ -76,14 +76,14 @@ This module is consumed as an imported SDK/code API (the `@webex/cc-store` packa | `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.types` | SDK | type re-exports including the existing `ContactServiceQueue`, `ContactServiceQueuesResponse`, `ContactServiceQueueSearchParams`, `EntryPointRecord`, `EntryPointListResponse`, and `EntryPointSearchParams` contracts plus Task destination controls | Typed SDK-backed domain surface using the SDK's established entity and list types directly | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantDropRoster`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask`; the Drop roster is main-leg, owner-aware, and may add the current Entry Point/EP-DN consult destination by number while ringing or answering Agent name before merge | stable semver | `packages/contact-center/store/src/task-utils.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: - Adding a new observable getter or mutator is additive (minor). Removing/renaming an observable, mutator, or changing the `CC_EVENTS`/`TASK_EVENTS` enum values is breaking (major) — widgets and the SDK event stream depend on the exact string values. - The `CC_EVENTS` / `TASK_EVENTS` enums are locally declared until the SDK exports them (see `// TODO: remove this once cc sdk exports this enum`, `store.types.ts:247`). They must stay byte-identical to the SDK's emitted event strings. ## Requires (dependencies) -- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.106`) — the entire CC runtime: `Webex.init()`, `webex.cc.*` methods, the CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()`. Consumed ONLY through the store. Fallback on unavailability: `Store.init()` rejects after a 6000ms timeout (`src/store.ts:140-142`); the wrapper wraps the rejection and invokes `onErrorCallback('Store', err)` (`src/storeEventsWrapper.ts:442-452`). +- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.109`) — the entire CC runtime: `Webex.init()`, `webex.cc.*` methods, the CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()`. Consumed ONLY through the store. Fallback on unavailability: `Store.init()` rejects after a 6000ms timeout (`src/store.ts:140-142`); the wrapper wraps the rejection and invokes `onErrorCallback('Store', err)` (`src/storeEventsWrapper.ts:442-452`). - `mobx` ^6.13.5 — observable state and `runInAction` for all mutations. - Internal: none upstream. The store is the lowest widget-layer dependency (`cc-components → widget packages → store → SDK`); it imports no widget package. @@ -111,14 +111,19 @@ 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` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | +| `STORE-R-022` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. Consult activity recognizes visible consult controls, `uiControls.main.endConsult`, and consult interaction states. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | +| `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | +| `STORE-R-024` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | +| `STORE-R-025` | `handleTaskHydrate` replaces the Store's cloned `currentTask` with a clone of the authoritative SDK task even when the interaction ID is unchanged. Participant Drop ownership and Primary-row state are always derived from the hydrated `interaction.owner`; the Store never elects a successor locally. | Agent Desktop receives the promoted agent through `ContactOwnerChanged` and remaining agents through an owner-changing `ContactUpdated`; the SDK normalizes those paths into hydration so every widget view updates without waiting for another participant event. | `src/storeEventsWrapper.ts`, `src/task-utils.ts` | `tests/storeEventsWrapper.ts` (owner-change hydration replaces the clone and updates promoted/secondary roster permissions) | Backend ownership and SDK hydration remain authoritative. | 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`. Terminal `task:end` and `task:consultEnd` signals coalesce that read into the next microtask because SDK final-state cleanup completes after event emission; consult flags still clear synchronously, and participant-left remains synchronous for surviving-call roster updates. Campaign-preview tasks carry extra state logic (RESERVED vs ENGAGED, an `acceptedCampaignIds` set) so a pending preview never promotes to `currentTask`. + +Ownership changes use the same authoritative hydration boundary. Agent Desktop's event flow distinguishes the newly promoted agent (`ContactOwnerChanged`) from other surviving agents (an owner-changing `ContactUpdated`); the SDK publishes the resulting owner snapshot through `task:hydrate`. `handleTaskHydrate` replaces the Store's shallow task clone, and owner-aware selectors recompute from `interaction.owner`. The Store does not infer, elect, or preserve a successor itself. 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`. @@ -234,8 +239,10 @@ sequenceDiagram else W->>S: setCurrentTask(task); setState(ENGAGED) end - SDK-->>W: task:end - W->>S: setIsDeclineButtonEnabled(false); refreshTaskList() + SDK-->>W: task:end / task:consultEnd + W->>S: clear immediate button/consult state + W->>W: coalesce one microtask refresh + W->>S: refreshTaskList() after SDK final cleanup Note over W,S: handleTaskRemove detaches all task listeners,
clears transcription, drops accepted-campaign id,
resets state, refreshTaskList() ``` @@ -346,7 +353,9 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | | `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | | `STORE-R-021` | None found | `handleRealtimeTranscription` untested | -| `STORE-R-022` | `tests/storeEventsWrapper.ts` no longer exposes raw collaboration-policy getters | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | +| `STORE-R-022` | `tests/task-utils.ts` (participant Drop roster, owner transfer, ANI/DNIS, filtering, consult gating, conference eligibility) | none | +| `STORE-R-023` | `tests/storeEventsWrapper.ts` no longer exposes raw collaboration-policy getters | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | +| `STORE-R-025` | `tests/storeEventsWrapper.ts` (owner-change hydration and immediate Drop-roster recomputation) | none | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index 82361e22f..0ebcf0ee9 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -23,7 +23,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/contact-center": "3.12.0-next.106", + "@webex/contact-center": "3.12.0-next.109", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 418f0817d..59ef3380d 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -417,6 +417,32 @@ export type Participant = { name?: string; }; +export type ConferenceParticipantDropType = 'Customer' | 'Agent' | 'EP-DN' | 'Supervisor'; + +/** + * Display and authorization data for one participant-drop roster row. + * `dropTargetId` is passed to the SDK only when `isReadOnly` is false. + */ +export type ConferenceParticipantDropTarget = { + participantType: ConferenceParticipantDropType; + displayName: string; + dropTargetId: string; + isPrimary: boolean; + isReadOnly: boolean; + /** Target is visible with an action, but the conference Drop request is not valid yet. */ + isDropDisabled: boolean; + requiresConfirmation: boolean; +}; + +/** + * Participant-drop roster derived from the authoritative main-call media leg. + */ +export type ConferenceParticipantDropRoster = { + customer: ConferenceParticipantDropTarget | null; + participants: ConferenceParticipantDropTarget[]; + isDropDisabled: boolean; +}; + /** * Desktop preference data structure containing E911 modal acknowledgment. * @public diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 7a003bdad..a0cad0f8a 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -72,6 +72,7 @@ class StoreWrapper implements IStoreWrapper { onTaskSelected?: (task: ITask, isClicked: boolean) => void; onErrorCallback?: (widgetName: string, error: Error) => void; private realtimeTranscriptionListeners: Record void> = {}; + private taskListRefreshScheduled = false; // Keyed by interactionId; the task is tracked alongside the listener so a // replacement task object (task:hydrate / task:merged) gets rebound. private realTimeAssistListeners: Record void}> = @@ -367,6 +368,22 @@ class StoreWrapper implements IStoreWrapper { }); }; + /** + * Terminal task events are emitted before the SDK removes the task from its collection. + * Defer and coalesce that authoritative read so ended calls disappear without a page refresh. + */ + private scheduleTaskListRefresh = (): void => { + if (this.taskListRefreshScheduled) { + return; + } + + this.taskListRefreshScheduled = true; + queueMicrotask(() => { + this.taskListRefreshScheduled = false; + this.refreshTaskList(); + }); + }; + setWrapupCodes = (wrapupCodes: IWrapupCode[]): void => { this.store.wrapupCodes = wrapupCodes; }; @@ -821,8 +838,7 @@ class StoreWrapper implements IStoreWrapper { handleTaskEnd = () => { this.setIsDeclineButtonEnabled(false); - - this.refreshTaskList(); + this.scheduleTaskListRefresh(); }; handleTaskAssigned = (event) => { @@ -895,8 +911,8 @@ class StoreWrapper implements IStoreWrapper { this.setIsQueueConsultInProgress(false); this.setCurrentConsultQueueId(null); this.setLastConsultDestination(null); - this.refreshTaskList(); this.setConsultStartTimeStamp(null); + this.scheduleTaskListRefresh(); }; handleConsultOffer = () => { diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index f3dd96ca6..72be302a1 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -1,5 +1,12 @@ import {EXCLUDED_PARTICIPANT_TYPES, RELATIONSHIP_TYPE_CONSULT} from './constants'; -import {ITask, MEDIA_TYPE_TELEPHONY_LOWER, Participant} from './store.types'; +import { + ConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, + ConferenceParticipantDropType, + ITask, + MEDIA_TYPE_TELEPHONY_LOWER, + Participant, +} from './store.types'; /** * Determines if a task is an incoming task @@ -133,6 +140,447 @@ export const getConferenceParticipants = (task: ITask, agentId: string): Partici return participantsList; }; +const TERMINAL_CONFERENCE_STATES = new Set(['ended', 'disconnected', 'terminated']); + +type RosterTaskData = ITask['data']; +type RosterInteraction = RosterTaskData['interaction']; +type RosterParticipant = NonNullable[string]; +type RosterMedia = NonNullable[string]; + +type TaskWithStateSnapshot = ITask & { + state?: { + context?: { + taskData?: RosterTaskData; + }; + }; +}; + +const normalizeParticipantType = (participantType?: string): string => + String(participantType || '') + .trim() + .toUpperCase() + .replaceAll('_', '-'); + +const getParticipantTypeTokens = (participant: RosterParticipant): string[] => + [participant.pType, participant.type] + .map((participantType) => normalizeParticipantType(participantType)) + .filter(Boolean); + +const participantHasType = (participant: RosterParticipant, participantType: string): boolean => + getParticipantTypeTokens(participant).includes(participantType); + +const hasVisibleControls = (controls: ITask['uiControls']['consult'] | undefined): boolean => + Boolean(controls && Object.values(controls).some((control) => control?.isVisible)); + +const hasActiveConsultSignal = (task: ITask): boolean => { + const interactionState = task?.data?.interaction?.state?.toLowerCase(); + + return Boolean( + hasVisibleControls(task?.uiControls?.consult) || + task?.uiControls?.main?.endConsult?.isVisible || + ['consult', 'consulting'].includes(interactionState) + ); +}; + +const isViewingAgentActiveOnMainLeg = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { + const participants = task?.data?.interaction?.participants ?? {}; + return mainParticipantIds.some((participantId) => { + const participant = participants[participantId]; + + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return false; + } + + return participantId === agentId || participant.id === agentId; + }); +}; + +const isEligibleConference = (task: ITask, mainParticipantIds: string[], agentId: string): boolean => { + const interaction = task?.data?.interaction; + const interactionState = interaction?.state?.toLowerCase(); + const isTerminated = Boolean(interaction?.isTerminated || TERMINAL_CONFERENCE_STATES.has(interactionState)); + + return Boolean( + interaction?.mediaType?.toLowerCase() === MEDIA_TYPE_TELEPHONY_LOWER && + mainParticipantIds.length > 0 && + !isTerminated && + isViewingAgentActiveOnMainLeg(task, mainParticipantIds, agentId) + ); +}; + +const getMediaRecencyTimestamp = (media: Record): number | undefined => { + const candidateTimestamps = [ + media.lastUpdated, + media.joinTimestamp, + media.consultTimestamp, + media.holdTimestamp, + media.eventTime, + media.createdAt, + ]; + + for (const value of candidateTimestamps) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + + return undefined; +}; + +const getMediaRecencyScore = (media: Record, fallbackIndex: number): number => + getMediaRecencyTimestamp(media) ?? fallbackIndex; + +type ConsultMediaEntry = { + mediaId: string; + media: RosterMedia; + interaction: RosterInteraction; + taskData: RosterTaskData; + isConfiguredMedia: boolean; + source: 'observable' | 'snapshot'; +}; + +const getConsultMediaEntry = ( + taskData: RosterTaskData, + source: ConsultMediaEntry['source'] +): ConsultMediaEntry | undefined => { + const interaction = taskData?.interaction; + const configuredConsultMediaId = taskData?.consultMediaResourceId; + const matchingMedia = Object.entries(interaction?.media ?? {}).filter(([, media]) => media.mType === 'consult'); + + if (!interaction || matchingMedia.length === 0) { + return undefined; + } + + const configuredEntry = configuredConsultMediaId + ? matchingMedia.find( + ([mediaId, media]) => mediaId === configuredConsultMediaId || media.mediaResourceId === configuredConsultMediaId + ) + : undefined; + const [mediaId, media] = + configuredEntry ?? + matchingMedia.reduce((latest, current, index) => { + const latestScore = getMediaRecencyScore(latest[1] as Record, index - 1); + const currentScore = getMediaRecencyScore(current[1] as Record, index); + return currentScore >= latestScore ? current : latest; + }); + + return { + mediaId: media.mediaResourceId || mediaId, + media, + interaction, + taskData, + isConfiguredMedia: Boolean(configuredEntry), + source, + }; +}; + +const selectCurrentConsultMediaEntry = ( + current: ConsultMediaEntry, + candidate: ConsultMediaEntry +): ConsultMediaEntry => { + const currentTimestamp = getMediaRecencyTimestamp(current.media as Record); + const candidateTimestamp = getMediaRecencyTimestamp(candidate.media as Record); + + if (current.mediaId === candidate.mediaId) { + if (currentTimestamp !== undefined && candidateTimestamp !== undefined && candidateTimestamp > currentTimestamp) { + return candidate; + } + + // Preserve the existing observable-data behavior when both sources describe + // the same leg and there is no positive evidence that the snapshot is newer. + return current; + } + + if (currentTimestamp !== candidateTimestamp) { + if (currentTimestamp !== undefined && candidateTimestamp !== undefined) { + return candidateTimestamp > currentTimestamp ? candidate : current; + } + + if (current.isConfiguredMedia === candidate.isConfiguredMedia) { + return candidateTimestamp !== undefined ? candidate : current; + } + } + + if (current.isConfiguredMedia !== candidate.isConfiguredMedia) { + return candidate.isConfiguredMedia ? candidate : current; + } + + // The state-machine context can advance before observable task hydration. + // Prefer it when recency and current-media signals cannot distinguish the legs. + return candidate.source === 'snapshot' ? candidate : current; +}; + +const getCurrentConsultMediaEntry = (task: ITask) => { + const taskWithSnapshot = task as TaskWithStateSnapshot; + const snapshotTaskData = taskWithSnapshot.state?.context?.taskData; + + if (!hasActiveConsultSignal(task)) { + return undefined; + } + + const candidates = [ + getConsultMediaEntry(task.data, 'observable'), + snapshotTaskData ? getConsultMediaEntry(snapshotTaskData, 'snapshot') : undefined, + ].filter((candidate): candidate is ConsultMediaEntry => Boolean(candidate)); + + return candidates.length > 0 ? candidates.reduce(selectCurrentConsultMediaEntry) : undefined; +}; + +const hasActiveNonHeldConsult = (task: ITask): boolean => { + const consultMedia = getCurrentConsultMediaEntry(task)?.media; + + // Treat an active consult without a hydrated media leg conservatively as non-held. + // Drop becomes available again once the SDK explicitly reports that consult leg held. + return Boolean(hasActiveConsultSignal(task) && consultMedia?.isHold !== true); +}; + +const getCustomerDropTargetId = (task: ITask): string => { + const interaction = task?.data?.interaction; + const direction = interaction?.contactDirection?.type?.toLowerCase(); + const callDetails = interaction?.callAssociatedDetails; + const processingDetails = interaction?.callProcessingDetails; + + if (direction === 'inbound') { + return callDetails?.ani || processingDetails?.ani || ''; + } + + if (direction === 'outbound') { + return callDetails?.dnis || processingDetails?.dnis || ''; + } + + return ''; +}; + +const getParticipantType = ( + normalizedType: string +): {participantType: ConferenceParticipantDropType; isSupervisor: boolean} | null => { + if (normalizedType === 'AGENT') { + return {participantType: 'Agent', isSupervisor: false}; + } + + if (['EP-DN', 'EPDN', 'DN'].includes(normalizedType)) { + return {participantType: 'EP-DN', isSupervisor: false}; + } + + if (normalizedType === 'SUPERVISOR') { + return {participantType: 'Supervisor', isSupervisor: true}; + } + + return null; +}; + +const getParticipantTypeDetails = ( + participant: RosterParticipant +): {participantType: ConferenceParticipantDropType; isSupervisor: boolean} | null => { + for (const participantType of getParticipantTypeTokens(participant)) { + const typeDetails = getParticipantType(participantType); + + if (typeDetails) { + return typeDetails; + } + } + + return null; +}; + +const isEpDnConsultDestination = (destinationType?: string): boolean => + ['ENTRYPOINT', 'ENTRY-POINT', 'DIALNUMBER', 'DIAL-NUMBER', 'EP-DN', 'EPDN', 'DN'].includes( + normalizeParticipantType(destinationType) + ); + +const isTrueLike = (value: boolean | string | undefined): boolean => + value === true || String(value).toLowerCase() === 'true'; + +/** + * Derives the owner-aware participant Drop roster from the active main-call media leg. + * The viewing agent is never returned as a target and backend authorization remains authoritative. + */ +export const getConferenceParticipantDropRoster = ( + task: ITask, + agentId: string +): ConferenceParticipantDropRoster | null => { + const interaction = task?.data?.interaction; + const mainCallMedia = getMainCallMediaEntry(task); + const mainParticipantIds = mainCallMedia?.participants ?? []; + + if (!interaction || !isEligibleConference(task, mainParticipantIds, agentId) || isSecondaryAgent(task)) { + return null; + } + + const participants = interaction.participants ?? {}; + const mainParticipantIdSet = new Set(mainParticipantIds); + + const isOwner = interaction.owner === agentId; + const participantRows: ConferenceParticipantDropTarget[] = []; + const includedParticipantIds = new Set(); + const currentConsult = getCurrentConsultMediaEntry(task); + let hasActiveCustomer = false; + + mainParticipantIdSet.forEach((participantId) => { + const participant = participants[participantId]; + + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return; + } + + const resolvedParticipantId = participant.id || participantId; + if (participantHasType(participant, 'CUSTOMER')) { + hasActiveCustomer = true; + return; + } + + if (participantId === agentId || resolvedParticipantId === agentId || participantHasType(participant, 'VVA')) { + return; + } + + const typeDetails = getParticipantTypeDetails(participant); + + if (!typeDetails) { + return; + } + + const {participantType, isSupervisor} = typeDetails; + const displayName = + participantType === 'EP-DN' + ? participant.dn || resolvedParticipantId || participantId + : participant.name || resolvedParticipantId || participantType; + + participantRows.push({ + participantType, + displayName: String(displayName), + dropTargetId: resolvedParticipantId, + isPrimary: interaction.owner === participantId || interaction.owner === resolvedParticipantId, + isReadOnly: !isOwner || isSupervisor, + isDropDisabled: false, + requiresConfirmation: false, + }); + includedParticipantIds.add(participantId); + includedParticipantIds.add(resolvedParticipantId); + }); + + const consultParticipantIds = currentConsult?.media.participants ?? []; + const consultParticipants = currentConsult?.interaction.participants ?? participants; + const pendingEpDnEntry = consultParticipantIds + .map((participantId) => ({participantId, participant: consultParticipants[participantId]})) + .find(({participantId, participant}) => { + if (!participant || participant.hasLeft) { + return false; + } + + const resolvedParticipantId = participant.id || participantId; + return ( + getParticipantTypeDetails(participant)?.participantType === 'EP-DN' && + participantId !== agentId && + resolvedParticipantId !== agentId + ); + }); + const consultProcessingDetails = currentConsult?.interaction.callProcessingDetails; + const rawDestinationAgentName = consultProcessingDetails?.consultDestinationAgentName; + const destinationAgentJoined = + isTrueLike(consultProcessingDetails?.consultDestinationAgentJoined) || Boolean(rawDestinationAgentName); + const destinationAgentName = destinationAgentJoined ? rawDestinationAgentName : ''; + const answeredAgentEntries = consultParticipantIds + .map((participantId) => ({participantId, participant: consultParticipants[participantId]})) + .filter(({participantId, participant}) => { + if (!participant || participant.hasLeft || participant.hasJoined === false) { + return false; + } + + const resolvedParticipantId = participant.id || participantId; + return ( + getParticipantTypeDetails(participant)?.participantType === 'Agent' && + participantId !== agentId && + resolvedParticipantId !== agentId + ); + }); + // A nested Entry Point consult can contain both the consulting conference + // Agent and the newly answering Agent. Prefer the explicit destination, then + // a consulted non-main participant, so the row never regresses to the caller. + const answeredAgentEntry = + answeredAgentEntries.find(({participant}) => + Boolean(destinationAgentName && participant?.name === destinationAgentName) + ) || + answeredAgentEntries.find(({participantId, participant}) => { + const resolvedParticipantId = participant?.id || participantId; + return participant?.isConsulted === true && !mainParticipantIdSet.has(resolvedParticipantId); + }) || + answeredAgentEntries.find(({participantId, participant}) => { + const resolvedParticipantId = participant?.id || participantId; + return !mainParticipantIdSet.has(participantId) && !mainParticipantIdSet.has(resolvedParticipantId); + }); + const epDnConsultIsActive = Boolean( + currentConsult && + (pendingEpDnEntry || + isEpDnConsultDestination(currentConsult.taskData.destinationType) || + isEpDnConsultDestination(task.data.destinationType)) + ); + + if (epDnConsultIsActive) { + const answeredAgent = answeredAgentEntry?.participant; + const pendingEpDn = pendingEpDnEntry?.participant; + const visibleParticipant = answeredAgent || pendingEpDn; + const visibleParticipantId = answeredAgentEntry?.participantId || pendingEpDnEntry?.participantId; + + if (visibleParticipant && visibleParticipantId) { + const resolvedParticipantId = visibleParticipant.id || visibleParticipantId; + const answeredAgentAlreadyVisible = participantRows.some( + (target) => + target.participantType === 'Agent' && + Boolean(destinationAgentName) && + target.displayName === destinationAgentName + ); + + if ( + !mainParticipantIdSet.has(visibleParticipantId) && + !includedParticipantIds.has(resolvedParticipantId) && + !answeredAgentAlreadyVisible + ) { + const participantType = answeredAgent ? 'Agent' : 'EP-DN'; + const displayName = answeredAgent + ? answeredAgent.name || destinationAgentName || resolvedParticipantId + : destinationAgentName || pendingEpDn?.dn || resolvedParticipantId || currentConsult.media.mediaResourceId; + + participantRows.push({ + participantType, + displayName: String(displayName), + dropTargetId: resolvedParticipantId, + isPrimary: false, + isReadOnly: !isOwner, + isDropDisabled: true, + requiresConfirmation: false, + }); + includedParticipantIds.add(visibleParticipantId); + includedParticipantIds.add(resolvedParticipantId); + } + } + } + + const customerDropTargetId = hasActiveCustomer ? getCustomerDropTargetId(task) : ''; + const customer: ConferenceParticipantDropTarget | null = customerDropTargetId + ? { + participantType: 'Customer', + displayName: customerDropTargetId, + dropTargetId: customerDropTargetId, + isPrimary: false, + isReadOnly: !isOwner, + isDropDisabled: false, + requiresConfirmation: true, + } + : null; + + // Customer-only calls retain the original 1-to-1 UI. Any supported + // non-customer row keeps the participant roster visible. + if (participantRows.length === 0) { + return null; + } + + return { + customer, + participants: participantRows, + isDropDisabled: hasActiveNonHeldConsult(task), + }; +}; + export function isInteractionOnHold(task: ITask): boolean { if (!task || !task.data || !task.data.interaction) { return false; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index dfd2d43b3..899eb8b8c 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -26,6 +26,7 @@ import {CC_EVENTS, TASK_EVENTS} from '../src/store.types'; import type {RealTimeAssistPayload} from '../src/store.types'; import storeWrapper from '../src/storeEventsWrapper'; import {ITask} from '../src/store.types'; +import {getConferenceParticipantDropRoster} from '../src/task-utils'; import { mockCC, mockTask as mockTaskFixture, @@ -2334,6 +2335,132 @@ describe('storeEventsWrapper', () => { }); }); + describe('owner-change hydration', () => { + const interactionId = 'owner-change-main'; + const oldOwnerId = 'agent1'; + const promotedOwnerId = 'agent2'; + const survivingSecondaryId = 'agent3'; + let originalAgentId: (typeof storeWrapper)['store']['agentId']; + let originalCurrentTask: (typeof storeWrapper)['store']['currentTask']; + let originalDeviceType: (typeof storeWrapper)['store']['deviceType']; + let originalGetAllTasks: (typeof storeWrapper)['store']['cc']['taskManager']['getAllTasks']; + let originalTaskList: (typeof storeWrapper)['store']['taskList']; + + const participant = (id: string, name: string, hasLeft = false) => ({ + id, + name, + pType: 'Agent', + type: 'Agent', + hasJoined: true, + hasLeft, + isInPredial: false, + }); + + const createOwnerTask = (owner: string, oldOwnerHasLeft = false): ITask => + makeMockTask({ + data: { + interactionId, + agentId: survivingSecondaryId, + isConferenceInProgress: true, + interaction: { + interactionId, + mediaType: 'telephony', + state: 'conference', + owner, + contactDirection: {type: 'inbound'}, + callAssociatedDetails: {ani: '+15550000001'}, + participants: { + [oldOwnerId]: participant(oldOwnerId, 'Original Owner', oldOwnerHasLeft), + [promotedOwnerId]: participant(promotedOwnerId, 'Promoted Owner'), + [survivingSecondaryId]: participant(survivingSecondaryId, 'Surviving Secondary'), + customer1: { + id: 'customer1', + name: 'Customer', + pType: 'Customer', + type: 'Customer', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + }, + media: { + [interactionId]: { + mediaResourceId: interactionId, + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + participants: oldOwnerHasLeft + ? [promotedOwnerId, survivingSecondaryId, 'customer1'] + : [oldOwnerId, promotedOwnerId, survivingSecondaryId, 'customer1'], + }, + }, + }, + }, + }); + + beforeEach(() => { + jest.clearAllMocks(); + originalAgentId = storeWrapper['store'].agentId; + originalCurrentTask = storeWrapper['store'].currentTask; + originalDeviceType = storeWrapper['store'].deviceType; + originalGetAllTasks = storeWrapper['store'].cc.taskManager.getAllTasks; + originalTaskList = storeWrapper['store'].taskList; + + storeWrapper['store'].agentId = survivingSecondaryId; + storeWrapper['store'].currentTask = null; + storeWrapper['store'].taskList = {}; + storeWrapper['store'].deviceType = 'EXTENSION'; + }); + + afterEach(() => { + storeWrapper['store'].agentId = originalAgentId; + storeWrapper['store'].currentTask = originalCurrentTask; + storeWrapper['store'].deviceType = originalDeviceType; + storeWrapper['store'].cc.taskManager.getAllTasks = originalGetAllTasks; + storeWrapper['store'].taskList = originalTaskList; + }); + + it('immediately replaces the cloned task and derives the authoritative promoted owner', () => { + const staleTask = createOwnerTask(oldOwnerId); + const hydratedTask = createOwnerTask(promotedOwnerId, true); + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({[interactionId]: staleTask}); + + storeWrapper.setCurrentTask(staleTask); + const staleCurrentTask = storeWrapper.currentTask as ITask; + const staleSecondaryRoster = getConferenceParticipantDropRoster(staleCurrentTask, survivingSecondaryId); + + expect(staleCurrentTask).not.toBe(staleTask); + expect(staleSecondaryRoster?.participants.find(({dropTargetId}) => dropTargetId === oldOwnerId)?.isPrimary).toBe( + true + ); + + // An owner-change task:hydrate is sufficient; no participant-join event or page refresh is needed. + storeWrapper.handleTaskHydrate(hydratedTask); + + const currentTask = storeWrapper.currentTask as ITask; + const secondaryRoster = getConferenceParticipantDropRoster(currentTask, survivingSecondaryId); + const promotedOwnerRoster = getConferenceParticipantDropRoster(currentTask, promotedOwnerId); + + expect(currentTask).not.toBe(staleCurrentTask); + expect(currentTask).not.toBe(hydratedTask); + expect(currentTask.data.interaction.owner).toBe(promotedOwnerId); + expect(secondaryRoster?.participants).not.toEqual( + expect.arrayContaining([expect.objectContaining({dropTargetId: oldOwnerId})]) + ); + expect(secondaryRoster?.participants.find(({dropTargetId}) => dropTargetId === promotedOwnerId)).toMatchObject({ + isPrimary: true, + isReadOnly: true, + }); + expect(secondaryRoster?.customer?.isReadOnly).toBe(true); + expect( + promotedOwnerRoster?.participants.find(({dropTargetId}) => dropTargetId === survivingSecondaryId) + ).toMatchObject({isPrimary: false, isReadOnly: false}); + expect(promotedOwnerRoster?.customer?.isReadOnly).toBe(false); + }); + }); + it('set TaskAssigned', () => { const setTaskAssignedSpy = jest.spyOn(storeWrapper, 'setTaskAssigned'); const mockTaskAssignedCallback = jest.fn(); @@ -2507,7 +2634,7 @@ describe('storeEventsWrapper', () => { }); describe('handleTaskEnd — campaign preview (unaccepted)', () => { - it('should call refreshTaskList and let the backend drive task removal', () => { + it('should defer refreshTaskList so SDK cleanup completes first', async () => { const task = createCampaignPreviewTask('campaign-1'); storeWrapper['store'].taskList = {'campaign-1': task}; storeWrapper['store'].currentTask = task; @@ -2518,13 +2645,14 @@ describe('storeEventsWrapper', () => { storeWrapper.handleTaskEnd(); - // refreshTaskList should be called (normal path, no force cleanup) - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); describe('handleTaskEnd — accepted campaign preview', () => { - it('should call refreshTaskList for accepted campaign', () => { + it('should defer refreshTaskList for accepted campaign', async () => { const task = createCampaignPreviewTask('campaign-accepted'); storeWrapper['store'].acceptedCampaignIds = new Set(['campaign-accepted']); storeWrapper['store'].taskList = {'campaign-accepted': task}; @@ -2537,8 +2665,9 @@ describe('storeEventsWrapper', () => { // acceptedCampaignIds should NOT be cleaned up here (deferred to handleTaskRemove) expect(storeWrapper['store'].acceptedCampaignIds.has('campaign-accepted')).toBe(true); - // refreshTaskList SHOULD be called (normal path) - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); }); }); @@ -2570,7 +2699,7 @@ describe('storeEventsWrapper', () => { }); describe('handleTaskEnd — non-campaign tasks', () => { - it('should call refreshTaskList for a regular (non-campaign) task', () => { + it('should refresh a regular task after SDK terminal cleanup', async () => { const regularTask = makeMockTask({ data: { interactionId: 'regular-1', @@ -2589,11 +2718,43 @@ describe('storeEventsWrapper', () => { storeWrapper.handleTaskEnd(); - // Should call refreshTaskList normally - expect(refreshSpy).toHaveBeenCalled(); + expect(refreshSpy).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(refreshSpy).toHaveBeenCalledTimes(1); // taskList should still contain the task (SDK still returns it) expect(storeWrapper['store'].taskList['regular-1']).toBeDefined(); }); + + it('coalesces consult-end and task-end into one deferred refresh', async () => { + const refreshSpy = jest.spyOn(storeWrapper, 'refreshTaskList'); + const setQueueProgressSpy = jest.spyOn(storeWrapper, 'setIsQueueConsultInProgress'); + + storeWrapper.handleConsultEnd(); + storeWrapper.handleTaskEnd(); + + expect(setQueueProgressSpy).toHaveBeenCalledWith(false); + expect(storeWrapper.consultStartTimeStamp).toBeNull(); + expect(refreshSpy).not.toHaveBeenCalled(); + + await Promise.resolve(); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('clears the ended current task after SDK cleanup removes it', async () => { + const regularTask = makeMockTask({ + data: {interactionId: 'ended-task', interaction: {state: 'connected'}}, + }); + storeWrapper['store'].taskList = {'ended-task': regularTask}; + storeWrapper['store'].currentTask = regularTask; + storeWrapper['store'].cc.taskManager.getAllTasks = jest.fn().mockReturnValue({}); + + storeWrapper.handleTaskEnd(); + + expect(storeWrapper.currentTask).toBe(regularTask); + await Promise.resolve(); + expect(storeWrapper.currentTask).toBeNull(); + }); }); describe('handleIncomingCampaignPreview — campaign type branching', () => { diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index 557448c68..3ed3dee47 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -1,5 +1,11 @@ -import {isIncomingTask, getConferenceParticipants, findHoldTimestamp} from '../src/task-utils'; +import { + isIncomingTask, + getConferenceParticipants, + getConferenceParticipantDropRoster, + findHoldTimestamp, +} from '../src/task-utils'; import {mockTask} from '../../test-fixtures/src/fixtures'; +import {createEnabledMainTaskUIControls} from '../../test-fixtures/src/taskUIControlsFixtures'; import {ITask} from '../src/store.types'; const participant = (hasJoined: boolean) => @@ -601,6 +607,656 @@ describe('getConferenceParticipants', () => { }); }); +describe('getConferenceParticipantDropRoster', () => { + const currentAgentId = 'agent1'; + + const activeParticipant = (id: string, pType: string, name?: string) => ({ + id, + pType, + type: pType, + name, + hasJoined: true, + hasLeft: false, + isInPredial: false, + }); + + const createDropRosterTask = ({ + owner = currentAgentId, + direction = 'inbound', + state = 'conference', + wrapUpRequired = false, + consultHold, + }: { + owner?: string; + direction?: string; + state?: string; + wrapUpRequired?: boolean; + consultHold?: boolean; + } = {}): ITask => { + const controls = createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}); + const media = { + main: { + mediaResourceId: 'main', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'agent2', 'epdn1', 'supervisor1', 'customer1', 'vva1', 'unsupported1'], + }, + ...(consultHold === undefined + ? {} + : { + consult: { + mediaResourceId: 'consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: consultHold, + holdTimestamp: consultHold ? Date.now() : null, + participants: ['agent1', 'consult-agent'], + }, + }), + }; + + if (consultHold !== undefined) { + controls.consult.endConsult = {isVisible: true, isEnabled: true}; + } + + return { + ...mockTask, + uiControls: controls, + data: { + ...mockTask.data, + interactionId: 'main', + wrapUpRequired, + isConferenceInProgress: true, + interaction: createPartialInteraction({ + ...mockTask.data.interaction, + interactionId: 'main', + mediaType: 'telephony', + state, + owner, + contactDirection: {type: direction}, + callAssociatedDetails: {ani: '+15550000001', dnis: '+15550000002'}, + callProcessingDetails: { + ...mockTask.data.interaction.callProcessingDetails, + ani: '+15550000001', + dnis: '+15550000002', + }, + media, + participants: { + agent1: activeParticipant('agent1', 'Agent', 'Current Agent'), + agent2: activeParticipant('agent2', 'Agent', 'Agent Two'), + epdn1: {...activeParticipant('epdn1', 'EP_DN', 'EP-DN'), dn: '+15550000003'}, + supervisor1: activeParticipant('supervisor1', 'Supervisor', 'Supervisor One'), + customer1: activeParticipant('customer1', 'Customer', 'Customer'), + vva1: activeParticipant('vva1', 'VVA', 'Virtual Agent'), + unsupported1: activeParticipant('unsupported1', 'Queue', 'Queue'), + 'consult-agent': activeParticipant('consult-agent', 'Agent', 'Consult Agent'), + }, + }), + }, + } as ITask; + }; + + it('derives Customer, Agent, EP-DN, and read-only Supervisor rows from the main leg', () => { + const task = createDropRosterTask(); + const roster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(roster).toEqual({ + customer: { + participantType: 'Customer', + displayName: '+15550000001', + dropTargetId: '+15550000001', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: true, + }, + participants: [ + { + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent2', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + { + participantType: 'EP-DN', + displayName: '+15550000003', + dropTargetId: 'epdn1', + isPrimary: false, + isReadOnly: false, + isDropDisabled: false, + requiresConfirmation: false, + }, + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }); + expect(roster?.participants).not.toEqual(expect.arrayContaining([expect.objectContaining({dropTargetId: 'vva1'})])); + expect(roster?.participants).not.toEqual( + expect.arrayContaining([expect.objectContaining({dropTargetId: 'consult-agent'})]) + ); + expect(getConferenceParticipants(task, currentAgentId)).toHaveLength(3); + }); + + it('uses outbound DNIS for the synthetic Customer target', () => { + const roster = getConferenceParticipantDropRoster(createDropRosterTask({direction: 'outbound'}), currentAgentId); + + expect(roster?.customer?.dropTargetId).toBe('+15550000002'); + }); + + it('omits departed, not-yet-joined, and customer rows without a valid direction number', () => { + const task = createDropRosterTask(); + task.data.interaction.participants.agent2.hasLeft = true; + task.data.interaction.participants.epdn1.hasJoined = false; + task.data.interaction.callAssociatedDetails.ani = ''; + task.data.interaction.callProcessingDetails.ani = ''; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toEqual({ + customer: null, + participants: [ + { + participantType: 'Supervisor', + displayName: 'Supervisor One', + dropTargetId: 'supervisor1', + isPrimary: false, + isReadOnly: true, + isDropDisabled: false, + requiresConfirmation: false, + }, + ], + isDropDisabled: false, + }); + }); + + it('makes every row read-only for a non-owner and marks the owner as Primary', () => { + const roster = getConferenceParticipantDropRoster(createDropRosterTask({owner: 'agent2'}), currentAgentId); + + expect(roster?.customer?.isReadOnly).toBe(true); + expect(roster?.participants.every((target) => target.isReadOnly)).toBe(true); + expect(roster?.participants.find((target) => target.dropTargetId === 'agent2')?.isPrimary).toBe(true); + }); + + it('disables Drop only while an active consult is not held', () => { + expect( + getConferenceParticipantDropRoster(createDropRosterTask({consultHold: false}), currentAgentId)?.isDropDisabled + ).toBe(true); + expect( + getConferenceParticipantDropRoster(createDropRosterTask({consultHold: true}), currentAgentId)?.isDropDisabled + ).toBe(false); + }); + + it('keeps Drop disabled while active consult controls precede consult media hydration', () => { + const task = createDropRosterTask({consultHold: false}); + delete task.data.interaction.media.consult; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.isDropDisabled).toBe(true); + }); + + it('uses main.endConsult to expose the current EP-DN consult and gate Drop until it is held', () => { + const task = createDropRosterTask({state: 'conference', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.consult.participants = ['agent1', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + task.uiControls.consult.endConsult = {isVisible: false, isEnabled: false}; + task.uiControls.main.endConsult = {isVisible: true, isEnabled: true}; + + const activeConsultRoster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(activeConsultRoster?.participants).toEqual( + expect.arrayContaining([ + expect.objectContaining({dropTargetId: 'agent2', isDropDisabled: false}), + expect.objectContaining({dropTargetId: '+15550000009', isDropDisabled: true}), + ]) + ); + expect(activeConsultRoster?.isDropDisabled).toBe(true); + + task.data.interaction.media.consult.isHold = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.isDropDisabled).toBe(false); + }); + + it('returns null outside an eligible telephony main call', () => { + const task = createDropRosterTask(); + task.data.interaction.mediaType = 'chat'; + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('keeps a valid multiparty roster through post-call and wrap-up signal downgrades', () => { + const task = createDropRosterTask({state: 'post_call', wrapUpRequired: true}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'epdn1']; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({dropTargetId: 'agent2'}), + expect.objectContaining({dropTargetId: 'epdn1'}), + ]); + }); + + it('uses main-leg membership for a Customer-plus-Agent multiparty call', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).not.toBeNull(); + }); + + it('keeps Participants after Customer leaves and conference signals downgrade', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.data.interaction.participants.customer1.hasLeft = true; + + const roster = getConferenceParticipantDropRoster(task, currentAgentId); + + expect(roster?.customer).toBeNull(); + expect(roster?.participants).toEqual([ + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'}), + expect.objectContaining({participantType: 'EP-DN', dropTargetId: 'epdn1'}), + expect.objectContaining({participantType: 'Supervisor', dropTargetId: 'supervisor1'}), + ]); + }); + + it('does not treat an initial one-agent/one-customer call as a conference roster', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('keeps the roster when Customer leaves one other Agent', () => { + const task = createDropRosterTask({state: 'post_call', wrapUpRequired: true}); + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + task.data.interaction.media.main.participants = ['agent1', 'agent2']; + task.data.interaction.participants.customer1.hasLeft = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toEqual({ + customer: null, + participants: [expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'})], + isDropDisabled: false, + }); + }); + + it('returns to the 1-to-1 UI when the final Agent leaves while Customer remains', () => { + const task = createDropRosterTask({state: 'connected'}); + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'customer1']; + task.data.interaction.participants.agent2.hasLeft = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('shows an Entry Point number while ringing, replaces it with the answering agent, and enables it after merge', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'EP-DN', + displayName: '+15550000009', + dropTargetId: '+15550000009', + isReadOnly: false, + isDropDisabled: true, + }), + ]); + + task.data.interaction.participants['answering-agent'] = activeParticipant( + 'answering-agent', + 'Agent', + 'Support Agent' + ); + task.data.interaction.media.consult.participants.push('answering-agent'); + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: true, + }), + ]); + + task.data.interaction.media.main.participants.push('answering-agent'); + task.data.interaction.state = 'conference'; + task.data.interaction.media.consult.isHold = true; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: false, + }), + ]); + }); + + it('keeps the consulting conference Agent and identifies the Entry Point answering Agent', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'customer1']; + task.data.interaction.media.consult.participants = ['agent2', 'entry-point-route']; + task.data.interaction.participants['entry-point-route'] = { + ...activeParticipant('+15550000009', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + dn: '+15550000009', + hasJoined: false, + }; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({displayName: 'Agent Two', dropTargetId: 'agent2'}), + expect.objectContaining({displayName: '+15550000009', isDropDisabled: true}), + ]); + + task.data.interaction.participants['answering-agent'] = { + ...activeParticipant('answering-agent', 'Agent', 'Support Agent'), + isConsulted: true, + }; + task.data.interaction.media.consult.participants.push('answering-agent'); + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({displayName: 'Agent Two', dropTargetId: 'agent2'}), + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'answering-agent', + isDropDisabled: true, + }), + ]); + }); + + it('uses the latest task snapshot when the Entry Point consult leg has not reached task.data yet', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + delete task.data.interaction.media.consult; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'snapshot-consult', + destinationType: 'entryPoint', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'snapshot-consult': { + mediaResourceId: 'snapshot-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'snapshot-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'snapshot-entry-point': { + ...activeParticipant('+15550000010', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + participantType: 'EP-DN', + displayName: '+15550000010', + dropTargetId: '+15550000010', + isDropDisabled: true, + }), + ]); + }); + + it('prefers a newer configured consult leg from the state snapshot over retained observable media', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'old-entry-point']; + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 1000; + task.data.interaction.participants['old-entry-point'] = { + ...activeParticipant('+15550000011', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'new-consult', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'new-consult': { + mediaResourceId: 'new-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + lastUpdated: 2000, + participants: ['agent1', 'new-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'new-entry-point': { + ...activeParticipant('+15550000012', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000012', + dropTargetId: '+15550000012', + }), + ]); + }); + + it('keeps a newer observable consult leg when the state snapshot is stale', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'current-entry-point']; + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 2000; + task.data.interaction.participants['current-entry-point'] = { + ...activeParticipant('+15550000013', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + consultMediaResourceId: 'old-consult', + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + 'old-consult': { + mediaResourceId: 'old-consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + mType: 'consult', + isHold: false, + holdTimestamp: null, + participants: ['agent1', 'old-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'old-entry-point': { + ...activeParticipant('+15550000014', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000013', + dropTargetId: '+15550000013', + }), + ]); + }); + + it('preserves observable data for the same leg unless the snapshot is demonstrably newer', () => { + const task = createDropRosterTask({state: 'consulting', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'observable-entry-point']; + task.data.interaction.participants['observable-entry-point'] = { + ...activeParticipant('+15550000015', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }; + + const snapshotTaskData = { + ...task.data, + interaction: { + ...task.data.interaction, + media: { + ...task.data.interaction.media, + consult: { + ...task.data.interaction.media.consult, + participants: ['agent1', 'snapshot-entry-point'], + }, + }, + participants: { + ...task.data.interaction.participants, + 'snapshot-entry-point': { + ...activeParticipant('+15550000016', 'entry-point-id', 'EP-DN'), + type: 'EpDn', + hasJoined: false, + }, + }, + }, + }; + + ( + task as ITask & { + state: {context: {taskData: ITask['data']}}; + } + ).state = {context: {taskData: snapshotTaskData}}; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000015', + dropTargetId: '+15550000015', + }), + ]); + + (task.data.interaction.media.consult as unknown as Record).lastUpdated = 1000; + (snapshotTaskData.interaction.media.consult as unknown as Record).lastUpdated = 2000; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)?.participants).toEqual([ + expect.objectContaining({ + displayName: '+15550000016', + dropTargetId: '+15550000016', + }), + ]); + }); + + it('does not revive a stale pending EP-DN consult leg', () => { + const task = createDropRosterTask({state: 'connected', consultHold: false}); + task.data.consultMediaResourceId = 'consult'; + task.data.interaction.media.main.participants = ['agent1', 'customer1']; + task.data.interaction.media.consult.participants = ['agent1', 'pending-epdn']; + task.data.interaction.participants['pending-epdn'] = { + ...activeParticipant('pending-epdn', 'EP_DN', 'EP-DN'), + dn: '+15550000009', + hasJoined: false, + }; + task.uiControls.consult = createEnabledMainTaskUIControls().consult; + + expect(getConferenceParticipantDropRoster(task, currentAgentId)).toBeNull(); + }); + + it('returns null when the viewing agent has departed or the task is terminated', () => { + const departedTask = createDropRosterTask(); + departedTask.data.interaction.participants.agent1.hasLeft = true; + expect(getConferenceParticipantDropRoster(departedTask, currentAgentId)).toBeNull(); + + const terminatedTask = createDropRosterTask({state: 'terminated'}); + terminatedTask.data.interaction.isTerminated = true; + expect(getConferenceParticipantDropRoster(terminatedTask, currentAgentId)).toBeNull(); + }); +}); + describe('findHoldTimestamp', () => { it('should return null when task data is missing or undefined', () => { const task = {} as Partial as ITask; diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 98e6b3489..9d7ae23ff 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -79,7 +79,7 @@ packages/contact-center/task/src/ | `cc-widgets.IncomingTask` | SDK (React component / Web Component) | `IncomingTask` — props: `incomingTask`; callbacks: `onAccepted({task})`, `onRejected({task})` | Render an offered task with accept/decline; notify consumer on accept/reject/RONA | Stable; adding optional props/callbacks = minor | `src/task.types.ts` (`IncomingTaskProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.TaskList` | SDK (React component / Web Component) | `TaskList` — props: `hasCampaignPreviewEnabled?`; callbacks: `onTaskAccepted(task)`, `onTaskDeclined(task, reason)`, `onTaskSelected({task, isClicked})` | List concurrent tasks; accept/decline/select | Stable; `hasCampaignPreviewEnabled` defaults true | `src/task.types.ts` (`TaskListProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.CallControl` | SDK (React component / Web Component) | `CallControl` — callbacks: `onHoldResume({isHeld,task})`, `onEnd({task})`, `onWrapUp({task,wrapUpReason})`, `onRecordingToggle({isRecording,task})`, `onToggleMute({isMuted,task})`; props: `conferenceEnabled?`, `consultTransferOptions?`, `callControlClassName?`, `callControlConsultClassName?` | Active-call controls for `store.currentTask` | Stable; `conferenceEnabled` defaults `true` | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName` | CallControl variant styled for a customer-data layout | Stable; same surface as CallControl | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName`; participant Drop is store-driven | CallControl variant styled for a customer-data layout with owner-aware conference participant removal | Stable; no new React prop or Web Component property/attribute | `src/task.types.ts` (`CallControlProps`); [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.OutdialCall` | SDK (React component / Web Component) | `OutdialCall` — props: `isAddressBookEnabled?` (default `true`); no consumer callbacks | Outbound dialpad + ANI selection; disabled when a telephony task is active | Stable | `src/task.types.ts` (`OutdialProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | | `cc-widgets.RealTimeTranscript` | SDK (React component / Web Component) | `RealTimeTranscript` — props: `liveTranscriptEntries?`, `className?` | Render live transcript for `store.currentTask` | Stable | `src/task.types.ts` (`RealTimeTranscriptProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | @@ -120,12 +120,13 @@ Compatibility notes: | `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | | `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | | `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | -| `TASK-R-024` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated destination policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | +| `TASK-R-024` | `useCallControl` owns Customer confirmation plus `requestParticipantDrop`/confirm/cancel orchestration. It revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous token that survives same-interaction task clones; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; suppresses stale completions after owner/agent/interaction/terminal changes; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts`, `src/task.types.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | +| `TASK-R-025` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated destination policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | ## Design Overview Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. -`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, additionally maintains a dozen `useState` values (recording, buddy agents, consult agent name, target type, timers, conference participants) and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. +`helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, maintains transient UI state (recording, buddy agents, consult agent name, target type, timers, participant Drop request/confirmation/announcement), derives both conference rosters directly from the current observable task, and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. `Utils/` is pure logic split out for testability: `task-util.ts` decides control visibility/enablement from device type, feature flags, and a large set of derived task-state booleans (consult status, hold status, conference progress, customer-in-call, participant counts); `timer-utils.ts` and `useHoldTimer.ts` compute timer labels/elapsed times. Keeping these pure means the visibility matrix and timer math are unit-tested without rendering. @@ -437,7 +438,7 @@ stateDiagram-v2 ## UI Flow - **IncomingTask:** task card with caller/queue/media info, RONA countdown badge, Accept/Decline buttons. Empty state = no card when `incomingTask` is null. Error state = empty fragment via ErrorBoundary. - **TaskList:** list of task cards; selected task highlighted (mirrors `currentTask`); per-task Accept/Decline; empty list renders nothing. Campaign-preview tasks render a `CampaignTask` when `hasCampaignPreviewEnabled` (default true). -- **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks. Disabled/enabled state of every button comes from `getControlsVisibility`. +- **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. The CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks plus owner-aware conference participant Drop; standard `CallControl` has no participant Drop UI. Disabled/enabled state of the established call-control buttons comes from `getControlsVisibility`. - **OutdialCall:** numeric dialpad with E.164/special-char validation, ANI selector dropdown, optional address book (`isAddressBookEnabled`), dial button disabled on invalid/empty input or while a telephony task is active. - **RealTimeTranscript:** scrolling transcript with normalized speaker ("You"/"Customer") and `HH:MM` display time; renders supplied `liveTranscriptEntries` when no live data for the current task. @@ -475,7 +476,7 @@ stateDiagram-v2 - DON'T add new task-event subscriptions without matching the exact event name in both `setTaskCallback` and the cleanup `removeTaskCallback`. ## Host Integration & Theming -These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides. The host supplies `store.onErrorCallback` to receive widget-crash notifications. +These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides and inherits participant Drop in both React and Web Component modes without new public inputs. The host supplies `store.onErrorCallback` to receive widget-crash and sanitized participant-Drop failure notifications. ## Test-Case Strategy (module) Tests are split between widget-shell render tests (each `tests//index.tsx` asserts the hook is called with the right props, the presentational component receives merged output, and the ErrorBoundary renders empty + invokes/handles-missing `onErrorCallback`) and exhaustive hook/util logic tests. `tests/helper.ts` is the large behavioral suite covering accept/decline, hold/resume, end, recording pause/resume (positive + SDK-failure negative cases), mute (including rapid toggles and failure revert), wrap-up + auto-wrap-up cancel, consult/transfer/conference, queue-consult flags, buddy-agent loading, and consulting-agent extraction. `tests/utils/task-util.ts` matrices `getControlsVisibility` across device types (BROWSER/AGENT_DN/EXTENSION) and media types (telephony/chat/email) plus EP-DN end-button rules and the error→safe-defaults path. `tests/utils/timer-utils.test.ts` and `tests/utils/useHoldTimer.test.ts` cover label priority, consult-on-hold, null-task defaults, and consult-vs-main hold prioritization. Edge cases asserted: missing interaction/participants, missing currentTask, error logging in every callback. Gaps: no unit test for the OutdialCall empty-destination alert, the `getOutdialANIEntries` missing-ANI-id throw, or the CallControl unaccepted-campaign-preview early return. @@ -505,7 +506,8 @@ Tests are split between widget-shell render tests (each `tests//index.ts | `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | | `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | | `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | -| `TASK-R-024` SDK destination-control pass-through | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`; cc-components focused destination tests | None | +| `TASK-R-024` participant Drop orchestration | `tests/helper.ts` (exact payload, duplicate prevention, success/failure cleanup, sanitized callback/logging, stale completion, roster re-derivation) | Live routing-event behavior is covered by SDK/manual integration tests | +| `TASK-R-025` SDK destination-control pass-through | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`; cc-components focused destination tests | None | ## Traceability - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 747d8f73e..d998fa47a 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -15,6 +15,8 @@ import { useOutdialCallProps, TargetType, TARGET_TYPE, + ParticipantDropAnnouncement, + PendingParticipantDropRequest, } from './task.types'; import store, { TASK_EVENTS, @@ -22,7 +24,8 @@ import store, { DestinationType, PaginatedListParams, getConferenceParticipants, - Participant, + getConferenceParticipantDropRoster, + ConferenceParticipantDropTarget, findMediaResourceId, MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, @@ -42,6 +45,46 @@ import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/servic const ENGAGED_LABEL = 'ENGAGED'; const ENGAGED_USERNAME = 'Engaged'; +const PARTICIPANT_DROP_SUCCESS_MESSAGE = 'Participant removed from the conference.'; +const PARTICIPANT_DROP_FAILURE_MESSAGE = 'Unable to drop participant from the call. Try again.'; + +const getLatestEligibleParticipantDropTarget = ( + task: ITask, + agentId: string, + target: ConferenceParticipantDropTarget +): ConferenceParticipantDropTarget | null => { + const latestRoster = getConferenceParticipantDropRoster(task, agentId); + const latestTarget = [latestRoster?.customer, ...(latestRoster?.participants ?? [])].find( + (candidate) => + candidate?.dropTargetId === target.dropTargetId && candidate.participantType === target.participantType + ); + + if ( + !latestRoster || + !latestTarget || + latestTarget.isReadOnly || + latestTarget.isDropDisabled || + latestRoster.isDropDisabled + ) { + return null; + } + + return latestTarget; +}; + +const isCurrentParticipantDropRequest = ( + request: PendingParticipantDropRequest, + pendingRequest: PendingParticipantDropRequest | null, + task: ITask | undefined, + agentId: string | undefined +): boolean => + Boolean( + pendingRequest?.token === request.token && + task?.data?.interactionId === request.taskId && + agentId === request.agentId && + task.data.interaction.owner === request.agentId && + !task.data.interaction.isTerminated + ); const getTranscriptSpeaker = (role?: string): string => { const normalizedRole = role?.toUpperCase(); @@ -376,8 +419,36 @@ export const useCallControl = (props: useCallControlProps) => { !!(initialControls?.consult?.endConsult?.isVisible || initialControls?.main?.endConsult?.isVisible) ); const [lastTargetType, setLastTargetType] = useState(TARGET_TYPE.AGENT); - const [conferenceParticipants, setConferenceParticipants] = useState([]); const lastWrapupAuxCodeIdRef = useRef(null); + const [pendingParticipantDropId, setPendingParticipantDropId] = useState(null); + const [participantDropAnnouncement, setParticipantDropAnnouncement] = useState( + null + ); + const [participantDropConfirmationTarget, setParticipantDropConfirmationTarget] = + useState(null); + const pendingParticipantDropRef = useRef(null); + const participantDropTaskRef = useRef(currentTask); + const participantDropAgentIdRef = useRef(agentId); + participantDropTaskRef.current = currentTask; + participantDropAgentIdRef.current = agentId; + + // Derive conference state during render so MobX tracks nested participant and owner changes. + const conferenceParticipants = currentTask && agentId ? getConferenceParticipants(currentTask, agentId) : []; + const conferenceParticipantDropRoster = + currentTask && agentId ? getConferenceParticipantDropRoster(currentTask, agentId) : null; + const participantDropTaskId = currentTask?.data?.interactionId; + const latestParticipantDropConfirmationTarget = + participantDropConfirmationTarget && + conferenceParticipantDropRoster?.customer?.dropTargetId === participantDropConfirmationTarget.dropTargetId + ? conferenceParticipantDropRoster.customer + : null; + const participantDropConfirmationDisabled = Boolean( + pendingParticipantDropId || + conferenceParticipantDropRoster?.isDropDisabled || + !latestParticipantDropConfirmationTarget || + latestParticipantDropConfirmationTarget.isReadOnly || + latestParticipantDropConfirmationTarget.isDropDisabled + ); // Subscribe to SDK-computed UI control updates useEffect(() => { @@ -447,11 +518,22 @@ export const useCallControl = (props: useCallControlProps) => { }, [controls?.consult?.endConsult?.isVisible, controls?.main?.endConsult?.isVisible]); useEffect(() => { - if (currentTask && store?.cc?.agentConfig?.agentId) { - const participants = getConferenceParticipants(currentTask, store.cc.agentConfig.agentId); - setConferenceParticipants(participants); + const pendingRequest = pendingParticipantDropRef.current; + + if (pendingRequest && (pendingRequest.taskId !== participantDropTaskId || pendingRequest.agentId !== agentId)) { + pendingParticipantDropRef.current = null; } - }, [currentTask, controls]); + + setPendingParticipantDropId(null); + setParticipantDropAnnouncement(null); + setParticipantDropConfirmationTarget(null); + }, [participantDropTaskId, agentId]); + + useEffect(() => { + if (participantDropConfirmationTarget && !latestParticipantDropConfirmationTarget) { + setParticipantDropConfirmationTarget(null); + } + }, [participantDropConfirmationTarget, latestParticipantDropConfirmationTarget]); // Function to extract consulting agent information const extractConsultingAgent = useCallback(() => { try { @@ -825,6 +907,121 @@ export const useCallControl = (props: useCallControlProps) => { } }; + const executeParticipantDrop = useCallback( + async (target: ConferenceParticipantDropTarget): Promise => { + const task = participantDropTaskRef.current; + const currentAgentId = participantDropAgentIdRef.current; + + if (!task || !currentAgentId || pendingParticipantDropRef.current) { + return; + } + + const latestTarget = getLatestEligibleParticipantDropTarget(task, currentAgentId, target); + + if (!latestTarget) { + return; + } + + const taskId = task.data.interactionId; + const request: PendingParticipantDropRequest = { + token: Symbol('participant-drop-request'), + taskId, + agentId: currentAgentId, + dropTargetId: latestTarget.dropTargetId, + }; + + pendingParticipantDropRef.current = request; + setPendingParticipantDropId(latestTarget.dropTargetId); + setParticipantDropAnnouncement(null); + + try { + await task.dropConferenceParticipant({participantId: latestTarget.dropTargetId}); + + const latestTask = participantDropTaskRef.current; + if ( + isCurrentParticipantDropRequest( + request, + pendingParticipantDropRef.current, + latestTask, + participantDropAgentIdRef.current + ) + ) { + setParticipantDropAnnouncement({type: 'success', message: PARTICIPANT_DROP_SUCCESS_MESSAGE}); + } + } catch { + const latestTask = participantDropTaskRef.current; + if ( + isCurrentParticipantDropRequest( + request, + pendingParticipantDropRef.current, + latestTask, + participantDropAgentIdRef.current + ) + ) { + const sanitizedError = new Error(PARTICIPANT_DROP_FAILURE_MESSAGE); + + setParticipantDropAnnouncement({type: 'error', message: PARTICIPANT_DROP_FAILURE_MESSAGE}); + logger.error('CC-Widgets: Conference participant Drop failed', { + module: 'useCallControl', + method: 'dropConferenceParticipant', + }); + store.onErrorCallback?.('CallControlCAD', sanitizedError); + } + } finally { + if (pendingParticipantDropRef.current?.token === request.token) { + pendingParticipantDropRef.current = null; + + if ( + participantDropTaskRef.current?.data?.interactionId === request.taskId && + participantDropAgentIdRef.current === request.agentId + ) { + setPendingParticipantDropId(null); + } + } + } + }, + [logger] + ); + + const requestParticipantDrop = useCallback( + async (target: ConferenceParticipantDropTarget): Promise => { + const task = participantDropTaskRef.current; + const currentAgentId = participantDropAgentIdRef.current; + + if (!task || !currentAgentId || pendingParticipantDropRef.current) { + return; + } + + const latestTarget = getLatestEligibleParticipantDropTarget(task, currentAgentId, target); + if (!latestTarget) { + return; + } + + if (latestTarget.requiresConfirmation) { + setParticipantDropConfirmationTarget(latestTarget); + return; + } + + await executeParticipantDrop(latestTarget); + }, + [executeParticipantDrop] + ); + + const confirmParticipantDrop = useCallback(async (): Promise => { + const target = latestParticipantDropConfirmationTarget; + + if (!target || participantDropConfirmationDisabled) { + return; + } + + setParticipantDropConfirmationTarget(null); + await executeParticipantDrop(target); + }, [executeParticipantDrop, latestParticipantDropConfirmationTarget, participantDropConfirmationDisabled]); + + const cancelParticipantDropConfirmation = useCallback((): void => { + setParticipantDropConfirmationTarget(null); + }, []); + const toggleMute = async () => { try { if (!controls?.main?.mute?.isVisible) { @@ -1273,6 +1470,14 @@ export const useCallControl = (props: useCallControlProps) => { secondsUntilAutoWrapup, cancelAutoWrapup, conferenceParticipants, + conferenceParticipantDropRoster, + pendingParticipantDropId, + participantDropAnnouncement, + participantDropConfirmationTarget: latestParticipantDropConfirmationTarget, + participantDropConfirmationDisabled, + requestParticipantDrop, + confirmParticipantDrop, + cancelParticipantDropConfirmation, getAddressBookEntries, getEntryPoints, getQueuesFetcher, @@ -1316,8 +1521,13 @@ export const useOutdialCall = (props: useOutdialCallProps) => { return; } - // Only pass origin if it's defined and not empty - const outdialRequest = origin ? cc.startOutdial(destination, origin) : cc.startOutdial(destination); + const outdialClient = cc as typeof cc & { + startOutdial(destination: string, origin?: string): Promise; + }; + + const outdialRequest = origin + ? outdialClient.startOutdial(destination, origin) + : outdialClient.startOutdial(destination); outdialRequest .then(() => { diff --git a/packages/contact-center/task/src/task.types.ts b/packages/contact-center/task/src/task.types.ts index 94c1affc6..ccf6d8aee 100644 --- a/packages/contact-center/task/src/task.types.ts +++ b/packages/contact-center/task/src/task.types.ts @@ -4,6 +4,7 @@ import { OutdialCallProps, RealTimeTranscriptComponentProps, RealTimeTranscriptEntry, + ParticipantDropAnnouncement, } from '@webex/cc-components'; import {RealTimeTranscriptionData} from '@webex/cc-store'; @@ -31,6 +32,13 @@ export type UseRealTimeTranscriptInternalProps = RealTimeTranscriptProps & { realtimeTranscriptionData?: Partial[]; }; +export type PendingParticipantDropRequest = { + token: symbol; + taskId: string; + agentId: string; + dropTargetId: string; +}; + export type CallControlProps = Partial< Pick< ControlProps, @@ -57,7 +65,7 @@ export type useOutdialCallProps = Pick; // Re-exported from store — single source of truth. export {CAMPAIGN_PREVIEW_OUTBOUND_TYPES, CAMPAIGN_PREVIEW_CAMPAIGN_TYPES} from '@webex/cc-store'; -export type {RealTimeTranscriptEntry}; +export type {RealTimeTranscriptEntry, ParticipantDropAnnouncement}; export interface OutdialProps { /** * Flag to determine if the address book is enabled. diff --git a/packages/contact-center/task/tests/CallControl/index.tsx b/packages/contact-center/task/tests/CallControl/index.tsx index 3e7bae023..81cddc1eb 100644 --- a/packages/contact-center/task/tests/CallControl/index.tsx +++ b/packages/contact-center/task/tests/CallControl/index.tsx @@ -57,6 +57,14 @@ describe('CallControl Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getEntryPoints: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), getQueuesFetcher: jest.fn().mockResolvedValue({data: [], meta: {page: 0, totalPages: 0}}), diff --git a/packages/contact-center/task/tests/CallControlCAD/index.tsx b/packages/contact-center/task/tests/CallControlCAD/index.tsx index b30905ba9..65b0f6d4e 100644 --- a/packages/contact-center/task/tests/CallControlCAD/index.tsx +++ b/packages/contact-center/task/tests/CallControlCAD/index.tsx @@ -60,6 +60,14 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -131,6 +139,14 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -184,6 +200,14 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), @@ -240,6 +264,14 @@ describe('CallControlCAD Component', () => { consultConference: jest.fn(), exitConference: jest.fn(), conferenceParticipants: [], + conferenceParticipantDropRoster: null, + pendingParticipantDropId: null, + participantDropAnnouncement: null, + participantDropConfirmationTarget: null, + participantDropConfirmationDisabled: true, + requestParticipantDrop: jest.fn(), + confirmParticipantDrop: jest.fn(), + cancelParticipantDropConfirmation: jest.fn(), getAddressBookEntries: jest.fn(), getEntryPoints: jest.fn(), getQueuesFetcher: jest.fn(), diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index a552c0334..86dc9b334 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -206,6 +206,28 @@ describe('useIncomingTask Hook', () => { expect(logger.error).not.toHaveBeenCalled(); }); + it('should reject an unaccepted consult exactly once on consult-end', async () => { + renderHook(() => + useIncomingTask({ + incomingTask: taskMock, + onAccepted: onTaskAccepted, + onRejected: onTaskDeclined, + logger, + }) + ); + + const consultEndCallback = taskMock.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_CONSULT_END)?.[1]; + + act(() => { + consultEndCallback?.(); + }); + + await waitFor(() => { + expect(onTaskDeclined).toHaveBeenCalledTimes(1); + expect(onTaskDeclined).toHaveBeenCalledWith({task: taskMock}); + }); + }); + it('should return if there is no taskId for incoming task', async () => { // Reset the mock first onTaskDeclined.mockClear(); @@ -746,6 +768,7 @@ describe('useCallControl', () => { toggleMute: jest.fn(() => Promise.resolve()), switchCall: jest.fn(() => Promise.resolve()), transferConference: jest.fn(() => Promise.resolve()), + dropConferenceParticipant: jest.fn(() => Promise.resolve()), }; const mockLogger = mockCC.LoggerProxy; @@ -754,6 +777,69 @@ describe('useCallControl', () => { const mockOnEnd = jest.fn(); const mockOnWrapUp = jest.fn(); + const createParticipantDropTask = (dropRequest = jest.fn().mockResolvedValue(undefined)): ITask => + ({ + ...mockCurrentTask, + dropConferenceParticipant: dropRequest, + uiControls: createEnabledMainTaskUIControls({exitConference: {isVisible: true, isEnabled: true}}), + data: { + ...mockCurrentTask.data, + interactionId: 'participant-drop-task', + isConferenceInProgress: true, + interaction: { + ...mockCurrentTask.data.interaction, + interactionId: 'participant-drop-task', + mediaType: 'telephony', + mediaChannel: 'telephony', + state: 'conference', + owner: 'agent1', + contactDirection: {type: 'inbound'}, + callAssociatedDetails: {ani: '+15550000001', dnis: '+15550000002'}, + callProcessingDetails: {...mockCurrentTask.data.interaction.callProcessingDetails}, + media: { + main: { + mediaResourceId: 'main', + mediaType: 'telephony', + mediaMgr: 'aqm', + participants: ['agent1', 'agent2', 'customer1'], + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + }, + }, + participants: { + agent1: { + id: 'agent1', + pType: 'Agent', + type: 'Agent', + name: 'Current Agent', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + agent2: { + id: 'agent2', + pType: 'Agent', + type: 'Agent', + name: 'Agent Two', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + customer1: { + id: 'customer1', + pType: 'Customer', + type: 'Customer', + name: 'Customer', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }, + }, + }, + }, + }) as ITask; + beforeEach(() => { store.refreshTaskList(); // Mock the MediaStreamTrack and MediaStream classes for the test environment @@ -799,6 +885,7 @@ describe('useCallControl', () => { // Restore the original Worker class and URL.createObjectURL global.Worker = originalWorker; delete global.URL.createObjectURL; + store.onErrorCallback = undefined; jest.clearAllMocks(); }); @@ -837,6 +924,537 @@ describe('useCallControl', () => { setTaskCallbackSpy.mockRestore(); }); + describe('conference participant Drop', () => { + it('retains surviving Participants when Customer leaves and conference signals downgrade', () => { + const task = createParticipantDropTask(); + task.data.interaction.media.main.participants = ['agent1', 'agent2', 'agent3', 'customer1']; + task.data.interaction.participants.agent3 = { + id: 'agent3', + pType: 'Agent', + type: 'Agent', + name: 'Agent Three', + hasJoined: true, + hasLeft: false, + isInPredial: false, + }; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + expect(result.current.conferenceParticipantDropRoster?.customer).not.toBeNull(); + + task.data.interaction.participants.customer1.hasLeft = true; + task.data.interaction.state = 'connected'; + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.customer).toBeNull(); + expect(result.current.conferenceParticipantDropRoster?.participants).toEqual([ + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'}), + expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent3'}), + ]); + }); + + it('keeps the roster when Customer leaves only one other Agent', () => { + const task = createParticipantDropTask(); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + expect(result.current.conferenceParticipantDropRoster).not.toBeNull(); + + task.data.interaction.participants.customer1.hasLeft = true; + task.data.interaction.state = 'post_call'; + task.data.wrapUpRequired = true; + task.data.isConferenceInProgress = false; + task.data.isConferencing = false; + task.data.interaction.callProcessingDetails.isConferencing = 'false'; + task.uiControls.main.exitConference = {isVisible: false, isEnabled: false}; + task.uiControls.main.wrapup = {isVisible: true, isEnabled: true}; + rerender(); + + expect(result.current.conferenceParticipantDropRoster).toEqual({ + customer: null, + participants: [expect.objectContaining({participantType: 'Agent', dropTargetId: 'agent2'})], + isDropDisabled: false, + }); + }); + + it('shows the Entry Point number while ringing, then replaces it with the answering agent', async () => { + const dropRequest = jest.fn().mockResolvedValue(undefined); + const task = createParticipantDropTask(dropRequest); + task.data.consultMediaResourceId = 'consult'; + task.data.destinationType = 'entryPoint'; + task.data.interaction.state = 'consulting'; + task.data.interaction.media.consult = { + mediaResourceId: 'consult', + mediaType: 'telephony', + mediaMgr: 'aqm', + participants: ['agent1', 'entry-point-route'], + mType: 'consult', + isHold: true, + holdTimestamp: Date.now(), + }; + task.data.interaction.participants['entry-point-route'] = { + id: '+15550000009', + pType: 'entry-point-id', + type: 'EpDn', + name: 'EP-DN', + dn: '+15550000009', + hasJoined: false, + hasLeft: false, + isInPredial: false, + }; + task.uiControls.consult.endConsult = {isVisible: true, isEnabled: true}; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const pendingTarget = result.current.conferenceParticipantDropRoster?.participants.find( + (target) => target.dropTargetId === '+15550000009' + ); + + expect(pendingTarget).toEqual(expect.objectContaining({displayName: '+15550000009', isDropDisabled: true})); + if (!pendingTarget) throw new Error('Expected pending EP-DN target'); + + await act(async () => { + await result.current.requestParticipantDrop(pendingTarget); + }); + + expect(dropRequest).not.toHaveBeenCalled(); + + task.data.interaction.media.consult.participants.push('agent2', 'agent3'); + task.data.interaction.participants.agent3 = { + id: 'agent3', + pType: 'Agent', + type: 'Agent', + name: 'Support Agent', + hasJoined: true, + hasLeft: false, + isInPredial: false, + isConsulted: true, + }; + task.data.interaction.callProcessingDetails.consultDestinationAgentJoined = 'true'; + task.data.interaction.callProcessingDetails.consultDestinationAgentName = 'Support Agent'; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.participants).toEqual([ + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Agent Two', + dropTargetId: 'agent2', + isDropDisabled: false, + }), + expect.objectContaining({ + participantType: 'Agent', + displayName: 'Support Agent', + dropTargetId: 'agent3', + isDropDisabled: true, + }), + ]); + }); + + it('delegates the exact target and publishes success only after the SDK promise resolves', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + expect(dropRequest).toHaveBeenCalledWith({participantId: 'agent2'}); + expect(result.current.pendingParticipantDropId).toBe('agent2'); + expect(result.current.participantDropAnnouncement).toBeNull(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'success', + message: 'Participant removed from the conference.', + }); + }); + + it('preserves the pending lock and feedback across a same-interaction task clone', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const clonedDropRequest = jest.fn().mockResolvedValue(undefined); + let activeTask = createParticipantDropTask(dropRequest); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + activeTask = createParticipantDropTask(clonedDropRequest); + rerender(); + + expect(result.current.pendingParticipantDropId).toBe('agent2'); + const clonedTarget = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!clonedTarget) throw new Error('Expected cloned Agent Drop target'); + + await act(async () => { + await result.current.requestParticipantDrop(clonedTarget); + }); + + expect(dropRequest).toHaveBeenCalledTimes(1); + expect(clonedDropRequest).not.toHaveBeenCalled(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'success', + message: 'Participant removed from the conference.', + }); + }); + + it('publishes a failure from the original request after a same-interaction task clone', async () => { + let rejectDrop!: (error: Error) => void; + const dropRequest = jest.fn( + () => + new Promise((_resolve, reject) => { + rejectDrop = reject; + }) + ); + const clonedDropRequest = jest.fn().mockResolvedValue(undefined); + let activeTask = createParticipantDropTask(dropRequest); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + activeTask = createParticipantDropTask(clonedDropRequest); + rerender(); + + await act(async () => { + rejectDrop(new Error('routing failure')); + await requestPromise; + }); + + expect(clonedDropRequest).not.toHaveBeenCalled(); + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'error', + message: 'Unable to drop participant from the call. Try again.', + }); + }); + + it('owns Customer confirmation state and invokes Drop only after confirmation', async () => { + const dropRequest = jest.fn().mockResolvedValue(undefined); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const customerTarget = result.current.conferenceParticipantDropRoster?.customer; + if (!customerTarget) throw new Error('Expected Customer Drop target'); + + await act(async () => { + await result.current.requestParticipantDrop(customerTarget); + }); + + expect(dropRequest).not.toHaveBeenCalled(); + expect(result.current.participantDropConfirmationTarget).toEqual(customerTarget); + expect(result.current.participantDropConfirmationDisabled).toBe(false); + + act(() => { + result.current.cancelParticipantDropConfirmation(); + }); + expect(result.current.participantDropConfirmationTarget).toBeNull(); + + await act(async () => { + await result.current.requestParticipantDrop(customerTarget); + }); + + await act(async () => { + await result.current.confirmParticipantDrop(); + }); + + expect(dropRequest).toHaveBeenCalledWith({participantId: customerTarget.dropTargetId}); + expect(result.current.participantDropConfirmationTarget).toBeNull(); + }); + + it('globally serializes same-target and cross-target requests', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const agentTarget = result.current.conferenceParticipantDropRoster?.participants[0]; + const customerTarget = result.current.conferenceParticipantDropRoster?.customer; + if (!agentTarget || !customerTarget) throw new Error('Expected Agent and Customer Drop targets'); + let firstRequest!: Promise; + + act(() => { + firstRequest = result.current.requestParticipantDrop(agentTarget); + void result.current.requestParticipantDrop(agentTarget); + void result.current.requestParticipantDrop(customerTarget); + }); + + expect(dropRequest).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveDrop(); + await firstRequest; + }); + }); + + it('reports a sanitized failure without logging participant data', async () => { + const sensitiveParticipantId = 'sensitive-participant-id'; + const dropRequest = jest.fn().mockRejectedValue(new Error(`Failed URL contained ${sensitiveParticipantId}`)); + const task = createParticipantDropTask(dropRequest); + task.data.interaction.media.main.participants[1] = sensitiveParticipantId; + task.data.interaction.participants[sensitiveParticipantId] = { + ...task.data.interaction.participants.agent2, + id: sensitiveParticipantId, + }; + delete task.data.interaction.participants.agent2; + const hostErrorCallback = jest.fn(); + store.onErrorCallback = hostErrorCallback; + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + + await act(async () => { + await result.current.requestParticipantDrop(target); + }); + + expect(result.current.participantDropAnnouncement).toEqual({ + type: 'error', + message: 'Unable to drop participant from the call. Try again.', + }); + expect(hostErrorCallback).toHaveBeenCalledWith( + 'CallControlCAD', + Error('Unable to drop participant from the call. Try again.') + ); + expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(sensitiveParticipantId); + expect(JSON.stringify(hostErrorCallback.mock.calls)).not.toContain(sensitiveParticipantId); + }); + + it('ignores stale completion and re-derives roster changes from the current task', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const firstTask = createParticipantDropTask(dropRequest); + const nextTask = createParticipantDropTask(); + nextTask.data.interaction.owner = 'agent2'; + let activeTask = firstTask; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + activeTask = nextTask; + rerender(); + + expect(result.current.conferenceParticipantDropRoster?.participants[0].isReadOnly).toBe(true); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.participantDropAnnouncement).toBeNull(); + + nextTask.data.interaction.participants.agent2.hasLeft = true; + rerender(); + expect(result.current.conferenceParticipantDropRoster).toBeNull(); + }); + + it('clears a pending request when the interaction changes', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const firstTask = createParticipantDropTask(dropRequest); + const nextTask = createParticipantDropTask(); + nextTask.data.interactionId = 'next-interaction'; + nextTask.data.interaction.interactionId = 'next-interaction'; + let activeTask = firstTask; + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: activeTask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + activeTask = nextTask; + rerender(); + expect(result.current.pendingParticipantDropId).toBeNull(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.participantDropAnnouncement).toBeNull(); + }); + + it('suppresses completion feedback when the current task terminates', async () => { + let resolveDrop!: () => void; + const dropRequest = jest.fn( + () => + new Promise((resolve) => { + resolveDrop = resolve; + }) + ); + const task = createParticipantDropTask(dropRequest); + const {result, rerender} = renderHook(() => + useCallControl({ + currentTask: task, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + const target = result.current.conferenceParticipantDropRoster?.participants[0]; + if (!target) throw new Error('Expected Agent Drop target'); + let requestPromise!: Promise; + + act(() => { + requestPromise = result.current.requestParticipantDrop(target); + }); + + task.data.interaction.isTerminated = true; + rerender(); + + await act(async () => { + resolveDrop(); + await requestPromise; + }); + + expect(result.current.pendingParticipantDropId).toBeNull(); + expect(result.current.participantDropAnnouncement).toBeNull(); + }); + }); + it('should not call any call backs if callbacks are not provided', async () => { mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); diff --git a/widgets-samples/cc/samples-cc-react-app/package.json b/widgets-samples/cc/samples-cc-react-app/package.json index 1317e5971..d2f06ac42 100644 --- a/widgets-samples/cc/samples-cc-react-app/package.json +++ b/widgets-samples/cc/samples-cc-react-app/package.json @@ -32,7 +32,7 @@ "react-dom": "18.3.1", "ts-loader": "^9.5.1", "typescript": "^5.6.3", - "webex": "3.12.0-next.84", + "webex": "3.12.0-next.184", "webpack": "^5.94.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^5.1.0", diff --git a/yarn.lock b/yarn.lock index 067f7d1dd..432d287ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9560,41 +9560,17 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.12.0-next.34": - version: 3.12.0-next.34 - resolution: "@webex/calling@npm:3.12.0-next.34" - dependencies: - "@types/platform": "npm:1.3.4" - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/internal-media-core": "npm:2.25.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-feature": "npm:3.12.0-next.15" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/media-helpers": "npm:3.12.0-next.4" - async-mutex: "npm:0.4.0" - backoff: "npm:2.5.0" - buffer: "npm:6.0.3" - lodash: "npm:^4.17.21" - platform: "npm:1.3.6" - uuid: "npm:8.3.2" - ws: "npm:8.17.1" - xstate: "npm:4.30.6" - checksum: 10c0/721f9cafa26c610115b59e0bab6dbe7e8106796d468ed441af12dd6c301acdfd1f366851d27ea7046c59c9abd3c92e8d2e8768cf154b843016f492cb3ddbecf0 - languageName: node - linkType: hard - -"@webex/calling@npm:3.12.0-next.87": - version: 3.12.0-next.87 - resolution: "@webex/calling@npm:3.12.0-next.87" +"@webex/calling@npm:3.12.0-next.89": + version: 3.12.0-next.89 + resolution: "@webex/calling@npm:3.12.0-next.89" dependencies: "@types/platform": "npm:1.3.4" "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/internal-media-core": "npm:2.28.2" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-feature": "npm:3.12.0-next.40" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-feature": "npm:3.12.0-next.41" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" "@webex/media-helpers": "npm:3.12.0-next.11" async-mutex: "npm:0.4.0" backoff: "npm:2.5.0" @@ -9604,7 +9580,7 @@ __metadata: uuid: "npm:8.3.2" ws: "npm:8.17.1" xstate: "npm:4.30.6" - checksum: 10c0/53ab87c4661c1be73920161df11e51198fb4c7a15229b9a95fc9d3e14812cdfa404e14b460852106c69f80478bf53bc8aafad9abe9cb095fb5a7bce9ca443fbd + checksum: 10c0/8a770b35df53e1b54fd69bd49ffa323cae8f15772746f6ded53be04f8b8a6880552ee6c3f2a759cf807b1c5a29a3e4e9a0969ec06b1860103f4bb02275f95250 languageName: node linkType: hard @@ -9815,7 +9791,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" - "@webex/contact-center": "npm:3.12.0-next.106" + "@webex/contact-center": "npm:3.12.0-next.109" "@webex/event-dictionary-ts": "npm:^1.0.2191" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" @@ -10042,13 +10018,6 @@ __metadata: languageName: node linkType: hard -"@webex/common-timers@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/common-timers@npm:3.12.0-next.1" - checksum: 10c0/8181a7e7195df093db1ca649b32ada69b949b9f8f9d18c275120584f60ec912f5b2fa7772186f63c959eace900abb1ea4619db5bbac5839d6feda5c89758ae9a - languageName: node - linkType: hard - "@webex/common-timers@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/common-timers@npm:3.12.0-next.5" @@ -10104,21 +10073,6 @@ __metadata: languageName: node linkType: hard -"@webex/common@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/common@npm:3.12.0-next.1" - dependencies: - backoff: "npm:^2.5.0" - bowser: "npm:^2.11.0" - core-decorators: "npm:^0.20.0" - global: "npm:^4.4.0" - lodash: "npm:^4.17.21" - safe-buffer: "npm:^5.2.0" - urlsafe-base64: "npm:^1.0.0" - checksum: 10c0/6b604810d385f432671c3435211ddf097ac98d1d779acf418d1a53a9d8064e889d3381b2d48b0e43c008c806ce64387dccb1001b714acef2ab4b93f44fbb2bfe - languageName: node - linkType: hard - "@webex/common@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/common@npm:3.12.0-next.5" @@ -10170,41 +10124,23 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-next.106": - version: 3.12.0-next.106 - resolution: "@webex/contact-center@npm:3.12.0-next.106" +"@webex/contact-center@npm:3.12.0-next.109": + version: 3.12.0-next.109 + resolution: "@webex/contact-center@npm:3.12.0-next.109" dependencies: "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.87" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.44" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" - "@webex/internal-plugin-support": "npm:3.12.0-next.46" - "@webex/plugin-authorization": "npm:3.12.0-next.40" - "@webex/plugin-logger": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/calling": "npm:3.12.0-next.89" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" + "@webex/internal-plugin-support": "npm:3.12.0-next.47" + "@webex/plugin-authorization": "npm:3.12.0-next.41" + "@webex/plugin-logger": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" jest-html-reporters: "npm:3.0.11" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" xstate: "npm:5.24.0" - checksum: 10c0/3921600575393326c6015f77d49232b7316c9a148ac247e9c9f6c2d546e098a3d748689189df059290d10b7ab9221cb99229b0284973b36382a33d8fb944ef6f - languageName: node - linkType: hard - -"@webex/contact-center@npm:3.12.0-next.42": - version: 3.12.0-next.42 - resolution: "@webex/contact-center@npm:3.12.0-next.42" - dependencies: - "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.34" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/internal-plugin-support": "npm:3.12.0-next.17" - "@webex/plugin-authorization": "npm:3.12.0-next.15" - "@webex/plugin-logger": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - jest-html-reporters: "npm:3.0.11" - lodash: "npm:^4.17.21" - checksum: 10c0/7b4caa4a12dcd1070b7ed720fc56e7df357ae48126aa7a48c5ab2f854210093120891e2ab4d7cc8966539dbe204b0bf8e7d9aed22abab8216098c5dd72f760b4 + checksum: 10c0/62d3c399eec7b63e137f6df5edf06ee1b7b5809e445d381f196fddef279672383376d4444bcfe0874d3b1542ef992f6fa763808720944d4acc20f3dfe07b13d7 languageName: node linkType: hard @@ -10221,6 +10157,19 @@ __metadata: languageName: node linkType: hard +"@webex/event-dictionary-ts@npm:^1.0.2215": + version: 1.0.2236 + resolution: "@webex/event-dictionary-ts@npm:1.0.2236" + dependencies: + amf-client-js: "npm:^5.2.6" + json-schema-to-typescript: "npm:^15.0.4" + minimist: "npm:^1.2.8" + shelljs: "npm:^0.8.5" + webapi-parser: "npm:^0.5.0" + checksum: 10c0/5f80e017ea0f60db52605d61971f7062821a747dd02f9c6cc3dee324233163a44be6c66b549a469305524d124447e5dd0e2f92303ee679864fa075cb954fcaf5 + languageName: node + linkType: hard + "@webex/event-dictionary-ts@npm:^1.0.2220": version: 1.0.2234 resolution: "@webex/event-dictionary-ts@npm:1.0.2234" @@ -10252,15 +10201,6 @@ __metadata: languageName: node linkType: hard -"@webex/helper-html@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/helper-html@npm:3.12.0-next.1" - dependencies: - lodash: "npm:^4.17.21" - checksum: 10c0/cf325224593d4f2b52d0542828ef60bc22343721aa84047e97b3c12bc133f1e105db06c01ae9fb6f32bef6cae2a8f2e8b6c4aecae5083ffc72fad811dc6f88be - languageName: node - linkType: hard - "@webex/helper-html@npm:3.12.0-next.6": version: 3.12.0-next.6 resolution: "@webex/helper-html@npm:3.12.0-next.6" @@ -10304,23 +10244,6 @@ __metadata: languageName: node linkType: hard -"@webex/helper-image@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/helper-image@npm:3.12.0-next.1" - dependencies: - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-file": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - exifr: "npm:^5.0.3" - gm: "npm:^1.23.1" - lodash: "npm:^4.17.21" - mime: "npm:^2.4.4" - safe-buffer: "npm:^5.2.0" - checksum: 10c0/1ab6b056cf208541876299b6ba984856c2f65ccf1f2f2c34b3e2eb0eb963b14eba4759178dbb99e86dfbbaa6e515ec958d591450ac16902339f138b86a78a461 - languageName: node - linkType: hard - "@webex/helper-image@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/helper-image@npm:3.12.0-next.5" @@ -10397,24 +10320,6 @@ __metadata: languageName: node linkType: hard -"@webex/http-core@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/http-core@npm:3.12.0-next.1" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - file-type: "npm:^16.0.1" - global: "npm:^4.4.0" - is-function: "npm:^1.0.1" - lodash: "npm:^4.17.21" - parse-headers: "npm:^2.0.2" - qs: "npm:^6.7.3" - request: "npm:^2.88.0" - safe-buffer: "npm:^5.2.0" - xtend: "npm:^4.0.2" - checksum: 10c0/438aae8087f00da83b7a8790a74605167fc60ff5256bb79cd900803ee547b99046b8d2c7898f0c4d91092f28eee65ba8a8c2df591c3d784fff1b5e2b65af3876 - languageName: node - linkType: hard - "@webex/http-core@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/http-core@npm:3.12.0-next.5" @@ -10450,26 +10355,6 @@ __metadata: languageName: node linkType: hard -"@webex/internal-media-core@npm:2.25.1": - version: 2.25.1 - resolution: "@webex/internal-media-core@npm:2.25.1" - dependencies: - "@babel/runtime": "npm:^7.18.9" - "@babel/runtime-corejs2": "npm:^7.25.0" - "@webex/rtcstats": "npm:^1.5.5" - "@webex/ts-sdp": "npm:1.8.2" - "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-client-media-engine": "npm:3.39.12" - events: "npm:^3.3.0" - ip-anonymize: "npm:^0.1.0" - typed-emitter: "npm:^2.1.0" - uuid: "npm:^8.3.2" - webrtc-adapter: "npm:^8.1.2" - xstate: "npm:^4.30.6" - checksum: 10c0/7a726f3226c1a0d4a1b60abccbeae8d5a60560bb11d5f0877c8411fbdb91f2f86f56ac83fad1bc5ace21d45afdbc20f810340e74b1986d785fd9bfb8cee04eb1 - languageName: node - linkType: hard - "@webex/internal-media-core@npm:2.28.2": version: 2.28.2 resolution: "@webex/internal-media-core@npm:2.28.2" @@ -10504,17 +10389,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-calendar@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-calendar@npm:3.12.0-next.17" +"@webex/internal-plugin-calendar@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-calendar@npm:3.12.0-next.47" dependencies: - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/9461f0f5659a686da11686def1fdc1b265dd5185a50fe3e6e800f4dd6b5f5a88612e6a50d0ecfa39999ee79998e94f77f90f809f89543d1676ae353b727677d5 + checksum: 10c0/20cda926984961c620420d432859f90e9cd030e215ac2b19ae19e3b80115070c9e59c37487f12eaa7b563a1d79093c1458bf09767d8fff5419efc339adfa7155 languageName: node linkType: hard @@ -10554,39 +10439,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.17" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/helper-html": "npm:3.12.0-next.1" - "@webex/helper-image": "npm:3.12.0-next.1" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/internal-plugin-user": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - crypto-js: "npm:^4.1.1" - lodash: "npm:^4.17.21" - node-scr: "npm:^0.3.0" - uuid: "npm:^3.3.2" - checksum: 10c0/0d7345f75b13b905b6c3cc5a94bb37f36788a1750789b09a392e2046686916eceddc1d1913c34d850d3b2c5a33b8f3b5945415985c0d637b9879d4a57cb4065d - languageName: node - linkType: hard - -"@webex/internal-plugin-conversation@npm:3.12.0-next.46": - version: 3.12.0-next.46 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.46" +"@webex/internal-plugin-conversation@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.47" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/helper-html": "npm:3.12.0-next.6" "@webex/helper-image": "npm:3.12.0-next.5" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.44" - "@webex/internal-plugin-user": "npm:3.12.0-next.41" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/internal-plugin-user": "npm:3.12.0-next.42" + "@webex/webex-core": "npm:3.12.0-next.41" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/3d024f2dcee3684825937a0aefa941e954bbb2136839e05b1fbff4e296b84c68930f5d65b7c2ebf44f3a55d2e905431da6d6eb260c348f388d0f9434849983d2 + checksum: 10c0/bfef0474e6fd59dd99007c971f10fa49ece546cf4e682a4322da8958d37b600a6a111cae2402cc387e70cac50d769a960444c7e369e12826d938ef3c1f68e93f languageName: node linkType: hard @@ -10623,51 +10490,34 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-device@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - ampersand-collection: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/c9bef045a01429c75ef55eee0fcdc08e6d647e4a35a81217963157a24c7e454093419a9a68203bdd3fb3f1081ba269d96d410b870e1eae5f4154691f40dfb8ed - languageName: node - linkType: hard - -"@webex/internal-plugin-device@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.40" +"@webex/internal-plugin-device@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-device@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/http-core": "npm:3.12.0-next.5" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" ampersand-collection: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/8b5d3cbb0f0d6dc53b401b853ab8c276ec8516ef4c965488e45db4c1c56ecfea041ebaae326ed3654fb5b5a118d28d24f29ca1356118b7bf12f17a4582e96e30 + checksum: 10c0/c12d90722d7881b42a6918e04153c2ceea933185f00c90a051aef7cc5b82e9982ab0f8a58bceda7823b956d5d38af6ff0f6afb70926eebd806eeb422d9cbfca4 languageName: node linkType: hard -"@webex/internal-plugin-dss@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-dss@npm:3.12.0-next.16" +"@webex/internal-plugin-dss@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-dss@npm:3.12.0-next.45" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/common-timers": "npm:3.12.0-next.5" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/bb7d8c041171235beb09df72731256824daa50e2a662b5aaad8af53105b6833371d75785613c9df851b658e337cc64cbb1c1f4868970124a7542dc5898a3817b + checksum: 10c0/89e88c21e9349e425aa933af2d089320e4124ef7255865631d39daee3da5943ac73a80e22fe8ca956cf0e457a666c84ac1c5788ba900b0ba98a0dfc5f08f7c36 languageName: node linkType: hard @@ -10723,43 +10573,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.16" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/test-helper-file": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - asn1js: "npm:^2.0.26" - debug: "npm:^4.3.4" - isomorphic-webcrypto: "npm:^2.3.8" - lodash: "npm:^4.17.21" - node-jose: "npm:^2.2.0" - node-kms: "npm:^0.4.1" - node-scr: "npm:^0.3.0" - pkijs: "npm:^2.1.84" - safe-buffer: "npm:^5.2.0" - uuid: "npm:^3.3.2" - valid-url: "npm:^1.0.9" - checksum: 10c0/ed33bdfca34f2fe0bb81ef7b7ebcd4b336c7c36fdd6bc659941e56b5666448e7daf19fb1a0f7481d25ddb1924cbda8a536b87328196facccda0dca98e0942bc7 - languageName: node - linkType: hard - -"@webex/internal-plugin-encryption@npm:3.12.0-next.44": - version: 3.12.0-next.44 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.44" +"@webex/internal-plugin-encryption@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.45" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/http-core": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.44" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" "@webex/test-helper-file": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" asn1js: "npm:^2.0.26" debug: "npm:^4.3.4" isomorphic-webcrypto: "npm:^2.3.8" @@ -10771,7 +10595,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/1ec9cb4ed3095c39f259820a986ebdaa5cb6b3a282e0e23639d248d73677c2cafdf7b47f62ae1a9a46a55692442dabc7f59d5decf9ae8b260472c054a3ac2cfa + checksum: 10c0/46f06b1e60228ed58525dc1ce45dc7a8946015f67e7e827662633f5ac69e5fcbb3bdbf73191ee3566197edb2c7d19f6d2344a5de97c0570e43136286dc17e54b languageName: node linkType: hard @@ -10798,34 +10622,23 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-feature@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.15" - dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - checksum: 10c0/c6d99e9b93ac300ec601c8d1da5a33e5249aadf49aec14166f5a14000045fcf07b28bf55890691680dab219f7f5291e9b2b74af56bf6473121eb862d402c439b - languageName: node - linkType: hard - -"@webex/internal-plugin-feature@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.40" +"@webex/internal-plugin-feature@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" - checksum: 10c0/a0d67aed66702a122f21d9baae0d8ddeea9f864573be18bea07b9e609c46af1821fc8319bd9ca4d5065a1504f9212bbab56e4a64f4a39d64239fa94bdf177421 + checksum: 10c0/c70bb582aaf8a56d56f1a39cbf21a20f4b20902c440dac4356447cd62e0870ee52066888d5c66e8d7d8d39789add999208dab8d448a9f153b3dd5a18859149e8 languageName: node linkType: hard -"@webex/internal-plugin-llm@npm:3.12.0-next.18": - version: 3.12.0-next.18 - resolution: "@webex/internal-plugin-llm@npm:3.12.0-next.18" +"@webex/internal-plugin-llm@npm:3.12.0-next.48": + version: 3.12.0-next.48 + resolution: "@webex/internal-plugin-llm@npm:3.12.0-next.48" dependencies: - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - checksum: 10c0/a045c09cce44b6e3d6048dbb79bc3a4becd5206ba3c9c91529dc5eee707847d21b8bc57423290e055949b3dbed70d2db8fc3560867642000a5a888f1250e6a7b + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + checksum: 10c0/c52c7b5d5694c298b746b2be9e2b001b2996fcad3302f89dc8083b5b1599d0152dac098a1557659d03712430c45fc2e6c3c55dad9b7f10c5c9b4af2a4fcb0b74 languageName: node linkType: hard @@ -10843,17 +10656,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-locus@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-locus@npm:3.12.0-next.16" +"@webex/internal-plugin-locus@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-locus@npm:3.12.0-next.45" dependencies: - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/7df66a258562ec27ced651a2be5dc002641414954df727f2d83dbc78e96b2d11bbe9209a0eb801a5bff2950796f100152ee28a60a245dd760b4aef70eadf3b2f + checksum: 10c0/9582f505e65ad9f96dc621f32d69577b517f408d266e9ea0883a61d6e10517bded348361fe318a362dfd8c5cac60aaef59af58fb43b84038222a6b880a39ff7a languageName: node linkType: hard @@ -10874,20 +10687,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-lyra@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-lyra@npm:3.12.0-next.17" +"@webex/internal-plugin-lyra@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-lyra@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/internal-plugin-feature": "npm:3.12.0-next.15" - "@webex/internal-plugin-locus": "npm:3.12.0-next.16" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/internal-plugin-feature": "npm:3.12.0-next.41" + "@webex/internal-plugin-locus": "npm:3.12.0-next.45" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" bowser: "npm:^2.11.0" uuid: "npm:^3.3.2" - checksum: 10c0/e94ed78b8f3bb50bcdbe68e7d4c0374a5ebe8ad19defb9a0cab90ceacc3ee4f6d2607d9ebc1ec751d1a4e4cfb55617973996ebc9e643da71054fe496b3a2bf04 + checksum: 10c0/eb920d13195e62a7d040ee9eeec12ed905854659cd0085fe0790de5f672e70db086593032e948de5afba7810ef7c2a9c9027a1150e6fb243084efc19faee959e languageName: node linkType: hard @@ -10939,51 +10752,27 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-mercury@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.16" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-feature": "npm:3.12.0-next.15" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-refresh-callback": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - backoff: "npm:^2.5.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - ws: "npm:^8.17.1" - checksum: 10c0/0cfe3cb25a5b42513103ff147b850476c6f0d885e317a13dc2fe34f3a1d9e605edbd75b421ea667b1470c414b82998340e37d64bb33176e19844fb1466347ded - languageName: node - linkType: hard - -"@webex/internal-plugin-mercury@npm:3.12.0-next.44": - version: 3.12.0-next.44 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.44" +"@webex/internal-plugin-mercury@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.45" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-feature": "npm:3.12.0-next.40" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-feature": "npm:3.12.0-next.41" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-refresh-callback": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" backoff: "npm:^2.5.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" ws: "npm:^8.17.1" - checksum: 10c0/0948067042f4a11900b239847fd3e68479acf3409795509b487712b5a521fed490bf72c33b008fb23485e95d5fd82b8713c0315c935b721a20aa0c12ad200d4a + checksum: 10c0/9c1aaea6f5e1797c7df9eba9c909d740157052a7d4760a00d40f3d06b616a0c422dc7f63a1166fe78368e6ddbaf50ac395ef7d09afb853365bc48a4085492144 languageName: node linkType: hard @@ -11016,37 +10805,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-metrics@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - ip-anonymize: "npm:^0.1.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/d8b1d3f1b1f34589f57e9a30030cd8922a95d009f3e2d0b57dd57ade34c439a371d38bb4c0960ccab65498c55b9b0f3d12451e3b0d66a95a0f59757b23c55a83 - languageName: node - linkType: hard - -"@webex/internal-plugin-metrics@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.40" +"@webex/internal-plugin-metrics@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" "@webex/event-dictionary-ts": "npm:^1.0.2220" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" ip-anonymize: "npm:^0.1.0" isbot: "npm:5.2.1" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/c395e5491e6a7d650ac73311c0084acf7ec53786ac3002cde1849efad9cea478fe954c0427da7001de659b61cc9e445bcd2357afe030658b5f08b81188143687 + checksum: 10c0/f1c5d5bbf4719f2f207f2f28b94f879b31bfc90b584167c3c5ad5c83987300f1133bde9ceec94019b73cf8030267271dbcd5002559b21b1da33cb6c7092a99aa languageName: node linkType: hard @@ -11066,19 +10839,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-presence@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-presence@npm:3.12.0-next.16" +"@webex/internal-plugin-presence@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/internal-plugin-presence@npm:3.12.0-next.45" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/test-helper-mocha": "npm:3.12.0-next.5" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" + "@webex/test-helper-test-users": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" - checksum: 10c0/557276a3f1c64eb4026a9dcba35df70b7ad890109026582297085add6c970ad53d98ca46fee66862850a5f7ac2b2d8368c8aff5ddf0eac52f3efc1ff14198417 + checksum: 10c0/6c0928cb4709d345a608a45d536ffcd1c24355c0e8f68149b080d5f840020ddf9d1861b2efa85d7a90d2f34cccf6a61822bfddde06c29eec669dc751ad61ee15 languageName: node linkType: hard @@ -11097,33 +10870,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.17" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/b5e13de9b8312333a7ff48630ea0bb306da5b2d250626806e08d3cad5a43c20a8a8f119175687d9320a7ea2e8c13c2d8daa8aa8d1cc6e400f5aab89b501f553d - languageName: node - linkType: hard - -"@webex/internal-plugin-search@npm:3.12.0-next.46": - version: 3.12.0-next.46 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.46" +"@webex/internal-plugin-search@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-search@npm:3.12.0-next.47" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.46" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.44" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e5dd18dc81181d93de255726ee275001d707cca659c5b749ce76a7c0983302ac8bc6518944cee543f2091e619d25a25475a9c88aef55e56c39ad350379f20f4f + checksum: 10c0/b133a290fe6d79d7e46a5428985137ef5b052610add90cb1018fcf81e5b29cd4a45955bb5a15d5065cee0fe982e6dc3678c907a2e4015322160da6d90dab6b6c languageName: node linkType: hard @@ -11144,41 +10902,24 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.17" +"@webex/internal-plugin-support@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/internal-plugin-support@npm:3.12.0-next.47" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-search": "npm:3.12.0-next.17" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-file": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/e2d792681c264c646d47187a3a7a8f995fc63b1c724f78762d2e019db8798ae5b7ca6a7a516cf8938e66915f8c969c08a400806ffb51edf787397629bacc3c1c - languageName: node - linkType: hard - -"@webex/internal-plugin-support@npm:3.12.0-next.46": - version: 3.12.0-next.46 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.46" - dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/internal-plugin-search": "npm:3.12.0-next.46" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-search": "npm:3.12.0-next.47" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-file": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/cf3dcb2a26778e5972a477593b94af0757d52fd6fd18798d8818050f89146b0920a151eda045d5f77bfec4cfe6800c7bd55a9d244134238192910f9e35f48e11 + checksum: 10c0/419554e9056b3744460fb8c6f2aabb8c10b83b2877d3358ffeb0e30d9cc3d453ac822bc8a244fc25a811a1926562b65e0d3682c4bd42f32009435a348effe9b9 languageName: node linkType: hard -"@webex/internal-plugin-task@npm:^3.12.0-next.17": +"@webex/internal-plugin-task@npm:^3.12.0-next.47": version: 3.12.0 resolution: "@webex/internal-plugin-task@npm:3.12.0" dependencies: @@ -11224,47 +10965,31 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-user@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.16" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/test-helper-test-users": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/5f625971f05369f2ae1b22570383f5e6739291c66d1ac0dd44b7d2dfab19c844c0daafd6d5faa1afea3ef62656a27091a89604ab5e53479a9bc1663e5cc735ca - languageName: node - linkType: hard - -"@webex/internal-plugin-user@npm:3.12.0-next.41": - version: 3.12.0-next.41 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.41" +"@webex/internal-plugin-user@npm:3.12.0-next.42": + version: 3.12.0-next.42 + resolution: "@webex/internal-plugin-user@npm:3.12.0-next.42" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" "@webex/test-helper-test-users": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/45746e930b4675d80a1415c5c2d1a21f0a715466e39c0c84b72722cd7eb463eb1c95dcf99037fda8110860f8dffa36dd25dea820bfd353133372cd8e90e675e7 + checksum: 10c0/2c680b2f7b876dfd232b2a9cdebb6db9dca3b20d92f5e9a7c85a1d98338fc9703b67800cc96dbd35d02a2c5899b6f70b1d4be08653b6f51bf8e71b571afaccfd languageName: node linkType: hard -"@webex/internal-plugin-voicea@npm:3.12.0-next.18": - version: 3.12.0-next.18 - resolution: "@webex/internal-plugin-voicea@npm:3.12.0-next.18" +"@webex/internal-plugin-voicea@npm:3.12.0-next.50": + version: 3.12.0-next.50 + resolution: "@webex/internal-plugin-voicea@npm:3.12.0-next.50" dependencies: - "@webex/internal-plugin-llm": "npm:3.12.0-next.18" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-llm": "npm:3.12.0-next.48" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" uuid: "npm:^3.3.2" - checksum: 10c0/a95e8af210eb469d6501256216748ce7e066f567d02c3fab17430d825dd422068cc536ec88e9a06feac01d03b81aa6c5d799091cafb83ab54dc53398ac62903a + checksum: 10c0/c59a1cf3676ec4dcd017818df4d8267c0c15eb0882c0c7abbe6af5ad5476c130dca04fd1b96867d5e4ae832edf1b74f248b68e6b6f87c466d39d5552692ec38e languageName: node linkType: hard @@ -11299,17 +11024,6 @@ __metadata: languageName: node linkType: hard -"@webex/media-helpers@npm:3.12.0-next.4": - version: 3.12.0-next.4 - resolution: "@webex/media-helpers@npm:3.12.0-next.4" - dependencies: - "@webex/internal-media-core": "npm:2.25.1" - "@webex/ts-events": "npm:^1.1.0" - "@webex/web-media-effects": "npm:2.33.5" - checksum: 10c0/9ac15b19c7c450f4831b4d6afafc7873c86781670ea0fcd468f25b193f50d13d2c0b13a70b927a94b4d372ebe03f2ecad141df32cb0836dbcdb604c7de9f9799 - languageName: node - linkType: hard - "@webex/package-tools@npm:0.0.0-next.6": version: 0.0.0-next.6 resolution: "@webex/package-tools@npm:0.0.0-next.6" @@ -11340,21 +11054,40 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-attachment-actions@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-attachment-actions@npm:3.12.0-next.17" +"@webex/plugin-attachment-actions@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-attachment-actions@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-messages": 3.12.0-next.17 - "@webex/plugin-people": 3.12.0-next.16 - checksum: 10c0/2e615f347362c443c4931a0bfbed9c551df76319755abecde51b78ed53b269cec337325ea9e13ffe260a58bb165817d63e5ac56d0cb5b70bfdb7d041979f87bb + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-messages": 3.12.0-next.47 + "@webex/plugin-people": 3.12.0-next.45 + checksum: 10c0/26cd3b6fd793dfdbcc93aefa8ad8f926766381a0a6f76ab1293b59328624f83a2e4919ff57d5e2127719eb7d53e711d1206349fdabbe32dd01148411dc8dcab2 + languageName: node + linkType: hard + +"@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.42": + version: 3.12.0-next.42 + resolution: "@webex/plugin-authorization-browser-first-party@npm:3.12.0-next.42" + dependencies: + "@webex/common": "npm:3.12.0-next.5" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.41" + "@webex/test-helper-automation": "npm:3.12.0-next.5" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/test-helper-mocha": "npm:3.12.0-next.5" + "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" + "@webex/test-helper-test-users": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" + crypto-js: "npm:^4.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^3.3.2" + checksum: 10c0/81844df9583c458828adc44cc6dfe2a6892791b13594a3006c446bfa4cfd62b2aec9db68a88a52709ea4b3d484d80101f2b2e12a0ab29b71a3dd46915f2e19fc languageName: node linkType: hard @@ -11375,37 +11108,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-browser@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/plugin-authorization-node": "npm:3.12.0-next.15" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.15" - "@webex/storage-adapter-spec": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - jsonwebtoken: "npm:^9.0.2" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/04f15ad355e5431dcad6a545f2b1e5c6e99e734743a310107996319cbcd080f6a5d0e115e297fdf414a3aa6c421bba7f786abfbee1fd7aec60d0333796712313 - languageName: node - linkType: hard - -"@webex/plugin-authorization-browser@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.40" +"@webex/plugin-authorization-browser@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/plugin-authorization-node": "npm:3.12.0-next.40" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/plugin-authorization-node": "npm:3.12.0-next.41" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.41" "@webex/storage-adapter-spec": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e4d1f87a22ff7ac3cb2d4dd63d040f05c2bf4e7ce8cbecae8a6bf3491e64a7b691912a03074056ab483a7ec8c1f7e17bf286815fcc8d7dd584a20cde787e654e + checksum: 10c0/6df2a01cf1274275f0fbd83832b27ea068793e00ecede71310c8f151bc1290403848f7d39f814f1fc04058a83ba0a27449e9f10e79f8cf10131a7ffa3ace39ee languageName: node linkType: hard @@ -11422,29 +11138,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-node@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" - jsonwebtoken: "npm:^9.0.0" - uuid: "npm:^3.3.2" - checksum: 10c0/8bca668f4e7a17504674a02291ea699df1b350deea8cb9ab1c411e484e17ab765274227f5d1d4a9f4b2e3a4e7223739e38ec1b551780758e12900687960b87f3 - languageName: node - linkType: hard - -"@webex/plugin-authorization-node@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.40" +"@webex/plugin-authorization-node@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" - "@webex/internal-plugin-device": "npm:3.12.0-next.40" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" jsonwebtoken: "npm:^9.0.0" uuid: "npm:^3.3.2" - checksum: 10c0/6f9f5ac5bb6d236b0270de34db9fcef4116f5b21aa26d92de18a2f1c693e9a92f8d2e43ec6a96497b85a68b30c1e937b14b888eb9f821189b0b584553323c3fe + checksum: 10c0/b22359724b3b352bb1f3890b4f0f24e569f0d234fd67f4af200745a4285c23800f08357f1e298b0373fa54c52089bd78f5fc27dcf011e873fa9796df820734ac languageName: node linkType: hard @@ -11458,23 +11161,13 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.15" - dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.15" - "@webex/plugin-authorization-node": "npm:3.12.0-next.15" - checksum: 10c0/97f50fb9b111628ea46be900e6f69ca61a7220c9dcc1b66a705f452178ecb9622ebdcb6f442139da09141fc34111b503035e38ce005d5ca5adff3cb86766148c - languageName: node - linkType: hard - -"@webex/plugin-authorization@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.40" +"@webex/plugin-authorization@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-authorization@npm:3.12.0-next.41" dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.40" - "@webex/plugin-authorization-node": "npm:3.12.0-next.40" - checksum: 10c0/1a3c75769a7b474106b786f9cd08488632c9adb9fb035e46307bf70d1c10f9e42117399db4f03b570adbe856a69c104ea191824109387d779b264ba4fb1ea152 + "@webex/plugin-authorization-browser": "npm:3.12.0-next.41" + "@webex/plugin-authorization-node": "npm:3.12.0-next.41" + checksum: 10c0/0d9e52d7c0acbfb8cb954384edf577d780b0f23f2fa1fd905db630746ad20c314a92f07f8851470c34fca01a14b278cbb737f2fb584f5b729a8f742e4c62712e languageName: node linkType: hard @@ -11496,31 +11189,31 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-device-manager@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-device-manager@npm:3.12.0-next.17" +"@webex/plugin-device-manager@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-device-manager@npm:3.12.0-next.47" dependencies: - "@webex/internal-plugin-calendar": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-lyra": "npm:3.12.0-next.17" - "@webex/internal-plugin-search": "npm:3.12.0-next.17" - "@webex/plugin-authorization": "npm:3.12.0-next.15" - "@webex/plugin-logger": "npm:3.12.0-next.15" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-calendar": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-lyra": "npm:3.12.0-next.47" + "@webex/internal-plugin-search": "npm:3.12.0-next.47" + "@webex/plugin-authorization": "npm:3.12.0-next.41" + "@webex/plugin-logger": "npm:3.12.0-next.41" + "@webex/test-helper-chai": "npm:3.12.0-next.5" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/4aaa87651b9666ab9b6824938ddf2f2c00839aff0b5c6a9e89699f75b587107e0eaa6852fe9dd2bdb99b8f5e1c411dc5c6022ce56c8b8bb9a7a2ef7544182cf2 + checksum: 10c0/d11b299403d6712b6bad8e582e49063f53c6941a0847c69c0a56e1458395aae554197a34ae6cdef73ec81489973ea6fb998679b29ac8ad8086ad106329a122be languageName: node linkType: hard -"@webex/plugin-encryption@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/plugin-encryption@npm:3.12.0-next.16" +"@webex/plugin-encryption@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/plugin-encryption@npm:3.12.0-next.45" dependencies: - "@webex/internal-plugin-encryption": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - checksum: 10c0/36e987ef8439913fdead76cdfec4184f1d39bb96a38bf3e2eacde4162a3e170d92593ba3649b08488e99244c9cec037c56e2ee0ce7d72fa2b9b264eb6679b1d8 + "@webex/internal-plugin-encryption": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" + checksum: 10c0/cdf3e292d98e374ac03bca0d224d5830f01221a6d9234db6d9f4efb57c40a865ea7568f25c401e22151cda75a1b1a6a548d9057be34a7b02fe9797b2a331e083 languageName: node linkType: hard @@ -11538,31 +11231,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-logger@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-logger@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/test-helper-chai": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - lodash: "npm:^4.17.21" - checksum: 10c0/3db9830fb48a66caa3ed0c9a2a3548f6bf9c3b9b07cfd0eeda0cb090c71db42399bbbfc5747848a7069a1d75cf34f072a08adf277caa76e50fc490afeeb833af - languageName: node - linkType: hard - -"@webex/plugin-logger@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/plugin-logger@npm:3.12.0-next.40" +"@webex/plugin-logger@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-logger@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/test-helper-chai": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" "@webex/test-helper-mock-webex": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" - checksum: 10c0/e09fc2597f46cb4e6449e57859a5c55532c94d480868eebbdba2896c05ee5c905d6bd03dce3404b2e060034be2e1fbddd1a20b6e13a6d3b92928300008ed009f + checksum: 10c0/d378d43d0225d6c265a8597fc74a94ee5b6ba94bd3578e7b2f72f0fb321c2c5e02cdc6a80b562b02ee069138139cf9b196f1102dad46f2406203a2ff040596b3 languageName: node linkType: hard @@ -11593,26 +11272,27 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-meetings@npm:3.12.0-next.62": - version: 3.12.0-next.62 - resolution: "@webex/plugin-meetings@npm:3.12.0-next.62" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-media-core": "npm:2.25.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-llm": "npm:3.12.0-next.18" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.15" - "@webex/internal-plugin-support": "npm:3.12.0-next.17" - "@webex/internal-plugin-user": "npm:3.12.0-next.16" - "@webex/internal-plugin-voicea": "npm:3.12.0-next.18" - "@webex/media-helpers": "npm:3.12.0-next.4" - "@webex/plugin-people": "npm:3.12.0-next.16" - "@webex/plugin-rooms": "npm:3.12.0-next.17" +"@webex/plugin-meetings@npm:3.12.0-next.127": + version: 3.12.0-next.127 + resolution: "@webex/plugin-meetings@npm:3.12.0-next.127" + dependencies: + "@webex/common": "npm:3.12.0-next.5" + "@webex/event-dictionary-ts": "npm:^1.0.2215" + "@webex/internal-media-core": "npm:2.28.2" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-llm": "npm:3.12.0-next.48" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.41" + "@webex/internal-plugin-support": "npm:3.12.0-next.47" + "@webex/internal-plugin-user": "npm:3.12.0-next.42" + "@webex/internal-plugin-voicea": "npm:3.12.0-next.50" + "@webex/media-helpers": "npm:3.12.0-next.11" + "@webex/plugin-people": "npm:3.12.0-next.45" + "@webex/plugin-rooms": "npm:3.12.0-next.47" "@webex/ts-sdp": "npm:^1.8.1" - "@webex/web-capabilities": "npm:^1.10.0" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/web-capabilities": "npm:^1.13.0" + "@webex/webex-core": "npm:3.12.0-next.41" ampersand-collection: "npm:^2.0.2" bowser: "npm:^2.11.0" btoa: "npm:^1.2.1" @@ -11626,7 +11306,7 @@ __metadata: uuid: "npm:^3.3.2" webrtc-adapter: "npm:^8.1.2" xxh3-ts: "npm:^2.0.1" - checksum: 10c0/65e30bde464ac5e2da09a6485d663323daee2ec26c528d761dd80b51566c277c338c80f949c28555e7329a5107534250692068c08ce546a5c4cfe15ff47e5ea4 + checksum: 10c0/a6edb140ff571f833af3de4f18155a22ae9378f1ee8854f3829bc10ee9d6d9d85dc7069ff08f890959448feaaf38efd075a50a6bbfd9c41052d1eebe588ad868 languageName: node linkType: hard @@ -11648,22 +11328,22 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-memberships@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-memberships@npm:3.12.0-next.17" +"@webex/plugin-memberships@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-memberships@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-messages": 3.12.0-next.17 - "@webex/plugin-people": 3.12.0-next.16 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/617e1eae28b9de947cd6ea33a779e88a1442b9a0de44290f28c9aae8a0b907bbbf14fe071cf373dce4dcb4f6d1368450269da2617a2b6ab330b240ac3b5ecbf6 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-messages": 3.12.0-next.47 + "@webex/plugin-people": 3.12.0-next.45 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/024d99150664ca14d4c5bac974613dc3ed0fd6d8bdceb163818e86088224f532f5c5a162795e4fc8cd5ea70a8d60a09b5527bd5a4648a9ce9e0c389674dec316 languageName: node linkType: hard @@ -11685,22 +11365,22 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-messages@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-messages@npm:3.12.0-next.17" +"@webex/plugin-messages@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-messages@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-people": 3.12.0-next.16 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/74015a95b1d35d9c360a16cffa712fcd5261405b7b5c4955ad1d2fd4b9bff0a5d196f5ea343edeb1aeb0a98042afc3f414911eb0dd84dc7db67077fb70fb724c + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-people": 3.12.0-next.45 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/b4ee388b277e811dbf7cbc51fbd7a4cdd6f4f33cfd6210ede459d7da3678579e98d3f6908ab179827087f416968e5c71cd8043c26f6d1a93b6d1fd5e05573aa6 languageName: node linkType: hard @@ -11715,14 +11395,14 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-people@npm:3.12.0-next.16": - version: 3.12.0-next.16 - resolution: "@webex/plugin-people@npm:3.12.0-next.16" +"@webex/plugin-people@npm:3.12.0-next.45": + version: 3.12.0-next.45 + resolution: "@webex/plugin-people@npm:3.12.0-next.45" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" - checksum: 10c0/328aedf95ae0cd8ebbd91b8424f0279551b6a66fdb8fde83d8bf4e130ecdc5e49aeb516e9d6a365c6b3a67e3e916b3352ff33ab5e1ed1c7622f4842508d055d4 + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" + checksum: 10c0/9b2ebbf32cf16af994910cfce92d054c931a095fec78242201be3fde1f2abed308c10c95b0378c60af47ea5576a0dbdb3e211a9044ada492628db303d4e24254 languageName: node linkType: hard @@ -11744,20 +11424,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-rooms@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/plugin-rooms@npm:3.12.0-next.17" +"@webex/plugin-rooms@npm:3.12.0-next.47": + version: 3.12.0-next.47 + resolution: "@webex/plugin-rooms@npm:3.12.0-next.47" dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.17" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/common": "npm:3.12.0-next.5" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.47" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/webex-core": "npm:3.12.0-next.41" debug: "npm:^4.3.4" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-people": 3.12.0-next.16 - checksum: 10c0/384483ea056ff2d2f7470521fc32743cb106264f0f830b3f79edce5f01dc7d47244e71189296ad2d15c76303e2b9428488f55ac0065a28a525aa02f5b3ad0525 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-people": 3.12.0-next.45 + checksum: 10c0/04cb907e1c711692823f9c45d9ea85a13754c04564abe5267085b7e5c5210590f6ff5fcd6aeb7ea2854d9a84b0c4f987fdb706295ca5160baa0096ebabc30398 languageName: node linkType: hard @@ -11774,17 +11454,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-team-memberships@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-team-memberships@npm:3.12.0-next.15" +"@webex/plugin-team-memberships@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-team-memberships@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-rooms": 3.12.0-next.17 - "@webex/plugin-teams": 3.12.0-next.15 - checksum: 10c0/dd6c8720f9daabad5e455b2782012559f605733e578e94b5d923d63380bc0ec6403d20c80c8ef05ba8fdd16937b76318c9a83c9dc7084cded0021985b109e603 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-rooms": 3.12.0-next.47 + "@webex/plugin-teams": 3.12.0-next.41 + checksum: 10c0/60a8c59b5760ad8d2c1f68d53afd4c48ff0c8c84e3d3ed10e52ba9e46eac54b6cdd82977255d7c08d445df525484d2ad31e431ec3821d17508b5b4ad349762ad languageName: node linkType: hard @@ -11804,18 +11484,18 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-teams@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-teams@npm:3.12.0-next.15" +"@webex/plugin-teams@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-teams@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-memberships": 3.12.0-next.17 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/b37c81368bdc3079da07c8bfc0f6e9c5818b34fab02a05003cc5ede264e82a951e4d343cb2317d087b64cf58ec4dff4030d86b56c2b76c1b62e28078f06eda5a + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-memberships": 3.12.0-next.47 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/caf787a817538fd938f8914e8dc9a13bdb5fb6d0399618d86ffb79c568323c55b15977bba081215693163bcf0433693ffaa09336c6bbabe7b3f847172bbb720e languageName: node linkType: hard @@ -11831,16 +11511,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-webhooks@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/plugin-webhooks@npm:3.12.0-next.15" +"@webex/plugin-webhooks@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/plugin-webhooks@npm:3.12.0-next.41" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" peerDependencies: - "@webex/plugin-logger": 3.12.0-next.15 - "@webex/plugin-rooms": 3.12.0-next.17 - checksum: 10c0/dd50f24056870b667686e96d7e578170eae030bd7cff69ca5c3ab3aaa5d54fd7e51d0c60903506b6f6980c7cfad720188a98fd6b6cb5459b46f359b11111e810 + "@webex/plugin-logger": 3.12.0-next.41 + "@webex/plugin-rooms": 3.12.0-next.47 + checksum: 10c0/afc51e58bb6fc4fc10e3a4ab186a5c43c119ec4421085dbf42bf53084434f8ebe94e4f73c9b11e5673c30a47f2f4b689d4abce71a4917e74e6dad756d9605175 languageName: node linkType: hard @@ -11880,25 +11560,14 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-local-storage@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.15" - dependencies: - "@webex/storage-adapter-spec": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - "@webex/webex-core": "npm:3.12.0-next.15" - checksum: 10c0/211a82fcf5d06d2eb7c47c08538e4d23334354767d442b03219382aaef0d8537186850345d1b128b1f8014df82c7e06eee3bea88edeea97125fa113f83c4c5e2 - languageName: node - linkType: hard - -"@webex/storage-adapter-local-storage@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.40" +"@webex/storage-adapter-local-storage@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.41" dependencies: "@webex/storage-adapter-spec": "npm:3.12.0-next.5" "@webex/test-helper-mocha": "npm:3.12.0-next.5" - "@webex/webex-core": "npm:3.12.0-next.40" - checksum: 10c0/b0dca260739f851b4b043fbb2cd337765eb246b3699d9ff7f42972e8312047a72c658efdcba0ad7c1018d18feaac19f154cebe2edbffaff2edad48003037c6d3 + "@webex/webex-core": "npm:3.12.0-next.41" + checksum: 10c0/de8f0afe03a1f59c782d703ed27f4c8c0717bd806925d7f8604eb799aa5a1050207d0ab4ed0936539c4d411a3db179f53ff47be3ad4592e9a3491cf3f474ae42 languageName: node linkType: hard @@ -11920,15 +11589,6 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-spec@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/storage-adapter-spec@npm:3.12.0-next.1" - dependencies: - "@webex/test-helper-chai": "npm:3.12.0-next.1" - checksum: 10c0/daaf7fa3eeae4749485d1f64eba9f2c20bbdaec1870873f6b393ec0f3dc6a8728c0dbf46c8969bb49c34208ad0cf56cbc5fde0575d3e1a786e8a744be2c57ea5 - languageName: node - linkType: hard - "@webex/storage-adapter-spec@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/storage-adapter-spec@npm:3.12.0-next.5" @@ -11969,6 +11629,17 @@ __metadata: languageName: unknown linkType: soft +"@webex/test-helper-automation@npm:3.12.0-next.5": + version: 3.12.0-next.5 + resolution: "@webex/test-helper-automation@npm:3.12.0-next.5" + dependencies: + es6-promise: "npm:^4.2.8" + lodash: "npm:^4.17.21" + require-dir: "npm:^0.3.2" + checksum: 10c0/a859924e8ba756997d7991a2a9a50567b5c5aec34d858071429e3999fae4674a330634ea47d629f7e33a0a1f2c7ef45e256541bbc68a646f2f5028b2956ee4ea + languageName: node + linkType: hard + "@webex/test-helper-chai@npm:2.60.4": version: 2.60.4 resolution: "@webex/test-helper-chai@npm:2.60.4" @@ -11991,17 +11662,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-chai@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-chai@npm:3.12.0-next.1" - dependencies: - "@webex/test-helper-file": "npm:3.12.0-next.1" - check-error: "npm:^1.0.2" - lodash: "npm:^4.17.21" - checksum: 10c0/5d8139752365ed2dc3cd4ae790b5ca542d2c689108df414082a907a8bdad674293fffe49858b7cec64bac1085579b6d55f7c3aea40edefc4d6d23f17b3b46bd1 - languageName: node - linkType: hard - "@webex/test-helper-chai@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-chai@npm:3.12.0-next.5" @@ -12039,19 +11699,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-file@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-file@npm:3.12.0-next.1" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/test-helper-make-local-url": "npm:3.12.0-next.1" - es6-promise: "npm:^4.2.8" - file-type: "npm:^16.0.1" - xhr: "npm:^2.5.0" - checksum: 10c0/6ed8abaec3268ac09dafbb9da2e639c3b3f10f01ffe619c10e78b9834caf36b1fba55d4cab031ff55f6b159be40f1c61a3f5ac6e782f1ba626e6782392485643 - languageName: node - linkType: hard - "@webex/test-helper-file@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-file@npm:3.12.0-next.5" @@ -12079,13 +11726,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-make-local-url@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-make-local-url@npm:3.12.0-next.1" - checksum: 10c0/13dd73a17c3d241b02affcabf831828dcef0df8850240142159ce15b0970a66ca2caf5d4c40cf54d9fde27b61abcf86d40a0895069e3f8d0f79f769f5c952ab4 - languageName: node - linkType: hard - "@webex/test-helper-make-local-url@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-make-local-url@npm:3.12.0-next.5" @@ -12111,15 +11751,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-mocha@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-mocha@npm:3.12.0-next.1" - dependencies: - bowser: "npm:^2.11.0" - checksum: 10c0/3df1caa81e06216abcfeee71b340684dc43c85660c6f4b1ecf7db692cd8901496aac8e9572acdd20b1b443f4330e4a1ed7f1a0ae9f94e9f7f6123011762b42f1 - languageName: node - linkType: hard - "@webex/test-helper-mocha@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-mocha@npm:3.12.0-next.5" @@ -12143,13 +11774,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-mock-web-socket@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-mock-web-socket@npm:3.12.0-next.1" - checksum: 10c0/a572fa41d1420a36d57fc57b9705297dff5c62fb479ef6b7433ce56102b3e331f894dad679a04ec98cf4c45f8007cbc4129a07036dc9d4f5a11b0f40a18adc5c - languageName: node - linkType: hard - "@webex/test-helper-mock-web-socket@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-mock-web-socket@npm:3.12.0-next.5" @@ -12179,17 +11803,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-mock-webex@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-mock-webex@npm:3.12.0-next.1" - dependencies: - ampersand-state: "npm:^5.0.3" - es6-promise: "npm:^4.2.8" - lodash: "npm:^4.17.21" - checksum: 10c0/d422edd71d6ac2031215ab479e409686526324698e96fb0baaf588047779e33fa138273c213716c4937f78b254646b74aa8fc08d663b0aeebb02439e4804f88a - languageName: node - linkType: hard - "@webex/test-helper-mock-webex@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-mock-webex@npm:3.12.0-next.5" @@ -12215,13 +11828,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-refresh-callback@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-refresh-callback@npm:3.12.0-next.1" - checksum: 10c0/3144709abb36533e76891052e8d805c8377129a099a5972f2f2dad99cff163b555d683babc5a5cb1316fe92d6ba9588e890121c67df3355e47137dab5d7829a0 - languageName: node - linkType: hard - "@webex/test-helper-refresh-callback@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-refresh-callback@npm:3.12.0-next.5" @@ -12247,15 +11853,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-retry@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-retry@npm:3.12.0-next.1" - dependencies: - es6-promise: "npm:^4.2.8" - checksum: 10c0/0a94cef8493b53a43c42187ca4cfa6e4fefd569203c97018b59f93c99225f9ff07e24ecce150f231e552c948789925c82abc7aa68a9a96e8aa59967f162126fc - languageName: node - linkType: hard - "@webex/test-helper-retry@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-retry@npm:3.12.0-next.5" @@ -12291,17 +11888,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-helper-test-users@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-helper-test-users@npm:3.12.0-next.1" - dependencies: - "@webex/test-helper-retry": "npm:3.12.0-next.1" - "@webex/test-users": "npm:3.12.0-next.1" - lodash: "npm:^4.17.21" - checksum: 10c0/e4f9f44c1cc88042fa6b8b5d1f0b8379158fac09cb5cdc4abd2d5c656b0f4bc863b16baea885f0f4aaea9aa7aa8e10663268490d85528f9e0fa1d3e819d11198 - languageName: node - linkType: hard - "@webex/test-helper-test-users@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-helper-test-users@npm:3.12.0-next.5" @@ -12341,20 +11927,6 @@ __metadata: languageName: node linkType: hard -"@webex/test-users@npm:3.12.0-next.1": - version: 3.12.0-next.1 - resolution: "@webex/test-users@npm:3.12.0-next.1" - dependencies: - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/test-helper-mocha": "npm:3.12.0-next.1" - btoa: "npm:^1.2.1" - lodash: "npm:^4.17.21" - node-random-name: "npm:^1.0.1" - uuid: "npm:^3.3.2" - checksum: 10c0/29ba5766b6c150ec3d1ec908dc4078816dedae21a773b056102fafe7ff474856573dfb53885e63475fdac3a35900827224578e24588d75127ebde5f00405ba68 - languageName: node - linkType: hard - "@webex/test-users@npm:3.12.0-next.5": version: 3.12.0-next.5 resolution: "@webex/test-users@npm:3.12.0-next.5" @@ -12434,7 +12006,7 @@ __metadata: languageName: node linkType: hard -"@webex/web-capabilities@npm:^1.12.0": +"@webex/web-capabilities@npm:^1.12.0, @webex/web-capabilities@npm:^1.13.0": version: 1.13.0 resolution: "@webex/web-capabilities@npm:1.13.0" dependencies: @@ -12452,25 +12024,6 @@ __metadata: languageName: node linkType: hard -"@webex/web-client-media-engine@npm:3.39.12": - version: 3.39.12 - resolution: "@webex/web-client-media-engine@npm:3.39.12" - dependencies: - "@webex/json-multistream": "npm:^2.4.3" - "@webex/rtcstats": "npm:^1.5.5" - "@webex/ts-events": "npm:^1.2.1" - "@webex/ts-sdp": "npm:1.8.2" - "@webex/web-capabilities": "npm:^1.10.0" - "@webex/web-media-effects": "npm:2.33.5" - "@webex/webrtc-core": "npm:2.13.7" - async: "npm:^3.2.4" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^2.1.0" - uuid: "npm:^8.3.2" - checksum: 10c0/16d7d144ff7f2a95c5d407481d2c9871910c1d2cd20350837e019f86c36be2fe27fe88ee5b182c003274777b6298b898193d88c5e8df5309379ec8b9edd306f7 - languageName: node - linkType: hard - "@webex/web-client-media-engine@npm:3.42.1": version: 3.42.1 resolution: "@webex/web-client-media-engine@npm:3.42.1" @@ -12490,21 +12043,6 @@ __metadata: languageName: node linkType: hard -"@webex/web-media-effects@npm:2.33.5": - version: 2.33.5 - resolution: "@webex/web-media-effects@npm:2.33.5" - dependencies: - "@webex/ladon-ts": "npm:^5.10.0" - events: "npm:^3.3.0" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^1.4.0" - uuid: "npm:^9.0.1" - worker-timers: "npm:^8.0.21" - yarn: "npm:^1.22.22" - checksum: 10c0/d09da2ca72143a5d79f3e46e2c04f13b38b8351490440b2d1142f926ca0b5df499962e8c6411241f588e74f1757cb8904f029783b25a6751a762e3abdfdf8642 - languageName: node - linkType: hard - "@webex/web-media-effects@npm:2.34.0": version: 2.34.0 resolution: "@webex/web-media-effects@npm:2.34.0" @@ -12563,29 +12101,9 @@ __metadata: languageName: node linkType: hard -"@webex/webex-core@npm:3.12.0-next.15": - version: 3.12.0-next.15 - resolution: "@webex/webex-core@npm:3.12.0-next.15" - dependencies: - "@webex/common": "npm:3.12.0-next.1" - "@webex/common-timers": "npm:3.12.0-next.1" - "@webex/http-core": "npm:3.12.0-next.1" - "@webex/storage-adapter-spec": "npm:3.12.0-next.1" - ampersand-collection: "npm:^2.0.2" - ampersand-events: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - core-decorators: "npm:^0.20.0" - crypto-js: "npm:^4.1.1" - jsonwebtoken: "npm:^9.0.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/295175e66e511f3d63649baba8cbf325a10b4f6adeddbfbfb2a7a483ee68c25f0730ea13a5c6e3c90ec5540d4cb94cc8d90083bbff14ac7d201b5217a60dbe80 - languageName: node - linkType: hard - -"@webex/webex-core@npm:3.12.0-next.40": - version: 3.12.0-next.40 - resolution: "@webex/webex-core@npm:3.12.0-next.40" +"@webex/webex-core@npm:3.12.0-next.41": + version: 3.12.0-next.41 + resolution: "@webex/webex-core@npm:3.12.0-next.41" dependencies: "@webex/common": "npm:3.12.0-next.5" "@webex/common-timers": "npm:3.12.0-next.5" @@ -12599,22 +12117,7 @@ __metadata: jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/60f0812479c996627f4fa989f9535cda37eba0f14d1ec97382f1414a2f7c07dc5044cf7b0e73b190814f2136072f55904dc476f32ad39113debe53554cd49ae9 - languageName: node - linkType: hard - -"@webex/webrtc-core@npm:2.13.7": - version: 2.13.7 - resolution: "@webex/webrtc-core@npm:2.13.7" - dependencies: - "@webex/ts-events": "npm:^1.2.1" - "@webex/web-capabilities": "npm:^1.6.1" - "@webex/web-media-effects": "npm:2.33.5" - events: "npm:^3.3.0" - js-logger: "npm:^1.6.1" - typed-emitter: "npm:^2.1.0" - webrtc-adapter: "npm:^8.1.2" - checksum: 10c0/48f781ca9d57330b6498e155e2b46bd914773dfc8ce21df80d0d79bf902a224ca4961f848999a3d1b779b49aef8ce7e797374e9c42eec016f88cd2fdc433895a + checksum: 10c0/a17374ad6147be5f1a01f030e9d2aa951be5408316e042829df20840edb7f16df44822716ab8873d4037cb46beef2144f2dc5ae15a995f24b81a02041acc0ec4 languageName: node linkType: hard @@ -30764,6 +30267,13 @@ __metadata: languageName: node linkType: hard +"require-dir@npm:^0.3.2": + version: 0.3.2 + resolution: "require-dir@npm:0.3.2" + checksum: 10c0/3009f8e59eaa6744cd55c7978e44aa1bbf9e48c786b4ccbad84c178bf32a393caad6612f9ef9baf8421921fafaed605840cf7f2d503836f372716313e9b12742 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -31276,7 +30786,7 @@ __metadata: ts-loader: "npm:^9.5.1" typescript: "npm:^5.6.3" typescript-eslint: "npm:^8.24.1" - webex: "npm:3.12.0-next.84" + webex: "npm:3.12.0-next.184" webpack: "npm:^5.94.0" webpack-cli: "npm:^5.1.4" webpack-dev-server: "npm:^5.1.0" @@ -35410,42 +34920,43 @@ __metadata: languageName: node linkType: hard -"webex@npm:3.12.0-next.84": - version: 3.12.0-next.84 - resolution: "webex@npm:3.12.0-next.84" +"webex@npm:3.12.0-next.184": + version: 3.12.0-next.184 + resolution: "webex@npm:3.12.0-next.184" dependencies: "@babel/polyfill": "npm:^7.12.1" "@babel/runtime-corejs2": "npm:^7.14.8" - "@webex/calling": "npm:3.12.0-next.34" - "@webex/common": "npm:3.12.0-next.1" - "@webex/contact-center": "npm:3.12.0-next.42" - "@webex/internal-plugin-calendar": "npm:3.12.0-next.17" - "@webex/internal-plugin-device": "npm:3.12.0-next.15" - "@webex/internal-plugin-dss": "npm:3.12.0-next.16" - "@webex/internal-plugin-llm": "npm:3.12.0-next.18" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.16" - "@webex/internal-plugin-presence": "npm:3.12.0-next.16" - "@webex/internal-plugin-support": "npm:3.12.0-next.17" - "@webex/internal-plugin-task": "npm:^3.12.0-next.17" - "@webex/internal-plugin-voicea": "npm:3.12.0-next.18" - "@webex/plugin-attachment-actions": "npm:3.12.0-next.17" - "@webex/plugin-authorization": "npm:3.12.0-next.15" - "@webex/plugin-device-manager": "npm:3.12.0-next.17" - "@webex/plugin-encryption": "npm:3.12.0-next.16" - "@webex/plugin-logger": "npm:3.12.0-next.15" - "@webex/plugin-meetings": "npm:3.12.0-next.62" - "@webex/plugin-memberships": "npm:3.12.0-next.17" - "@webex/plugin-messages": "npm:3.12.0-next.17" - "@webex/plugin-people": "npm:3.12.0-next.16" - "@webex/plugin-rooms": "npm:3.12.0-next.17" - "@webex/plugin-team-memberships": "npm:3.12.0-next.15" - "@webex/plugin-teams": "npm:3.12.0-next.15" - "@webex/plugin-webhooks": "npm:3.12.0-next.15" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.15" - "@webex/webex-core": "npm:3.12.0-next.15" + "@webex/calling": "npm:3.12.0-next.89" + "@webex/common": "npm:3.12.0-next.5" + "@webex/contact-center": "npm:3.12.0-next.109" + "@webex/internal-plugin-calendar": "npm:3.12.0-next.47" + "@webex/internal-plugin-device": "npm:3.12.0-next.41" + "@webex/internal-plugin-dss": "npm:3.12.0-next.45" + "@webex/internal-plugin-llm": "npm:3.12.0-next.48" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.45" + "@webex/internal-plugin-presence": "npm:3.12.0-next.45" + "@webex/internal-plugin-support": "npm:3.12.0-next.47" + "@webex/internal-plugin-task": "npm:^3.12.0-next.47" + "@webex/internal-plugin-voicea": "npm:3.12.0-next.50" + "@webex/plugin-attachment-actions": "npm:3.12.0-next.47" + "@webex/plugin-authorization": "npm:3.12.0-next.41" + "@webex/plugin-authorization-browser-first-party": "npm:3.12.0-next.42" + "@webex/plugin-device-manager": "npm:3.12.0-next.47" + "@webex/plugin-encryption": "npm:3.12.0-next.45" + "@webex/plugin-logger": "npm:3.12.0-next.41" + "@webex/plugin-meetings": "npm:3.12.0-next.127" + "@webex/plugin-memberships": "npm:3.12.0-next.47" + "@webex/plugin-messages": "npm:3.12.0-next.47" + "@webex/plugin-people": "npm:3.12.0-next.45" + "@webex/plugin-rooms": "npm:3.12.0-next.47" + "@webex/plugin-team-memberships": "npm:3.12.0-next.41" + "@webex/plugin-teams": "npm:3.12.0-next.41" + "@webex/plugin-webhooks": "npm:3.12.0-next.41" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.41" + "@webex/webex-core": "npm:3.12.0-next.41" lodash: "npm:^4.17.21" safe-buffer: "npm:^5.2.0" - checksum: 10c0/2498002ee3e82e639209852eabb942bdabfae305d63e9cc0f5d3b2a015110c18f1e8edd198a6b095a7058eac96c947ee8df8d997d8d8434334ea0fa066be57c3 + checksum: 10c0/d15212ca96b07f6f682ec87a6d64861e7725d56593997284c189d0dc365470696f56e07218a32f1da13196b5510363af1856d4b76d342739ccc8d55fa4bd1098 languageName: node linkType: hard