@coventia/softphone 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/{call-BTYlT2SU.d.cts → call-DUVnDxsz.d.cts} +152 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-DUVnDxsz.d.ts} +152 -5
  3. package/dist/{chunk-N2N2EQQJ.js → chunk-7R6LOOAI.js} +4 -2
  4. package/dist/chunk-7R6LOOAI.js.map +1 -0
  5. package/dist/{chunk-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  6. package/dist/chunk-LMJXX4YM.js.map +1 -0
  7. package/dist/{chunk-5BGEHMPP.js → chunk-YWI6EQYS.js} +40 -7
  8. package/dist/chunk-YWI6EQYS.js.map +1 -0
  9. package/dist/element/index.d.ts +154 -5
  10. package/dist/element/index.js +512 -152
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-CDjkrXHY.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-EPCKf-Bl.d.ts} +1 -1
  14. package/dist/index.cjs +396 -14
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +5 -5
  17. package/dist/index.d.ts +5 -5
  18. package/dist/index.js +256 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/react/index.cjs +15 -0
  21. package/dist/react/index.cjs.map +1 -1
  22. package/dist/react/index.d.cts +14 -3
  23. package/dist/react/index.d.ts +14 -3
  24. package/dist/react/index.js +2 -2
  25. package/dist/{softphone-DhuhcKCy.d.cts → softphone-BtdgZWkm.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DHG9MTNg.d.cts} +17 -2
  27. package/dist/softphone.css +395 -1
  28. package/dist/{sources-N4BOEDRL.js → sources-5L2R4HTV.js} +104 -5
  29. package/dist/sources-5L2R4HTV.js.map +1 -0
  30. package/dist/testing/index.cjs +41 -5
  31. package/dist/testing/index.cjs.map +1 -1
  32. package/dist/testing/index.d.cts +8 -2
  33. package/dist/testing/index.d.ts +8 -2
  34. package/dist/testing/index.js +41 -5
  35. package/dist/testing/index.js.map +1 -1
  36. package/dist/ui/index.cjs +490 -139
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +2 -2
  39. package/dist/ui/index.d.ts +2 -2
  40. package/dist/ui/index.js +478 -141
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +38 -5
  43. package/dist/zadarma/index.cjs.map +1 -1
  44. package/dist/zadarma/index.d.cts +2 -2
  45. package/dist/zadarma/index.d.ts +2 -2
  46. package/dist/zadarma/index.js +1 -1
  47. package/dist/zadarma-engine-4VOPIBXG.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-4VOPIBXG.js.map} +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-3V2L6UTK.js.map +0 -1
  51. package/dist/chunk-5BGEHMPP.js.map +0 -1
  52. package/dist/chunk-N2N2EQQJ.js.map +0 -1
  53. package/dist/sources-N4BOEDRL.js.map +0 -1
  54. package/dist/zadarma-engine-DN2SZSQ3.js +0 -5
@@ -117,6 +117,19 @@ interface CoventiaBackendConfig {
117
117
  * (SPEC §6.6).
118
118
  */
119
119
  readonly contacts?: ContactsSource;
120
+ /**
121
+ * Transfer outcomes, overriding Coventia's own polling — for a `MockProvider`-style test double, or
122
+ * a future non-Zadarma provider that computes outcomes differently. Not a per-partner customisation
123
+ * point the way `contacts` is: unlike a contact list, a transfer outcome is not a partner's own
124
+ * data, so there is normally no reason to set this (`docs/specs/transfer-outcome-relay.md`, Phase A).
125
+ */
126
+ readonly transferOutcomes?: TransferOutcomeSource;
127
+ /**
128
+ * Live agent presence, overriding Coventia's own WebSocket — for a `MockProvider`-style test
129
+ * double, or a host with its own presence transport. Not a per-partner customisation point the way
130
+ * `contacts` is (`docs/specs/transfer-presence-picker.md`, Phase B).
131
+ */
132
+ readonly presence?: PresenceSource;
120
133
  }
