@visns-studio/visns-components 6.30.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.
package/README.md CHANGED
@@ -9,6 +9,34 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
+ ## Recent Updates (v6.31.0)
13
+
14
+ ### The header badge self-heals too, and the pop takes its timings from the server
15
+
16
+ Follow-up to 6.30.0. That release stopped the two holders of the shared
17
+ `call-queue-monitor.{env}` channel from destroying each other's subscription,
18
+ and taught the call pop to notice a missing channel and rebuild it. This one
19
+ finishes the job.
20
+
21
+ - **`useZoomPhoneLive` / `ZoomPhoneBadge` resubscribe the same way the pop
22
+ does** — on every socket reconnect and every `resubscribeCheckMs` (new
23
+ option/prop, default 60 s, `0` disables) while the tab is visible. Before,
24
+ a badge whose channel was taken away kept its green `Live` stamp and never
25
+ received another presence event.
26
+ - **`hasPrivateChannel(instance, name, held)`** takes the subscription the
27
+ caller is listening on. With two self-healing holders, whichever rebuilt
28
+ first left the registry looking healthy while the other still held the dead
29
+ object; passing what you hold makes the answer "no" until you re-acquire.
30
+ - **`CallQueuePop` adopts `missed_grace_ms` and `max_ringing_ms` from the
31
+ live snapshot** when the server sends them, so the browser and the server
32
+ agree on how long a declined leg keeps the card. The `missedGraceMs` prop's
33
+ default is now **20000** (it was 10000 — half the server's, so a card left
34
+ 10 s before the server considered the call gone).
35
+ - **Phantom cards expire.** New `maxRingingMs` prop (default 120000, or the
36
+ snapshot's `max_ringing_ms`): a card whose `startedAt` is older than that
37
+ is removed and logged — a lost ended webhook must not ring on a screen
38
+ forever. `expiredCallIds(calls, now, maxRingingMs)` is exported.
39
+
12
40
  ## Recent Updates (v6.30.0)
13
41
 
14
42
  ### `CallQueuePop` no longer loses its channel to the header's phone badge
@@ -2290,6 +2318,9 @@ endpoint nor an Echo instance present it renders nothing and logs nothing.
2290
2318
  | `syncChannelName` | `'throughlife-call-queue-pop'` | The `BroadcastChannel` that keeps every open tab's stack in step. Falsy switches cross-tab sync off. |
2291
2319
  | `clientDetailFields` | `CLIENT_DETAIL_FIELDS` (adviser / coding / age / city) | `[{ key, label, demo? }]` — the client-block rows, in card order; rows with an empty value are dropped; `demo` seeds the demo card. Pass your CRM's own fields so the card never names a field it does not have. Must be referentially stable (a module-scope constant, not an inline literal) — it sits in the demo effect's dependency list. |
2292
2320
  | `demoEnabled` | `true` | Registers `window.callPopDemo()` / `window.callPopClear()` for reviewing the UI without a backend. |
2321
+ | `missedGraceMs` | `20000` | How long a card survives a `.queue.missed` before it is taken as gone — a call rings several devices, so one declined leg only starts this timer and a further `.queue.ringing` cancels it. Was `10000`, half the server's own `missed_grace_seconds`, which took the card off screen ten seconds before the server considered the call gone. The snapshot's `missed_grace_ms` wins over it. |
2322
+ | `maxRingingMs` | `120000` | The backstop for an end that never arrived: a card still ringing after this is removed client-side, because a dropped `.queue.ended` otherwise leaves a phantom card ringing all afternoon. Demo cards and cards already leaving are exempt. The snapshot's `max_ringing_ms` wins over it. |
2323
+ | `resubscribeCheckMs` | `60000` | How often the pop confirms its Echo channel is still subscribed (visible tabs only), resubscribing and refetching the snapshot when it is not. `0` turns the check off. |
2293
2324
 
2294
2325
  **Payload contract.** Snake_case and camelCase are both accepted, so a Laravel
2295
2326
  resource passes through untouched: `call_id`/`callId`, `queue_id`/`queueId`,
@@ -2299,6 +2330,22 @@ resource passes through untouched: `call_id`/`callId`, `queue_id`/`queueId`,
2299
2330
  The snapshot's `pickup_codes` map is keyed by Zoom call queue id — a queue
2300
2331
  absent from it still pops, its card simply has no Pick up button.
2301
2332
 
2333
+ The snapshot carries three optional settings alongside the calls, and each wins
2334
+ over the matching prop once it lands — the server holds the webhook ledger and
2335
+ the config, so it is the side that decides when a call is over:
2336
+
2337
+ ```jsonc
2338
+ {
2339
+ "calls": [ /* … */ ],
2340
+ "pickup_codes": { "77": "*996439" },
2341
+ "channel": "call-queue-monitor.production", // environment-scoped Echo channel
2342
+ "missed_grace_ms": 20000, // overrides `missedGraceMs`
2343
+ "max_ringing_ms": 120000 // overrides `maxRingingMs`
2344
+ }
2345
+ ```
2346
+
2347
+ An older backend sends none of them and the props stand in unchanged.
2348
+
2302
2349
  Named exports for testing: `toLocalDigits`, `formatAuPhone`, `formatEventDate`,
2303
2350
  `formatDueDate`, `toCallWorkspaceId`, `normaliseCall`, `normalisePickupCodes`,
2304
2351
  `formatElapsed`, `hasMonitorPermission`, `clientDetails`.
package/package.json CHANGED
@@ -93,7 +93,7 @@
93
93
  "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
94
94
  },
