@coventia/softphone 0.2.0 → 0.3.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 (47) hide show
  1. package/dist/{call-DUVnDxsz.d.cts → call-CaPDWVk3.d.cts} +50 -1
  2. package/dist/{call-DUVnDxsz.d.ts → call-CaPDWVk3.d.ts} +50 -1
  3. package/dist/{chunk-YWI6EQYS.js → chunk-EYEDPD7Y.js} +108 -15
  4. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  5. package/dist/{chunk-LMJXX4YM.js → chunk-UHNLEBBO.js} +4 -2
  6. package/dist/chunk-UHNLEBBO.js.map +1 -0
  7. package/dist/element/index.d.ts +76 -11
  8. package/dist/element/index.js +1411 -478
  9. package/dist/element/index.js.map +1 -1
  10. package/dist/{engine-EPCKf-Bl.d.ts → engine-BgBsIcEa.d.ts} +1 -1
  11. package/dist/{engine-CDjkrXHY.d.cts → engine-CU2rEo3g.d.cts} +1 -1
  12. package/dist/index.cjs +318 -32
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +5 -5
  15. package/dist/index.d.ts +5 -5
  16. package/dist/index.js +157 -19
  17. package/dist/index.js.map +1 -1
  18. package/dist/react/index.cjs +2 -0
  19. package/dist/react/index.cjs.map +1 -1
  20. package/dist/react/index.d.cts +2 -2
  21. package/dist/react/index.d.ts +2 -2
  22. package/dist/react/index.js +1 -1
  23. package/dist/{softphone-BtdgZWkm.d.ts → softphone-DEt0TtQw.d.ts} +26 -10
  24. package/dist/{softphone-DHG9MTNg.d.cts → softphone-dZMkW8V1.d.cts} +26 -10
  25. package/dist/softphone.css +192 -46
  26. package/dist/{sources-5L2R4HTV.js → sources-M3GYMS6V.js} +58 -3
  27. package/dist/sources-M3GYMS6V.js.map +1 -0
  28. package/dist/testing/index.d.cts +2 -2
  29. package/dist/testing/index.d.ts +2 -2
  30. package/dist/ui/index.cjs +1308 -366
  31. package/dist/ui/index.cjs.map +1 -1
  32. package/dist/ui/index.d.cts +40 -7
  33. package/dist/ui/index.d.ts +40 -7
  34. package/dist/ui/index.js +1308 -369
  35. package/dist/ui/index.js.map +1 -1
  36. package/dist/zadarma/index.cjs +106 -13
  37. package/dist/zadarma/index.cjs.map +1 -1
  38. package/dist/zadarma/index.d.cts +2 -2
  39. package/dist/zadarma/index.d.ts +2 -2
  40. package/dist/zadarma/index.js +1 -1
  41. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  42. package/dist/{zadarma-engine-4VOPIBXG.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  43. package/package.json +1 -1
  44. package/dist/chunk-LMJXX4YM.js.map +0 -1
  45. package/dist/chunk-YWI6EQYS.js.map +0 -1
  46. package/dist/sources-5L2R4HTV.js.map +0 -1
  47. package/dist/zadarma-engine-4VOPIBXG.js +0 -5
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/context.tsx","../src/react/hooks.ts"],"names":[],"mappings":"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","file":"chunk-UHNLEBBO.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 currentRingtoneOutputId: audio.currentRingtoneOutputId,\r\n canSelectOutput: audio.canSelectOutput,\r\n canSelectRingtoneOutput: audio.canSelectRingtoneOutput,\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"]}
@@ -199,6 +199,12 @@ interface PresenceConnection {
199
199
  * `presence-socket.ts`) that reports early is expected to wait for the connection to exist first.
200
200
  */
201
201
  readonly reportCallState: (onCall: boolean) => void;
202
+ /**
203
+ * Says this phone is going away ON PURPOSE (the user turned it off, the app is quitting), so the backend
204
+ * drops it at once instead of waiting out the close grace it gives every ordinary close. Optional: a host's
205
+ * own source that has no such message simply omits it and gets the grace. Sent just before `close()`.
206
+ */
207
+ readonly bye?: () => void;
202
208
  /**
203
209
  * Ends this connection deliberately. Never causes the SAME callbacks' `onClose` to fire — that is
204
210
  * reserved for the connection ending on its OWN (a network drop, a server-side close, a rejected
@@ -227,9 +233,52 @@ interface PresenceCallbacks {
227
233
  * long-lived — the returned promise resolves once, when the connection opens, not once per update.
228
234
  */
229
235
  type PresenceSource = (callbacks: PresenceCallbacks) => Promise<PresenceConnection>;
236
+ /** One ringtone a user can pick (custom-ringtone.md §4.1). */
237
+ interface RingtoneOption {
238
+ /** Stable id — what is stored as the user's or the org's choice. */
239
+ readonly id: string;
240
+ /** Display name, in Spanish, as both pickers show it. */
241
+ readonly name: string;
242
+ /**
243
+ * Absolute URL of the MP3, or `null` for the tone the SDK synthesises itself (`clasico`) — the one
244
+ * that is always available and plays whenever a file is not ready.
245
+ */
246
+ readonly url: string | null;
247
+ /**
248
+ * Where the music sits inside the decoded file, in seconds — loop between these, not over the whole file. An
249
+ * MP3 round trip adds a fixed delay at the start and padding at the end; looping the whole buffer would make a
250
+ * continuous ringtone stumble at every restart. `null` for the built-in tone, or when the backend gave none.
251
+ */
252
+ readonly loop: {
253
+ readonly start: number;
254
+ readonly end: number;
255
+ } | null;
256
+ }
257
+ /** The catalogue and the three levels of choice, as the backend resolves them for the caller. */
258
+ interface RingtoneSettings {
259
+ /** In display order. Always contains the built-in tone. */
260
+ readonly catalogue: readonly RingtoneOption[];
261
+ /** The org's default, or `null` when the org has not set one. */
262
+ readonly orgDefault: string | null;
263
+ /** This user's own choice, or `null` = "use the org default". */
264
+ readonly mine: string | null;
265
+ /** What actually rings: `mine`, else `orgDefault`, else the built-in tone — always in `catalogue`. */
266
+ readonly effective: string;
267
+ }
268
+ /** The ringtone settings of the user the softphone is signed in as, in its organization. */
269
+ interface RingtoneSource {
270
+ get(signal?: AbortSignal): Promise<RingtoneSettings>;
271
+ /** Saves this user's choice; `null` clears it (back to the org default). Resolves to the new settings. */
272
+ setMine(id: string | null): Promise<RingtoneSettings>;
273
+ /**
274
+ * The settings this user last had on this device, if remembered — instant, no network. Used only to start
275
+ * loading the likely ringtone early; `get()` is always the truth. Optional: a source may not remember.
276
+ */
277
+ cached?(): Promise<RingtoneSettings | null>;
278
+ }
230
279
 
231
- /** Contacts, history, caller ID, the agents directory, transfer outcomes and presence, all from one
232
- * config. */
280
+ /** Contacts, history, caller ID, the agents directory, transfer outcomes, presence and ringtone
281
+ * settings, all from one config. */
233
282
  interface BackendSources {
234
283
  readonly contacts: ContactsSource;
235
284
  readonly history: HistorySource;
@@ -242,6 +291,8 @@ interface BackendSources {
242
291
  }) => Promise<TransferOutcomesPage>;
243
292
  /** Phase B (presence-gated transfer picker) — opened by `presence-socket.ts`, never by UI directly. */
244
293
  readonly presence: PresenceSource;
294
+ /** The caller's ringtone settings (custom-ringtone.md §4.4). Always Coventia's — no override. */
295
+ readonly ringtones: RingtoneSource;
245
296
  }
