@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 +47 -0
- package/package.json +1 -1
- package/src/components/callQueue/CallQueuePop.jsx +112 -13
- package/src/components/callQueue/callQueueHelpers.js +66 -0
- package/src/components/echo/sharedPrivateChannel.js +15 -2
- package/src/components/phone/ZoomPhoneBadge.jsx +5 -0
- package/src/components/phone/phonePresenceHelpers.js +31 -0
- package/src/components/phone/useZoomPhoneLive.js +135 -9
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.
|
|
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 =
|
|
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
|
|
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
|
-
},
|
|
571
|
+
}, effectiveMissedGraceMs);
|
|
522
572
|
|
|
523
573
|
missedTimers.current.set(id, timer);
|
|
524
574
|
},
|
|
525
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
};
|