@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
@@ -1,4 +1,4 @@
1
- import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-DUVnDxsz.js';
1
+ import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, r as SoftphoneState, n as SoftphoneError } from './call-CaPDWVk3.js';
2
2
 
3
3
  /**
4
4
  * The `CallEngine` seam — INTERNAL, not exported from src/index.ts.
@@ -1,4 +1,4 @@
1
- import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-DUVnDxsz.cjs';
1
+ import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, r as SoftphoneState, n as SoftphoneError } from './call-CaPDWVk3.cjs';
2
2
 
3
3
  /**
4
4
  * The `CallEngine` seam — INTERNAL, not exported from src/index.ts.
package/dist/index.cjs CHANGED
@@ -293,6 +293,10 @@ function coventiaSources(config) {
293
293
  if (ws.readyState !== WS.OPEN) return;
294
294
  ws.send(JSON.stringify({ type: "call-state", onCall }));
295
295
  },
296
+ bye: () => {
297
+ if (ws.readyState !== WS.OPEN) return;
298
+ ws.send(JSON.stringify({ type: "bye" }));
299
+ },
296
300
  close: () => {
297
301
  ws.onclose = null;
298
302
  ws.onerror = null;
@@ -327,6 +331,56 @@ function coventiaSources(config) {
327
331
  })();
328
332
  });
329
333
  };
334
+ const fileBase = config.baseUrl.replace(/\/+$/, "");
335
+ const toLoop = (l) => l && Number.isFinite(l.start) && Number.isFinite(l.end) && l.start >= 0 && l.end > l.start ? { start: l.start, end: l.end } : null;
336
+ const toRingtoneSettings = (body) => {
337
+ const catalogue = (body.catalogue ?? []).map((r) => ({
338
+ id: r.id,
339
+ name: r.name,
340
+ url: r.path ? `${fileBase}${r.path}` : null,
341
+ loop: r.path ? toLoop(r.loop) : null
342
+ }));
343
+ const builtIn = catalogue.find((r) => r.url === null)?.id ?? "clasico";
344
+ const effective = catalogue.some((r) => r.id === body.effective) ? body.effective : builtIn;
345
+ return { catalogue, orgDefault: body.orgDefault ?? null, mine: body.user ?? null, effective };
346
+ };
347
+ const CACHE_PREFIX = "coventia-softphone:ringtones:";
348
+ const cacheKey = async () => {
349
+ try {
350
+ const { identity } = await config.tokenSource.getToken();
351
+ return `${CACHE_PREFIX}${fileBase}|${config.organizationId ?? "-"}|${identity}`;
352
+ } catch {
353
+ return null;
354
+ }
355
+ };
356
+ const remember = async (settings) => {
357
+ const key = await cacheKey();
358
+ try {
359
+ if (key) globalThis.localStorage?.setItem(key, JSON.stringify(settings));
360
+ } catch {
361
+ }
362
+ return settings;
363
+ };
364
+ const recall = async () => {
365
+ const key = await cacheKey();
366
+ try {
367
+ const raw = key ? globalThis.localStorage?.getItem(key) : null;
368
+ const s = raw ? JSON.parse(raw) : null;
369
+ const ok = s && Array.isArray(s.catalogue) && s.catalogue.every((r) => typeof r?.id === "string" && typeof r.name === "string" && (r.url === null || typeof r.url === "string")) && s.catalogue.some((r) => r.id === s.effective);
370
+ return ok ? { ...s, catalogue: s.catalogue.map((r) => ({ ...r, loop: toLoop(r.loop) })) } : null;
371
+ } catch {
372
+ return null;
373
+ }
374
+ };
375
+ const ringtones = {
376
+ get: async (signal) => remember(toRingtoneSettings(await get(`${base}/ringtones`, org, signal))),
377
+ // `null` clears: the empty id is dropped from the query by the request helper, and the backend reads a
378
+ // missing `ringtone_id` as "clear".
379
+ setMine: async (id) => remember(
380
+ toRingtoneSettings(await post(`${base}/ringtones/user`, { ...org, ringtone_id: id ?? "" }))
381
+ ),
382
+ cached: recall
383
+ };
330
384
  const contacts = config.contacts ?? coventiaContacts;
331
385
  const resolve = config.resolveContact ?? (config.contacts ? resolverFromContacts(config.contacts) : coventiaResolve);
332
386
  const history = withNameResolution(rawHistory, resolve);
@@ -338,7 +392,8 @@ function coventiaSources(config) {
338
392
  resolveContact: resolve,
339
393
  listAgents: coventiaListAgents,
340
394
  transferOutcomes,
341
- presence
395
+ presence,
396
+ ringtones
342
397
  };
343
398
  }
344
399
  var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
@@ -1222,11 +1277,13 @@ function buildPushUrl(key, sip) {
1222
1277
  const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
1223
1278
  return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
1224
1279
  }
1225
- var PING, KEY_REFRESH_BUFFER_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1280
+ var PING, KEY_REFRESH_BUFFER_MS, REBUILD_CONNECT_TIMEOUT_MS, REBUILD_RETRY_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1226
1281
  var init_zadarma = __esm({
1227
1282
  "src/transports/providers/zadarma.ts"() {
1228
1283
  PING = { localMs: 3e4, globalMs: 6e4 };
1229
1284
  KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1285
+ REBUILD_CONNECT_TIMEOUT_MS = 1e4;
1286
+ REBUILD_RETRY_MS = 3e4;
1230
1287
  ZADARMA_QUIRKS = {
1231
1288
  // Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
1232
1289
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
@@ -1250,6 +1307,12 @@ var init_zadarma = __esm({
1250
1307
  #connected = false;
1251
1308
  #pingTimers = [];
1252
1309
  #refreshTimer;
1310
+ /** The replacement socket of a rebuild in flight, not yet current. */
1311
+ #candidate = null;
1312
+ /** Abandons the rebuild in flight (closing its candidate). */
1313
+ #abortRebuild = null;
1314
+ #rebuilding = false;
1315
+ #retryTimer;
1253
1316
  #unwatchBackground;
