@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.
- package/README.md +123 -0
- package/package.json +1 -1
- package/src/components/callQueue/CallQueueDiagnostics.jsx +13 -0
- package/src/components/callQueue/CallQueuePop.jsx +308 -43
- package/src/components/callQueue/callPopStatus.js +23 -0
- package/src/components/callQueue/callQueueHelpers.js +66 -0
- package/src/components/echo/sharedPrivateChannel.js +217 -0
- package/src/components/phone/ZoomPhoneBadge.jsx +5 -0
- package/src/components/phone/phonePresenceHelpers.js +31 -0
- package/src/components/phone/useZoomPhoneLive.js +147 -7
- package/src/components/styles/TicketConversation.module.scss +22 -1
- package/src/components/tickets/TicketConversation.jsx +96 -5
- package/src/index.js +15 -0
|
@@ -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 =
|
|
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
|
|
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
|
-
},
|
|
571
|
+
}, effectiveMissedGraceMs);
|
|
493
572
|
|
|
494
573
|
missedTimers.current.set(id, timer);
|
|
495
574
|
},
|
|
496
|
-
[
|
|
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'
|
|
894
|
-
* diagnostics panel says why each refresh
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1331
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1336
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|