121
134
  interface SoftphoneConfig {
122
135
  readonly tokenSource: TokenSource;
@@ -261,6 +274,98 @@ interface AgentsDirectory {
261
274
  readonly me: PhoneAgent | null;
262
275
  readonly people: readonly PhoneAgent[];
263
276
  }
277
+ /**
278
+ * `'connected'` | `'not-connected'` — whether an attended transfer's second ring, after `Completar`,
279
+ * actually reached the target. Kebab-case to match `HangupReason` (`'no-answer'`, `'transferred'`);
280
+ * the backend's own wire value stays `not_connected` — this is the one place that gets translated.
281
+ */
282
+ type TransferOutcomeStatus = 'connected' | 'not-connected';
283
+ /** One resolved transfer, as `GET /phone/transfers/outcomes` reports it. */
284
+ interface TransferOutcomeEntry {
285
+ /** Opaque, strictly increasing per organization, never re-issued. The cursor for the NEXT poll. */
286
+ readonly id: string;
287
+ /** The extension the call was transferred to. */
288
+ readonly target: string;
289
+ readonly status: TransferOutcomeStatus;
290
+ readonly at: Date;
291
+ }
292
+ interface TransferOutcomesPage {
293
+ /** The newest `id` currently retained, or `'0'` when there are none — pass back as `since`. */
294
+ readonly cursor: string;
295
+ readonly outcomes: readonly TransferOutcomeEntry[];
296
+ }
297
+ /**
298
+ * Poll for transfer outcomes. `since` omitted ⇒ everything currently retained — used for the FIRST
299
+ * poll after starting a watch, which exists only to learn the current cursor (spec §5.3's
300
+ * no-mis-attribution rule: that first poll's own outcomes are discarded, only `cursor` is kept).
301
+ */
302
+ type TransferOutcomeSource = (params: {
303
+ readonly since?: string;
304
+ }) => Promise<TransferOutcomesPage>;
305
+ /**
306
+ * `SoftphoneEvent.TransferOutcome`'s payload. Unlike `TransferOutcomeEntry`, this never carries `id`
307
+ * (a polling-internal concern) and adds `caller` — the backend never stores a caller number (spec
308
+ * §5.3: "no caller number, nothing about other users"), so this is filled in locally, from the `Call`
309
+ * that was transferred, at the moment an outcome is matched to it.
310
+ */
311
+ interface TransferOutcomeEvent {
312
+ readonly status: TransferOutcomeStatus;
313
+ readonly target: string;
314
+ /** The original caller's number, for "Llamar de vuelta" — null if it was never known. */
315
+ readonly caller: string | null;
316
+ readonly at: Date;
317
+ }
318
+ /**
319
+ * `'disponible'` | `'en_llamada'` | `'desconectado'` — Spanish because this is what the picker shows,
320
+ * not an internal code (spec §4.1). `derivePresence` (the backend's own reducer, mirrored nowhere in
321
+ * the SDK — the SDK only ever receives an already-derived state, never `onCall`/`onlineFresh` apart).
322
+ */
323
+ type PresenceState = 'disponible' | 'en_llamada' | 'desconectado';
324
+ /** One org member with a phone extension, as the picker lists them. */
325
+ interface PresenceEntry {
326
+ readonly userId: string;
327
+ readonly extension: string;
328
+ /** `null` when the backend has no name to offer — the picker falls back to the extension. */
329
+ readonly name: string | null;
330
+ readonly state: PresenceState;
331
+ }
332
+ /** What a live presence connection hands back once open. */
333
+ interface PresenceConnection {
334
+ /**
335
+ * Report this softphone's own on-call-ness, so a colleague transferring TO this agent sees it
336
+ * without a round trip through the webhook path (spec §4.1's `onCall` OR-combine). Calling this
337
+ * before the connection is genuinely open is never required of an implementation — a caller (see
338
+ * `presence-socket.ts`) that reports early is expected to wait for the connection to exist first.
339
+ */
340
+ readonly reportCallState: (onCall: boolean) => void;
341
+ /**
342
+ * Ends this connection deliberately. Never causes the SAME callbacks' `onClose` to fire — that is
343
+ * reserved for the connection ending on its OWN (a network drop, a server-side close, a rejected
344
+ * ticket), which is the only thing a caller should ever treat as "reconnect now".
345
+ */
346
+ readonly close: () => void;
347
+ }
348
+ interface PresenceCallbacks {
349
+ /** Once, right after connecting — the full current roster (spec §4.3). */
350
+ readonly onSnapshot: (agents: readonly PresenceEntry[]) => void;
351
+ /** Zero or more times after that — one agent whose state just changed. */
352
+ readonly onDelta: (agent: PresenceEntry) => void;
353
+ /**
354
+ * The connection ended on its own. Never called after the CALLER's own `close()` — see
355
+ * `PresenceConnection.close`'s own note. The only signal a watch layer needs to know it must
356
+ * reconnect, deliberately carrying no reason: a dropped connection and a rejected ticket look
357
+ * identical from here, and both get the same treatment (spec §4.4 — quiet retry, nothing surfaced).
358
+ */
359
+ readonly onClose: () => void;
360
+ }
361
+ /**
362
+ * Opens one live presence connection. Coventia-backed by default (mints a one-time ticket, opens
363
+ * `wss://…/phone/presence`); a host may override it entirely, or omit it — omitted, or while the
364
+ * connection is down, the picker simply has no live data to show (spec §4.4, success criterion #5:
365
+ * no error surfaces). Unlike `TransferOutcomeSource` (poll, get a page, done), this is push-based and
366
+ * long-lived — the returned promise resolves once, when the connection opens, not once per update.
367
+ */
368
+ type PresenceSource = (callbacks: PresenceCallbacks) => Promise<PresenceConnection>;
264
369
 
265
370
  /**
266
371
  * The error taxonomy.
@@ -456,7 +561,23 @@ declare enum SoftphoneEvent {
456
561
  * `StateChanged`: `state` is reachability, this is whether this tab is even allowed to be
457
562
  * reachable at all while another tab holding the same extension stays open.
458
563
  */
459
- LeaderChanged = "leaderChanged"
564
+ LeaderChanged = "leaderChanged",
565
+ /**
566
+ * `Completar` completed a transfer, and the target's SECOND ring either connected or didn't —
567
+ * `docs/specs/transfer-outcome-relay.md` (Phase A). Fires for BOTH outcomes so a host can react to
568
+ * either; only `'not-connected'` is worth surfacing to the agent (spec §5.5) — roughly a minute
569
+ * after the transfer for a target who never answers, seconds for a decline or the caller hanging up
570
+ * first. Requires a `TransferOutcomeSource` (from `coventia`/`apiBase`, or a host override); never
571
+ * fires at all when none is configured, since there is then nothing to poll.
572
+ */
573
+ TransferOutcome = "transferOutcome",
574
+ /**
575
+ * `softphone.presence` changed — the live agent-presence list (Phase B,
576
+ * `docs/specs/transfer-presence-picker.md`) gained a snapshot or an updated entry. Requires a
577
+ * `PresenceSource` (from `coventia`/`apiBase`, or a host override) AND this tab currently being
578
+ * `online && isLeader`; never fires at all otherwise, same reasoning as `TransferOutcome`.
579
+ */
580
+ PresenceChanged = "presenceChanged"
460
581
  }
