@visns-studio/visns-components 6.25.0 → 6.26.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.
@@ -13,17 +13,34 @@ import { toast } from 'react-toastify';
13
13
 
14
14
  import CustomFetch from '../Fetch';
15
15
  import styles from '../styles/CallQueuePop.module.scss';
16
+ import { subscriptionMonitor } from '../sms/smsLiveState';
17
+
18
+ // Instrumentation only: every write below records what the plumbing did, and
19
+ // nothing the pop renders or decides ever reads back out of this store.
20
+ import {
21
+ appendCallPopLog,
22
+ installCallPopStatusHook,
23
+ noteCallPopConnectionState,
24
+ noteCallPopEvent,
25
+ updateCallPopStatus,
26
+ } from './callPopStatus';
16
27
 
17
28
  // Pure helpers live in a plain .js sibling so `node --test` can import them;
18
29
  // re-exported below so this file's public surface is unchanged.
19
30
  import {
20
31
  CLIENT_DETAIL_FIELDS,
21
32
  FALLBACK_QUEUE_NAME,
33
+ KIND_DIRECT,
22
34
  MONITOR_PERMISSION,
35
+ applyMissed,
36
+ applyRinging,
37
+ callBadgeLabel,
38
+ calleeLabel,
23
39
  clientDetails,
24
40
  defaultClientTasksUrl,
25
41
  defaultClientUrl,
26
42
  defaultTaskUrl,
43
+ directRingingLine,
27
44
  formatAuPhone,
28
45
  formatDueDate,
29
46
  formatElapsed,
@@ -31,13 +48,21 @@ import {
31
48
  hasMonitorPermission,
32
49
  normaliseCall,
33
50
  normalisePickupCodes,
51
+ pickupKeyFor,
52
+ reconcileSnapshot,
53
+ resolvePickupCode,
34
54
  toCallWorkspaceId,
35
55
  toLocalDigits,
36
56
  } from './callQueueHelpers';
37
57
 
38
58
  export {
39
59
  CLIENT_DETAIL_FIELDS,
60
+ applyMissed,
61
+ applyRinging,
62
+ callBadgeLabel,
63
+ calleeLabel,
40
64
  clientDetails,
65
+ directRingingLine,
41
66
  formatAuPhone,
42
67
  formatDueDate,
43
68
  formatElapsed,
@@ -45,6 +70,9 @@ export {
45
70
  hasMonitorPermission,
46
71
  normaliseCall,
47
72
  normalisePickupCodes,
73
+ pickupKeyFor,
74
+ reconcileSnapshot,
75
+ resolvePickupCode,
48
76
  toCallWorkspaceId,
49
77
  toLocalDigits,
50
78
  };
@@ -66,9 +94,28 @@ export {
66
94
  * the host app has a broadcaster configured)
67
95
  * With neither present the component renders nothing and logs nothing.
68
96
  *
69
- * Pickup codes arrive as the snapshot's `pickup_codes` map, keyed by Zoom call
70
- * queue id. A queue with no code still pops its card just has no Pick up
71
- * button, because there is no dial string to offer.
97
+ * Direct calls pop too: a call ringing one staff member's own extension (or
98
+ * transferred to it) arrives with `kind: 'direct'`, no queue at all, and the
99
+ * name of whoever it is ringing. Everyone watching sees it, which is the whole
100
+ * point — a colleague's phone ringing out is exactly the call somebody else
101
+ * should be picking up.
102
+ *
103
+ * Pickup codes arrive as the snapshot's `pickup_codes` map, keyed by each
104
+ * call's `pickup_key` — the Zoom call queue id for a queue call, the literal
105
+ * 'direct' for a direct one (whose interception goes through a call pickup
106
+ * GROUP, one code for the lot). A call with no code still pops; its card just
107
+ * has no Pick up button, because there is no dial string to offer.
108
+ *
109
+ * A call rings several devices at once, so one leg being declined settles
110
+ * nothing: `.queue.missed` marks the card and starts a `missedGraceMs` timer
111
+ * instead of removing it, and a further `.queue.ringing` for the same call
112
+ * cancels that timer. Only `.queue.answered`/`.queue.ended` take a card away
113
+ * outright.
114
+ *
115
+ * The snapshot is a catch-up mechanism, not just a first paint: it runs again
116
+ * whenever the socket reconnects and whenever a hidden tab is looked at again,
117
+ * and reconciles both ways, so a call that started or ended while this browser
118
+ * was deaf is not stuck on (or missing from) the screen.
72
119
  *
73
120
  * The caller -> client match arrives already resolved, as each call's `client`
74
121
  * block (computed once by the Zoom webhook, server-side). This component never
@@ -88,6 +135,11 @@ export {
88
135
  * window.callPopClear() -> clear the stack (in all tabs)
89
136
  * Demo calls bypass the permission gate and never hit a real endpoint.
90
137
  *
138
+ * The permission gate is optional: `monitorPermission={null}` pops for every
139
+ * signed-in user instead of only those holding a named permission. A host that
140
+ * does that must open its broadcast channel authorisation the same way — the
141
+ * pop cannot show what the socket refuses to deliver.
142
+ *
91
143
  * Library note: this is the portable version of the CRM's original component.
92
144
  * Every host-specific decision it hard-coded — the Echo instance, the channel
93
145
  * name, the endpoints, the routes it links to, whether Pick up is live — is now
@@ -104,6 +156,33 @@ const EVENT_RINGING = '.queue.ringing';
104
156
  const EVENT_ANSWERED = '.queue.answered';
105
157
  const EVENT_ENDED = '.queue.ended';
106
158
 
159
+ /**
160
+ * One ringing leg was declined or timed out. NOT the same as `.queue.ended`:
161
+ * a call rings several devices at once, so a declined leg says nothing about
162
+ * the others — the card is only marked, and a grace timer decides.
163
+ */
164
+ const EVENT_MISSED = '.queue.missed';
165
+
166
+ /**
167
+ * The diagnostics panel's round-trip probe. It carries no call and pops
168
+ * nothing — it is broadcast on the same channel purely so a browser can prove
169
+ * the socket is delivering (see CallQueueDiagnostics).
170
+ */
171
+ const EVENT_PING = '.queue.diagnostic-ping';
172
+
173
+ /** Terse console breadcrumbs, so a support call can read them out. */
174
+ const trace = (message, detail) => {
175
+ try {
176
+ if (detail === undefined) {
177
+ console.info(`[call-pop] ${message}`);
178
+ } else {
179
+ console.info(`[call-pop] ${message}`, detail);
180
+ }
181
+ } catch (error) {
182
+ // A console that refuses to log is not worth failing over.
183
+ }
184
+ };
185
+
107
186
  /** Default BroadcastChannel used to keep every open tab's stack in step. */
108
187
  const SYNC_CHANNEL = 'throughlife-call-queue-pop';
109
188
 
@@ -113,6 +192,21 @@ const DEFAULT_CHANNEL = 'call-queue-monitor';
113
192
  /** How long the exit animation runs before the card is dropped from state. */
114
193
  const EXIT_MS = 220;
115
194
 
195
+ /**
196
+ * How long a card survives a `.queue.missed` before it is taken as gone. Long
197
+ * enough for the next leg's `.queue.ringing` to arrive and cancel it, short
198
+ * enough that a call nobody took stops sitting on screen. Override with the
199
+ * `missedGraceMs` prop.
200
+ */
201
+ const DEFAULT_MISSED_GRACE_MS = 10000;
202
+
203
+ /**
204
+ * Minimum gap between snapshot refreshes, for every reason except mount. A
205
+ * flapping socket and a user cycling between tabs both ask repeatedly; one
206
+ * request per five seconds is plenty to catch up with.
207
+ */
208
+ const SNAPSHOT_MIN_GAP_MS = 5000;
209
+
116
210
  /** Title shown on alternate ticks while a call rings in a hidden tab. */
117
211
  const FLASH_TITLE = '📞 Incoming call…';
118
212
 
@@ -193,6 +287,7 @@ const CallQueuePop = ({
193
287
  callWorkspacePath = '/call/{number}',
194
288
  syncChannelName = SYNC_CHANNEL,
195
289
  demoEnabled = true,
290
+ missedGraceMs = DEFAULT_MISSED_GRACE_MS,
196
291
  }) => {
197
292
  const [calls, setCalls] = useState([]);
198
293
  const [now, setNow] = useState(() => Date.now());
@@ -212,6 +307,32 @@ const CallQueuePop = ({
212
307
  // against an unmounted tree.
213
308
  const exitTimers = useRef([]);
214
309
 
310
+ // Grace timers started by `.queue.missed`, keyed by callId, so a later
311
+ // `.queue.ringing` for the same call can cancel exactly one of them.
312
+ const missedTimers = useRef(new Map());
313
+
314
+ // The stack, readable from a callback that must not be re-created every
315
+ // time it changes (the snapshot refresh, which reconciles against it).
316
+ const callsRef = useRef(calls);
317
+ callsRef.current = calls;
318
+
319
+ // callIds this browser has settled by hand (Dismiss, Pick up, or a
320
+ // cross-tab remove). The server still lists them as ringing, so without
321
+ // this a refresh would put the card straight back.
322
+ const dismissedIds = useRef(new Set());
323
+
324
+ // Snapshot refresh bookkeeping: when the last one went out, and the single
325
+ // trailing request parked behind the minimum gap.
326
+ const lastSnapshotAt = useRef(0);
327
+ const pendingSnapshot = useRef(null);
328
+
329
+ // Has the socket left `connected` since the last snapshot? Only then is
330
+ // coming back a reconnect worth catching up from.
331
+ const sawDisconnect = useRef(false);
332
+
333
+ // Cleared on unmount, so an in-flight request's callback knows to stop.
334
+ const mounted = useRef(true);
335
+
215
336
  // callIds whose drill-down request has already gone out — the endpoint is
216
337
  // hit once per call however often the chip is toggled.
217
338
  const taskFetched = useRef(new Set());
@@ -250,6 +371,10 @@ const CallQueuePop = ({
250
371
  [endpoints]
251
372
  );
252
373
 
374
+ // `monitorPermission={null}` means the deployment gates on being signed in
375
+ // and nothing else — every member of staff sees every call. The prop is
376
+ // passed straight through, because only `undefined` picks up the default.
377
+ const gateIsOpen = monitorPermission === null || monitorPermission === '';
253
378
  const canMonitor = hasMonitorPermission(userProfile, monitorPermission);
254
379
  const hasDemoCalls = calls.some((call) => call.isDemo);
255
380
  const hasRinging = calls.some((call) => !call.leaving);
@@ -264,42 +389,105 @@ const CallQueuePop = ({
264
389
  return trialBadge ? TRIAL_BADGE_TEXT : '';
265
390
  }, [trialBadge]);
266
391
 
267
- /** Add a call, ignoring duplicates of a callId already on screen. */
268
- const addCall = useCallback((call) => {
269
- if (!call) {
270
- return;
392
+ /** Cancel one call's grace timer, if it has one. Always safe to call. */
393
+ const clearMissedTimer = useCallback((callId) => {
394
+ const id = String(callId);
395
+ const timer = missedTimers.current.get(id);
396
+
397
+ if (timer !== undefined) {
398
+ clearTimeout(timer);
399
+ missedTimers.current.delete(id);
271
400
  }
401
+ }, []);
272
402
 
273
- setCalls((previous) => {
274
- if (previous.some((item) => item.callId === call.callId)) {
275
- return previous;
403
+ /**
404
+ * Add a call, or clear the missed mark of one already on screen — a second
405
+ * `.queue.ringing` for a call whose first leg was declined proves it is
406
+ * still live (see applyRinging).
407
+ */
408
+ const addCall = useCallback(
409
+ (call) => {
410
+ if (!call) {
411
+ return;
276
412
  }
277
413
 
278
- return [...previous, call];
279
- });
280
- }, []);
414
+ clearMissedTimer(call.callId);
415
+ setCalls((previous) => applyRinging(previous, call));
416
+ },
417
+ [clearMissedTimer]
418
+ );
281
419
 
282
420
  /** Animate a card out, then drop it from state. */
283
- const removeCall = useCallback((callId) => {
284
- setCalls((previous) =>
285
- previous.map((call) =>
286
- call.callId === String(callId)
287
- ? { ...call, leaving: true }
288
- : call
289
- )
290
- );
421
+ const removeCall = useCallback(
422
+ (callId) => {
423
+ // Whatever settles the call settles its grace timer too, so a
424
+ // dismissed or answered card can never be removed twice.
425
+ clearMissedTimer(callId);
291
426
 
292
- const timer = setTimeout(() => {
293
427
  setCalls((previous) =>
294
- previous.filter((call) => call.callId !== String(callId))
428
+ previous.map((call) =>
429
+ call.callId === String(callId)
430
+ ? { ...call, leaving: true }
431
+ : call
432
+ )
295
433
  );
296
- }, EXIT_MS);
297
434
 
298
- exitTimers.current.push(timer);
299
- }, []);
435
+ const timer = setTimeout(() => {
436
+ setCalls((previous) =>
437
+ previous.filter((call) => call.callId !== String(callId))
438
+ );
439
+ }, EXIT_MS);
440
+
441
+ exitTimers.current.push(timer);
442
+ },
443
+ [clearMissedTimer]
444
+ );
445
+
446
+ /**
447
+ * A ringing leg was declined or timed out. The call may still be ringing on
448
+ * someone else's device, so the card is marked and given `missedGraceMs` to
449
+ * prove it: a `.queue.ringing` inside that window cancels the timer, and
450
+ * nothing arriving lets it remove the card the same way `.queue.ended`
451
+ * would.
452
+ */
453
+ const markMissed = useCallback(
454
+ (callId) => {
455
+ const id = String(callId ?? '');
456
+
457
+ if (id === '') {
458
+ return;
459
+ }
460
+
461
+ // Already counting down, or never on screen here in the first
462
+ // place: either way there is nothing to start.
463
+ if (missedTimers.current.has(id)) {
464
+ return;
465
+ }
466
+
467
+ if (
468
+ !callsRef.current.some(
469
+ (call) => call.callId === id && !call.leaving
470
+ )
471
+ ) {
472
+ return;
473
+ }
474
+
475
+ setCalls((previous) => applyMissed(previous, id, Date.now()));
476
+
477
+ const timer = setTimeout(() => {
478
+ missedTimers.current.delete(id);
479
+ removeCall(id);
480
+ }, missedGraceMs);
481
+
482
+ missedTimers.current.set(id, timer);
483
+ },
484
+ [missedGraceMs, removeCall]
485
+ );
300
486
 
301
487
  /** Drop the whole stack (no exit animation — used by clear). */
302
488
  const clearCalls = useCallback(() => {
489
+ missedTimers.current.forEach((timer) => clearTimeout(timer));
490
+ missedTimers.current.clear();
303
491
  setCalls([]);
304
492
  }, []);
305
493
 
@@ -359,6 +547,9 @@ const CallQueuePop = ({
359
547
  */
360
548
  const dismissCall = useCallback(
361
549
  (callId) => {
550
+ // Remembered so a later snapshot refresh does not re-add the card:
551
+ // the server has no idea this browser settled the call.
552
+ dismissedIds.current.add(String(callId));
362
553
  removeCall(callId);
363
554
  broadcastSync({ type: 'remove', callId: String(callId) });
364
555
  },
@@ -450,7 +641,11 @@ const CallQueuePop = ({
450
641
  }
451
642
 
452
643
  /** Add a demo call here and mirror it into every other open tab. */
453
- const pushDemo = (call) => {
644
+ const pushDemo = (payload) => {
645
+ // Through the same normaliser as a real call, so a demo card has
646
+ // every field the reconcile and grace logic reads.
647
+ const call = normaliseCall(payload);
648
+
454
649
  addDemoPickupCode(call);
455
650
  addCall(call);
456
651
  broadcastSync({ type: 'add', call });
@@ -593,6 +788,9 @@ const CallQueuePop = ({
593
788
 
594
789
  if (message.type === 'remove') {
595
790
  if (message.callId) {
791
+ // Another tab settled it by hand, so this tab must not
792
+ // let a refresh bring it back either.
793
+ dismissedIds.current.add(String(message.callId));
596
794
  removeCall(message.callId);
597
795
  }
598
796
 
@@ -640,74 +838,265 @@ const CallQueuePop = ({
640
838
  syncChannelName,
641
839
  ]);
642
840
 
643
- /** Snapshot of what is already ringing when the page loads. */
841
+ /**
842
+ * Diagnostics wiring. Three passive observers of state the pop already
843
+ * keeps: the permission verdict, how many cards are on screen, and the
844
+ * `window.callPopStatus()` console hook that sits beside the demo hooks.
845
+ */
644
846
  useEffect(() => {
645
- if (!canMonitor) {
646
- return undefined;
847
+ updateCallPopStatus({ canMonitor });
848
+
849
+ // Only worth saying when a permission was actually asked for: with the
850
+ // gate open a signed-out user is the only way to fail, and "no
851
+ // permission" would be a misleading thing to read in the log.
852
+ if (!canMonitor && !gateIsOpen) {
853
+ appendCallPopLog(
854
+ 'User does not hold the call queue monitor permission.',
855
+ 'warn'
856
+ );
647
857
  }
858
+ }, [canMonitor, gateIsOpen]);
648
859
 
649
- let cancelled = false;
650
-
651
- try {
652
- const request = CustomFetch(
653
- liveUrl,
654
- 'GET',
655
- {},
656
- function (result) {
657
- if (cancelled) {
658
- return;
659
- }
860
+ useEffect(() => {
861
+ updateCallPopStatus({
862
+ visibleCalls: calls.filter((call) => !call.leaving).length,
863
+ });
864
+ }, [calls]);
660
865
 
661
- if (
662
- typeof result?.channel === 'string' &&
663
- result.channel !== ''
664
- ) {
665
- setEchoChannel(result.channel);
666
- }
866
+ useEffect(() => installCallPopStatusHook(), []);
667
867
 
668
- if (result?.pickup_codes ?? result?.pickupCodes) {
669
- const codes = normalisePickupCodes(
670
- result.pickup_codes ?? result.pickupCodes
868
+ /**
869
+ * Fetch `/ajax/call-queue/live` and reconcile the stack against it.
870
+ *
871
+ * This is the pop's catch-up path, not just its first paint: the same
872
+ * request runs on mount, after the socket comes back, and when a hidden tab
873
+ * is looked at again — every moment a `.queue.*` event could have been
874
+ * missed. What comes back is authoritative in both directions (see
875
+ * reconcileSnapshot for the two exceptions), so a call that started while
876
+ * the socket was down appears, and one that ended while it was down goes.
877
+ *
878
+ * @param {string} reason 'mount' | 'reconnect' | 'visible' — logged, so the
879
+ * diagnostics panel says why each refresh happened.
880
+ */
881
+ const refreshSnapshot = useCallback(
882
+ (reason) => {
883
+ try {
884
+ const request = CustomFetch(
885
+ liveUrl,
886
+ 'GET',
887
+ {},
888
+ function (result) {
889
+ if (!mounted.current) {
890
+ return;
891
+ }
892
+
893
+ if (
894
+ typeof result?.channel === 'string' &&
895
+ result.channel !== ''
896
+ ) {
897
+ setEchoChannel(result.channel);
898
+ }
899
+
900
+ if (result?.pickup_codes ?? result?.pickupCodes) {
901
+ const codes = normalisePickupCodes(
902
+ result.pickup_codes ?? result.pickupCodes
903
+ );
904
+
905
+ // Merged, not replaced: an injected demo code
906
+ // already in the map must survive the snapshot
907
+ // landing.
908
+ setPickupCodes((previous) => ({
909
+ ...previous,
910
+ ...codes,
911
+ }));
912
+ }
913
+
914
+ const payload = Array.isArray(result)
915
+ ? result
916
+ : result?.calls;
917
+
918
+ if (!Array.isArray(payload)) {
919
+ // A response with no `calls` array says nothing
920
+ // about what is ringing, so nothing is reconciled
921
+ // away on the strength of it.
922
+ updateCallPopStatus({
923
+ snapshotAt: new Date().toISOString(),
924
+ snapshotError: null,
925
+ snapshotCalls: 0,
926
+ });
927
+ trace(`snapshot (${reason}) ok — no calls block`);
928
+
929
+ return;
930
+ }
931
+
932
+ const incoming = payload
933
+ .map((item) => normaliseCall(item))
934
+ .filter(Boolean);
935
+
936
+ const { add, remove } = reconcileSnapshot(
937
+ callsRef.current,
938
+ incoming,
939
+ Date.now(),
940
+ { skipIds: dismissedIds.current }
671
941
  );
672
942
 
673
- // Merged, not replaced: an injected demo code already
674
- // in the map must survive the snapshot landing.
675
- setPickupCodes((previous) => ({
676
- ...previous,
677
- ...codes,
678
- }));
943
+ add.forEach((call) => addCall(call));
944
+ remove.forEach((callId) => removeCall(callId));
945
+
946
+ // A dismissal only has to outlive the call it
947
+ // silenced: once the server stops listing it, forget
948
+ // it, so the set cannot grow all day.
949
+ if (dismissedIds.current.size > 0) {
950
+ const listed = new Set(
951
+ incoming.map((call) => call.callId)
952
+ );
953
+
954
+ dismissedIds.current.forEach((callId) => {
955
+ if (!listed.has(callId)) {
956
+ dismissedIds.current.delete(callId);
957
+ }
958
+ });
959
+ }
960
+
961
+ appendCallPopLog(
962
+ `Snapshot (${reason}) — ${incoming.length} ringing, ` +
963
+ `${add.length} added, ${remove.length} removed`,
964
+ 'info',
965
+ {
966
+ snapshotAt: new Date().toISOString(),
967
+ snapshotError: null,
968
+ snapshotCalls: incoming.length,
969
+ }
970
+ );
971
+ trace(
972
+ `snapshot (${reason}) ok — ${incoming.length} call(s) ringing`
973
+ );
974
+ },
975
+ function (message) {
976
+ // Absence of the endpoint is expected pre-backend.
977
+ if (!mounted.current) {
978
+ return;
979
+ }
980
+
981
+ const failure =
982
+ typeof message === 'string' && message !== ''
983
+ ? message
984
+ : 'request failed';
985
+
986
+ appendCallPopLog(
987
+ `Snapshot (${reason}) failed: ${failure}`,
988
+ 'error',
989
+ {
990
+ snapshotAt: new Date().toISOString(),
991
+ snapshotError: failure,
992
+ }
993
+ );
994
+ trace(`snapshot (${reason}) failed — ${failure}`);
679
995
  }
996
+ );
997
+
998
+ if (request && typeof request.catch === 'function') {
999
+ request.catch(() => {});
1000
+ }
1001
+ } catch (error) {
1002
+ // Silent by design — but no longer invisible.
1003
+ appendCallPopLog(
1004
+ `Snapshot request could not be sent: ${error?.message ?? error}`,
1005
+ 'error',
1006
+ { snapshotError: String(error?.message ?? error) }
1007
+ );
1008
+ }
1009
+ },
1010
+ [addCall, liveUrl, removeCall]
1011
+ );
680
1012
 
681
- const payload = Array.isArray(result)
682
- ? result
683
- : result?.calls;
1013
+ /**
1014
+ * Ask for a refresh, at most one per `SNAPSHOT_MIN_GAP_MS` — a socket that
1015
+ * flaps and a user cycling through tabs both ask far more often than the
1016
+ * server needs to be asked. A throttled request is not dropped: one
1017
+ * trailing refresh is parked until the gap expires, so the last reason to
1018
+ * catch up always does.
1019
+ *
1020
+ * 'mount' bypasses the gap: it is the first paint, and there is nothing to
1021
+ * have throttled it.
1022
+ */
1023
+ const requestSnapshot = useCallback(
1024
+ (reason) => {
1025
+ const send = () => {
1026
+ lastSnapshotAt.current = Date.now();
1027
+ sawDisconnect.current = false;
1028
+ refreshSnapshot(reason);
1029
+ };
684
1030
 
685
- if (!Array.isArray(payload)) {
686
- return;
687
- }
1031
+ if (reason === 'mount') {
1032
+ send();
688
1033
 
689
- payload.forEach((item) => ingestCall(item));
690
- },
691
- function () {
692
- // Absence of the endpoint is expected pre-backend.
693
- }
694
- );
1034
+ return;
1035
+ }
1036
+
1037
+ const wait =
1038
+ SNAPSHOT_MIN_GAP_MS - (Date.now() - lastSnapshotAt.current);
1039
+
1040
+ if (wait <= 0) {
1041
+ send();
695
1042
 
696
- if (request && typeof request.catch === 'function') {
697
- request.catch(() => {});
1043
+ return;
698
1044
  }
699
- } catch (error) {
700
- // Silent by design.
1045
+
1046
+ if (pendingSnapshot.current !== null) {
1047
+ return;
1048
+ }
1049
+
1050
+ pendingSnapshot.current = setTimeout(() => {
1051
+ pendingSnapshot.current = null;
1052
+ send();
1053
+ }, wait);
1054
+ },
1055
+ [refreshSnapshot]
1056
+ );
1057
+
1058
+ /** Snapshot of what is already ringing when the page loads. */
1059
+ useEffect(() => {
1060
+ if (!canMonitor) {
1061
+ return undefined;
701
1062
  }
702
1063
 
703
- return () => {
704
- cancelled = true;
1064
+ requestSnapshot('mount');
1065
+
1066
+ return undefined;
1067
+ }, [canMonitor, requestSnapshot]);
1068
+
1069
+ /**
1070
+ * Catch up when a backgrounded tab is looked at again. A tab that has been
1071
+ * hidden for an hour may have had its socket quietly reaped; this is the
1072
+ * cheap way to find out, and it costs one request at most every five
1073
+ * seconds however often the user switches back and forth.
1074
+ */
1075
+ useEffect(() => {
1076
+ if (!canMonitor || typeof document === 'undefined') {
1077
+ return undefined;
1078
+ }
1079
+
1080
+ const onVisibilityChange = () => {
1081
+ if (document.visibilityState === 'visible') {
1082
+ requestSnapshot('visible');
1083
+ }
705
1084
  };
706
- }, [canMonitor, ingestCall, liveUrl]);
1085
+
1086
+ document.addEventListener('visibilitychange', onVisibilityChange);
1087
+
1088
+ return () =>
1089
+ document.removeEventListener(
1090
+ 'visibilitychange',
1091
+ onVisibilityChange
1092
+ );
1093
+ }, [canMonitor, requestSnapshot]);
707
1094
 
708
1095
  /** Live updates over Laravel Echo, when the broadcaster is wired up. */
709
1096
  useEffect(() => {
710
1097
  if (!canMonitor || !activeChannel || typeof window === 'undefined') {
1098
+ updateCallPopStatus({ channel: activeChannel ?? null });
1099
+
711
1100
  return undefined;
712
1101
  }
713
1102
 
@@ -718,24 +1107,54 @@ const CallQueuePop = ({
718
1107
  const instance = typeof source === 'function' ? source() : source;
719
1108
 
720
1109
  if (!instance) {
1110
+ appendCallPopLog(
1111
+ 'No Echo instance: the app has no broadcaster configured, ' +
1112
+ 'so nothing will ever arrive live.',
1113
+ 'error',
1114
+ {
1115
+ channel: activeChannel,
1116
+ echoAvailable: false,
1117
+ subscribed: false,
1118
+ }
1119
+ );
1120
+ trace('no Echo instance — live updates are off');
1121
+
721
1122
  return undefined;
722
1123
  }
723
1124
 
1125
+ appendCallPopLog(`Subscribing to ${activeChannel}`, 'info', {
1126
+ channel: activeChannel,
1127
+ echoAvailable: true,
1128
+ subscribed: false,
1129
+ subscriptionError: null,
1130
+ });
1131
+ trace(`subscribing to private-${activeChannel}`);
1132
+
724
1133
  let subscription = null;
1134
+ let stopMonitoring = () => {};
1135
+ let unbindState = () => {};
725
1136
 
726
1137
  try {
727
1138
  // Until /broadcasting/auth knows this channel the subscription just
728
1139
  // fails auth — pusher-js logs a warning, nothing of ours throws.
1140
+ // That silence is exactly what the monitor below breaks.
729
1141
  subscription = instance.private(activeChannel);
730
1142
 
731
1143
  subscription.listen(EVENT_RINGING, (event) => {
732
- ingestCall(event?.call ?? event);
1144
+ const call = event?.call ?? event;
1145
+
1146
+ noteCallPopEvent(EVENT_RINGING);
1147
+ trace('event .queue.ringing', call?.callId ?? call?.call_id);
1148
+ ingestCall(call);
733
1149
  });
734
1150
 
735
1151
  subscription.listen(EVENT_ANSWERED, (event) => {
736
1152
  const callId =
737
1153
  event?.callId ?? event?.call_id ?? event?.call?.callId;
738
1154
 
1155
+ noteCallPopEvent(EVENT_ANSWERED);
1156
+ trace('event .queue.answered', callId);
1157
+
739
1158
  if (callId) {
740
1159
  removeCall(callId);
741
1160
  }
@@ -745,28 +1164,187 @@ const CallQueuePop = ({
745
1164
  const callId =
746
1165
  event?.callId ?? event?.call_id ?? event?.call?.callId;
747
1166
 
1167
+ noteCallPopEvent(EVENT_ENDED);
1168
+ trace('event .queue.ended', callId);
1169
+
748
1170
  if (callId) {
749
1171
  removeCall(callId);
750
1172
  }
751
1173
  });
1174
+
1175
+ // One leg said no. The call is not over — it may be ringing on
1176
+ // three other devices — so this starts a grace period rather than
1177
+ // removing anything.
1178
+ subscription.listen(EVENT_MISSED, (event) => {
1179
+ const callId =
1180
+ event?.callId ?? event?.call_id ?? event?.call?.callId;
1181
+
1182
+ noteCallPopEvent(EVENT_MISSED);
1183
+ trace('event .queue.missed', callId);
1184
+
1185
+ if (callId) {
1186
+ markMissed(callId);
1187
+ }
1188
+ });
1189
+
1190
+ // The diagnostics panel's round-trip probe. It settles nothing and
1191
+ // pops nothing — it only proves this browser is being delivered to.
1192
+ subscription.listen(EVENT_PING, (event) => {
1193
+ const nonce = event?.nonce ?? event?.id ?? null;
1194
+
1195
+ noteCallPopEvent(EVENT_PING, {
1196
+ lastPing: {
1197
+ nonce: nonce === null ? null : String(nonce),
1198
+ receivedAt: new Date().toISOString(),
1199
+ },
1200
+ });
1201
+ trace('event .queue.diagnostic-ping', nonce);
1202
+ });
1203
+
1204
+ stopMonitoring = subscriptionMonitor(subscription, {
1205
+ onSuccess: () => {
1206
+ appendCallPopLog(`Subscribed to ${activeChannel}`, 'info', {
1207
+ subscribed: true,
1208
+ subscribedAt: new Date().toISOString(),
1209
+ subscriptionError: null,
1210
+ });
1211
+ trace(`subscribed to private-${activeChannel}`);
1212
+ },
1213
+ onError: (payload) => {
1214
+ // pusher-js hands over `{type, error, status}`; Reverb and
1215
+ // Laravel's auth endpoint both put the HTTP code on
1216
+ // `status`, which is the number that says WHY (403 = the
1217
+ // channel authorisation said no, 404 = no auth route).
1218
+ const httpStatus =
1219
+ payload?.status ??
1220
+ payload?.error?.status ??
1221
+ payload?.data?.code ??
1222
+ null;
1223
+ const message =
1224
+ payload?.error?.message ??
1225
+ payload?.error?.data?.message ??
1226
+ payload?.message ??
1227
+ 'channel authorisation failed';
1228
+
1229
+ appendCallPopLog(
1230
+ `Subscription refused (${httpStatus ?? 'no status'}): ${message}`,
1231
+ 'error',
1232
+ {
1233
+ subscribed: false,
1234
+ subscriptionError: {
1235
+ status: httpStatus,
1236
+ message: String(message),
1237
+ },
1238
+ }
1239
+ );
1240
+ trace(
1241
+ `subscription error on private-${activeChannel}`,
1242
+ httpStatus ?? message
1243
+ );
1244
+ },
1245
+ });
752
1246
  } catch (error) {
753
1247
  subscription = null;
1248
+ appendCallPopLog(
1249
+ `Could not subscribe to ${activeChannel}: ${error?.message ?? error}`,
1250
+ 'error',
1251
+ {
1252
+ subscribed: false,
1253
+ subscriptionError: {
1254
+ status: null,
1255
+ message: String(error?.message ?? error),
1256
+ },
1257
+ }
1258
+ );
1259
+ trace('subscribe threw', error?.message ?? error);
1260
+ }
1261
+
1262
+ // The socket underneath the channel. Every property access is guarded:
1263
+ // the Echo instance need not be pusher-backed at all, and a diagnostics
1264
+ // read must never be what breaks the pop.
1265
+ try {
1266
+ const connection = instance.connector?.pusher?.connection;
1267
+
1268
+ if (connection && typeof connection.bind === 'function') {
1269
+ const onStateChange = (states) => {
1270
+ const current = states?.current ?? states;
1271
+
1272
+ noteCallPopConnectionState(current ?? null);
1273
+ trace(`socket ${current}`);
1274
+
1275
+ // Coming back is the interesting half. Everything that
1276
+ // rang while the socket was away was broadcast to nobody,
1277
+ // so the snapshot is the only way to learn about it — but
1278
+ // only when the socket actually went away, otherwise every
1279
+ // ordinary connecting -> connected on load would refetch.
1280
+ if (current === 'connected') {
1281
+ if (sawDisconnect.current) {
1282
+ requestSnapshot('reconnect');
1283
+ }
1284
+
1285
+ return;
1286
+ }
1287
+
1288
+ if (current) {
1289
+ sawDisconnect.current = true;
1290
+ }
1291
+ };
1292
+
1293
+ connection.bind('state_change', onStateChange);
1294
+ noteCallPopConnectionState(connection.state ?? null);
1295
+
1296
+ // A socket that is already down at subscribe time counts as a
1297
+ // departure, so its eventual `connected` catches up too.
1298
+ if (connection.state && connection.state !== 'connected') {
1299
+ sawDisconnect.current = true;
1300
+ }
1301
+
1302
+ unbindState = () => {
1303
+ try {
1304
+ connection.unbind?.('state_change', onStateChange);
1305
+ } catch (error) {
1306
+ // Already gone.
1307
+ }
1308
+ };
1309
+ }
1310
+ } catch (error) {
1311
+ // No reachable connection object: the channel confirmation alone
1312
+ // says whether this browser is live, which is what it said before.
754
1313
  }
755
1314
 
756
1315
  return () => {
1316
+ unbindState();
1317
+
1318
+ try {
1319
+ stopMonitoring();
1320
+ } catch (error) {
1321
+ // Already gone.
1322
+ }
1323
+
757
1324
  try {
758
1325
  if (subscription) {
759
1326
  subscription.stopListening(EVENT_RINGING);
760
1327
  subscription.stopListening(EVENT_ANSWERED);
761
1328
  subscription.stopListening(EVENT_ENDED);
1329
+ subscription.stopListening(EVENT_MISSED);
1330
+ subscription.stopListening(EVENT_PING);
762
1331
  }
763
1332
 
764
1333
  instance.leave(activeChannel);
765
1334
  } catch (error) {
766
1335
  // Nothing to clean up.
767
1336
  }
1337
+
1338
+ updateCallPopStatus({ subscribed: false });
768
1339
  };
769
- }, [activeChannel, canMonitor, ingestCall, removeCall]);
1340
+ }, [
1341
+ activeChannel,
1342
+ canMonitor,
1343
+ ingestCall,
1344
+ markMissed,
1345
+ removeCall,
1346
+ requestSnapshot,
1347
+ ]);
770
1348
 
771
1349
  /**
772
1350
  * One interval for the whole stack, started only while cards are on screen
@@ -1023,12 +1601,28 @@ const CallQueuePop = ({
1023
1601
  };
1024
1602
  }, [hasRinging]);
1025
1603
 
1026
- /** Drop any pending exit timers, notifications and titles on unmount. */
1027
- useEffect(
1028
- () => () => {
1604
+ /** Drop any pending timers, requests and notifications on unmount. */
1605
+ useEffect(() => {
1606
+ // Set here rather than only at declaration: React can unmount and
1607
+ // remount the same instance (StrictMode does exactly that in
1608
+ // development), and a `mounted` flag that never comes back would
1609
+ // silently swallow every snapshot response after it.
1610
+ mounted.current = true;
1611
+
1612
+ return () => {
1613
+ mounted.current = false;
1614
+
1029
1615
  exitTimers.current.forEach((timer) => clearTimeout(timer));
1030
1616
  exitTimers.current = [];
1031
1617
 
1618
+ missedTimers.current.forEach((timer) => clearTimeout(timer));
1619
+ missedTimers.current.clear();
1620
+
1621
+ if (pendingSnapshot.current !== null) {
1622
+ clearTimeout(pendingSnapshot.current);
1623
+ pendingSnapshot.current = null;
1624
+ }
1625
+
1032
1626
  notifications.current.forEach((notification) => {
1033
1627
  try {
1034
1628
  notification.close();
@@ -1039,13 +1633,16 @@ const CallQueuePop = ({
1039
1633
 
1040
1634
  notifications.current.clear();
1041
1635
  notified.current.clear();
1042
- },
1043
- []
1044
- );
1636
+ };
1637
+ }, []);
1045
1638
 
1046
- /** The dial string for a call's queue, or '' when that queue has none. */
1047
- const pickupCodeFor = (call) =>
1048
- (call.queueId !== null && pickupCodes[call.queueId]) || '';
1639
+ /**
1640
+ * The dial string for a call, or '' when nothing is configured for it.
1641
+ * Keyed on `pickupKey`, not the queue id: a direct call is intercepted
1642
+ * through a Zoom call pickup GROUP, whose code the snapshot files under
1643
+ * 'direct'.
1644
+ */
1645
+ const pickupCodeFor = (call) => resolvePickupCode(call, pickupCodes);
1049
1646
 
1050
1647
  /** A task's popup target, from the `taskUrl` builder or `{id}` template. */
1051
1648
  const hrefForTask = (task) =>
@@ -1126,6 +1723,11 @@ const CallQueuePop = ({
1126
1723
  const tasksPanelId = `cqpop-tasks-${call.callId}`;
1127
1724
  const tasksOpen = expandedTasks[call.callId] === true;
1128
1725
 
1726
+ // A direct call rang a person, not a queue: the badge says so,
1727
+ // and the card names who it is ringing (and who passed it on).
1728
+ const isDirect = call.kind === KIND_DIRECT;
1729
+ const ringingLine = directRingingLine(call);
1730
+
1129
1731
  // Demo cards ship a ready-made list; everything else waits on
1130
1732
  // (or has already had) the drill-down fetch.
1131
1733
  const tasksPanel = Array.isArray(call.tasks)
@@ -1135,14 +1737,20 @@ const CallQueuePop = ({
1135
1737
  return (
1136
1738
  <div
1137
1739
  className={`${styles.cqpopCard}${
1138
- call.leaving ? ` ${styles.cqpopCardLeaving}` : ''
1139
- }`}
1740
+ isDirect ? ` ${styles.cqpopCardDirect}` : ''
1741
+ }${call.leaving ? ` ${styles.cqpopCardLeaving}` : ''}`}
1140
1742
  key={`cqpop-${call.callId}`}
1141
1743
  >
1142
1744
  <div className={styles.cqpopCardHead}>
1143
- <span className={styles.cqpopBadge}>
1745
+ <span
1746
+ className={`${styles.cqpopBadge}${
1747
+ isDirect
1748
+ ? ` ${styles.cqpopBadgeDirect}`
1749
+ : ''
1750
+ }`}
1751
+ >
1144
1752
  <span className={styles.cqpopDot} />
1145
- {call.queueName}
1753
+ {callBadgeLabel(call)}
1146
1754
  </span>
1147
1755
  <span className={styles.cqpopTimer}>
1148
1756
  Ringing · {formatElapsed(call.startedAt, now)}
@@ -1168,6 +1776,21 @@ const CallQueuePop = ({
1168
1776
  </div>
1169
1777
  ) : null}
1170
1778
 
1779
+ {ringingLine ? (
1780
+ <div className={styles.cqpopCallee}>
1781
+ {ringingLine}
1782
+ </div>
1783
+ ) : null}
1784
+
1785
+ {/* One leg said no, but the call may still be
1786
+ ringing elsewhere — say so rather than let the
1787
+ card sit there looking untouched. */}
1788
+ {call.missedAt ? (
1789
+ <div className={styles.cqpopMissed}>
1790
+ One line declined
1791
+ </div>
1792
+ ) : null}
1793
+
1171
1794
  {client ? (
1172
1795
  <div className={styles.cqpopClient}>
1173
1796
  <div className={styles.cqpopClientHead}>