@visns-studio/visns-components 6.28.0 → 6.31.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.
@@ -20,15 +20,25 @@ import { subscriptionMonitor } from '../sms/smsLiveState';
20
20
  import {
21
21
  appendCallPopLog,
22
22
  installCallPopStatusHook,
23
+ noteCallPopChannelPresent,
23
24
  noteCallPopConnectionState,
24
25
  noteCallPopEvent,
25
26
  updateCallPopStatus,
26
27
  } from './callPopStatus';
27
28
 
29
+ // The header's phone badge listens on this SAME channel, and Echo's `leave()`
30
+ // destroys a channel for everyone who holds it — see sharedPrivateChannel.js.
31
+ import {
32
+ acquirePrivateChannel,
33
+ hasPrivateChannel,
34
+ releasePrivateChannel,
35
+ } from '../echo/sharedPrivateChannel';
36
+
28
37
  // Pure helpers live in a plain .js sibling so `node --test` can import them;
29
38
  // re-exported below so this file's public surface is unchanged.
30
39
  import {
31
40
  CLIENT_DETAIL_FIELDS,
41
+ DEFAULT_MAX_RINGING_MS,
32
42
  FALLBACK_QUEUE_NAME,
33
43
  KIND_DIRECT,
34
44
  MONITOR_PERMISSION,
@@ -42,6 +52,7 @@ import {
42
52
  defaultTaskUrl,
43
53
  demoClientDetails,
44
54
  directRingingLine,
55
+ expiredCallIds,
45
56
  formatAuPhone,
46
57
  formatDueDate,
47
58
  formatElapsed,
@@ -65,6 +76,7 @@ export {
65
76
  clientDetails,
66
77
  demoClientDetails,
67
78
  directRingingLine,
79
+ expiredCallIds,
68
80
  formatAuPhone,
69
81
  formatDueDate,
70
82
  formatElapsed,
@@ -112,13 +124,28 @@ export {
112
124
  * nothing: `.queue.missed` marks the card and starts a `missedGraceMs` timer
113
125
  * instead of removing it, and a further `.queue.ringing` for the same call
114
126
  * cancels that timer. Only `.queue.answered`/`.queue.ended` take a card away
115
- * outright.
127
+ * outright — plus one backstop: a card still ringing after `maxRingingMs` is
128
+ * dropped, because the alternative to trusting an event that never arrived is
129
+ * a phantom card ringing all afternoon.
130
+ *
131
+ * Both of those windows belong to the server, which is the side that decides
132
+ * when a call is over; it sends its own figures as the snapshot's
133
+ * `missed_grace_ms` and `max_ringing_ms`, and those win over the props.
116
134
  *
117
135
  * The snapshot is a catch-up mechanism, not just a first paint: it runs again
118
136
  * whenever the socket reconnects and whenever a hidden tab is looked at again,
119
137
  * and reconciles both ways, so a call that started or ended while this browser
120
138
  * was deaf is not stuck on (or missing from) the screen.
121
139
  *
140
+ * The channel is SHARED: the header's phone badge listens on the same private
141
+ * channel, and laravel-echo's `leave()` destroys a channel for everyone rather
142
+ * than just for its caller — so the badge unmounting (the CRM hides its header
143
+ * on record pages) used to silently unsubscribe the pop, with `subscribed`
144
+ * still reading true. Both sides now go through the ref-counted registry in
145
+ * ../echo/sharedPrivateChannel, and the pop rechecks the channel on every
146
+ * socket reconnect and every `resubscribeCheckMs` while the tab is visible,
147
+ * resubscribing when it finds it gone.
148
+ *
122
149
  * The caller -> client match arrives already resolved, as each call's `client`
123
150
  * block (computed once by the Zoom webhook, server-side). This component never
124
151
  * looks a caller up itself — every watching member of staff sees every pop, and
@@ -199,8 +226,15 @@ const EXIT_MS = 220;
199
226
  * enough for the next leg's `.queue.ringing` to arrive and cancel it, short
200
227
  * enough that a call nobody took stops sitting on screen. Override with the
201
228
  * `missedGraceMs` prop.
229
+ *
230
+ * 20s to match the server's own `missed_grace_seconds`. It was 10s — half the
231
+ * server's figure — which took the card away ten seconds BEFORE the server
232
+ * considered the call gone: a leg declining on a call still ringing elsewhere
233
+ * cleared the pop off everybody's screen while the phone was still ringing.
234
+ * The snapshot's `missed_grace_ms` overrides this, so the two sides cannot
235
+ * drift apart again.
202
236
  */
203
- const DEFAULT_MISSED_GRACE_MS = 10000;
237
+ const DEFAULT_MISSED_GRACE_MS = 20000;
204
238
 
205
239
  /**
206
240
  * Minimum gap between snapshot refreshes, for every reason except mount. A
@@ -209,6 +243,16 @@ const DEFAULT_MISSED_GRACE_MS = 10000;
209
243
  */
210
244
  const SNAPSHOT_MIN_GAP_MS = 5000;
211
245
 
246
+ /**
247
+ * How often the pop checks that its channel is still there, and resubscribes
248
+ * if it is not. A dropped socket announces itself; a channel taken away by
249
+ * another component's `echo.leave()` announces nothing at all, so the only way
250
+ * to find out is to look. Once a minute is cheap (a property read, and one
251
+ * throttled snapshot) and bounds the blind window at a minute. Override with
252
+ * the `resubscribeCheckMs` prop; 0 turns the poll off.
253
+ */
254
+ const DEFAULT_RESUBSCRIBE_CHECK_MS = 60000;
255
+
212
256
  /** Title shown on alternate ticks while a call rings in a hidden tab. */
213
257
  const FLASH_TITLE = '📞 Incoming call…';
214
258
 
@@ -254,6 +298,25 @@ const resolveUrl = (option, token, value, fallback) => {
254
298
  return fallback(value);
255
299
  };
256
300
 
301
+ /**
302
+ * A duration from the snapshot, or null when there is nothing usable there.
303
+ *
304
+ * Coerced rather than type-checked because a Laravel config value that has been
305
+ * through `.env` arrives as the string '20000' just as readily as the number,
306
+ * and a window the server clearly meant to state should not be ignored over
307
+ * that. Zero and negatives are null: "no timeout" is not a thing either of
308
+ * these windows can express, so a 0 is a misconfiguration, and falling back to
309
+ * the prop is the safer reading of it.
310
+ */
311
+ const positiveMs = (value) => {
312
+ if (value === null || value === undefined || value === '') {
313
+ return null;
314
+ }
315
+
316
+ const number = Number(value);
317
+
318
+ return Number.isFinite(number) && number > 0 ? number : null;
319
+ };
257
320
 
258
321
  /**
259
322
  * Is the user actually looking at this tab? Both checks matter: a focused but
@@ -300,6 +363,8 @@ const CallQueuePop = ({
300
363
  clientDetailFields = CLIENT_DETAIL_FIELDS,
301
364
  demoEnabled = true,
302
365
  missedGraceMs = DEFAULT_MISSED_GRACE_MS,
366
+ maxRingingMs = DEFAULT_MAX_RINGING_MS,
367
+ resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
303
368
  }) => {
304
369
  const [calls, setCalls] = useState([]);
305
370
  const [now, setNow] = useState(() => Date.now());
@@ -310,6 +375,20 @@ const CallQueuePop = ({
310
375
  // Environment-scoped Echo channel name, delivered by the snapshot.
311
376
  const [echoChannel, setEchoChannel] = useState(null);
312
377
 
378
+ /*
379
+ * The two timing windows, as the server states them in its snapshot.
380
+ *
381
+ * The server is the side that decides when a call is over — it holds the
382
+ * webhook ledger and the config — so whatever it sends wins over the props,
383
+ * which are only the figures to use until it has spoken (and for a host
384
+ * running an older backend, which sends neither).
385
+ */
386
+ const [serverMissedGraceMs, setServerMissedGraceMs] = useState(null);
387
+ const [serverMaxRingingMs, setServerMaxRingingMs] = useState(null);
388
+
389
+ const effectiveMissedGraceMs = serverMissedGraceMs ?? missedGraceMs;
390
+ const effectiveMaxRingingMs = serverMaxRingingMs ?? maxRingingMs;
391
+
313
392
  // Open-task drill-down, keyed by callId: which cards are expanded, and the
314
393
  // tasks fetched for each as `{ status: idle|loading|ready|error, tasks }`.
315
394
  const [expandedTasks, setExpandedTasks] = useState({});
@@ -457,7 +536,7 @@ const CallQueuePop = ({
457
536
 
458
537
  /**
459
538
  * A ringing leg was declined or timed out. The call may still be ringing on
460
- * someone else's device, so the card is marked and given `missedGraceMs` to
539
+ * someone else's device, so the card is marked and given the grace period to
461
540
  * prove it: a `.queue.ringing` inside that window cancels the timer, and
462
541
  * nothing arriving lets it remove the card the same way `.queue.ended`
463
542
  * would.
@@ -489,11 +568,11 @@ const CallQueuePop = ({
489
568
  const timer = setTimeout(() => {
490
569
  missedTimers.current.delete(id);
491
570
  removeCall(id);
492
- }, missedGraceMs);
571
+ }, effectiveMissedGraceMs);
493
572
 
494
573
  missedTimers.current.set(id, timer);
495
574
  },
496
- [missedGraceMs, removeCall]
575
+ [effectiveMissedGraceMs, removeCall]
497
576
  );
498
577
 
499
578
  /** Drop the whole stack (no exit animation — used by clear). */
@@ -890,8 +969,9 @@ const CallQueuePop = ({
890
969
  * reconcileSnapshot for the two exceptions), so a call that started while
891
970
  * the socket was down appears, and one that ended while it was down goes.
892
971
  *
893
- * @param {string} reason 'mount' | 'reconnect' | 'visible' — logged, so the
894
- * diagnostics panel says why each refresh happened.
972
+ * @param {string} reason 'mount' | 'reconnect' | 'visible' | 'resubscribe'
973
+ * | 'poll' — logged, so the diagnostics panel says why each refresh
974
+ * happened. Nothing validates the value; it is a label.
895
975
  */
896
976
  const refreshSnapshot = useCallback(
897
977
  (reason) => {
@@ -912,6 +992,25 @@ const CallQueuePop = ({
912
992
  setEchoChannel(result.channel);
913
993
  }
914
994
 
995
+ // Timing windows, when this server states them. Both
996
+ // are optional: an older backend sends neither, and the
997
+ // props stand in.
998
+ const grace = positiveMs(
999
+ result?.missed_grace_ms ?? result?.missedGraceMs
1000
+ );
1001
+
1002
+ if (grace !== null) {
1003
+ setServerMissedGraceMs(grace);
1004
+ }
1005
+
1006
+ const maxRinging = positiveMs(
1007
+ result?.max_ringing_ms ?? result?.maxRingingMs
1008
+ );
1009
+
1010
+ if (maxRinging !== null) {
1011
+ setServerMaxRingingMs(maxRinging);
1012
+ }
1013
+
915
1014
  if (result?.pickup_codes ?? result?.pickupCodes) {
916
1015
  const codes = normalisePickupCodes(
917
1016
  result.pickup_codes ?? result.pickupCodes
@@ -1148,14 +1247,24 @@ const CallQueuePop = ({
1148
1247
  let subscription = null;
1149
1248
  let stopMonitoring = () => {};
1150
1249
  let unbindState = () => {};
1250
+ let resubscribeTimer = null;
1251
+
1252
+ /**
1253
+ * Bind every `.queue.*` listener plus the subscription monitor.
1254
+ *
1255
+ * Broken out of the effect body because a resubscribe has to do all of
1256
+ * this again against a brand new subscription object — the old one is
1257
+ * a corpse once Echo has dropped the channel.
1258
+ *
1259
+ * @param {object} target The subscription to bind to.
1260
+ * @returns {Function} Stops the monitor again.
1261
+ */
1262
+ const attachListeners = (target) => {
1263
+ if (!target) {
1264
+ return () => {};
1265
+ }
1151
1266
 
1152
- try {
1153
- // Until /broadcasting/auth knows this channel the subscription just
1154
- // fails auth — pusher-js logs a warning, nothing of ours throws.
1155
- // That silence is exactly what the monitor below breaks.
1156
- subscription = instance.private(activeChannel);
1157
-
1158
- subscription.listen(EVENT_RINGING, (event) => {
1267
+ target.listen(EVENT_RINGING, (event) => {
1159
1268
  const call = event?.call ?? event;
1160
1269
 
1161
1270
  noteCallPopEvent(EVENT_RINGING);
@@ -1163,7 +1272,7 @@ const CallQueuePop = ({
1163
1272
  ingestCall(call);
1164
1273
  });
1165
1274
 
1166
- subscription.listen(EVENT_ANSWERED, (event) => {
1275
+ target.listen(EVENT_ANSWERED, (event) => {
1167
1276
  const callId =
1168
1277
  event?.callId ?? event?.call_id ?? event?.call?.callId;
1169
1278
 
@@ -1175,7 +1284,7 @@ const CallQueuePop = ({
1175
1284
  }
1176
1285
  });
1177
1286
 
1178
- subscription.listen(EVENT_ENDED, (event) => {
1287
+ target.listen(EVENT_ENDED, (event) => {
1179
1288
  const callId =
1180
1289
  event?.callId ?? event?.call_id ?? event?.call?.callId;
1181
1290
 
@@ -1190,7 +1299,7 @@ const CallQueuePop = ({
1190
1299
  // One leg said no. The call is not over — it may be ringing on
1191
1300
  // three other devices — so this starts a grace period rather than
1192
1301
  // removing anything.
1193
- subscription.listen(EVENT_MISSED, (event) => {
1302
+ target.listen(EVENT_MISSED, (event) => {
1194
1303
  const callId =
1195
1304
  event?.callId ?? event?.call_id ?? event?.call?.callId;
1196
1305
 
@@ -1204,7 +1313,7 @@ const CallQueuePop = ({
1204
1313
 
1205
1314
  // The diagnostics panel's round-trip probe. It settles nothing and
1206
1315
  // pops nothing — it only proves this browser is being delivered to.
1207
- subscription.listen(EVENT_PING, (event) => {
1316
+ target.listen(EVENT_PING, (event) => {
1208
1317
  const nonce = event?.nonce ?? event?.id ?? null;
1209
1318
 
1210
1319
  noteCallPopEvent(EVENT_PING, {
@@ -1216,7 +1325,7 @@ const CallQueuePop = ({
1216
1325
  trace('event .queue.diagnostic-ping', nonce);
1217
1326
  });
1218
1327
 
1219
- stopMonitoring = subscriptionMonitor(subscription, {
1328
+ return subscriptionMonitor(target, {
1220
1329
  onSuccess: () => {
1221
1330
  appendCallPopLog(`Subscribed to ${activeChannel}`, 'info', {
1222
1331
  subscribed: true,
@@ -1258,6 +1367,113 @@ const CallQueuePop = ({
1258
1367
  );
1259
1368
  },
1260
1369
  });
1370
+ };
1371
+
1372
+ /** Unbind whatever `attachListeners` bound. Never throws. */
1373
+ const detachListeners = (target) => {
1374
+ try {
1375
+ stopMonitoring();
1376
+ } catch (error) {
1377
+ // Already gone.
1378
+ }
1379
+
1380
+ stopMonitoring = () => {};
1381
+
1382
+ try {
1383
+ if (target) {
1384
+ target.stopListening(EVENT_RINGING);
1385
+ target.stopListening(EVENT_ANSWERED);
1386
+ target.stopListening(EVENT_ENDED);
1387
+ target.stopListening(EVENT_MISSED);
1388
+ target.stopListening(EVENT_PING);
1389
+ }
1390
+ } catch (error) {
1391
+ // A dead channel has nothing left to unbind.
1392
+ }
1393
+ };
1394
+
1395
+ /**
1396
+ * Confirm the channel is still there, and rebuild it when it is not.
1397
+ *
1398
+ * The failure this exists for is silent by construction: another
1399
+ * component calling `echo.leave()` on the shared channel unsubscribes
1400
+ * us without any event, so `subscribed` stays true and the pop simply
1401
+ * stops popping. Nothing tells us — we have to look.
1402
+ *
1403
+ * @returns {boolean} True when a resubscribe was performed.
1404
+ */
1405
+ const ensureSubscribed = () => {
1406
+ let present = false;
1407
+
1408
+ try {
1409
+ present = hasPrivateChannel(instance, activeChannel, subscription);
1410
+ } catch (error) {
1411
+ present = false;
1412
+ }
1413
+
1414
+ noteCallPopChannelPresent(present);
1415
+
1416
+ if (present) {
1417
+ return false;
1418
+ }
1419
+
1420
+ appendCallPopLog('Channel was gone — resubscribing', 'warn', {
1421
+ channel: activeChannel,
1422
+ subscribed: false,
1423
+ subscriptionError: null,
1424
+ });
1425
+ trace(`channel private-${activeChannel} was gone — resubscribing`);
1426
+
1427
+ detachListeners(subscription);
1428
+ // Release before acquiring so the ref count nets out unchanged:
1429
+ // this component held one reference before and holds one after.
1430
+ releasePrivateChannel(instance, activeChannel);
1431
+
1432
+ try {
1433
+ subscription = acquirePrivateChannel(instance, activeChannel);
1434
+ stopMonitoring = attachListeners(subscription);
1435
+ noteCallPopChannelPresent(
1436
+ hasPrivateChannel(instance, activeChannel, subscription)
1437
+ );
1438
+ } catch (error) {
1439
+ subscription = null;
1440
+ appendCallPopLog(
1441
+ `Could not resubscribe to ${activeChannel}: ${error?.message ?? error}`,
1442
+ 'error',
1443
+ {
1444
+ subscribed: false,
1445
+ channelPresent: false,
1446
+ subscriptionError: {
1447
+ status: null,
1448
+ message: String(error?.message ?? error),
1449
+ },
1450
+ }
1451
+ );
1452
+ trace('resubscribe threw', error?.message ?? error);
1453
+
1454
+ return false;
1455
+ }
1456
+
1457
+ // Anything that rang while we were unsubscribed was broadcast to
1458
+ // nobody here, so the snapshot is the only way to learn about it.
1459
+ requestSnapshot('resubscribe');
1460
+
1461
+ return true;
1462
+ };
1463
+
1464
+ try {
1465
+ // Until /broadcasting/auth knows this channel the subscription just
1466
+ // fails auth — pusher-js logs a warning, nothing of ours throws.
1467
+ // That silence is exactly what the monitor below breaks.
1468
+ //
1469
+ // Ref-counted rather than `instance.private()` directly: the
1470
+ // header's phone badge shares this channel and unmounts on record
1471
+ // pages, and its `leave()` used to take our subscription with it.
1472
+ subscription = acquirePrivateChannel(instance, activeChannel);
1473
+ stopMonitoring = attachListeners(subscription);
1474
+ noteCallPopChannelPresent(
1475
+ hasPrivateChannel(instance, activeChannel, subscription)
1476
+ );
1261
1477
  } catch (error) {
1262
1478
  subscription = null;
1263
1479
  appendCallPopLog(
@@ -1265,6 +1481,7 @@ const CallQueuePop = ({
1265
1481
  'error',
1266
1482
  {
1267
1483
  subscribed: false,
1484
+ channelPresent: false,
1268
1485
  subscriptionError: {
1269
1486
  status: null,
1270
1487
  message: String(error?.message ?? error),
@@ -1293,6 +1510,11 @@ const CallQueuePop = ({
1293
1510
  // only when the socket actually went away, otherwise every
1294
1511
  // ordinary connecting -> connected on load would refetch.
1295
1512
  if (current === 'connected') {
1513
+ // A socket can come back without our channel: pusher-js
1514
+ // resubscribes what it still holds, and a channel that
1515
+ // was `leave()`n is no longer among them.
1516
+ ensureSubscribed();
1517
+
1296
1518
  if (sawDisconnect.current) {
1297
1519
  requestSnapshot('reconnect');
1298
1520
  }
@@ -1327,30 +1549,42 @@ const CallQueuePop = ({
1327
1549
  // says whether this browser is live, which is what it said before.
1328
1550
  }
1329
1551
 
1330
- return () => {
1331
- unbindState();
1552
+ // The backstop. `state_change` covers a socket that visibly went away;
1553
+ // this covers the case with no signal at all — the channel removed
1554
+ // underneath a perfectly healthy socket. Only while the tab is being
1555
+ // looked at: a backgrounded tab that nobody can see a pop in has
1556
+ // nothing to catch up for, and the visibilitychange effect above
1557
+ // already refreshes on the way back.
1558
+ if (resubscribeCheckMs > 0) {
1559
+ resubscribeTimer = setInterval(() => {
1560
+ if (
1561
+ typeof document !== 'undefined' &&
1562
+ document.visibilityState !== 'visible'
1563
+ ) {
1564
+ return;
1565
+ }
1332
1566
 
1333
- try {
1334
- stopMonitoring();
1335
- } catch (error) {
1336
- // Already gone.
1567
+ ensureSubscribed();
1568
+ requestSnapshot('poll');
1569
+ }, resubscribeCheckMs);
1570
+ }
1571
+
1572
+ return () => {
1573
+ if (resubscribeTimer !== null) {
1574
+ clearInterval(resubscribeTimer);
1575
+ resubscribeTimer = null;
1337
1576
  }
1338
1577
 
1339
- try {
1340
- if (subscription) {
1341
- subscription.stopListening(EVENT_RINGING);
1342
- subscription.stopListening(EVENT_ANSWERED);
1343
- subscription.stopListening(EVENT_ENDED);
1344
- subscription.stopListening(EVENT_MISSED);
1345
- subscription.stopListening(EVENT_PING);
1346
- }
1578
+ unbindState();
1579
+ detachListeners(subscription);
1347
1580
 
1348
- instance.leave(activeChannel);
1349
- } catch (error) {
1350
- // Nothing to clean up.
1351
- }
1581
+ // Only really leaves the channel when nobody else (the header's
1582
+ // phone badge) is still holding it.
1583
+ releasePrivateChannel(instance, activeChannel);
1352
1584
 
1353
- updateCallPopStatus({ subscribed: false });
1585
+ // `channelPresent` goes back to unknown rather than false: the pop
1586
+ // is no longer watching, so it has nothing to report either way.
1587
+ updateCallPopStatus({ subscribed: false, channelPresent: null });
1354
1588
  };
1355
1589
  }, [
1356
1590
  activeChannel,
@@ -1359,25 +1593,56 @@ const CallQueuePop = ({
1359
1593
  markMissed,
1360
1594
  removeCall,
1361
1595
  requestSnapshot,
1596
+ resubscribeCheckMs,
1362
1597
  ]);
1363
1598
 
1364
1599
  /**
1365
1600
  * One interval for the whole stack, started only while cards are on screen
1366
1601
  * and torn down the moment the stack empties.
1602
+ *
1603
+ * It ticks the elapsed timers, and it sweeps. The sweep is the backstop for
1604
+ * an end that never came: `.queue.answered`/`.queue.ended` are the only
1605
+ * things that take a card away, and one dropped webhook or one lost frame
1606
+ * leaves a card ringing on screen indefinitely — long after the caller hung
1607
+ * up, which is exactly the call somebody then rings back for nothing. Any
1608
+ * card older than the effective max ringing time goes (see expiredCallIds).
1367
1609
  */
1368
1610
  useEffect(() => {
1369
1611
  if (calls.length === 0) {
1370
1612
  return undefined;
1371
1613
  }
1372
1614
 
1373
- setNow(Date.now());
1615
+ const tick = () => {
1616
+ const timestamp = Date.now();
1617
+
1618
+ setNow(timestamp);
1619
+
1620
+ const expired = expiredCallIds(
1621
+ callsRef.current,
1622
+ timestamp,
1623
+ effectiveMaxRingingMs
1624
+ );
1625
+
1626
+ // One line per card, not per tick: `removeCall` marks it leaving
1627
+ // immediately, and `expiredCallIds` skips a card on its way out.
1628
+ expired.forEach((callId) => {
1629
+ appendCallPopLog(
1630
+ `Call ${callId} has been ringing for over ` +
1631
+ `${Math.round(effectiveMaxRingingMs / 1000)}s with no ` +
1632
+ 'answered/ended event — removing the card',
1633
+ 'warn'
1634
+ );
1635
+ trace('expired card', callId);
1636
+ removeCall(callId);
1637
+ });
1638
+ };
1639
+
1640
+ tick();
1374
1641
 
1375
- const interval = setInterval(() => {
1376
- setNow(Date.now());
1377
- }, 1000);
1642
+ const interval = setInterval(tick, 1000);
1378
1643
 
1379
1644
  return () => clearInterval(interval);
1380
- }, [calls.length]);
1645
+ }, [calls.length, effectiveMaxRingingMs, removeCall]);
1381
1646
 
1382
1647
  /**
1383
1648
  * Ask for notification permission, lazily and only for gated-in users.
@@ -49,6 +49,15 @@ export const emptyCallPopStatus = () => ({
49
49
  /** Has the channel reported pusher:subscription_succeeded? */
50
50
  subscribed: false,
51
51
  subscribedAt: null,
52
+ /**
53
+ * Does Echo still hold the channel? null = not checked yet.
54
+ *
55
+ * Separate from `subscribed` on purpose: a channel torn down by somebody
56
+ * else's `echo.leave()` leaves `subscribed` stuck at true — nothing tells
57
+ * the pop it has been unsubscribed — so "we think we are subscribed" and
58
+ * "the channel is actually there" have to be two readings, not one.
59
+ */
60
+ channelPresent: null,
52
61
  /** `{status, message}` from pusher:subscription_error, else null. */
53
62
  subscriptionError: null,
54
63
  /** The one-shot `/ajax/call-queue/live` snapshot. */
@@ -207,6 +216,20 @@ export const noteCallPopConnectionState = (state) => {
207
216
  });
208
217
  };
209
218
 
219
+ /**
220
+ * Record whether Echo still holds the pop's channel.
221
+ *
222
+ * Written by the pop's periodic check and whenever it (re)subscribes, so the
223
+ * diagnostics panel can show the one thing `subscribed` cannot: a subscription
224
+ * that was confirmed once and has since been thrown away underneath us.
225
+ *
226
+ * @param {boolean|null} present
227
+ */
228
+ export const noteCallPopChannelPresent = (present) =>
229
+ updateCallPopStatus({
230
+ channelPresent: typeof present === 'boolean' ? present : null,
231
+ });
232
+
210
233
  /**
211
234
  * Expose the reader on `window` next to the demo hooks, so support can run
212
235
  * `callPopStatus()` in a staff member's console without a build of anything.
@@ -575,6 +575,72 @@ export const reconcileSnapshot = (
575
575
  return { add, remove };
576
576
  };
577
577
 
578
+ /**
579
+ * How long a card may ring before this browser stops believing in it.
580
+ *
581
+ * A card's whole life depends on a `.queue.answered` / `.queue.ended` arriving,
582
+ * and that is one Zoom webhook and one broadcast away from never happening — a
583
+ * webhook Zoom retried into a dead queue worker, a socket frame lost on the way
584
+ * here. Nothing else removes the card, so it rings on screen forever and
585
+ * somebody eventually picks up a call that finished twenty minutes ago.
586
+ *
587
+ * Two minutes is well past the point Zoom itself stops ringing a queue, so a
588
+ * card older than that is not a call anybody can still answer. The server sends
589
+ * its own figure as the snapshot's `max_ringing_ms`; this is the fallback.
590
+ */
591
+ export const DEFAULT_MAX_RINGING_MS = 120000;
592
+
593
+ /**
594
+ * Cards that have rung too long to still be real — the ids to drop.
595
+ *
596
+ * Deliberately blunt, because what it defends against is the ABSENCE of a
597
+ * signal rather than a signal: only the age of the card is consulted. A card
598
+ * with no parseable `startedAt` is left alone — "we cannot date it" is not
599
+ * evidence the call is over — as is one already animating out, and a demo card,
600
+ * which has no server behind it to end it (`reconcileSnapshot` exempts demo
601
+ * cards for the same reason).
602
+ *
603
+ * @param {Array} calls Cards on screen (normalised calls).
604
+ * @param {number} now
605
+ * @param {number} maxRingingMs Age limit in ms; 0 or less disables the sweep.
606
+ *
607
+ * @returns {Array<string>} callIds to remove.
608
+ */
609
+ export const expiredCallIds = (
610
+ calls,
611
+ now = Date.now(),
612
+ maxRingingMs = DEFAULT_MAX_RINGING_MS
613
+ ) => {
614
+ const list = Array.isArray(calls) ? calls : [];
615
+
616
+ if (!Number.isFinite(maxRingingMs) || maxRingingMs <= 0) {
617
+ return [];
618
+ }
619
+
620
+ return list
621
+ .filter((call) => {
622
+ if (!call || call.leaving || call.isDemo) {
623
+ return false;
624
+ }
625
+
626
+ // `null` is checked before `new Date`, which reads it as the
627
+ // epoch rather than as a bad date and would age every undated card
628
+ // straight off the screen.
629
+ if (call.startedAt === null || call.startedAt === undefined) {
630
+ return false;
631
+ }
632
+
633
+ const started = new Date(call.startedAt).getTime();
634
+
635
+ if (Number.isNaN(started)) {
636
+ return false;
637
+ }
638
+
639
+ return now - started > maxRingingMs;
640
+ })
641
+ .map((call) => call.callId);
642
+ };
643
+
578
644
  /**
579
645
  * Coerce the snapshot's `pickup_codes` block into a plain `{ queueId: code }`
580
646
  * map, dropping anything that is not a non-empty string on both sides. A queue