246
297
 
247
298
  /**
@@ -684,12 +735,20 @@ interface AudioDevices {
684
735
  readonly inputs: readonly MediaDeviceInfo[];
685
736
  readonly outputs: readonly MediaDeviceInfo[];
686
737
  /**
687
- * The currently-selected input/output, or `null` — no explicit choice made, the browser's own
688
- * default is in use. Never a stale ID: if the selected device is unplugged, its selection is
689
- * cleared back to `null` rather than pointing at a device that no longer exists.
738
+ * The currently-selected input/output/ringtone output, or `null` — no explicit choice made, the
739
+ * browser's own default is in use. Never a stale ID: if the selected device is unplugged, its
740
+ * selection reads `null` rather than pointing at a device that no longer exists.
741
+ *
742
+ * **Choices are remembered** — in `localStorage` (`coventia-softphone:devices`), so they survive a
743
+ * reload. What is remembered is the user's *preference*, kept apart from these current values: an
744
+ * unplugged headset reads `null` here, and reads as selected again the moment it is plugged back in,
745
+ * without the user choosing it twice. Device ids are per browser and per site, which is exactly the
746
+ * scope of that storage (custom-ringtone.md §4.5). Where storage is blocked (private mode, a
747
+ * sandboxed iframe) choices simply last for the page, as they used to.
690
748
  */
