@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.
- package/dist/{call-DUVnDxsz.d.cts → call-CaPDWVk3.d.cts} +50 -1
- package/dist/{call-DUVnDxsz.d.ts → call-CaPDWVk3.d.ts} +50 -1
- package/dist/{chunk-YWI6EQYS.js → chunk-EYEDPD7Y.js} +108 -15
- package/dist/chunk-EYEDPD7Y.js.map +1 -0
- package/dist/{chunk-LMJXX4YM.js → chunk-UHNLEBBO.js} +4 -2
- package/dist/chunk-UHNLEBBO.js.map +1 -0
- package/dist/element/index.d.ts +76 -11
- package/dist/element/index.js +1411 -478
- package/dist/element/index.js.map +1 -1
- package/dist/{engine-EPCKf-Bl.d.ts → engine-BgBsIcEa.d.ts} +1 -1
- package/dist/{engine-CDjkrXHY.d.cts → engine-CU2rEo3g.d.cts} +1 -1
- package/dist/index.cjs +318 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +157 -19
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +2 -0
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +2 -2
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +1 -1
- package/dist/{softphone-BtdgZWkm.d.ts → softphone-DEt0TtQw.d.ts} +26 -10
- package/dist/{softphone-DHG9MTNg.d.cts → softphone-dZMkW8V1.d.cts} +26 -10
- package/dist/softphone.css +192 -46
- package/dist/{sources-5L2R4HTV.js → sources-M3GYMS6V.js} +58 -3
- package/dist/sources-M3GYMS6V.js.map +1 -0
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/ui/index.cjs +1308 -366
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +40 -7
- package/dist/ui/index.d.ts +40 -7
- package/dist/ui/index.js +1308 -369
- package/dist/ui/index.js.map +1 -1
- package/dist/zadarma/index.cjs +106 -13
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +2 -2
- package/dist/zadarma/index.d.ts +2 -2
- package/dist/zadarma/index.js +1 -1
- package/dist/zadarma-engine-L5DEFXUS.js +5 -0
- package/dist/{zadarma-engine-4VOPIBXG.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-LMJXX4YM.js.map +0 -1
- package/dist/chunk-YWI6EQYS.js.map +0 -1
- package/dist/sources-5L2R4HTV.js.map +0 -1
- 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,
|
|
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,
|
|
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
|
-
|
|
1274
|
-
this.#
|
|
1275
|
-
this.#
|
|
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
|
-
|
|
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.#
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2148
|
-
|
|
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.#
|
|
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
|
|
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.#
|
|
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.
|
|
2787
|
+
var VERSION = "0.3.0";
|
|
2502
2788
|
|
|
2503
2789
|
exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
|
|
2504
2790
|
exports.CallEvent = CallEvent;
|