461
582
  interface SoftphoneEventMap {
462
583
  [SoftphoneEvent.StateChanged]: {
@@ -473,6 +594,10 @@ interface SoftphoneEventMap {
473
594
  [SoftphoneEvent.LeaderChanged]: {
474
595
  readonly isLeader: boolean;
475
596
  };
597
+ [SoftphoneEvent.TransferOutcome]: TransferOutcomeEvent;
598
+ [SoftphoneEvent.PresenceChanged]: {
599
+ readonly agents: readonly PresenceEntry[];
600
+ };
476
601
  }
477
602
  declare enum CallEvent {
478
603
  /** Any transition. The catch-all; the specific events below are conveniences. */
@@ -663,18 +788,40 @@ interface Call extends TypedEmitter<CallEventMap> {
663
788
  hold(): Promise<void>;
664
789
  unhold(): Promise<void>;
665
790
  sendDigits(digits: string): void;
666
- /** Blind transfer (SIP REFER). Ends this call. */
791
+ /**
792
+ * Blind transfer (SIP REFER). Ends this call — but **whether the transfer itself succeeded or
793
+ * failed is unknowable from here**: against Zadarma, a REFER's outcome is not reported back to the
794
+ * browser at all (confirmed on real calls — `docs/specs/transfer-availability.md` §2), so a REFER to
795
+ * an unreachable target ends this call exactly the same way a successful one does. Prefer
796
+ * `startConsult`/`completeTransfer` (below) when the caller must not be put at risk — that path
797
+ * correctly reports busy/no-answer/failed because the consult leg is an ordinary outbound call, not
798
+ * a REFER.
799
+ */
667
800
  transfer(target: string): Promise<void>;
668
801
  /**
669
802
  * Attended transfer, part 1: call the target while holding this call.
670
803
  *
671
- * Returns the consult leg — itself a Call, with its own state machine and events.
804
+ * Returns the consult leg — itself a Call, with its own state machine and events. Unlike blind
805
+ * `transfer()`, a consult leg that goes unanswered reports a real reason (busy/no-answer/rejected/
806
+ * failed) and never puts this call at risk — see `docs/specs/transfer-availability.md` §5.
672
807
  */
673
808
  startConsult(target: string): Promise<Call>;
674
- /** Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces) */
809
+ /**
810
+ * Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces)
811
+ *
812
+ * **Confirmed non-functional against Zadarma, on real calls, three times
813
+ * (`docs/specs/transfer-availability.md` §12.3).** Zadarma accepts the REFER…Replaces but never
814
+ * actually bridges the two legs — the consult leg is silently orphaned and the caller is lost, with
815
+ * no error thrown here to signal it. The pattern that DOES work (the same one `/ui`'s own
816
+ * `ConsultScreen` uses): hang up the consult leg, then call `transfer(target)` on THIS call, to the
817
+ * same already-confirmed-answering target — a blind transfer to a target already known to be there
818
+ * works, even though `completeTransfer()` itself does not. Left in the type for hosts against a
819
+ * different (non-Zadarma) SIP provider where REFER…Replaces may work as specified; do not reach for
820
+ * it against Zadarma.
821
+ */
675
822
  completeTransfer(): Promise<void>;
676
823
  /** Abandon the consult and resume this call. */
677
824
  cancelConsult(): Promise<void>;
678
825
  }
679
826
 
680
- export { type AgentsDirectory as A, CALL_TRANSITIONS as C, ERROR_CATALOGUE as E, type FatalErrorCode as F, type HangupReason as H, INITIAL_STATES as I, type LogLevel as L, type NonFatalErrorCode as N, type RemoteParty as R, type SoftphoneConfig as S, type TokenEndpointOptions as T, type VoiceToken as V, type Call as a, type CallDirection as b, CallEvent as c, type CallEventMap as d, type CallOptions as e, type CallState as f, type Contact as g, type FatalSoftphoneError as h, type HoldOriginator as i, type NonFatalSoftphoneError as j, type SoftphoneError as k, type SoftphoneErrorCode as l, SoftphoneEvent as m, type SoftphoneEventMap as n, type SoftphoneState as o, type TokenSource as p, type TypedEmitter as q, canTransition as r, isFatal as s, isTerminal as t, type ContactsSource as u, type HistorySource as v, type CallHistoryEntry as w, type ContactsPage as x };
827
+ export { type AgentsDirectory as A, CALL_TRANSITIONS as C, ERROR_CATALOGUE as E, type FatalErrorCode as F, type HangupReason as H, INITIAL_STATES as I, type LogLevel as L, type NonFatalErrorCode as N, type PresenceEntry as P, type RemoteParty as R, type SoftphoneConfig as S, type TokenEndpointOptions as T, type VoiceToken as V, type Call as a, type CallDirection as b, CallEvent as c, type CallEventMap as d, type CallOptions as e, type CallState as f, type Contact as g, type FatalSoftphoneError as h, type HoldOriginator as i, type NonFatalSoftphoneError as j, type SoftphoneError as k, type SoftphoneErrorCode as l, SoftphoneEvent as m, type SoftphoneEventMap as n, type SoftphoneState as o, type TokenSource as p, type TypedEmitter as q, canTransition as r, isFatal as s, isTerminal as t, type ContactsSource as u, type HistorySource as v, type TransferOutcomesPage as w, type PresenceSource as x, type CallHistoryEntry as y, type ContactsPage as z };
@@ -117,6 +117,19 @@ interface CoventiaBackendConfig {
117
117
  * (SPEC §6.6).
118
118
  */
119
119
  readonly contacts?: ContactsSource;
120
+ /**
121
+ * Transfer outcomes, overriding Coventia's own polling — for a `MockProvider`-style test double, or
122
+ * a future non-Zadarma provider that computes outcomes differently. Not a per-partner customisation
123
+ * point the way `contacts` is: unlike a contact list, a transfer outcome is not a partner's own
124
+ * data, so there is normally no reason to set this (`docs/specs/transfer-outcome-relay.md`, Phase A).
125
+ */
126
+ readonly transferOutcomes?: TransferOutcomeSource;
127
+ /**
128
+ * Live agent presence, overriding Coventia's own WebSocket — for a `MockProvider`-style test
129
+ * double, or a host with its own presence transport. Not a per-partner customisation point the way
130
+ * `contacts` is (`docs/specs/transfer-presence-picker.md`, Phase B).
131
+ */
132
+ readonly presence?: PresenceSource;
120
133
  }
121
134
  interface SoftphoneConfig {
122
135
  readonly tokenSource: TokenSource;
@@ -261,6 +274,98 @@ interface AgentsDirectory {
261
274
  readonly me: PhoneAgent | null;
262
275
  readonly people: readonly PhoneAgent[];
263
276
  }
277
+ /**
278
+ * `'connected'` | `'not-connected'` — whether an attended transfer's second ring, after `Completar`,
279
+ * actually reached the target. Kebab-case to match `HangupReason` (`'no-answer'`, `'transferred'`);
280
+ * the backend's own wire value stays `not_connected` — this is the one place that gets translated.
281
+ */
282
+ type TransferOutcomeStatus = 'connected' | 'not-connected';
283
+ /** One resolved transfer, as `GET /phone/transfers/outcomes` reports it. */
284
+ interface TransferOutcomeEntry {
285
+ /** Opaque, strictly increasing per organization, never re-issued. The cursor for the NEXT poll. */
286
+ readonly id: string;
287
+ /** The extension the call was transferred to. */
288
+ readonly target: string;
289
+ readonly status: TransferOutcomeStatus;
290
+ readonly at: Date;
291
+ }
292
+ interface TransferOutcomesPage {
293
+ /** The newest `id` currently retained, or `'0'` when there are none — pass back as `since`. */
294
+ readonly cursor: string;
295
+ readonly outcomes: readonly TransferOutcomeEntry[];
296
+ }
297
+ /**
298
+ * Poll for transfer outcomes. `since` omitted ⇒ everything currently retained — used for the FIRST
299
+ * poll after starting a watch, which exists only to learn the current cursor (spec §5.3's
300
+ * no-mis-attribution rule: that first poll's own outcomes are discarded, only `cursor` is kept).
301
+ */
302
+ type TransferOutcomeSource = (params: {
303
+ readonly since?: string;
304
+ }) => Promise<TransferOutcomesPage>;
305
+ /**
306
+ * `SoftphoneEvent.TransferOutcome`'s payload. Unlike `TransferOutcomeEntry`, this never carries `id`
307
+ * (a polling-internal concern) and adds `caller` — the backend never stores a caller number (spec
308
+ * §5.3: "no caller number, nothing about other users"), so this is filled in locally, from the `Call`
309
+ * that was transferred, at the moment an outcome is matched to it.
310
+ */
311
+ interface TransferOutcomeEvent {
312
+ readonly status: TransferOutcomeStatus;
313
+ readonly target: string;
314
+ /** The original caller's number, for "Llamar de vuelta" — null if it was never known. */
315
+ readonly caller: string | null;
316
+ readonly at: Date;
317
+ }
318
+ /**
319
+ * `'disponible'` | `'en_llamada'` | `'desconectado'` — Spanish because this is what the picker shows,
320
+ * not an internal code (spec §4.1). `derivePresence` (the backend's own reducer, mirrored nowhere in
321
+ * the SDK — the SDK only ever receives an already-derived state, never `onCall`/`onlineFresh` apart).
322
+ */
323
+ type PresenceState = 'disponible' | 'en_llamada' | 'desconectado';
324
+ /** One org member with a phone extension, as the picker lists them. */
325
+ interface PresenceEntry {
326
+ readonly userId: string;
327
+ readonly extension: string;
328
+ /** `null` when the backend has no name to offer — the picker falls back to the extension. */
329
+ readonly name: string | null;
330
+ readonly state: PresenceState;
331
+ }
332
+ /** What a live presence connection hands back once open. */
333
+ interface PresenceConnection {
334
+ /**
335
+ * Report this softphone's own on-call-ness, so a colleague transferring TO this agent sees it
336
+ * without a round trip through the webhook path (spec §4.1's `onCall` OR-combine). Calling this
337
+ * before the connection is genuinely open is never required of an implementation — a caller (see
338
+ * `presence-socket.ts`) that reports early is expected to wait for the connection to exist first.
339
+ */
340
+ readonly reportCallState: (onCall: boolean) => void;
341
+ /**
342
+ * Ends this connection deliberately. Never causes the SAME callbacks' `onClose` to fire — that is
343
+ * reserved for the connection ending on its OWN (a network drop, a server-side close, a rejected
344
+ * ticket), which is the only thing a caller should ever treat as "reconnect now".
345
+ */
346
+ readonly close: () => void;
347
+ }
348
+ interface PresenceCallbacks {
349
+ /** Once, right after connecting — the full current roster (spec §4.3). */
350
+ readonly onSnapshot: (agents: readonly PresenceEntry[]) => void;
351
+ /** Zero or more times after that — one agent whose state just changed. */
352
+ readonly onDelta: (agent: PresenceEntry) => void;
353
+ /**
354
+ * The connection ended on its own. Never called after the CALLER's own `close()` — see
355
+ * `PresenceConnection.close`'s own note. The only signal a watch layer needs to know it must
356
+ * reconnect, deliberately carrying no reason: a dropped connection and a rejected ticket look
357
+ * identical from here, and both get the same treatment (spec §4.4 — quiet retry, nothing surfaced).
358
+ */
359
+ readonly onClose: () => void;
360
+ }
361
+ /**
362
+ * Opens one live presence connection. Coventia-backed by default (mints a one-time ticket, opens
363
+ * `wss://…/phone/presence`); a host may override it entirely, or omit it — omitted, or while the
364
+ * connection is down, the picker simply has no live data to show (spec §4.4, success criterion #5:
365
+ * no error surfaces). Unlike `TransferOutcomeSource` (poll, get a page, done), this is push-based and
366
+ * long-lived — the returned promise resolves once, when the connection opens, not once per update.
367
+ */
368
+ type PresenceSource = (callbacks: PresenceCallbacks) => Promise<PresenceConnection>;
264
369
 
265
370
  /**
266
371
  * The error taxonomy.
@@ -456,7 +561,23 @@ declare enum SoftphoneEvent {
456
561
  * `StateChanged`: `state` is reachability, this is whether this tab is even allowed to be
457
562
  * reachable at all while another tab holding the same extension stays open.
458
563
  */
459
- LeaderChanged = "leaderChanged"
564
+ LeaderChanged = "leaderChanged",
565
+ /**
566
+ * `Completar` completed a transfer, and the target's SECOND ring either connected or didn't —
567
+ * `docs/specs/transfer-outcome-relay.md` (Phase A). Fires for BOTH outcomes so a host can react to
568
+ * either; only `'not-connected'` is worth surfacing to the agent (spec §5.5) — roughly a minute
569
+ * after the transfer for a target who never answers, seconds for a decline or the caller hanging up
570
+ * first. Requires a `TransferOutcomeSource` (from `coventia`/`apiBase`, or a host override); never
571
+ * fires at all when none is configured, since there is then nothing to poll.
572
+ */
573
+ TransferOutcome = "transferOutcome",
574
+ /**
575
+ * `softphone.presence` changed — the live agent-presence list (Phase B,
576
+ * `docs/specs/transfer-presence-picker.md`) gained a snapshot or an updated entry. Requires a
577
+ * `PresenceSource` (from `coventia`/`apiBase`, or a host override) AND this tab currently being
578
+ * `online && isLeader`; never fires at all otherwise, same reasoning as `TransferOutcome`.
579
+ */
580
+ PresenceChanged = "presenceChanged"
460
581
  }
461
582
  interface SoftphoneEventMap {
462
583
  [SoftphoneEvent.StateChanged]: {
@@ -473,6 +594,10 @@ interface SoftphoneEventMap {
473
594
  [SoftphoneEvent.LeaderChanged]: {
474
595
  readonly isLeader: boolean;
475
596
  };
597
+ [SoftphoneEvent.TransferOutcome]: TransferOutcomeEvent;
598
+ [SoftphoneEvent.PresenceChanged]: {
599
+ readonly agents: readonly PresenceEntry[];
600
+ };
476
601
  }
477
602
  declare enum CallEvent {
478
603
  /** Any transition. The catch-all; the specific events below are conveniences. */
@@ -663,18 +788,40 @@ interface Call extends TypedEmitter<CallEventMap> {
663
788
  hold(): Promise<void>;
664
789
  unhold(): Promise<void>;
665
790
  sendDigits(digits: string): void;
666
- /** Blind transfer (SIP REFER). Ends this call. */
791
+ /**
792
+ * Blind transfer (SIP REFER). Ends this call — but **whether the transfer itself succeeded or
793
+ * failed is unknowable from here**: against Zadarma, a REFER's outcome is not reported back to the
794
+ * browser at all (confirmed on real calls — `docs/specs/transfer-availability.md` §2), so a REFER to
795
+ * an unreachable target ends this call exactly the same way a successful one does. Prefer
796
+ * `startConsult`/`completeTransfer` (below) when the caller must not be put at risk — that path
797
+ * correctly reports busy/no-answer/failed because the consult leg is an ordinary outbound call, not
798
+ * a REFER.
799
+ */
667
800
  transfer(target: string): Promise<void>;
668
801
  /**
669
802
  * Attended transfer, part 1: call the target while holding this call.
670
803
  *
671
- * Returns the consult leg — itself a Call, with its own state machine and events.
804
+ * Returns the consult leg — itself a Call, with its own state machine and events. Unlike blind
805
+ * `transfer()`, a consult leg that goes unanswered reports a real reason (busy/no-answer/rejected/
806
+ * failed) and never puts this call at risk — see `docs/specs/transfer-availability.md` §5.
672
807
  */
673
808
  startConsult(target: string): Promise<Call>;
674
- /** Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces) */
809
+ /**
810
+ * Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces)
811
+ *
812
+ * **Confirmed non-functional against Zadarma, on real calls, three times
813
+ * (`docs/specs/transfer-availability.md` §12.3).** Zadarma accepts the REFER…Replaces but never
814
+ * actually bridges the two legs — the consult leg is silently orphaned and the caller is lost, with
815
+ * no error thrown here to signal it. The pattern that DOES work (the same one `/ui`'s own
816
+ * `ConsultScreen` uses): hang up the consult leg, then call `transfer(target)` on THIS call, to the
817
+ * same already-confirmed-answering target — a blind transfer to a target already known to be there
818
+ * works, even though `completeTransfer()` itself does not. Left in the type for hosts against a
819
+ * different (non-Zadarma) SIP provider where REFER…Replaces may work as specified; do not reach for
820
+ * it against Zadarma.
821
+ */
675
822
  completeTransfer(): Promise<void>;
676
823
  /** Abandon the consult and resume this call. */
677
824
  cancelConsult(): Promise<void>;
678
825
  }
679
826
 
680
- export { type AgentsDirectory as A, CALL_TRANSITIONS as C, ERROR_CATALOGUE as E, type FatalErrorCode as F, type HangupReason as H, INITIAL_STATES as I, type LogLevel as L, type NonFatalErrorCode as N, type RemoteParty as R, type SoftphoneConfig as S, type TokenEndpointOptions as T, type VoiceToken as V, type Call as a, type CallDirection as b, CallEvent as c, type CallEventMap as d, type CallOptions as e, type CallState as f, type Contact as g, type FatalSoftphoneError as h, type HoldOriginator as i, type NonFatalSoftphoneError as j, type SoftphoneError as k, type SoftphoneErrorCode as l, SoftphoneEvent as m, type SoftphoneEventMap as n, type SoftphoneState as o, type TokenSource as p, type TypedEmitter as q, canTransition as r, isFatal as s, isTerminal as t, type ContactsSource as u, type HistorySource as v, type CallHistoryEntry as w, type ContactsPage as x };
827
+ export { type AgentsDirectory as A, CALL_TRANSITIONS as C, ERROR_CATALOGUE as E, type FatalErrorCode as F, type HangupReason as H, INITIAL_STATES as I, type LogLevel as L, type NonFatalErrorCode as N, type PresenceEntry as P, type RemoteParty as R, type SoftphoneConfig as S, type TokenEndpointOptions as T, type VoiceToken as V, type Call as a, type CallDirection as b, CallEvent as c, type CallEventMap as d, type CallOptions as e, type CallState as f, type Contact as g, type FatalSoftphoneError as h, type HoldOriginator as i, type NonFatalSoftphoneError as j, type SoftphoneError as k, type SoftphoneErrorCode as l, SoftphoneEvent as m, type SoftphoneEventMap as n, type SoftphoneState as o, type TokenSource as p, type TypedEmitter as q, canTransition as r, isFatal as s, isTerminal as t, type ContactsSource as u, type HistorySource as v, type TransferOutcomesPage as w, type PresenceSource as x, type CallHistoryEntry as y, type ContactsPage as z };
@@ -7,6 +7,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
7
7
  SoftphoneEvent2["DeviceChange"] = "deviceChange";
8
8
  SoftphoneEvent2["Error"] = "error";
9
9
  SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
10
+ SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
11
+ SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
10
12
  return SoftphoneEvent2;
11
13
  })(SoftphoneEvent || {});
12
14
  var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
@@ -25,5 +27,5 @@ var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
25
27
  })(CallEvent || {});
26
28
 
27
29
  export { CallEvent, SoftphoneEvent };
28
- //# sourceMappingURL=chunk-N2N2EQQJ.js.map
29
- //# sourceMappingURL=chunk-N2N2EQQJ.js.map
30
+ //# sourceMappingURL=chunk-7R6LOOAI.js.map
31
+ //# sourceMappingURL=chunk-7R6LOOAI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/events.ts"],"names":["SoftphoneEvent","CallEvent"],"mappings":";AAcO,IAAK,cAAA,qBAAAA,eAAAA,KAAL;AAEL,EAAAA,gBAAA,cAAA,CAAA,GAAe,cAAA;AAEf,EAAAA,gBAAA,cAAA,CAAA,GAAe,cAAA;AAMf,EAAAA,gBAAA,mBAAA,CAAA,GAAoB,mBAAA;AAQpB,EAAAA,gBAAA,iBAAA,CAAA,GAAkB,iBAAA;AAOlB,EAAAA,gBAAA,cAAA,CAAA,GAAe,cAAA;AAEf,EAAAA,gBAAA,OAAA,CAAA,GAAQ,OAAA;AAMR,EAAAA,gBAAA,eAAA,CAAA,GAAgB,eAAA;AAShB,EAAAA,gBAAA,iBAAA,CAAA,GAAkB,iBAAA;AAOlB,EAAAA,gBAAA,iBAAA,CAAA,GAAkB,iBAAA;AAjDR,EAAA,OAAAA,eAAAA;AAAA,CAAA,EAAA,cAAA,IAAA,EAAA;AAgEL,IAAK,SAAA,qBAAAC,UAAAA,KAAL;AAEL,EAAAA,WAAA,cAAA,CAAA,GAAe,cAAA;AAEf,EAAAA,WAAA,UAAA,CAAA,GAAW,UAAA;AAEX,EAAAA,WAAA,OAAA,CAAA,GAAQ,OAAA;AACR,EAAAA,WAAA,OAAA,CAAA,GAAQ,OAAA;AAER,EAAAA,WAAA,MAAA,CAAA,GAAO,MAAA;AACP,EAAAA,WAAA,QAAA,CAAA,GAAS,QAAA;AAET,EAAAA,WAAA,cAAA,CAAA,GAAe,cAAA;AACf,EAAAA,WAAA,aAAA,CAAA,GAAc,aAAA;AACd,EAAAA,WAAA,YAAA,CAAA,GAAa,YAAA;AAEb,EAAAA,WAAA,oBAAA,CAAA,GAAqB,oBAAA;AAMrB,EAAAA,WAAA,cAAA,CAAA,GAAe,cAAA;AAtBL,EAAA,OAAAA,UAAAA;AAAA,CAAA,EAAA,SAAA,IAAA,EAAA","file":"chunk-7R6LOOAI.js","sourcesContent":["import type { Call, CallState, HangupReason, HoldOriginator } from './call.js';\r\nimport type { SoftphoneError } from './errors.js';\r\nimport type { PresenceEntry, SoftphoneState, TransferOutcomeEvent } from './types.js';\r\n\r\n/**\r\n * Typed events.\r\n *\r\n * Enums rather than magic strings: autocomplete, and rename-safety. Daily's magic strings are the\r\n * counter-example — a typo in `'participant-joined'` is a silent no-op forever.\r\n *\r\n * Each map narrows the listener payload PER EVENT, which is the thing that would have been painful\r\n * to express in JSDoc and is a large part of why this package is TypeScript (SPEC §2.2).\r\n */\r\n\r\nexport enum SoftphoneEvent {\r\n /** Lifecycle. `online` = reachable, NOT SIP-registered — see SoftphoneState. */\r\n StateChanged = 'stateChanged',\r\n /** An INVITE landed. The Call is `ringing`; answer or reject it. */\r\n IncomingCall = 'incomingCall',\r\n /**\r\n * The active call changed — a call began (inbound OR outbound) or ended. Fires for both\r\n * directions, unlike IncomingCall. This is what a UI binds to in order to track \"the current call\"\r\n * without threading the Call through every component.\r\n */\r\n ActiveCallChanged = 'activeCallChanged',\r\n /**\r\n * The browser refused to play audio (autoplay policy).\r\n *\r\n * This is the single most under-documented failure in the industry and it is how calls get MISSED:\r\n * a blocked ringtone rings silently. Surface a button and call `startAudio()` from its click\r\n * handler. Only Twilio and LiveKit document it properly.\r\n */\r\n PlaybackBlocked = 'playbackBlocked',\r\n /**\r\n * `softphone.audio` changed: a device was plugged/unplugged (`inputs`/`outputs`), or the selected\r\n * input/output changed (`currentInputId`/`currentOutputId`). One event for all of it — re-read\r\n * `softphone.audio` rather than inspecting the payload, which carries nothing (`useDevices` does\r\n * exactly this).\r\n */\r\n DeviceChange = 'deviceChange',\r\n /** Fatal or non-fatal — narrow on `error.fatal`. */\r\n Error = 'error',\r\n /**\r\n * This tab's leadership changed (`multi-tab-leader-election.md`) — a DIFFERENT axis from\r\n * `StateChanged`: `state` is reachability, this is whether this tab is even allowed to be\r\n * reachable at all while another tab holding the same extension stays open.\r\n */\r\n LeaderChanged = 'leaderChanged',\r\n /**\r\n * `Completar` completed a transfer, and the target's SECOND ring either connected or didn't —\r\n * `docs/specs/transfer-outcome-relay.md` (Phase A). Fires for BOTH outcomes so a host can react to\r\n * either; only `'not-connected'` is worth surfacing to the agent (spec §5.5) — roughly a minute\r\n * after the transfer for a target who never answers, seconds for a decline or the caller hanging up\r\n * first. Requires a `TransferOutcomeSource` (from `coventia`/`apiBase`, or a host override); never\r\n * fires at all when none is configured, since there is then nothing to poll.\r\n */\r\n TransferOutcome = 'transferOutcome',\r\n /**\r\n * `softphone.presence` changed — the live agent-presence list (Phase B,\r\n * `docs/specs/transfer-presence-picker.md`) gained a snapshot or an updated entry. Requires a\r\n * `PresenceSource` (from `coventia`/`apiBase`, or a host override) AND this tab currently being\r\n * `online && isLeader`; never fires at all otherwise, same reasoning as `TransferOutcome`.\r\n */\r\n PresenceChanged = 'presenceChanged',\r\n}\r\n\r\nexport interface SoftphoneEventMap {\r\n [SoftphoneEvent.StateChanged]: { readonly state: SoftphoneState; readonly previous: SoftphoneState };\r\n [SoftphoneEvent.IncomingCall]: Call;\r\n [SoftphoneEvent.ActiveCallChanged]: { readonly call: Call | null };\r\n [SoftphoneEvent.PlaybackBlocked]: void;\r\n [SoftphoneEvent.DeviceChange]: void;\r\n [SoftphoneEvent.Error]: SoftphoneError;\r\n [SoftphoneEvent.LeaderChanged]: { readonly isLeader: boolean };\r\n [SoftphoneEvent.TransferOutcome]: TransferOutcomeEvent;\r\n [SoftphoneEvent.PresenceChanged]: { readonly agents: readonly PresenceEntry[] };\r\n}\r\n\r\nexport enum CallEvent {\r\n /** Any transition. The catch-all; the specific events below are conveniences. */\r\n StateChanged = 'stateChanged',\r\n /** Media is flowing. */\r\n Accepted = 'accepted',\r\n /** Terminal. Always fires exactly once, whatever the reason. */\r\n Ended = 'ended',\r\n Muted = 'muted',\r\n /** `originator: 'remote'` means the OTHER party held US — new capability; today's softphone is blind to it. */\r\n Held = 'held',\r\n Unheld = 'unheld',\r\n /** Media lost; an ICE restart is in flight. The call is NOT over — show a banner. */\r\n Reconnecting = 'reconnecting',\r\n Reconnected = 'reconnected',\r\n DigitsSent = 'digitsSent',\r\n /** The contact name resolved after the call was already ringing. */\r\n RemotePartyUpdated = 'remotePartyUpdated',\r\n /**\r\n * The remote audio stream is available. Attach it to an `<audio>` element and play it — this is\r\n * how the caller is actually HEARD. Core cannot do this itself (it must not touch `document` —\r\n * SPEC §10.2); the UI layer or the host owns the audio element.\r\n */\r\n RemoteStream = 'remoteStream',\r\n}\r\n\r\nexport interface CallEventMap {\r\n [CallEvent.StateChanged]: { readonly state: CallState; readonly previous: CallState };\r\n [CallEvent.Accepted]: void;\r\n [CallEvent.Ended]: { readonly reason: HangupReason; readonly error?: SoftphoneError };\r\n [CallEvent.Muted]: { readonly muted: boolean };\r\n [CallEvent.Held]: { readonly originator: HoldOriginator };\r\n [CallEvent.Unheld]: { readonly originator: HoldOriginator };\r\n [CallEvent.Reconnecting]: void;\r\n [CallEvent.Reconnected]: void;\r\n [CallEvent.DigitsSent]: { readonly digits: string };\r\n [CallEvent.RemotePartyUpdated]: void;\r\n [CallEvent.RemoteStream]: { readonly stream: MediaStream };\r\n}\r\n\r\n/**\r\n * `on()` returns an unsubscribe function.\r\n *\r\n * Deliberate: it makes the cleanup impossible to get wrong in a React effect\r\n * (`useEffect(() => call.on(...), [call])`) and it avoids Vonage's design, where `on()` returns an\r\n * opaque `symbol` you must retain in order to ever call `off()`.\r\n */\r\nexport interface TypedEmitter<M> {\r\n on<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;\r\n once<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;\r\n off<E extends keyof M>(event: E, listener: (payload: M[E]) => void): void;\r\n}\r\n"]}
@@ -51,6 +51,20 @@ function useActiveCall() {
51
51
  useCallback(() => phone.activeCall, [phone])
52
52
  );
53
53
  }
54
+ function usePresence(active) {
55
+ const phone = useSoftphone();
56
+ return useSyncExternalStore(
57
+ useCallback(
58
+ (cb) => {
59
+ if (!active) return () => {
60
+ };
61
+ return phone.on("presenceChanged" /* PresenceChanged */, () => cb());
62
+ },
63
+ [phone, active]
64
+ ),
65
+ useCallback(() => phone.presence, [phone])
66
+ );
67
+ }
54
68
  var CALL_EVENTS = [
55
69
  "stateChanged" /* StateChanged */,
56
70
  "muted" /* Muted */,
@@ -124,6 +138,6 @@ function useDevices() {
124
138
  return useSyncExternalStore(subscribe, getSnapshot);
125
139
  }
126
140
 
127
- export { SoftphoneProvider, useActiveCall, useCall, useDevices, useIsLeader, useLastError, useSoftphone, useSoftphoneState };
128
- //# sourceMappingURL=chunk-3V2L6UTK.js.map
129
- //# sourceMappingURL=chunk-3V2L6UTK.js.map
141
+ export { SoftphoneProvider, useActiveCall, useCall, useDevices, useIsLeader, useLastError, usePresence, useSoftphone, useSoftphoneState };
142
+ //# sourceMappingURL=chunk-LMJXX4YM.js.map
143
+ //# sourceMappingURL=chunk-LMJXX4YM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/context.tsx","../src/react/hooks.ts"],"names":[],"mappings":"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","file":"chunk-LMJXX4YM.js","sourcesContent":["import { createContext, useContext, type ReactNode } from 'react';\r\nimport type { Softphone } from '../core/softphone.js';\r\n\r\n/**\r\n * The provider takes an INSTANCE the host constructed — not config props.\r\n *\r\n * This is LiveKit's hard-won lesson (SPEC §5.1): they are deprecating their config-props\r\n * `<LiveKitRoom>` in favour of a provider that accepts an instance. Holding the `Softphone` above the\r\n * React tree lets the app own the connection, survive route changes and unmounts without dropping a\r\n * call, and unit-test the engine with no React at all. Config-props would rebuild the engine on\r\n * every prop change — catastrophic for something holding a live `RTCPeerConnection`.\r\n */\r\nconst SoftphoneContext = createContext<Softphone | null>(null);\r\n\r\nexport interface SoftphoneProviderProps {\r\n readonly softphone: Softphone;\r\n readonly children: ReactNode;\r\n}\r\n\r\nexport function SoftphoneProvider({ softphone, children }: SoftphoneProviderProps) {\r\n return <SoftphoneContext.Provider value={softphone}>{children}</SoftphoneContext.Provider>;\r\n}\r\n\r\n/** The `Softphone` instance from context. Stable — does not re-render on state changes. */\r\nexport function useSoftphone(): Softphone {\r\n const softphone = useContext(SoftphoneContext);\r\n if (!softphone) {\r\n throw new Error('useSoftphone() must be used within a <SoftphoneProvider>.');\r\n }\r\n return softphone;\r\n}\r\n","// Built-in, no shim: the peer floor is React 18 (SPEC §2.7), which ships useSyncExternalStore.\r\nimport { useCallback, useRef, useSyncExternalStore } from 'react';\r\nimport { useSoftphone } from './context.js';\r\nimport { SoftphoneEvent, CallEvent } from '../core/events.js';\r\nimport type { Call, CallState } from '../core/call.js';\r\nimport type { PresenceEntry, RemoteParty, SoftphoneState } from '../core/types.js';\r\nimport type { AudioDevices } from '../core/softphone.js';\r\nimport type { FatalSoftphoneError } from '../core/errors.js';\r\n\r\n/** Reactive softphone lifecycle state (`stopped` → `online` → …). */\r\n/**\r\n * The reason the phone cannot currently be used, or `null`.\r\n *\r\n * Reads `softphone.lastError`, which is STATE rather than only an event — the widget is usually\r\n * opened *after* something broke, and a listener mounted at that point would have missed the event\r\n * entirely and rendered a dialpad with no explanation.\r\n *\r\n * Re-reads on both channels because both can change it: `Error` sets it, and `StateChanged` clears it\r\n * on reaching `online`. Subscribing to only the first would leave a stale gate on screen after the\r\n * phone recovered.\r\n */\r\nexport function useLastError(): FatalSoftphoneError | null {\r\n const phone = useSoftphone();\r\n return useSyncExternalStore(\r\n useCallback(\r\n (cb: () => void) => {\r\n const offError = phone.on(SoftphoneEvent.Error, () => cb());\r\n const offState = phone.on(SoftphoneEvent.StateChanged, () => cb());\r\n return () => {\r\n offError();\r\n offState();\r\n };\r\n },\r\n [phone],\r\n ),\r\n useCallback(() => phone.lastError, [phone]),\r\n );\r\n}\r\n\r\nexport function useSoftphoneState(): SoftphoneState {\r\n const phone = useSoftphone();\r\n return useSyncExternalStore(\r\n useCallback((cb: () => void) => phone.on(SoftphoneEvent.StateChanged, () => cb()), [phone]),\r\n useCallback(() => phone.state, [phone]),\r\n );\r\n}\r\n\r\n/**\r\n * Whether THIS tab is the one allowed to be reachable (`multi-tab-leader-election.md`). `false` by\r\n * default and while waiting for another open tab to release it — show your own \"active in another\r\n * tab\" state for that duration; the `/ui` widget already does.\r\n */\r\nexport function useIsLeader(): boolean {\r\n const phone = useSoftphone();\r\n return useSyncExternalStore(\r\n useCallback((cb: () => void) => phone.on(SoftphoneEvent.LeaderChanged, () => cb()), [phone]),\r\n useCallback(() => phone.isLeader, [phone]),\r\n );\r\n}\r\n\r\n/**\r\n * The current call, or null. Re-renders when it changes — inbound, outbound, or ended.\r\n *\r\n * `phone.activeCall` returns a stable reference for a given call, so it is a safe snapshot.\r\n */\r\nexport function useActiveCall(): Call | null {\r\n const phone = useSoftphone();\r\n return useSyncExternalStore(\r\n useCallback((cb: () => void) => phone.on(SoftphoneEvent.ActiveCallChanged, () => cb()), [phone]),\r\n useCallback(() => phone.activeCall, [phone]),\r\n );\r\n}\r\n\r\n/**\r\n * The live agent-presence list (`phone.presence`, Phase B) — reactive on `SoftphoneEvent.\r\n * PresenceChanged`. `active` gates the SUBSCRIPTION only, not the underlying connection: the socket\r\n * itself stays governed entirely by `online && isLeader` inside the softphone (spec's own\r\n * always-connected decision), so passing `false` here does not close it — it only stops this\r\n * particular component from re-rendering on every update while it has no reason to care, e.g. the\r\n * transfer step being closed (spec §4.5: \"subscribe when the step opens... unsubscribe when it\r\n * closes\"). `phone.presence` is still read fresh on every call, so flipping `active` back to `true`\r\n * never shows a stale snapshot.\r\n */\r\nexport function usePresence(active: boolean): readonly PresenceEntry[] {\r\n const phone = useSoftphone();\r\n return useSyncExternalStore(\r\n useCallback(\r\n (cb: () => void) => {\r\n if (!active) return () => {};\r\n return phone.on(SoftphoneEvent.PresenceChanged, () => cb());\r\n },\r\n [phone, active],\r\n ),\r\n useCallback(() => phone.presence, [phone]),\r\n );\r\n}\r\n\r\n/** An immutable snapshot of a call's reactive fields. */\r\nexport interface CallView {\r\n readonly state: CallState;\r\n readonly isMuted: boolean;\r\n readonly isOnHold: boolean;\r\n readonly remote: RemoteParty;\r\n readonly remoteStream: MediaStream | null;\r\n readonly answeredAt: Date | null;\r\n}\r\n\r\nconst CALL_EVENTS: readonly CallEvent[] = [\r\n CallEvent.StateChanged,\r\n CallEvent.Muted,\r\n CallEvent.Held,\r\n CallEvent.Unheld,\r\n CallEvent.RemotePartyUpdated,\r\n CallEvent.RemoteStream,\r\n CallEvent.Ended,\r\n];\r\n\r\n/**\r\n * Reactive view of a specific call. Re-renders only when one of the call's own events fires — no\r\n * polling, no re-render storms.\r\n *\r\n * The snapshot is CACHED and invalidated only on an event, so `useSyncExternalStore` gets a stable\r\n * reference between changes (returning a fresh object every render would infinite-loop).\r\n */\r\nexport function useCall(call: Call | null): CallView | null {\r\n const cache = useRef<{ call: Call; view: CallView } | null>(null);\r\n\r\n const subscribe = useCallback(\r\n (cb: () => void) => {\r\n if (!call) return () => {};\r\n const unsubs = CALL_EVENTS.map((event) =>\r\n call.on(event, () => {\r\n cache.current = null; // invalidate; getSnapshot rebuilds on the next read\r\n cb();\r\n }),\r\n );\r\n return () => {\r\n for (const off of unsubs) off();\r\n };\r\n },\r\n [call],\r\n );\r\n\r\n const getSnapshot = useCallback((): CallView | null => {\r\n if (!call) return null;\r\n if (cache.current?.call === call) return cache.current.view;\r\n const view: CallView = {\r\n state: call.state,\r\n isMuted: call.isMuted,\r\n isOnHold: call.isOnHold,\r\n remote: call.remote,\r\n remoteStream: call.remoteStream,\r\n answeredAt: call.answeredAt,\r\n };\r\n cache.current = { call, view };\r\n return view;\r\n }, [call]);\r\n\r\n return useSyncExternalStore(subscribe, getSnapshot);\r\n}\r\n\r\n/**\r\n * The audio device surface (`phone.audio`) — reactive. Re-renders on `SoftphoneEvent.DeviceChange`: a\r\n * device plugged/unplugged, or a selection changed (SPEC §7 Phase 2).\r\n *\r\n * The snapshot is CACHED and invalidated only on that event, same reasoning as `useCall`'s own\r\n * `CallView`: `phone.audio` is ONE stable object whose fields mutate in place, so returning it\r\n * directly would never change by reference and `useSyncExternalStore` would never re-render. A fresh\r\n * object, rebuilt only when something actually changed, is what makes this reactive without polling.\r\n */\r\nexport function useDevices(): AudioDevices {\r\n const phone = useSoftphone();\r\n const cache = useRef<{ audio: AudioDevices; view: AudioDevices } | null>(null);\r\n\r\n const subscribe = useCallback(\r\n (cb: () => void) =>\r\n phone.on(SoftphoneEvent.DeviceChange, () => {\r\n cache.current = null; // invalidate; getSnapshot rebuilds on the next read\r\n cb();\r\n }),\r\n [phone],\r\n );\r\n\r\n const getSnapshot = useCallback((): AudioDevices => {\r\n const audio = phone.audio;\r\n if (cache.current?.audio === audio) return cache.current.view;\r\n const view: AudioDevices = {\r\n inputs: audio.inputs,\r\n outputs: audio.outputs,\r\n currentInputId: audio.currentInputId,\r\n currentOutputId: audio.currentOutputId,\r\n canSelectOutput: audio.canSelectOutput,\r\n setInput: (deviceId) => audio.setInput(deviceId),\r\n setOutput: (deviceId) => audio.setOutput(deviceId),\r\n setRingtoneOutput: (deviceId) => audio.setRingtoneOutput(deviceId),\r\n refresh: () => audio.refresh(),\r\n };\r\n cache.current = { audio, view };\r\n return view;\r\n }, [phone]);\r\n\r\n return useSyncExternalStore(subscribe, getSnapshot);\r\n}\r\n"]}
@@ -259,6 +259,14 @@ var SipCallEngine = class {
259
259
  #uaInFlight = null;
260
260
  #domain = "";
261
261
  #activeCall = null;
262
+ /**
263
+ * The consult leg from `startConsult` (SPEC §5.6), if one is live on this UA right now.
264
+ *
265
+ * Tracked separately from `#activeCall` so `#maybeTeardownUA` can tell "both legs are actually gone"
266
+ * apart from "the main leg ended, but a consult is still up" — see that method's own comment for the
267
+ * bug this exists to prevent.
268
+ */
269
+ #consultCall = null;
262
270
  /** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
263
271
  * `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
264
272
  #inputDeviceId = null;
@@ -377,9 +385,7 @@ var SipCallEngine = class {
377
385
  const ms = this.provider.quirks?.noInviteTeardownMs;
378
386
  if (!ms) return;
379
387
  this.#clearNoInviteTimer();
380
- this.#noInviteTimer = setTimeout(() => {
381
- if (!this.#activeCall) this.#teardownUA();
382
- }, ms);
388
+ this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
383
389
  }
384
390
  /**
385
391
  * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
@@ -501,6 +507,7 @@ var SipCallEngine = class {
501
507
  }
502
508
  this.#ua = null;
503
509
  this.#activeCall = null;
510
+ this.#consultCall = null;
504
511
  }
505
512
  /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
506
513
  /**
@@ -543,7 +550,7 @@ var SipCallEngine = class {
543
550
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
544
551
  if (code) this.#reportError(code, event.cause);
545
552
  if (this.#activeCall === call) this.#activeCall = null;
546
- if (this.provider.registrationMode === "on-demand") this.#teardownUA();
553
+ this.#maybeTeardownUA();
547
554
  }
548
555
  });
549
556
  return call;
@@ -554,12 +561,38 @@ var SipCallEngine = class {
554
561
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
555
562
  pcConfig: PC_CONFIG
556
563
  });
557
- return new SipEngineCall(consultSession, {
564
+ const consultCall = new SipEngineCall(consultSession, {
558
565
  id: nextCallId(),
559
566
  buildTarget: (x) => this.#buildTarget(x),
560
567
  placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
561
568
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
562
569
  });
570
+ this.#consultCall = consultCall;
571
+ consultCall.onEvent((event) => {
572
+ if (event.type === "ended") {
573
+ if (this.#consultCall === consultCall) this.#consultCall = null;
574
+ this.#maybeTeardownUA();
575
+ }
576
+ });
577
+ return consultCall;
578
+ }
579
+ /**
580
+ * On-demand registration (SPEC §3.3): the UA is disposable once NOTHING is left on it — but "the
581
+ * main leg ended" (or "no INVITE followed this push") is not the same fact as "nothing is left."
582
+ * `completeTransfer()` (SPEC §5.6) ends the main leg via a REFER…Replaces BYE *while the consult leg
583
+ * is still what the PBX needs to bridge the caller onto* — tearing the UA down (and with it, EVERY
584
+ * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
585
+ * consult leg out from under the transfer before the bridge could use it, and the caller was simply
586
+ * gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
587
+ * `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
588
+ * including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
589
+ * minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
590
+ * route through here rather than checking `#activeCall` alone.
591
+ */
592
+ #maybeTeardownUA() {
593
+ if (this.provider.registrationMode !== "on-demand") return;
594
+ if (this.#activeCall || this.#consultCall) return;
595
+ this.#teardownUA();
563
596
  }
564
597
  #startRejectGuard() {
565
598
  const ms = this.provider.quirks?.rejectGuardMs;
@@ -941,5 +974,5 @@ function createZadarmaEngine(config) {
941
974
  }
942
975
 
943
976
  export { ZADARMA_QUIRKS, createZadarmaEngine };
944
- //# sourceMappingURL=chunk-5BGEHMPP.js.map
945
- //# sourceMappingURL=chunk-5BGEHMPP.js.map
977
+ //# sourceMappingURL=chunk-YWI6EQYS.js.map
978
+ //# sourceMappingURL=chunk-YWI6EQYS.js.map