691
749
  readonly currentInputId: string | null;
692
750
  readonly currentOutputId: string | null;
751
+ readonly currentRingtoneOutputId: string | null;
693
752
  /**
694
753
  * Re-enumerate. `inputs`/`outputs` already stay current on their own — a device physically
695
754
  * plugged or unplugged fires the browser's own `devicechange` and needs no help — but a
@@ -714,17 +773,23 @@ interface AudioDevices {
714
773
  */
715
774
  setOutput(deviceId: string | null): Promise<void>;
716
775
  /**
717
- * The RINGTONE output, separate from the call speaker.
776
+ * The RINGTONE output, separate from the call speaker, or `null` for the browser's default device.
718
777
  *
719
778
  * Of every SDK surveyed only Twilio ships this, and users want it: ring on the laptop speakers so
720
- * you hear it across the room, take the call in the headset.
779
+ * you hear it across the room, take the call in the headset. Only the inbound ring (and its preview
780
+ * in Ajustes) follows it; the call itself, the ringback and the short cues stay where they were.
721
781
  *
722
- * **Not yet implemented** — still Phase 2 groundwork, like `NoopAudioDevices` before it. Calling it
723
- * resolves without effect until a later pass wires it up.
782
+ * Like `setOutput`, this only records the choice and fires `SoftphoneEvent.DeviceChange` — the `/ui`
783
+ * layer is what routes the ring. A no-op for routing when `canSelectRingtoneOutput` is false.
724
784
  */
725
785
  setRingtoneOutput(deviceId: string | null): Promise<void>;
726
786
  /** False when the browser lacks `setSinkId` (Safari, most of Firefox) — `setOutput` is then a no-op. */
727
787
  readonly canSelectOutput: boolean;
788
+ /**
789
+ * Whether the ring can be sent to a chosen device. False on Safari (no `setSinkId` at all) — the
790
+ * ring then plays on the default output and a UI should not offer the picker.
791
+ */
792
+ readonly canSelectRingtoneOutput: boolean;
728
793
  }
729
794
  /**
730
795
  * The engine. Long-lived; owns registration, credentials, devices, and the provider.
@@ -854,8 +919,8 @@ interface SoftphoneElementConfig {
854
919
  */
855
920
  readonly requireMic?: boolean;
856
921
  /**
857
- * Play call audio — the remote party, the inbound ring, the outbound ringback. **On by default**;
858
- * see `SoftphoneWidgetProps.sounds`.
922
+ * Play call audio — the remote party, the inbound ring, the outbound ringback, the end-of-call cue
923
+ * and the keypad's key tones. **On by default**; see `SoftphoneWidgetProps.sounds`.
859
924
  *
860
925
  * Turning this off makes a connected call silent in both directions, because nothing else attaches
861
926
  * the remote `MediaStream`. Only correct when the host attaches `call.remoteStream` itself, or for