@visns-studio/visns-components 6.24.4 → 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.
- package/package.json +4 -2
- package/src/components/Autocomplete.jsx +189 -119
- package/src/components/DataGrid.jsx +472 -31
- package/src/components/Navigation.jsx +475 -51
- package/src/components/auth/ClientAuthFrame.jsx +5 -0
- package/src/components/auth/ClientAuthScreen.jsx +29 -0
- package/src/components/callQueue/CallQueueDiagnostics.jsx +1043 -0
- package/src/components/callQueue/CallQueuePop.jsx +713 -90
- package/src/components/callQueue/CallQueueSettings.jsx +308 -146
- package/src/components/callQueue/callPopStatus.js +236 -0
- package/src/components/callQueue/callQueueHelpers.js +284 -2
- package/src/components/columns/ColumnRenderers.jsx +3 -46
- package/src/components/columns/StackedRow.jsx +186 -0
- package/src/components/controls/DataGridSearch.jsx +110 -2
- package/src/components/controls/DataGridSortSheet.jsx +155 -0
- package/src/components/generic/GenericAuth.jsx +50 -18
- package/src/components/generic/GenericDashboard.jsx +20 -1
- package/src/components/generic/GenericDetail.jsx +446 -259
- package/src/components/mapboxSearchBox.js +640 -0
- package/src/components/navBadges.js +63 -1
- package/src/components/navDrawer.js +147 -0
- package/src/components/sms/SmsThreadPanel.jsx +34 -6
- package/src/components/sms/smsHelpers.js +15 -0
- package/src/components/styles/CallQueueDiagnostics.module.scss +398 -0
- package/src/components/styles/CallQueuePop.module.scss +29 -0
- package/src/components/styles/CallQueueSettings.module.scss +93 -0
- package/src/components/styles/ClientAuth.module.scss +39 -0
- package/src/components/styles/DataGrid.module.scss +158 -5
- package/src/components/styles/Field.module.scss +52 -1
- package/src/components/styles/Form.module.scss +82 -0
- package/src/components/styles/GenericClientPortal.module.scss +72 -20
- package/src/components/styles/GenericDashboard.module.scss +50 -0
- package/src/components/styles/GenericDetail.module.scss +63 -1
- package/src/components/styles/GenericDynamic.module.scss +23 -0
- package/src/components/styles/GenericFormBuilder.module.scss +11 -0
- package/src/components/styles/GenericIndex.module.scss +6 -1
- package/src/components/styles/Navigation.module.scss +460 -7
- package/src/components/styles/Sms.module.scss +92 -0
- package/src/components/styles/StackedRow.module.scss +182 -0
- package/src/components/styles/TicketConversation.module.scss +76 -0
- package/src/components/styles/Vault.module.scss +192 -0
- package/src/components/styles/density.css +10 -0
- package/src/components/styles/global-datagrid.css +163 -0
- package/src/components/styles/global.css +20 -0
- package/src/components/tickets/TicketConversation.jsx +13 -8
- package/src/components/utils/ConfirmDialog.js +22 -3
- package/src/components/utils/cardLayout.js +666 -0
- package/src/components/utils/contactChannels.js +130 -0
- package/src/components/utils/editPlacement.js +95 -0
- package/src/components/utils/useDensity.js +303 -7
- package/src/index.js +42 -0
|
@@ -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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
/**
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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.
|
|
428
|
+
previous.map((call) =>
|
|
429
|
+
call.callId === String(callId)
|
|
430
|
+
? { ...call, leaving: true }
|
|
431
|
+
: call
|
|
432
|
+
)
|
|
295
433
|
);
|
|
296
|
-
}, EXIT_MS);
|
|
297
434
|
|
|
298
|
-
|
|
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 = (
|
|
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
|
-
/**
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
662
|
-
typeof result?.channel === 'string' &&
|
|
663
|
-
result.channel !== ''
|
|
664
|
-
) {
|
|
665
|
-
setEchoChannel(result.channel);
|
|
666
|
-
}
|
|
866
|
+
useEffect(() => installCallPopStatusHook(), []);
|
|
667
867
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
}
|
|
1031
|
+
if (reason === 'mount') {
|
|
1032
|
+
send();
|
|
688
1033
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
697
|
-
request.catch(() => {});
|
|
1043
|
+
return;
|
|
698
1044
|
}
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
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
|
-
/**
|
|
1047
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
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
|
|
1745
|
+
<span
|
|
1746
|
+
className={`${styles.cqpopBadge}${
|
|
1747
|
+
isDirect
|
|
1748
|
+
? ` ${styles.cqpopBadgeDirect}`
|
|
1749
|
+
: ''
|
|
1750
|
+
}`}
|
|
1751
|
+
>
|
|
1144
1752
|
<span className={styles.cqpopDot} />
|
|
1145
|
-
{call
|
|
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}>
|