1254
1317
  #closed = false;
1255
1318
  #listeners = /* @__PURE__ */ new Set();
@@ -1265,20 +1328,35 @@ var init_zadarma = __esm({
1265
1328
  if (this.#closed) return;
1266
1329
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1267
1330
  this.#socket = socket;
1331
+ this.#attach(socket);
1332
+ this.#armRefresh(keyExpiresAt);
1333
+ }
1334
+ /**
1335
+ * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
1336
+ * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
1337
+ * otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
1338
+ * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
1339
+ * repeated signal mid-call and reuses a UA that already exists) while one seen by neither is a missed call.
1340
+ *
1341
+ * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
1342
+ * of changing the connection state (it is not current yet).
1343
+ */
1344
+ #attach(socket, hooks) {
1268
1345
  socket.on("connect", () => {
1346
+ if (hooks && socket === this.#candidate) return hooks.onConnect();
1347
+ if (socket !== this.#socket) return;
1269
1348
  this.#setConnected(true);
1270
1349
  this.#startPings(socket);
1271
1350
  });
1272
1351
  socket.on("update", () => this.#emit());
1273
- socket.on("disconnect", () => {
1274
- this.#stopPings();
1275
- this.#setConnected(false);
1276
- });
1277
- socket.on("connect_error", () => {
1352
+ const lost = () => {
1353
+ if (hooks && socket === this.#candidate) return hooks.onFailure();
1354
+ if (socket !== this.#socket) return;
1278
1355
  this.#stopPings();
1279
1356
  this.#setConnected(false);
1280
- });
1281
- this.#armRefresh(keyExpiresAt);
1357
+ };
1358
+ socket.on("disconnect", lost);
1359
+ socket.on("connect_error", lost);
1282
1360
  }
1283
1361
  /**
1284
1362
  * Presence pings — **the reason an idle tab keeps receiving calls**.
@@ -1317,22 +1395,92 @@ var init_zadarma = __esm({
1317
1395
  if (!Number.isFinite(ms) || ms <= 0) return;
1318
1396
  this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
1319
1397
  }
1398
+ /**
1399
+ * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
1400
+ *
1401
+ * Why it must happen at all: Zadarma's gateway decides the extension is alive from the raw Engine.IO ping, which
1402
+ * a hidden tab's throttled timers silently stop sending, so while the tab is hidden the socket is rebuilt every
1403
+ * 20 s (`push-socket-reliability.md` §5.3), and once before the key expires. Why it must not leave a gap: the
1404
+ * old close-then-reopen reported `connected = false` for the ~0.5 s in between, which took the softphone
1405
+ * `offline`, tore down the presence socket and left Zadarma with no socket to push an incoming call to.
1406
+ *
1407
+ * Now the OLD socket stays up and stays current until the NEW one has actually connected; only then do we swap,
1408
+ * move the pings and close the old one. A healthy rebuild never changes `connected`. A failed one — the key fetch
1409
+ * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
1410
+ * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
1411
+ * sip (multi-tab-leader-election.md §1), so the overlap is safe.
1412
+ */
1320
1413
  async #reopen() {
1321
- if (this.#closed) return;
1322
- this.#stopPings();
1323
- this.#socket?.close();
1324
- this.#socket = null;
1325
- this.#setConnected(false);
1414
+ if (this.#closed || this.#rebuilding) return;
1415
+ if (!this.#socket) {
1416
+ try {
1417
+ await this.#open();
1418
+ } catch {
1419
+ }
1420
+ return;
1421
+ }
1422
+ this.#rebuilding = true;
1326
1423
  try {
1327
- await this.#open();
1424
+ const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1425
+ if (this.#closed) return;
1426
+ const swapped = await new Promise((resolve) => {
1427
+ const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1428
+ this.#candidate = socket;
1429
+ let settled = false;
1430
+ const finish = (ok) => {
1431
+ if (settled) return;
1432
+ settled = true;
1433
+ clearTimeout(timer);
1434
+ this.#abortRebuild = null;
1435
+ if (ok && socket.connected) {
1436
+ this.#swapIn(socket, keyExpiresAt);
1437
+ } else {
1438
+ this.#candidate = null;
1439
+ socket.close();
1440
+ }
1441
+ resolve(ok && socket.connected);
1442
+ };
1443
+ const timer = setTimeout(() => finish(false), REBUILD_CONNECT_TIMEOUT_MS);
1444
+ this.#abortRebuild = () => finish(false);
1445
+ this.#attach(socket, { onConnect: () => finish(true), onFailure: () => finish(false) });
1446
+ });
1447
+ if (!swapped) this.#armRetry();
1328
1448
  } catch {
1449
+ this.#armRetry();
1450
+ } finally {
1451
+ this.#rebuilding = false;
1329
1452
  }
1330
1453
  }
1454
+ /**
1455
+ * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
1456
+ * between "the new socket is up" and "it is the current one". Order matters: `#socket` moves first, so the old
1457
+ * socket's `disconnect` (our own `close()` of it, just below) is ignored by `#attach`.
1458
+ */
1459
+ #swapIn(socket, keyExpiresAt) {
1460
+ const previous = this.#socket;
1461
+ this.#socket = socket;
1462
+ this.#candidate = null;
1463
+ clearTimeout(this.#retryTimer);
1464
+ this.#retryTimer = void 0;
1465
+ this.#stopPings();
1466
+ this.#startPings(socket);
1467
+ this.#setConnected(true);
1468
+ previous?.close();
1469
+ this.#armRefresh(keyExpiresAt);
1470
+ }
1471
+ #armRetry() {
1472
+ if (this.#closed) return;
1473
+ clearTimeout(this.#retryTimer);
1474
+ this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
1475
+ }
1331
1476
  disconnect() {
1332
1477
  this.#closed = true;
1333
1478
  this.#stopPings();
1334
1479
  clearTimeout(this.#refreshTimer);
1335
1480
  this.#refreshTimer = void 0;
1481
+ clearTimeout(this.#retryTimer);
1482
+ this.#retryTimer = void 0;
1483
+ this.#abortRebuild?.();
1336
1484
  this.#unwatchBackground?.();
1337
1485
  this.#unwatchBackground = void 0;
1338
1486
  this.#socket?.close();
@@ -1689,6 +1837,9 @@ function canTransition(from, to) {
1689
1837
  function isTerminal(state) {
1690
1838
  return CALL_TRANSITIONS[state].length === 0;
1691
1839
  }
1840
+ function isOnCallState(state) {
1841
+ return state !== "ringing" && !isTerminal(state);
1842
+ }
1692
1843
 
1693
1844
  // src/core/call-impl.ts
1694
1845
  var CallImpl = class _CallImpl extends Emitter {
@@ -1906,7 +2057,12 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
1906
2057
  resolveContact: async (number) => (await load()).resolveContact(number),
1907
2058
  listAgents: async () => (await load()).listAgents(),
1908
2059
  transferOutcomes: async (params) => (await load()).transferOutcomes(params),
1909
- presence: async (callbacks) => (await load()).presence(callbacks)
2060
+ presence: async (callbacks) => (await load()).presence(callbacks),
2061
+ ringtones: {
2062
+ get: async (signal) => (await load()).ringtones.get(signal),
2063
+ setMine: async (id) => (await load()).ringtones.setMine(id),
2064
+ cached: async () => (await load()).ringtones.cached?.() ?? null
2065
+ }
1910
2066
  };
1911
2067
  }
1912
2068
 
@@ -2051,13 +2207,18 @@ function startPresenceSocket(params) {
2051
2207
  let agents = /* @__PURE__ */ new Map();
2052
2208
  let lastReportedOnCall = null;
2053
2209
  let connection = null;
2054
- const stop = () => {
2210
+ let byeOnArrival = false;
2211
+ const stop = (options) => {
2055
2212
  if (stopped) return;
2056
2213
  stopped = true;
2057
2214
  if (reconnectTimer !== null) {
2058
2215
  cancelTimeout(reconnectTimer);
2059
2216
  reconnectTimer = null;
2060
2217
  }
2218
+ if (options?.bye) {
2219
+ byeOnArrival = true;
2220
+ connection?.bye?.();
2221
+ }
2061
2222
  connection?.close();
2062
2223
  connection = null;
2063
2224
  };
@@ -2096,6 +2257,7 @@ function startPresenceSocket(params) {
2096
2257
  return;
2097
2258
  }
2098
2259
  if (stopped) {
2260
+ if (byeOnArrival) opened.bye?.();
2099
2261
  opened.close();
2100
2262
  return;
2101
2263
  }
@@ -2117,18 +2279,36 @@ function startPresenceSocket(params) {
2117
2279
  // src/core/softphone-impl.ts
2118
2280
  init_errors();
2119
2281
  var liveInstance = false;
2282
+ var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
2120
2283
  var BrowserAudioDevices = class {
2121
2284
  inputs = [];
2122
2285
  outputs = [];
2123
2286
  currentInputId = null;
2124
2287
  currentOutputId = null;
2288
+ currentRingtoneOutputId = null;
2125
2289
  canSelectOutput;
2290
+ canSelectRingtoneOutput;
2126
2291
  #engine;
2127
2292
  #onChange;
2293
+ /**
2294
+ * What the user CHOSE, remembered across reloads — distinct from the `current*` fields, which are
2295
+ * what is usable right now. Keeping them apart is what stops a fresh page load from wiping a saved
2296
+ * choice: until the microphone has been granted, Chrome enumerates every device with a blank id, so
2297
+ * "is the saved device in the list?" is no on every first enumeration. The preference survives
2298
+ * that; `current*` simply reads `null` until the device is really there.
2299
+ */
2300
+ #preferred;
2301
+ /** The input the engine was last told about — it starts on the system default (`null`). */
2302
+ #engineInputId = null;
2128
2303
  constructor(engine, onChange) {
2129
2304
  this.#engine = engine;
2130
2305
  this.#onChange = onChange;
2131
2306
  this.canSelectOutput = isBrowser() && typeof HTMLMediaElement !== "undefined" && "setSinkId" in HTMLMediaElement.prototype;
2307
+ this.canSelectRingtoneOutput = this.canSelectOutput;
2308
+ this.#preferred = readStoredDevices();
2309
+ this.currentInputId = this.#preferred.input;
2310
+ this.currentOutputId = this.#preferred.output;
2311
+ this.currentRingtoneOutputId = this.#preferred.ringtone;
2132
2312
  if (isBrowser() && navigator.mediaDevices) {
2133
2313
  void this.refresh();
2134
2314
  navigator.mediaDevices.addEventListener?.("devicechange", () => void this.refresh());
@@ -2144,26 +2324,63 @@ var BrowserAudioDevices = class {
2144
2324
  }
2145
2325
  this.inputs = all.filter((d) => d.kind === "audioinput");
2146
2326
  this.outputs = all.filter((d) => d.kind === "audiooutput");
2147
- if (this.currentInputId && !this.inputs.some((d) => d.deviceId === this.currentInputId)) {
2148
- this.currentInputId = null;
2149
- }
2150
- if (this.currentOutputId && !this.outputs.some((d) => d.deviceId === this.currentOutputId)) {
2151
- this.currentOutputId = null;
2152
- }
2327
+ this.currentInputId = usable(this.#preferred.input, this.inputs);
2328
+ this.currentOutputId = usable(this.#preferred.output, this.outputs);
2329
+ this.currentRingtoneOutputId = usable(this.#preferred.ringtone, this.outputs);
2153
2330
  this.#onChange();
2331
+ if (this.currentInputId && this.currentInputId !== this.#engineInputId) {
2332
+ await this.#tellEngine(this.currentInputId);
2333
+ }
2154
2334
  }
2155
2335
  async setInput(deviceId) {
2156
2336
  this.currentInputId = deviceId;
2337
+ this.#remember({ input: deviceId });
2157
2338
  this.#onChange();
2339
+ await this.#tellEngine(deviceId);
2340
+ }
2341
+ async #tellEngine(deviceId) {
2342
+ this.#engineInputId = deviceId;
2158
2343
  await this.#engine.setInputDevice?.(deviceId);
2159
2344
  }
2160
2345
  async setOutput(deviceId) {
2161
2346
  this.currentOutputId = deviceId;
2347
+ this.#remember({ output: deviceId });
2162
2348
  this.#onChange();
2163
2349
  }
2164
- async setRingtoneOutput() {
2350
+ async setRingtoneOutput(deviceId) {
2351
+ this.currentRingtoneOutputId = deviceId;
2352
+ this.#remember({ ringtone: deviceId });
2353
+ this.#onChange();
2354
+ }
2355
+ #remember(patch) {
2356
+ this.#preferred = { ...this.#preferred, ...patch };
2357
+ writeStoredDevices(this.#preferred);
2165
2358
  }
2166
2359
  };
2360
+ var DEVICES_KEY = "coventia-softphone:devices";
2361
+ var NO_DEVICES = { input: null, output: null, ringtone: null };
2362
+ function usable(id, list) {
2363
+ return id && list.some((d) => d.deviceId === id) ? id : null;
2364
+ }
2365
+ function readStoredDevices() {
2366
+ if (!isBrowser()) return NO_DEVICES;
2367
+ try {
2368
+ const raw = JSON.parse(globalThis.localStorage?.getItem(DEVICES_KEY) ?? "null");
2369
+ if (!raw || typeof raw !== "object") return NO_DEVICES;
2370
+ const pick = (v) => typeof v === "string" && v ? v : null;
2371
+ const r = raw;
2372
+ return { input: pick(r.input), output: pick(r.output), ringtone: pick(r.ringtone) };
2373
+ } catch {
2374
+ return NO_DEVICES;
2375
+ }
2376
+ }
2377
+ function writeStoredDevices(devices) {
2378
+ if (!isBrowser()) return;
2379
+ try {
2380
+ globalThis.localStorage?.setItem(DEVICES_KEY, JSON.stringify(devices));
2381
+ } catch {
2382
+ }
2383
+ }
2167
2384
  var SoftphoneImpl = class extends Emitter {
2168
2385
  #state = "stopped";
2169
2386
  #lastError = null;
@@ -2191,6 +2408,8 @@ var SoftphoneImpl = class extends Emitter {
2191
2408
  * rather than by any one call.
2192
2409
  */
2193
2410
  #presenceWatch = null;
2411
+ /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
2412
+ #presenceTeardownTimer = null;
2194
2413
  #presenceAgents = [];
2195
2414
  /**
2196
2415
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
@@ -2250,6 +2469,7 @@ var SoftphoneImpl = class extends Emitter {
2250
2469
  }
2251
2470
  async stop() {
2252
2471
  if (!this.#startedHere) return;
2472
+ this.#sayGoodbye();
2253
2473
  this.#tabLock.release();
2254
2474
  if (this.#isLeader) {
2255
2475
  await this.#engine.stop();
@@ -2265,8 +2485,7 @@ var SoftphoneImpl = class extends Emitter {
2265
2485
  this.#destroyed = true;
2266
2486
  for (const stopWatch of this.#activeWatches) stopWatch();
2267
2487
  this.#activeWatches.clear();
2268
- this.#presenceWatch?.stop();
2269
- this.#presenceWatch = null;
2488
+ this.#sayGoodbye();
2270
2489
  await this.stop().catch(() => {
2271
2490
  });
2272
2491
  this.#setState("destroyed");
@@ -2301,11 +2520,15 @@ var SoftphoneImpl = class extends Emitter {
2301
2520
  (target, callerNumber) => this.#onTransfer(target, callerNumber)
2302
2521
  );
2303
2522
  this.#activeCall = call;
2523
+ const offState = call.on("stateChanged" /* StateChanged */, () => this.#syncCallState());
2524
+ this.#syncCallState();
2304
2525
  const off = call.on("ended" /* Ended */, () => {
2305
2526
  off();
2527
+ offState();
2306
2528
  if (this.#activeCall === call) {
2307
2529
  this.#activeCall = null;
2308
2530
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
2531
+ this.#syncCallState();
2309
2532
  }
2310
2533
  });
2311
2534
  if (this.#resolveContact) {
@@ -2325,6 +2548,21 @@ var SoftphoneImpl = class extends Emitter {
2325
2548
  this.emit("stateChanged" /* StateChanged */, { state, previous });
2326
2549
  this.#syncPresenceWatch();
2327
2550
  }
2551
+ /**
2552
+ * Whether this softphone is on a call right now, as presence means it (`isOnCallState`). There is a single
2553
+ * active-call slot, and a consult leg is a child of that call, so the parent staying alive covers a consult.
2554
+ */
2555
+ #isOnCall() {
2556
+ return this.#activeCall !== null && isOnCallState(this.#activeCall.state);
2557
+ }
2558
+ /**
2559
+ * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
2560
+ * remembers the latest value to replay after a reconnect, so this can be called on every transition without
2561
+ * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
2562
+ */
2563
+ #syncCallState() {
2564
+ this.#presenceWatch?.reportCallState(this.#isOnCall());
2565
+ }
2328
2566
  #setLeader(isLeader) {
2329
2567
  if (isLeader === this.#isLeader) return;
2330
2568
  this.#isLeader = isLeader;
@@ -2343,9 +2581,21 @@ var SoftphoneImpl = class extends Emitter {
2343
2581
  * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
2344
2582
  * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2345
2583
  * split `#onTransfer` already has with `startTransferWatch`.
2584
+ *
2585
+ * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
2586
+ * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
2587
+ * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
2588
+ * tears it down at once: those are not blips, and a follower must never hold a connection.
2346
2589
  */
2347
2590
  #syncPresenceWatch() {
2348
- const shouldRun = Boolean(this.sources) && this.#state === "online" && this.#isLeader;
2591
+ const eligible = Boolean(this.sources) && this.#isLeader;
2592
+ const shouldRun = eligible && this.#state === "online";
2593
+ const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
2594
+ if (!blip) this.#cancelPresenceTeardown();
2595
+ if (blip && this.#presenceWatch !== null) {
2596
+ this.#schedulePresenceTeardown();
2597
+ return;
2598
+ }
2349
2599
  if (shouldRun === (this.#presenceWatch !== null)) return;
2350
2600
  if (shouldRun) {
2351
2601
  this.#presenceWatch = startPresenceSocket({
@@ -2356,13 +2606,49 @@ var SoftphoneImpl = class extends Emitter {
2356
2606
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
2357
2607
  }
2358
2608
  });
2609
+ this.#syncCallState();
2359
2610
  } else {
2360
- this.#presenceWatch?.stop();
2361
- this.#presenceWatch = null;
2362
- this.#presenceAgents = [];
2363
- this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
2611
+ this.#stopPresence();
2364
2612
  }
2365
2613
  }
2614
+ /**
2615
+ * Starts the grace period before an `offline` / `reconnecting` leader loses its presence socket. Idempotent
2616
+ * on purpose: a second notification inside the same blip must NOT push the deadline back.
2617
+ */
2618
+ #schedulePresenceTeardown() {
2619
+ if (this.#presenceTeardownTimer !== null) return;
2620
+ this.#presenceTeardownTimer = setTimeout(() => {
2621
+ this.#presenceTeardownTimer = null;
2622
+ if (this.#state === "online" && this.#isLeader) return;
2623
+ this.#stopPresence();
2624
+ }, PRESENCE_TEARDOWN_GRACE_MS);
2625
+ }
2626
+ #cancelPresenceTeardown() {
2627
+ if (this.#presenceTeardownTimer === null) return;
2628
+ clearTimeout(this.#presenceTeardownTimer);
2629
+ this.#presenceTeardownTimer = null;
2630
+ }
2631
+ /**
2632
+ * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
2633
+ * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
2634
+ * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
2635
+ */
2636
+ #stopPresence(options) {
2637
+ this.#cancelPresenceTeardown();
2638
+ if (!this.#presenceWatch) return;
2639
+ this.#presenceWatch.stop(options);
2640
+ this.#presenceWatch = null;
2641
+ this.#presenceAgents = [];
2642
+ this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
2643
+ }
2644
+ /**
2645
+ * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
2646
+ * this phone at once instead of waiting out the close grace. Idempotent — a second call finds no watch.
2647
+ */
2648
+ #sayGoodbye() {
2649
+ this.#presenceWatch?.reportCallState(false);
2650
+ this.#stopPresence({ bye: true });
2651
+ }
2366
2652
  /**
2367
2653
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2368
2654
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -2498,7 +2784,7 @@ function isCallEngine(value) {
2498
2784
 
2499
2785
  // src/index.ts
2500
2786
  init_errors();
2501
- var VERSION = "0.2.0";
2787
+ var VERSION = "0.3.0";
2502
2788
 
2503
2789
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2504
2790
  exports.CallEvent = CallEvent;