95
95
  "name": "@visns-studio/visns-components",
96
- "version": "6.30.0",
96
+ "version": "6.31.0",
97
97
  "description": "Various packages to assist in the development of our Custom Applications.",
98
98
  "main": "src/index.js",
99
99
  "files": [
@@ -38,6 +38,7 @@ import {
38
38
  // re-exported below so this file's public surface is unchanged.
39
39
  import {
40
40
  CLIENT_DETAIL_FIELDS,
41
+ DEFAULT_MAX_RINGING_MS,
41
42
  FALLBACK_QUEUE_NAME,
42
43
  KIND_DIRECT,
43
44
  MONITOR_PERMISSION,
@@ -51,6 +52,7 @@ import {
51
52
  defaultTaskUrl,
52
53
  demoClientDetails,
53
54
  directRingingLine,
55
+ expiredCallIds,
54
56
  formatAuPhone,
55
57
  formatDueDate,
56
58
  formatElapsed,
@@ -74,6 +76,7 @@ export {
74
76
  clientDetails,
75
77
  demoClientDetails,
76
78
  directRingingLine,
79
+ expiredCallIds,
77
80
  formatAuPhone,
78
81
  formatDueDate,
79
82
  formatElapsed,
@@ -121,7 +124,13 @@ export {
121
124
  * nothing: `.queue.missed` marks the card and starts a `missedGraceMs` timer
122
125
  * instead of removing it, and a further `.queue.ringing` for the same call
123
126
  * cancels that timer. Only `.queue.answered`/`.queue.ended` take a card away
124
- * 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.
125
134
  *
126
135
  * The snapshot is a catch-up mechanism, not just a first paint: it runs again
127
136
  * whenever the socket reconnects and whenever a hidden tab is looked at again,
@@ -217,8 +226,15 @@ const EXIT_MS = 220;
217
226
  * enough for the next leg's `.queue.ringing` to arrive and cancel it, short
218
227
  * enough that a call nobody took stops sitting on screen. Override with the
219
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.
220
236
  */
221
- const DEFAULT_MISSED_GRACE_MS = 10000;
237
+ const DEFAULT_MISSED_GRACE_MS = 20000;
222
238
 
223
239
  /**
224
240
  * Minimum gap between snapshot refreshes, for every reason except mount. A
@@ -282,6 +298,25 @@ const resolveUrl = (option, token, value, fallback) => {
282
298
  return fallback(value);
283
299
  };
284
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
+ };
285
320
 
286
321
  /**
287
322
  * Is the user actually looking at this tab? Both checks matter: a focused but
@@ -328,6 +363,7 @@ const CallQueuePop = ({
328
363
  clientDetailFields = CLIENT_DETAIL_FIELDS,
329
364
  demoEnabled = true,
330
365
  missedGraceMs = DEFAULT_MISSED_GRACE_MS,
366
+ maxRingingMs = DEFAULT_MAX_RINGING_MS,
331
367
  resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
332
368
  }) => {
333
369
  const [calls, setCalls] = useState([]);
@@ -339,6 +375,20 @@ const CallQueuePop = ({
339
375
  // Environment-scoped Echo channel name, delivered by the snapshot.
340
376
  const [echoChannel, setEchoChannel] = useState(null);
341
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
+
342
392
  // Open-task drill-down, keyed by callId: which cards are expanded, and the
343
393
  // tasks fetched for each as `{ status: idle|loading|ready|error, tasks }`.
344
394
  const [expandedTasks, setExpandedTasks] = useState({});
@@ -486,7 +536,7 @@ const CallQueuePop = ({
486
536
 
487
537
  /**
488
538
  * A ringing leg was declined or timed out. The call may still be ringing on
489
- * 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
490
540
  * prove it: a `.queue.ringing` inside that window cancels the timer, and
491
541
  * nothing arriving lets it remove the card the same way `.queue.ended`
492
542
  * would.
@@ -518,11 +568,11 @@ const CallQueuePop = ({
518
568
  const timer = setTimeout(() => {
519
569
  missedTimers.current.delete(id);
520
570
  removeCall(id);
521
- }, missedGraceMs);
571
+ }, effectiveMissedGraceMs);
522
572
 
523
573
  missedTimers.current.set(id, timer);
524
574
  },
525
- [missedGraceMs, removeCall]
575
+ [effectiveMissedGraceMs, removeCall]
526
576
  );
527
577
 
528
578
  /** Drop the whole stack (no exit animation — used by clear). */
@@ -942,6 +992,25 @@ const CallQueuePop = ({
942
992
  setEchoChannel(result.channel);
943
993
  }
944
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
+
945
1014
  if (result?.pickup_codes ?? result?.pickupCodes) {
946
1015
  const codes = normalisePickupCodes(
947
1016
  result.pickup_codes ?? result.pickupCodes
@@ -1337,7 +1406,7 @@ const CallQueuePop = ({
1337
1406
  let present = false;
1338
1407
 
1339
1408
  try {
1340
- present = hasPrivateChannel(instance, activeChannel);
1409
+ present = hasPrivateChannel(instance, activeChannel, subscription);
1341
1410
  } catch (error) {
1342
1411
  present = false;
1343
1412
  }
@@ -1364,7 +1433,7 @@ const CallQueuePop = ({
1364
1433
  subscription = acquirePrivateChannel(instance, activeChannel);
1365
1434
  stopMonitoring = attachListeners(subscription);
1366
1435
  noteCallPopChannelPresent(
1367
- hasPrivateChannel(instance, activeChannel)
1436
+ hasPrivateChannel(instance, activeChannel, subscription)
1368
1437
  );
1369
1438
  } catch (error) {
1370
1439
  subscription = null;
@@ -1403,7 +1472,7 @@ const CallQueuePop = ({
1403
1472
  subscription = acquirePrivateChannel(instance, activeChannel);
1404
1473
  stopMonitoring = attachListeners(subscription);
1405
1474
  noteCallPopChannelPresent(
1406
- hasPrivateChannel(instance, activeChannel)
1475
+ hasPrivateChannel(instance, activeChannel, subscription)
1407
1476
  );
1408
1477
  } catch (error) {
1409
1478
  subscription = null;
@@ -1530,20 +1599,50 @@ const CallQueuePop = ({
1530
1599
  /**
1531
1600
  * One interval for the whole stack, started only while cards are on screen
1532
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).
1533
1609
  */
1534
1610
  useEffect(() => {
1535
1611
  if (calls.length === 0) {
1536
1612
  return undefined;
1537
1613
  }
1538
1614
 
1539
- 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();
1540
1641
 
1541
- const interval = setInterval(() => {
1542
- setNow(Date.now());
1543
- }, 1000);
1642
+ const interval = setInterval(tick, 1000);
1544
1643
 
1545
1644
  return () => clearInterval(interval);
1546
- }, [calls.length]);
1645
+ }, [calls.length, effectiveMaxRingingMs, removeCall]);
1547
1646
 
1548
1647
  /**
1549
1648
  * Ask for notification permission, lazily and only for gated-in users.
@@ -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
@@ -168,7 +168,8 @@ export const releasePrivateChannel = (instance, name) => {
168
168
  };
169
169
 
170
170
  /**
171
- * Is this channel actually still subscribed?
171
+ * Is this channel actually still subscribed — and, when the caller says what
172
+ * it is holding, is THAT the subscription the registry currently has?
172
173
  *
173
174
  * True only when the registry has a live entry AND — when the connector
174
175
  * exposes its cache — Echo still holds `private-<name>`. The second half is
@@ -176,12 +177,20 @@ export const releasePrivateChannel = (instance, name) => {
176
177
  * kills the channel, and that mismatch is precisely the failure this module
177
178
  * was written for. Callers use it to notice and resubscribe.
178
179
  *
180
+ * The optional `held` argument closes a race between two self-healing
181
+ * holders. When holder A notices the channel gone and rebuilds it, the
182
+ * registry (and Echo's cache) hold a fresh subscription again — so holder B's
183
+ * check, a moment later, would see "present" and keep listening on the dead
184
+ * object A just replaced. Passing what you hold makes the answer "no" until
185
+ * you have re-acquired, so both holders end up on the live subscription.
186
+ *
179
187
  * @param {object} instance Echo instance.
180
188
  * @param {string} name Channel name, unprefixed.
189
+ * @param {object} [held] The subscription this caller is listening on.
181
190
  *
182
191
  * @returns {boolean}
183
192
  */
184
- export const hasPrivateChannel = (instance, name) => {
193
+ export const hasPrivateChannel = (instance, name, held = undefined) => {
185
194
  if (!instance || !name) {
186
195
  return false;
187
196
  }
@@ -192,6 +201,10 @@ export const hasPrivateChannel = (instance, name) => {
192
201
  return false;
193
202
  }
194
203
 
204
+ if (held !== undefined && entry.subscription !== held) {
205
+ return false;
206
+ }
207
+
195
208
  // `null` = the connector cannot tell us; the registry is then all we have.
196
209
  return echoHolds(instance, name) !== false;
197
210
  };
@@ -90,6 +90,10 @@ const ZoomPhoneBadgeInner = ({
90
90
  // somebody is looking at is worth more than one behind a closed chip.
91
91
  pollInterval = 60_000,
92
92
  openPollInterval = 20_000,
93
+ // How often the subscription confirms it still has its channel. Left
94
+ // undefined so useZoomPhoneLive's own default (60s) applies; a host only
95
+ // passes this to slow the check down, or to switch it off with 0.
96
+ resubscribeCheckMs,
93
97
  // ⌘⇧U / Ctrl+Shift+U. Not ⌘⇧P: browsers open a private window on that.
94
98
  shortcut = 'mod+shift+u',
95
99
  navigate,
@@ -194,6 +198,7 @@ const ZoomPhoneBadgeInner = ({
194
198
  echo,
195
199
  channel,
196
200
  onPresence: handlePresence,
201
+ resubscribeCheckMs,
197
202
  });
198
203
 
199
204
  /*
@@ -15,6 +15,7 @@
15
15
  // `node --test`, before tests/jsxHooks.mjs (which is what resolves the
16
16
  // library's extensionless imports) has been registered.
17
17
  import { initialsFor, normaliseNumberForDisplay } from '../sms/smsHelpers.js';
18
+ import { isDeadConnectionState } from '../sms/smsLiveState.js';
18
19
 
19
20
  export { initialsFor, normaliseNumberForDisplay };
20
21
 
@@ -296,3 +297,33 @@ export const freshnessLabel = (fetchedAt, live, now = Date.now()) => {
296
297
 
297
298
  return `Updated ${Math.floor(seconds / 3600)}h ago`;
298
299
  };
300
+
301
+ /**
302
+ * Has the roster's channel been taken away, and is now the moment to rebuild it?
303
+ *
304
+ * The failure being watched for is silent: another component calling
305
+ * `echo.leave()` on the shared `call-queue-monitor.{env}` channel unsubscribes
306
+ * everybody without raising a single event, so `live` stays true and the
307
+ * presence events simply stop. Nothing announces it — the only way to find out
308
+ * is to look, which is why the hook looks on a timer.
309
+ *
310
+ * `present` is what the shared-channel registry says (`hasPrivateChannel`), and
311
+ * `state` is the pusher connection state. A dead socket is deliberately NOT a
312
+ * reason to resubscribe: nothing can be subscribed while it is down, pusher-js
313
+ * restores what it still holds when it returns, and the hook re-checks on every
314
+ * `connected` transition anyway. An unknown state (`null` — the host's Echo may
315
+ * expose no reachable connection object) is not treated as dead.
316
+ *
317
+ * @param {object} options
318
+ * @param {boolean} options.present Does the registry still hold the channel?
319
+ * @param {string|null} [options.state] Pusher connection state, when known.
320
+ *
321
+ * @returns {boolean}
322
+ */
323
+ export const shouldResubscribe = ({ present, state = null } = {}) => {
324
+ if (present) {
325
+ return false;
326
+ }
327
+
328
+ return !isDeadConnectionState(state);
329
+ };
@@ -11,12 +11,25 @@ import {
11
11
  // sharedPrivateChannel.js.
12
12
  import {
13
13
  acquirePrivateChannel,
14
+ hasPrivateChannel,
14
15
  releasePrivateChannel,
15
16
  } from '../echo/sharedPrivateChannel';
16
17
 
18
+ import { shouldResubscribe } from './phonePresenceHelpers';
19
+
17
20
  /** The event the backend broadcasts when one extension's state changes. */
18
21
  export const EVENT_PRESENCE = '.phone.presence';
19
22
 
23
+ /**
24
+ * How often the hook confirms its channel is still subscribed.
25
+ *
26
+ * A socket that drops announces itself; a channel destroyed by somebody else's
27
+ * `echo.leave()` announces nothing at all, so the only way to notice is to
28
+ * look. Once a minute costs a property read and bounds the blind window at a
29
+ * minute — the same figure, for the same reason, as the call pop's own check.
30
+ */
31
+ export const DEFAULT_RESUBSCRIBE_CHECK_MS = 60000;
32
+
20
33
  /**
21
34
  * Live phone presence: Pusher when it is wired up, polling when it is not.
22
35
  *
@@ -36,11 +49,20 @@ export const EVENT_PRESENCE = '.phone.presence';
36
49
  * user without the permission never opens a Pusher connection at all. The
37
50
  * account has a hard concurrent-connection quota.
38
51
  *
52
+ * The channel is shared, and a shared channel can be taken away without a
53
+ * word: `echo.leave()` anywhere else destroys it for every holder. So this
54
+ * hook does what the call pop does — rechecks the channel on every socket
55
+ * reconnect and every `resubscribeCheckMs` while the tab is visible, and
56
+ * rebuilds its subscription when it finds the channel gone. Without that the
57
+ * roster stays on a green `Live` stamp and never receives another event.
58
+ *
39
59
  * @param {object} options
40
60
  * @param {object|Function|null} options.echo Echo instance, or `() => echo`.
41
61
  * @param {string|null} options.channel Private channel name.
42
62
  * @param {boolean} [options.enabled] Off entirely when false.
43
63
  * @param {Function} [options.onPresence] `({cleared, keys, call}) => void`.
64
+ * @param {number} [options.resubscribeCheckMs] How often to confirm the
65
+ * channel is still there (ms, visible tabs only). 0 turns the check off.
44
66
  *
45
67
  * @returns {{live: boolean}}
46
68
  */
@@ -49,6 +71,7 @@ const useZoomPhoneLive = ({
49
71
  channel = null,
50
72
  enabled = true,
51
73
  onPresence,
74
+ resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
52
75
  } = {}) => {
53
76
  const [live, setLive] = useState(false);
54
77
 
@@ -86,17 +109,24 @@ const useZoomPhoneLive = ({
86
109
  let subscription = null;
87
110
  let unmonitor = () => {};
88
111
  let unbindState = () => {};
89
-
90
- try {
91
- // Ref-counted: the call pop is very likely already on this
92
- // channel, and the two must share one subscription.
93
- subscription = acquirePrivateChannel(instance, channel);
94
-
95
- subscription.listen(EVENT_PRESENCE, (event) => {
112
+ let resubscribeTimer = null;
113
+
114
+ /**
115
+ * Bind the presence listener and the confirmation monitor to a channel.
116
+ *
117
+ * Broken out of the effect body because a resubscribe has to do it all
118
+ * again against a brand new subscription object — the old one is a
119
+ * corpse once Echo has dropped the channel.
120
+ *
121
+ * @param {object} target The subscription to bind to.
122
+ * @returns {Function} Stops the monitor again.
123
+ */
124
+ const attachListener = (target) => {
125
+ target.listen(EVENT_PRESENCE, (event) => {
96
126
  presenceRef.current?.(event || {});
97
127
  });
98
128
 
99
- unmonitor = subscriptionMonitor(subscription, {
129
+ return subscriptionMonitor(target, {
100
130
  onSuccess: () => {
101
131
  confirmed = true;
102
132
  sync();
@@ -106,6 +136,13 @@ const useZoomPhoneLive = ({
106
136
  sync();
107
137
  },
108
138
  });
139
+ };
140
+
141
+ try {
142
+ // Ref-counted: the call pop is very likely already on this
143
+ // channel, and the two must share one subscription.
144
+ subscription = acquirePrivateChannel(instance, channel);
145
+ unmonitor = attachListener(subscription);
109
146
  } catch (error) {
110
147
  // A channel that will not subscribe leaves the poller in charge,
111
148
  // which is the whole point of the poller.
@@ -114,6 +151,67 @@ const useZoomPhoneLive = ({
114
151
  return undefined;
115
152
  }
116
153
 
154
+ /**
155
+ * Confirm the channel is still there, and rebuild it when it is not.
156
+ *
157
+ * Never throws: this runs from a socket callback and from an interval,
158
+ * and a roster that cannot resubscribe must fall back to its poll, not
159
+ * take the header down with it.
160
+ *
161
+ * @returns {boolean} True when a resubscribe was performed.
162
+ */
163
+ const ensureSubscribed = () => {
164
+ let present = false;
165
+
166
+ try {
167
+ present = hasPrivateChannel(instance, channel, subscription);
168
+ } catch (error) {
169
+ present = false;
170
+ }
171
+
172
+ if (!shouldResubscribe({ present, state: connectionState })) {
173
+ return false;
174
+ }
175
+
176
+ try {
177
+ unmonitor();
178
+ } catch (error) {
179
+ // Already gone.
180
+ }
181
+
182
+ unmonitor = () => {};
183
+
184
+ try {
185
+ subscription?.stopListening(EVENT_PRESENCE);
186
+ } catch (error) {
187
+ // A dead channel has nothing left to unbind.
188
+ }
189
+
190
+ // Release before acquiring so the ref count nets out unchanged:
191
+ // this hook held one reference before and holds one after.
192
+ releasePrivateChannel(instance, channel);
193
+
194
+ // Nothing has arrived on the new subscription yet, so `live` goes
195
+ // back to false until the monitor confirms it. A green stamp over
196
+ // an unconfirmed channel is the exact lie this hook exists to
197
+ // avoid telling.
198
+ confirmed = false;
199
+
200
+ try {
201
+ subscription = acquirePrivateChannel(instance, channel);
202
+ unmonitor = attachListener(subscription);
203
+ } catch (error) {
204
+ subscription = null;
205
+ sync();
206
+
207
+ return false;
208
+ }
209
+
210
+ sync();
211
+
212
+ return true;
213
+ };
214
+
117
215
  // The socket underneath. When it goes, the channel goes with it whatever
118
216
  // the confirmation said; pusher-js resubscribes on its own once it is
119
217
  // back, and the subscribed callback re-confirms.
@@ -128,6 +226,11 @@ const useZoomPhoneLive = ({
128
226
 
129
227
  if (isDeadConnectionState(current)) confirmed = false;
130
228
 
229
+ // A socket can come back without our channel: pusher-js
230
+ // resubscribes what it still holds, and a channel someone
231
+ // else `leave()`d is no longer among them.
232
+ if (current === 'connected') ensureSubscribed();
233
+
131
234
  sync();
132
235
  };
133
236
 
@@ -147,9 +250,32 @@ const useZoomPhoneLive = ({
147
250
  // decides, which is the same answer we gave before.
148
251
  }
149
252
 
253
+ // The backstop. `state_change` covers a socket that visibly went away;
254
+ // this covers the case with no signal at all — the channel removed
255
+ // underneath a perfectly healthy socket. Visible tabs only: a roster
256
+ // nobody is looking at has nothing to catch up for, and the badge's own
257
+ // poll refills it either way.
258
+ if (resubscribeCheckMs > 0) {
259
+ resubscribeTimer = setInterval(() => {
260
+ if (
261
+ typeof document !== 'undefined' &&
262
+ document.visibilityState !== 'visible'
263
+ ) {
264
+ return;
265
+ }
266
+
267
+ ensureSubscribed();
268
+ }, resubscribeCheckMs);
269
+ }
270
+
150
271
  sync();
151
272
 
152
273
  return () => {
274
+ if (resubscribeTimer !== null) {
275
+ clearInterval(resubscribeTimer);
276
+ resubscribeTimer = null;
277
+ }
278
+
153
279
  unbindState();
154
280
 
155
281
  try {
@@ -171,7 +297,7 @@ const useZoomPhoneLive = ({
171
297
 
172
298
  setLive(false);
173
299
  };
174
- }, [enabled, channel]);
300
+ }, [enabled, channel, resubscribeCheckMs]);
175
301
 
176
302
  return { live };
177
303
  };