@visns-studio/visns-components 6.27.0 → 6.30.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 +82 -0
- package/package.json +1 -1
- package/src/components/callQueue/CallQueueDiagnostics.jsx +13 -0
- package/src/components/callQueue/CallQueuePop.jsx +226 -45
- package/src/components/callQueue/callPopStatus.js +23 -0
- package/src/components/callQueue/callQueueHelpers.js +52 -15
- package/src/components/echo/sharedPrivateChannel.js +204 -0
- package/src/components/phone/useZoomPhoneLive.js +16 -2
- package/src/components/styles/TicketConversation.module.scss +22 -1
- package/src/components/tickets/TicketConversation.jsx +96 -5
- package/src/index.js +25 -0
package/README.md
CHANGED
|
@@ -9,6 +9,82 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
|
|
|
9
9
|
|
|
10
10
|
VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
|
|
11
11
|
|
|
12
|
+
## Recent Updates (v6.30.0)
|
|
13
|
+
|
|
14
|
+
### `CallQueuePop` no longer loses its channel to the header's phone badge
|
|
15
|
+
|
|
16
|
+
Two components listen on the same private channel (`call-queue-monitor.{env}`):
|
|
17
|
+
the call pop, mounted at the app root, and `ZoomPhoneBadge` in the header. A
|
|
18
|
+
host that hides its header on some routes (the CRM does, on client and task
|
|
19
|
+
record pages) unmounted the badge, whose cleanup called `echo.leave()` — and
|
|
20
|
+
laravel-echo's `leave()` destroys a channel for every holder, not just the
|
|
21
|
+
caller. The pop kept reporting `subscribed: true` and simply stopped popping
|
|
22
|
+
until the tab was reloaded. Production's webhook ledger showed every ring
|
|
23
|
+
broadcast OK, which is exactly why this took a while to find.
|
|
24
|
+
|
|
25
|
+
- **`acquirePrivateChannel` / `releasePrivateChannel` / `hasPrivateChannel`**
|
|
26
|
+
(new, exported): a ref-counted front for `echo.private()` / `echo.leave()`.
|
|
27
|
+
The channel is opened on the first acquire and only really left when the
|
|
28
|
+
last holder releases it. Both `CallQueuePop` and `useZoomPhoneLive` go
|
|
29
|
+
through it. **A host component that listens on a channel one of ours also
|
|
30
|
+
listens on must use it too** — a direct `echo.leave()` will still take the
|
|
31
|
+
channel away from everyone.
|
|
32
|
+
- **The pop rechecks its channel** on every socket reconnect and every
|
|
33
|
+
`resubscribeCheckMs` (new prop, default 60 s, `0` disables) while the tab is
|
|
34
|
+
visible, and resubscribes when it finds the channel gone — then refetches
|
|
35
|
+
the live snapshot so anything that rang meanwhile is caught up.
|
|
36
|
+
- **Diagnostics gain a "Channel live" row** beside "Subscribed", fed by the
|
|
37
|
+
new `channelPresent` status field (`noteCallPopChannelPresent`, exported).
|
|
38
|
+
The two readings disagreeing is the symptom this release fixes; before,
|
|
39
|
+
there was no second reading.
|
|
40
|
+
|
|
41
|
+
## Recent Updates (v6.29.0)
|
|
42
|
+
|
|
43
|
+
### `TicketConversation` names who a reply reaches
|
|
44
|
+
|
|
45
|
+
A helpdesk reply goes to everybody on the email chain, not to the one person
|
|
46
|
+
whose message happens to be on screen — and on a forwarded ticket that is
|
|
47
|
+
regularly somebody the agent has never seen. The composer said *"This is
|
|
48
|
+
emailed to the customer."*, which is true and is not enough to check a send
|
|
49
|
+
against.
|
|
50
|
+
|
|
51
|
+
The server now says who, in two places, and the component draws both:
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
// GET {base}/{ticketId}/conversation
|
|
55
|
+
{
|
|
56
|
+
"reply_audience": { // or null — not answerable by email, or unknown
|
|
57
|
+
"to": [{ "name": "Dana Reid", "address": "dana@acme.test" }],
|
|
58
|
+
"cc": [{ "name": null, "address": "accounts@acme.test" }]
|
|
59
|
+
},
|
|
60
|
+
"data": [
|
|
61
|
+
{ "id": 4, "direction": "out", "recipients": { "to": [], "cc": [] } },
|
|
62
|
+
{ "id": 3, "direction": "note", "recipients": null }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **Before sending** — the `.consequence` sentence beside the Internal note
|
|
68
|
+
switch becomes `Emailed to Dana Reid · Cc accounts@acme.test` (the ` · Cc …`
|
|
69
|
+
half is omitted when Cc is empty). An internal note is unchanged, and a
|
|
70
|
+
`reply_audience` of `null` falls back to the old sentence rather than naming
|
|
71
|
+
nobody.
|
|
72
|
+
- **After sending** — each entry carrying `recipients` draws one quiet
|
|
73
|
+
`To … · Cc …` line between its head and its body. **Both directions**: on our
|
|
74
|
+
reply it says who we reached, on the customer's it says who else at their end
|
|
75
|
+
has already read it, which is what tells an agent whether the answer needs to
|
|
76
|
+
go anywhere new. Nothing on a note, or on a row imported before the server
|
|
77
|
+
knew any of this (`recipients: null`).
|
|
78
|
+
- A recipient renders as `name || address`; the **full `Name <address>` list is
|
|
79
|
+
in the `title`**, because two people called Dana is exactly the case a name
|
|
80
|
+
alone cannot settle.
|
|
81
|
+
- **Nothing is folded to "+3 more".** The recipient worth noticing is the
|
|
82
|
+
unexpected one, which is the one a fold would hide — so both lines wrap and
|
|
83
|
+
take the space they need.
|
|
84
|
+
|
|
85
|
+
The `POST {base}/{ticketId}/reply` response's `data` carries `recipients` too,
|
|
86
|
+
so a reply prepended by the composer shows its own line immediately.
|
|
87
|
+
|
|
12
88
|
## Recent Updates (v6.27.0)
|
|
13
89
|
|
|
14
90
|
### A tint on one column: `cellColours`
|
|
@@ -2212,6 +2288,7 @@ endpoint nor an Echo instance present it renders nothing and logs nothing.
|
|
|
2212
2288
|
| `calendarPath` | `'/calendar'` | |
|
|
2213
2289
|
| `callWorkspacePath` | `'/call/{number}'` | Template, or `(workspaceId, call) => path`. `{number}` is the `61…` form the workspace route expects. |
|
|
2214
2290
|
| `syncChannelName` | `'throughlife-call-queue-pop'` | The `BroadcastChannel` that keeps every open tab's stack in step. Falsy switches cross-tab sync off. |
|
|
2291
|
+
| `clientDetailFields` | `CLIENT_DETAIL_FIELDS` (adviser / coding / age / city) | `[{ key, label, demo? }]` — the client-block rows, in card order; rows with an empty value are dropped; `demo` seeds the demo card. Pass your CRM's own fields so the card never names a field it does not have. Must be referentially stable (a module-scope constant, not an inline literal) — it sits in the demo effect's dependency list. |
|
|
2215
2292
|
| `demoEnabled` | `true` | Registers `window.callPopDemo()` / `window.callPopClear()` for reviewing the UI without a backend. |
|
|
2216
2293
|
|
|
2217
2294
|
**Payload contract.** Snake_case and camelCase are both accepted, so a Laravel
|
|
@@ -2226,6 +2303,11 @@ Named exports for testing: `toLocalDigits`, `formatAuPhone`, `formatEventDate`,
|
|
|
2226
2303
|
`formatDueDate`, `toCallWorkspaceId`, `normaliseCall`, `normalisePickupCodes`,
|
|
2227
2304
|
`formatElapsed`, `hasMonitorPermission`, `clientDetails`.
|
|
2228
2305
|
|
|
2306
|
+
`CLIENT_DETAIL_FIELDS`, `clientDetails` and `demoClientDetails` are exported
|
|
2307
|
+
from the package itself (`@visns-studio/visns-components`), so a host can build
|
|
2308
|
+
its own field list beside the default or reuse the demo seeding; the helpers
|
|
2309
|
+
module is not re-exported wholesale, only those three alongside the component.
|
|
2310
|
+
|
|
2229
2311
|
#### CallQueueSettings
|
|
2230
2312
|
|
|
2231
2313
|
The admin table behind the pop: one row per Zoom call queue, carrying the
|
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
94
94
|
},
|
|
95
95
|
"name": "@visns-studio/visns-components",
|
|
96
|
-
"version": "6.
|
|
96
|
+
"version": "6.30.0",
|
|
97
97
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
98
98
|
"main": "src/index.js",
|
|
99
99
|
"files": [
|
|
@@ -559,6 +559,19 @@ const CallQueueDiagnostics = ({
|
|
|
559
559
|
? 'bad'
|
|
560
560
|
: 'ok'
|
|
561
561
|
)}
|
|
562
|
+
{/* Deliberately beside "Subscribed", not folded into
|
|
563
|
+
it: a channel torn down by somebody else's
|
|
564
|
+
`echo.leave()` leaves "Subscribed" stuck on Yes, and
|
|
565
|
+
the two readings disagreeing is the symptom. */}
|
|
566
|
+
{row(
|
|
567
|
+
'Channel live',
|
|
568
|
+
yesNo(status.channelPresent, 'not checked'),
|
|
569
|
+
status.channelPresent === false
|
|
570
|
+
? 'bad'
|
|
571
|
+
: status.channelPresent === true
|
|
572
|
+
? 'ok'
|
|
573
|
+
: undefined
|
|
574
|
+
)}
|
|
562
575
|
{row(
|
|
563
576
|
'Last event',
|
|
564
577
|
status.lastEventName
|
|
@@ -20,11 +20,20 @@ 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 {
|
|
@@ -40,6 +49,7 @@ import {
|
|
|
40
49
|
defaultClientTasksUrl,
|
|
41
50
|
defaultClientUrl,
|
|
42
51
|
defaultTaskUrl,
|
|
52
|
+
demoClientDetails,
|
|
43
53
|
directRingingLine,
|
|
44
54
|
formatAuPhone,
|
|
45
55
|
formatDueDate,
|
|
@@ -62,6 +72,7 @@ export {
|
|
|
62
72
|
callBadgeLabel,
|
|
63
73
|
calleeLabel,
|
|
64
74
|
clientDetails,
|
|
75
|
+
demoClientDetails,
|
|
65
76
|
directRingingLine,
|
|
66
77
|
formatAuPhone,
|
|
67
78
|
formatDueDate,
|
|
@@ -117,6 +128,15 @@ export {
|
|
|
117
128
|
* and reconciles both ways, so a call that started or ended while this browser
|
|
118
129
|
* was deaf is not stuck on (or missing from) the screen.
|
|
119
130
|
*
|
|
131
|
+
* The channel is SHARED: the header's phone badge listens on the same private
|
|
132
|
+
* channel, and laravel-echo's `leave()` destroys a channel for everyone rather
|
|
133
|
+
* than just for its caller — so the badge unmounting (the CRM hides its header
|
|
134
|
+
* on record pages) used to silently unsubscribe the pop, with `subscribed`
|
|
135
|
+
* still reading true. Both sides now go through the ref-counted registry in
|
|
136
|
+
* ../echo/sharedPrivateChannel, and the pop rechecks the channel on every
|
|
137
|
+
* socket reconnect and every `resubscribeCheckMs` while the tab is visible,
|
|
138
|
+
* resubscribing when it finds it gone.
|
|
139
|
+
*
|
|
120
140
|
* The caller -> client match arrives already resolved, as each call's `client`
|
|
121
141
|
* block (computed once by the Zoom webhook, server-side). This component never
|
|
122
142
|
* looks a caller up itself — every watching member of staff sees every pop, and
|
|
@@ -207,6 +227,16 @@ const DEFAULT_MISSED_GRACE_MS = 10000;
|
|
|
207
227
|
*/
|
|
208
228
|
const SNAPSHOT_MIN_GAP_MS = 5000;
|
|
209
229
|
|
|
230
|
+
/**
|
|
231
|
+
* How often the pop checks that its channel is still there, and resubscribes
|
|
232
|
+
* if it is not. A dropped socket announces itself; a channel taken away by
|
|
233
|
+
* another component's `echo.leave()` announces nothing at all, so the only way
|
|
234
|
+
* to find out is to look. Once a minute is cheap (a property read, and one
|
|
235
|
+
* throttled snapshot) and bounds the blind window at a minute. Override with
|
|
236
|
+
* the `resubscribeCheckMs` prop; 0 turns the poll off.
|
|
237
|
+
*/
|
|
238
|
+
const DEFAULT_RESUBSCRIBE_CHECK_MS = 60000;
|
|
239
|
+
|
|
210
240
|
/** Title shown on alternate ticks while a call rings in a hidden tab. */
|
|
211
241
|
const FLASH_TITLE = '📞 Incoming call…';
|
|
212
242
|
|
|
@@ -286,8 +316,19 @@ const CallQueuePop = ({
|
|
|
286
316
|
calendarPath = '/calendar',
|
|
287
317
|
callWorkspacePath = '/call/{number}',
|
|
288
318
|
syncChannelName = SYNC_CHANNEL,
|
|
319
|
+
// The client-block rows, in card order — the host's own fields, so the
|
|
320
|
+
// card never names one its CRM does not have. The default is this
|
|
321
|
+
// component's original financial-planning shape (adviser / coding / age /
|
|
322
|
+
// city) and the demo card seeds itself from the same list, so a host that
|
|
323
|
+
// passes its own fields also demos its own fields.
|
|
324
|
+
//
|
|
325
|
+
// MUST be referentially stable — a module-scope constant, not an inline
|
|
326
|
+
// literal — because it sits in the demo effect's dependency list, and a
|
|
327
|
+
// fresh array per render would re-register the demo helpers every time.
|
|
328
|
+
clientDetailFields = CLIENT_DETAIL_FIELDS,
|
|
289
329
|
demoEnabled = true,
|
|
290
330
|
missedGraceMs = DEFAULT_MISSED_GRACE_MS,
|
|
331
|
+
resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
|
|
291
332
|
}) => {
|
|
292
333
|
const [calls, setCalls] = useState([]);
|
|
293
334
|
const [now, setNow] = useState(() => Date.now());
|
|
@@ -680,20 +721,22 @@ const CallQueuePop = ({
|
|
|
680
721
|
queueName: 'Test Call Queue',
|
|
681
722
|
callerNumber: '+61298765432',
|
|
682
723
|
callerName: 'Margaret Chen',
|
|
683
|
-
//
|
|
684
|
-
//
|
|
724
|
+
// The rich card reviewed without a real match. The detail
|
|
725
|
+
// rows are seeded from `clientDetailFields` itself, so the
|
|
726
|
+
// demo shows exactly the host's own fields with the host's
|
|
727
|
+
// own sample values rather than another CRM's. id 0
|
|
685
728
|
// renders the name as plain text, not a link.
|
|
686
729
|
client: {
|
|
687
730
|
id: 0,
|
|
688
731
|
name: 'Chen, Margaret (Mrs)',
|
|
689
732
|
matched_on: 'home',
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
733
|
+
// Before the spread, so a host listing `email` among
|
|
734
|
+
// its own fields demos its own sample value while a
|
|
735
|
+
// host that does not still gets one.
|
|
693
736
|
email: 'margaret.chen@example.com',
|
|
694
|
-
|
|
737
|
+
...demoClientDetails(clientDetailFields),
|
|
695
738
|
next_event: {
|
|
696
|
-
title: 'Client
|
|
739
|
+
title: 'Client review',
|
|
697
740
|
date: new Date(
|
|
698
741
|
Date.now() + 2 * 24 * 60 * 60 * 1000
|
|
699
742
|
).toISOString(),
|
|
@@ -705,19 +748,19 @@ const CallQueuePop = ({
|
|
|
705
748
|
tasks: [
|
|
706
749
|
{
|
|
707
750
|
id: 90001,
|
|
708
|
-
label: 'Confirm
|
|
751
|
+
label: 'Confirm the site visit time',
|
|
709
752
|
due_date: new Date(
|
|
710
753
|
Date.now() + 3 * 24 * 60 * 60 * 1000
|
|
711
754
|
).toISOString(),
|
|
712
|
-
task_type: '
|
|
755
|
+
task_type: 'Ticket',
|
|
713
756
|
},
|
|
714
757
|
{
|
|
715
758
|
id: 90002,
|
|
716
|
-
label: 'Send updated
|
|
759
|
+
label: 'Send the updated quote',
|
|
717
760
|
due_date: new Date(
|
|
718
761
|
Date.now() + 9 * 24 * 60 * 60 * 1000
|
|
719
762
|
).toISOString(),
|
|
720
|
-
task_type: '
|
|
763
|
+
task_type: 'Quote',
|
|
721
764
|
},
|
|
722
765
|
],
|
|
723
766
|
startedAt: new Date().toISOString(),
|
|
@@ -745,6 +788,7 @@ const CallQueuePop = ({
|
|
|
745
788
|
addDemoPickupCode,
|
|
746
789
|
broadcastSync,
|
|
747
790
|
clearCalls,
|
|
791
|
+
clientDetailFields,
|
|
748
792
|
demoEnabled,
|
|
749
793
|
dropDemoPickupCode,
|
|
750
794
|
]);
|
|
@@ -875,8 +919,9 @@ const CallQueuePop = ({
|
|
|
875
919
|
* reconcileSnapshot for the two exceptions), so a call that started while
|
|
876
920
|
* the socket was down appears, and one that ended while it was down goes.
|
|
877
921
|
*
|
|
878
|
-
* @param {string} reason 'mount' | 'reconnect' | 'visible'
|
|
879
|
-
* diagnostics panel says why each refresh
|
|
922
|
+
* @param {string} reason 'mount' | 'reconnect' | 'visible' | 'resubscribe'
|
|
923
|
+
* | 'poll' — logged, so the diagnostics panel says why each refresh
|
|
924
|
+
* happened. Nothing validates the value; it is a label.
|
|
880
925
|
*/
|
|
881
926
|
const refreshSnapshot = useCallback(
|
|
882
927
|
(reason) => {
|
|
@@ -1133,14 +1178,24 @@ const CallQueuePop = ({
|
|
|
1133
1178
|
let subscription = null;
|
|
1134
1179
|
let stopMonitoring = () => {};
|
|
1135
1180
|
let unbindState = () => {};
|
|
1181
|
+
let resubscribeTimer = null;
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Bind every `.queue.*` listener plus the subscription monitor.
|
|
1185
|
+
*
|
|
1186
|
+
* Broken out of the effect body because a resubscribe has to do all of
|
|
1187
|
+
* this again against a brand new subscription object — the old one is
|
|
1188
|
+
* a corpse once Echo has dropped the channel.
|
|
1189
|
+
*
|
|
1190
|
+
* @param {object} target The subscription to bind to.
|
|
1191
|
+
* @returns {Function} Stops the monitor again.
|
|
1192
|
+
*/
|
|
1193
|
+
const attachListeners = (target) => {
|
|
1194
|
+
if (!target) {
|
|
1195
|
+
return () => {};
|
|
1196
|
+
}
|
|
1136
1197
|
|
|
1137
|
-
|
|
1138
|
-
// Until /broadcasting/auth knows this channel the subscription just
|
|
1139
|
-
// fails auth — pusher-js logs a warning, nothing of ours throws.
|
|
1140
|
-
// That silence is exactly what the monitor below breaks.
|
|
1141
|
-
subscription = instance.private(activeChannel);
|
|
1142
|
-
|
|
1143
|
-
subscription.listen(EVENT_RINGING, (event) => {
|
|
1198
|
+
target.listen(EVENT_RINGING, (event) => {
|
|
1144
1199
|
const call = event?.call ?? event;
|
|
1145
1200
|
|
|
1146
1201
|
noteCallPopEvent(EVENT_RINGING);
|
|
@@ -1148,7 +1203,7 @@ const CallQueuePop = ({
|
|
|
1148
1203
|
ingestCall(call);
|
|
1149
1204
|
});
|
|
1150
1205
|
|
|
1151
|
-
|
|
1206
|
+
target.listen(EVENT_ANSWERED, (event) => {
|
|
1152
1207
|
const callId =
|
|
1153
1208
|
event?.callId ?? event?.call_id ?? event?.call?.callId;
|
|
1154
1209
|
|
|
@@ -1160,7 +1215,7 @@ const CallQueuePop = ({
|
|
|
1160
1215
|
}
|
|
1161
1216
|
});
|
|
1162
1217
|
|
|
1163
|
-
|
|
1218
|
+
target.listen(EVENT_ENDED, (event) => {
|
|
1164
1219
|
const callId =
|
|
1165
1220
|
event?.callId ?? event?.call_id ?? event?.call?.callId;
|
|
1166
1221
|
|
|
@@ -1175,7 +1230,7 @@ const CallQueuePop = ({
|
|
|
1175
1230
|
// One leg said no. The call is not over — it may be ringing on
|
|
1176
1231
|
// three other devices — so this starts a grace period rather than
|
|
1177
1232
|
// removing anything.
|
|
1178
|
-
|
|
1233
|
+
target.listen(EVENT_MISSED, (event) => {
|
|
1179
1234
|
const callId =
|
|
1180
1235
|
event?.callId ?? event?.call_id ?? event?.call?.callId;
|
|
1181
1236
|
|
|
@@ -1189,7 +1244,7 @@ const CallQueuePop = ({
|
|
|
1189
1244
|
|
|
1190
1245
|
// The diagnostics panel's round-trip probe. It settles nothing and
|
|
1191
1246
|
// pops nothing — it only proves this browser is being delivered to.
|
|
1192
|
-
|
|
1247
|
+
target.listen(EVENT_PING, (event) => {
|
|
1193
1248
|
const nonce = event?.nonce ?? event?.id ?? null;
|
|
1194
1249
|
|
|
1195
1250
|
noteCallPopEvent(EVENT_PING, {
|
|
@@ -1201,7 +1256,7 @@ const CallQueuePop = ({
|
|
|
1201
1256
|
trace('event .queue.diagnostic-ping', nonce);
|
|
1202
1257
|
});
|
|
1203
1258
|
|
|
1204
|
-
|
|
1259
|
+
return subscriptionMonitor(target, {
|
|
1205
1260
|
onSuccess: () => {
|
|
1206
1261
|
appendCallPopLog(`Subscribed to ${activeChannel}`, 'info', {
|
|
1207
1262
|
subscribed: true,
|
|
@@ -1243,6 +1298,113 @@ const CallQueuePop = ({
|
|
|
1243
1298
|
);
|
|
1244
1299
|
},
|
|
1245
1300
|
});
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
/** Unbind whatever `attachListeners` bound. Never throws. */
|
|
1304
|
+
const detachListeners = (target) => {
|
|
1305
|
+
try {
|
|
1306
|
+
stopMonitoring();
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
// Already gone.
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
stopMonitoring = () => {};
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
if (target) {
|
|
1315
|
+
target.stopListening(EVENT_RINGING);
|
|
1316
|
+
target.stopListening(EVENT_ANSWERED);
|
|
1317
|
+
target.stopListening(EVENT_ENDED);
|
|
1318
|
+
target.stopListening(EVENT_MISSED);
|
|
1319
|
+
target.stopListening(EVENT_PING);
|
|
1320
|
+
}
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
// A dead channel has nothing left to unbind.
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Confirm the channel is still there, and rebuild it when it is not.
|
|
1328
|
+
*
|
|
1329
|
+
* The failure this exists for is silent by construction: another
|
|
1330
|
+
* component calling `echo.leave()` on the shared channel unsubscribes
|
|
1331
|
+
* us without any event, so `subscribed` stays true and the pop simply
|
|
1332
|
+
* stops popping. Nothing tells us — we have to look.
|
|
1333
|
+
*
|
|
1334
|
+
* @returns {boolean} True when a resubscribe was performed.
|
|
1335
|
+
*/
|
|
1336
|
+
const ensureSubscribed = () => {
|
|
1337
|
+
let present = false;
|
|
1338
|
+
|
|
1339
|
+
try {
|
|
1340
|
+
present = hasPrivateChannel(instance, activeChannel);
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
present = false;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
noteCallPopChannelPresent(present);
|
|
1346
|
+
|
|
1347
|
+
if (present) {
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
appendCallPopLog('Channel was gone — resubscribing', 'warn', {
|
|
1352
|
+
channel: activeChannel,
|
|
1353
|
+
subscribed: false,
|
|
1354
|
+
subscriptionError: null,
|
|
1355
|
+
});
|
|
1356
|
+
trace(`channel private-${activeChannel} was gone — resubscribing`);
|
|
1357
|
+
|
|
1358
|
+
detachListeners(subscription);
|
|
1359
|
+
// Release before acquiring so the ref count nets out unchanged:
|
|
1360
|
+
// this component held one reference before and holds one after.
|
|
1361
|
+
releasePrivateChannel(instance, activeChannel);
|
|
1362
|
+
|
|
1363
|
+
try {
|
|
1364
|
+
subscription = acquirePrivateChannel(instance, activeChannel);
|
|
1365
|
+
stopMonitoring = attachListeners(subscription);
|
|
1366
|
+
noteCallPopChannelPresent(
|
|
1367
|
+
hasPrivateChannel(instance, activeChannel)
|
|
1368
|
+
);
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
subscription = null;
|
|
1371
|
+
appendCallPopLog(
|
|
1372
|
+
`Could not resubscribe to ${activeChannel}: ${error?.message ?? error}`,
|
|
1373
|
+
'error',
|
|
1374
|
+
{
|
|
1375
|
+
subscribed: false,
|
|
1376
|
+
channelPresent: false,
|
|
1377
|
+
subscriptionError: {
|
|
1378
|
+
status: null,
|
|
1379
|
+
message: String(error?.message ?? error),
|
|
1380
|
+
},
|
|
1381
|
+
}
|
|
1382
|
+
);
|
|
1383
|
+
trace('resubscribe threw', error?.message ?? error);
|
|
1384
|
+
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// Anything that rang while we were unsubscribed was broadcast to
|
|
1389
|
+
// nobody here, so the snapshot is the only way to learn about it.
|
|
1390
|
+
requestSnapshot('resubscribe');
|
|
1391
|
+
|
|
1392
|
+
return true;
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
try {
|
|
1396
|
+
// Until /broadcasting/auth knows this channel the subscription just
|
|
1397
|
+
// fails auth — pusher-js logs a warning, nothing of ours throws.
|
|
1398
|
+
// That silence is exactly what the monitor below breaks.
|
|
1399
|
+
//
|
|
1400
|
+
// Ref-counted rather than `instance.private()` directly: the
|
|
1401
|
+
// header's phone badge shares this channel and unmounts on record
|
|
1402
|
+
// pages, and its `leave()` used to take our subscription with it.
|
|
1403
|
+
subscription = acquirePrivateChannel(instance, activeChannel);
|
|
1404
|
+
stopMonitoring = attachListeners(subscription);
|
|
1405
|
+
noteCallPopChannelPresent(
|
|
1406
|
+
hasPrivateChannel(instance, activeChannel)
|
|
1407
|
+
);
|
|
1246
1408
|
} catch (error) {
|
|
1247
1409
|
subscription = null;
|
|
1248
1410
|
appendCallPopLog(
|
|
@@ -1250,6 +1412,7 @@ const CallQueuePop = ({
|
|
|
1250
1412
|
'error',
|
|
1251
1413
|
{
|
|
1252
1414
|
subscribed: false,
|
|
1415
|
+
channelPresent: false,
|
|
1253
1416
|
subscriptionError: {
|
|
1254
1417
|
status: null,
|
|
1255
1418
|
message: String(error?.message ?? error),
|
|
@@ -1278,6 +1441,11 @@ const CallQueuePop = ({
|
|
|
1278
1441
|
// only when the socket actually went away, otherwise every
|
|
1279
1442
|
// ordinary connecting -> connected on load would refetch.
|
|
1280
1443
|
if (current === 'connected') {
|
|
1444
|
+
// A socket can come back without our channel: pusher-js
|
|
1445
|
+
// resubscribes what it still holds, and a channel that
|
|
1446
|
+
// was `leave()`n is no longer among them.
|
|
1447
|
+
ensureSubscribed();
|
|
1448
|
+
|
|
1281
1449
|
if (sawDisconnect.current) {
|
|
1282
1450
|
requestSnapshot('reconnect');
|
|
1283
1451
|
}
|
|
@@ -1312,30 +1480,42 @@ const CallQueuePop = ({
|
|
|
1312
1480
|
// says whether this browser is live, which is what it said before.
|
|
1313
1481
|
}
|
|
1314
1482
|
|
|
1315
|
-
|
|
1316
|
-
|
|
1483
|
+
// The backstop. `state_change` covers a socket that visibly went away;
|
|
1484
|
+
// this covers the case with no signal at all — the channel removed
|
|
1485
|
+
// underneath a perfectly healthy socket. Only while the tab is being
|
|
1486
|
+
// looked at: a backgrounded tab that nobody can see a pop in has
|
|
1487
|
+
// nothing to catch up for, and the visibilitychange effect above
|
|
1488
|
+
// already refreshes on the way back.
|
|
1489
|
+
if (resubscribeCheckMs > 0) {
|
|
1490
|
+
resubscribeTimer = setInterval(() => {
|
|
1491
|
+
if (
|
|
1492
|
+
typeof document !== 'undefined' &&
|
|
1493
|
+
document.visibilityState !== 'visible'
|
|
1494
|
+
) {
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1317
1497
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1498
|
+
ensureSubscribed();
|
|
1499
|
+
requestSnapshot('poll');
|
|
1500
|
+
}, resubscribeCheckMs);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return () => {
|
|
1504
|
+
if (resubscribeTimer !== null) {
|
|
1505
|
+
clearInterval(resubscribeTimer);
|
|
1506
|
+
resubscribeTimer = null;
|
|
1322
1507
|
}
|
|
1323
1508
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
subscription.stopListening(EVENT_RINGING);
|
|
1327
|
-
subscription.stopListening(EVENT_ANSWERED);
|
|
1328
|
-
subscription.stopListening(EVENT_ENDED);
|
|
1329
|
-
subscription.stopListening(EVENT_MISSED);
|
|
1330
|
-
subscription.stopListening(EVENT_PING);
|
|
1331
|
-
}
|
|
1509
|
+
unbindState();
|
|
1510
|
+
detachListeners(subscription);
|
|
1332
1511
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
}
|
|
1512
|
+
// Only really leaves the channel when nobody else (the header's
|
|
1513
|
+
// phone badge) is still holding it.
|
|
1514
|
+
releasePrivateChannel(instance, activeChannel);
|
|
1337
1515
|
|
|
1338
|
-
|
|
1516
|
+
// `channelPresent` goes back to unknown rather than false: the pop
|
|
1517
|
+
// is no longer watching, so it has nothing to report either way.
|
|
1518
|
+
updateCallPopStatus({ subscribed: false, channelPresent: null });
|
|
1339
1519
|
};
|
|
1340
1520
|
}, [
|
|
1341
1521
|
activeChannel,
|
|
@@ -1344,6 +1524,7 @@ const CallQueuePop = ({
|
|
|
1344
1524
|
markMissed,
|
|
1345
1525
|
removeCall,
|
|
1346
1526
|
requestSnapshot,
|
|
1527
|
+
resubscribeCheckMs,
|
|
1347
1528
|
]);
|
|
1348
1529
|
|
|
1349
1530
|
/**
|
|
@@ -1707,7 +1888,7 @@ const CallQueuePop = ({
|
|
|
1707
1888
|
{calls.map((call) => {
|
|
1708
1889
|
const pickupCode = pickupCodeFor(call);
|
|
1709
1890
|
const client = call.client;
|
|
1710
|
-
const details = clientDetails(client);
|
|
1891
|
+
const details = clientDetails(client, clientDetailFields);
|
|
1711
1892
|
const nextEvent = client?.next_event ?? null;
|
|
1712
1893
|
const nextEventDate = formatEventDate(nextEvent?.date);
|
|
1713
1894
|
const openTasks = Number(client?.open_tasks ?? 0);
|
|
@@ -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.
|
|
@@ -210,32 +210,69 @@ export const defaultTaskUrl = (task) => `/tasks/detail/${task?.id}`;
|
|
|
210
210
|
/** Default detail route for a matched client. */
|
|
211
211
|
export const defaultClientUrl = (client) => `/clients/${client?.id}`;
|
|
212
212
|
|
|
213
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* The client fields shown as label/value rows, in card order — the DEFAULT,
|
|
215
|
+
* inherited from the financial-planning CRM this component was ported from.
|
|
216
|
+
* Each entry's `demo` is the sample value the demo card shows for it.
|
|
217
|
+
*
|
|
218
|
+
* A host whose caller enrichment returns a different shape passes its own list
|
|
219
|
+
* as `clientDetailFields` rather than living with rows it can never fill.
|
|
220
|
+
*/
|
|
214
221
|
export const CLIENT_DETAIL_FIELDS = [
|
|
215
|
-
{ key: 'adviser', label: 'Adviser' },
|
|
216
|
-
{ key: 'coding', label: 'Coding' },
|
|
217
|
-
{ key: 'age', label: 'Age' },
|
|
218
|
-
{ key: 'city', label: 'City' },
|
|
222
|
+
{ key: 'adviser', label: 'Adviser', demo: 'Alina Bailey' },
|
|
223
|
+
{ key: 'coding', label: 'Coding', demo: 'Early Retirement' },
|
|
224
|
+
{ key: 'age', label: 'Age', demo: 66 },
|
|
225
|
+
{ key: 'city', label: 'City', demo: 'Fremantle' },
|
|
219
226
|
];
|
|
220
227
|
|
|
221
228
|
/**
|
|
222
229
|
* Detail rows for a client block, with the empty ones dropped — a card only
|
|
223
230
|
* ever shows what the CRM actually knows, never a row with a blank value.
|
|
231
|
+
*
|
|
232
|
+
* `fields` is the host's own list, so the card only ever names fields its CRM
|
|
233
|
+
* actually has. An entry with no string `key` is malformed and is ignored
|
|
234
|
+
* rather than drawn as a row with nothing behind it.
|
|
224
235
|
*/
|
|
225
|
-
export const clientDetails = (client) => {
|
|
236
|
+
export const clientDetails = (client, fields = CLIENT_DETAIL_FIELDS) => {
|
|
226
237
|
if (!client || typeof client !== 'object') {
|
|
227
238
|
return [];
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
241
|
+
const list = Array.isArray(fields) ? fields : CLIENT_DETAIL_FIELDS;
|
|
242
|
+
|
|
243
|
+
return list
|
|
244
|
+
.filter((field) => field && typeof field.key === 'string')
|
|
245
|
+
.map(({ key, label }) => ({
|
|
246
|
+
label,
|
|
247
|
+
value: client[key],
|
|
248
|
+
}))
|
|
249
|
+
.filter(
|
|
250
|
+
(row) =>
|
|
251
|
+
row.value !== null &&
|
|
252
|
+
row.value !== undefined &&
|
|
253
|
+
String(row.value).trim() !== ''
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The demo card's client block, built from the same list the real card draws
|
|
259
|
+
* from — so a host passing its own fields sees its own fields in the demo
|
|
260
|
+
* rather than another CRM's. An entry carrying no `demo` contributes nothing.
|
|
261
|
+
*/
|
|
262
|
+
export const demoClientDetails = (fields = CLIENT_DETAIL_FIELDS) => {
|
|
263
|
+
const list = Array.isArray(fields) ? fields : CLIENT_DETAIL_FIELDS;
|
|
264
|
+
|
|
265
|
+
return list.reduce((carry, field) => {
|
|
266
|
+
if (!field || typeof field.key !== 'string') {
|
|
267
|
+
return carry;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (field.demo === undefined) {
|
|
271
|
+
return carry;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { ...carry, [field.key]: field.demo };
|
|
275
|
+
}, {});
|
|
239
276
|
};
|
|
240
277
|
|
|
241
278
|
/** Seconds since `startedAt` rendered as m:ss (never negative). */
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared private channel registry.
|
|
3
|
+
* -----------------------------------------------------------------------
|
|
4
|
+
* Ref-counting in front of `echo.private(name)` / `echo.leave(name)`, for the
|
|
5
|
+
* case where two independent components subscribe to the SAME private channel.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: laravel-echo's `leave(name)` is not "stop listening for me",
|
|
8
|
+
* it is "destroy this channel" — the connector does
|
|
9
|
+
*
|
|
10
|
+
* this.channels[name].unsubscribe(); delete this.channels[name];
|
|
11
|
+
*
|
|
12
|
+
* so the FIRST component to unmount takes the channel away from everyone still
|
|
13
|
+
* using it. That is exactly what happened with `call-queue-monitor.{env}`: the
|
|
14
|
+
* CRM header hides itself on client and task record pages, the header's phone
|
|
15
|
+
* badge unmounted and called `leave()`, and the call pop — mounted at the app
|
|
16
|
+
* root, with stable effect deps, so it never resubscribed — silently stopped
|
|
17
|
+
* receiving calls while still reporting `subscribed: true`.
|
|
18
|
+
*
|
|
19
|
+
* With this registry a channel is opened on the first acquire and only really
|
|
20
|
+
* left when the last holder releases it.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately plain JS: no React, no DOM, no laravel-echo import. It is
|
|
23
|
+
* framework-free so `node --test` can import the source directly, and so it
|
|
24
|
+
* works with whatever the host handed us as an "Echo instance".
|
|
25
|
+
*
|
|
26
|
+
* Nothing here throws. A registry that breaks the subscription it is meant to
|
|
27
|
+
* protect would be worse than the bug it fixes.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `WeakMap<instance, Map<name, {subscription, refs}>>`.
|
|
32
|
+
*
|
|
33
|
+
* Keyed per Echo instance rather than by name alone: an app may hold more than
|
|
34
|
+
* one broadcaster (and every test file certainly does), and two instances'
|
|
35
|
+
* `call-queue-monitor.production` are two different channels. Weak so a
|
|
36
|
+
* discarded Echo instance takes its entries with it.
|
|
37
|
+
*/
|
|
38
|
+
let registries = new WeakMap();
|
|
39
|
+
|
|
40
|
+
/** The per-instance map, created on demand. */
|
|
41
|
+
const registryFor = (instance) => {
|
|
42
|
+
let registry = registries.get(instance);
|
|
43
|
+
|
|
44
|
+
if (!registry) {
|
|
45
|
+
registry = new Map();
|
|
46
|
+
registries.set(instance, registry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return registry;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Echo's own channel cache, when the connector exposes one.
|
|
54
|
+
*
|
|
55
|
+
* `PusherConnector`/`SocketIoConnector` both keep `this.channels`, keyed by the
|
|
56
|
+
* PREFIXED name (`private-<name>` for a private channel), and `leaveChannel()`
|
|
57
|
+
* deletes from it. Reading it is how we notice that somebody outside this
|
|
58
|
+
* registry — an older build, another library, a host's own cleanup — has torn
|
|
59
|
+
* our channel down behind our back. A connector without it (a null broadcaster,
|
|
60
|
+
* a stub in a test) simply leaves the registry as the only source of truth.
|
|
61
|
+
*/
|
|
62
|
+
const echoChannelCache = (instance) => {
|
|
63
|
+
try {
|
|
64
|
+
const channels = instance?.connector?.channels;
|
|
65
|
+
|
|
66
|
+
return channels && typeof channels === 'object' ? channels : null;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Is Echo still holding `private-<name>`? `null` = it cannot tell us. */
|
|
73
|
+
const echoHolds = (instance, name) => {
|
|
74
|
+
const cache = echoChannelCache(instance);
|
|
75
|
+
|
|
76
|
+
if (!cache) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Object.prototype.hasOwnProperty.call(cache, `private-${name}`);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Subscribe to a private channel, or join the subscription already open.
|
|
85
|
+
*
|
|
86
|
+
* `instance.private(name)` is called on the first acquire for this
|
|
87
|
+
* (instance, name) pair only; later acquires bump the count and hand back the
|
|
88
|
+
* same subscription object, so every holder listens on one channel.
|
|
89
|
+
*
|
|
90
|
+
* One exception: if Echo's own cache no longer holds the channel — somebody
|
|
91
|
+
* called `leave()` outside this registry — the cached subscription is dead and
|
|
92
|
+
* handing it back would give the caller an object that will never fire again.
|
|
93
|
+
* In that case we resubscribe and store the new one.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} instance Echo instance.
|
|
96
|
+
* @param {string} name Channel name, unprefixed (Echo adds `private-`).
|
|
97
|
+
*
|
|
98
|
+
* @returns {object|null} The subscription, or null when there is nothing to
|
|
99
|
+
* subscribe to (no instance, no name, `private` threw).
|
|
100
|
+
*/
|
|
101
|
+
export const acquirePrivateChannel = (instance, name) => {
|
|
102
|
+
if (!instance || typeof instance.private !== 'function' || !name) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const registry = registryFor(instance);
|
|
107
|
+
const entry = registry.get(name);
|
|
108
|
+
|
|
109
|
+
// A live entry whose channel Echo still has (or cannot report on) is the
|
|
110
|
+
// ordinary case: share it.
|
|
111
|
+
if (entry && echoHolds(instance, name) !== false) {
|
|
112
|
+
entry.refs += 1;
|
|
113
|
+
|
|
114
|
+
return entry.subscription;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const subscription = instance.private(name);
|
|
118
|
+
|
|
119
|
+
if (entry) {
|
|
120
|
+
// Torn down behind our back: keep the holders we know about, replace
|
|
121
|
+
// the corpse they are holding.
|
|
122
|
+
entry.subscription = subscription;
|
|
123
|
+
entry.refs += 1;
|
|
124
|
+
} else {
|
|
125
|
+
registry.set(name, { subscription, refs: 1 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return subscription;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Let go of a private channel. `instance.leave(name)` runs only when the last
|
|
133
|
+
* holder releases it; releasing a name nobody acquired is a no-op.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} instance Echo instance.
|
|
136
|
+
* @param {string} name Channel name, unprefixed.
|
|
137
|
+
*
|
|
138
|
+
* @returns {number} Holders remaining (0 once the channel has been left).
|
|
139
|
+
*/
|
|
140
|
+
export const releasePrivateChannel = (instance, name) => {
|
|
141
|
+
if (!instance || !name) {
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const registry = registries.get(instance);
|
|
146
|
+
const entry = registry?.get(name);
|
|
147
|
+
|
|
148
|
+
if (!entry) {
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
entry.refs -= 1;
|
|
153
|
+
|
|
154
|
+
if (entry.refs > 0) {
|
|
155
|
+
return entry.refs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
registry.delete(name);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
instance.leave?.(name);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Already gone, or an instance that never had a socket. Either way the
|
|
164
|
+
// registry is clean, which is the part we control.
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return 0;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Is this channel actually still subscribed?
|
|
172
|
+
*
|
|
173
|
+
* True only when the registry has a live entry AND — when the connector
|
|
174
|
+
* exposes its cache — Echo still holds `private-<name>`. The second half is
|
|
175
|
+
* the whole point: an external `leave()` leaves our ref count untouched but
|
|
176
|
+
* kills the channel, and that mismatch is precisely the failure this module
|
|
177
|
+
* was written for. Callers use it to notice and resubscribe.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} instance Echo instance.
|
|
180
|
+
* @param {string} name Channel name, unprefixed.
|
|
181
|
+
*
|
|
182
|
+
* @returns {boolean}
|
|
183
|
+
*/
|
|
184
|
+
export const hasPrivateChannel = (instance, name) => {
|
|
185
|
+
if (!instance || !name) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const entry = registries.get(instance)?.get(name);
|
|
190
|
+
|
|
191
|
+
if (!entry) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// `null` = the connector cannot tell us; the registry is then all we have.
|
|
196
|
+
return echoHolds(instance, name) !== false;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Drop every entry. Used by tests; never call this from application code. */
|
|
200
|
+
export const _resetSharedPrivateChannels = () => {
|
|
201
|
+
registries = new WeakMap();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export default acquirePrivateChannel;
|
|
@@ -6,6 +6,14 @@ import {
|
|
|
6
6
|
subscriptionMonitor,
|
|
7
7
|
} from '../sms/smsLiveState';
|
|
8
8
|
|
|
9
|
+
// The call queue pop listens on this SAME channel. Echo's `leave()` destroys a
|
|
10
|
+
// channel for everyone, so neither of us may call it directly — see
|
|
11
|
+
// sharedPrivateChannel.js.
|
|
12
|
+
import {
|
|
13
|
+
acquirePrivateChannel,
|
|
14
|
+
releasePrivateChannel,
|
|
15
|
+
} from '../echo/sharedPrivateChannel';
|
|
16
|
+
|
|
9
17
|
/** The event the backend broadcasts when one extension's state changes. */
|
|
10
18
|
export const EVENT_PRESENCE = '.phone.presence';
|
|
11
19
|
|
|
@@ -80,7 +88,9 @@ const useZoomPhoneLive = ({
|
|
|
80
88
|
let unbindState = () => {};
|
|
81
89
|
|
|
82
90
|
try {
|
|
83
|
-
|
|
91
|
+
// Ref-counted: the call pop is very likely already on this
|
|
92
|
+
// channel, and the two must share one subscription.
|
|
93
|
+
subscription = acquirePrivateChannel(instance, channel);
|
|
84
94
|
|
|
85
95
|
subscription.listen(EVENT_PRESENCE, (event) => {
|
|
86
96
|
presenceRef.current?.(event || {});
|
|
@@ -150,7 +160,11 @@ const useZoomPhoneLive = ({
|
|
|
150
160
|
|
|
151
161
|
try {
|
|
152
162
|
subscription?.stopListening(EVENT_PRESENCE);
|
|
153
|
-
|
|
163
|
+
// Our own listener goes, the channel only if nobody else (the
|
|
164
|
+
// call pop) is still holding it. The header hides itself on
|
|
165
|
+
// record pages, so this cleanup runs while the pop is very
|
|
166
|
+
// much still watching.
|
|
167
|
+
releasePrivateChannel(instance, channel);
|
|
154
168
|
} catch (error) {
|
|
155
169
|
// Nothing to clean up.
|
|
156
170
|
}
|
|
@@ -210,6 +210,19 @@
|
|
|
210
210
|
white-space: nowrap;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/* Who an email reached, under the head and above the message.
|
|
214
|
+
Deliberately quiet — it is a qualifier on the entry, not part of it — and
|
|
215
|
+
deliberately NOT truncated to "+3 more": the recipient worth noticing is the
|
|
216
|
+
one nobody expected, which is exactly the one a fold would hide. So it wraps
|
|
217
|
+
and takes as many lines as it needs. */
|
|
218
|
+
.recipients {
|
|
219
|
+
margin: -0.15rem 0 0.45rem;
|
|
220
|
+
color: var(--muted-color, #6b7280);
|
|
221
|
+
font-size: 0.74rem;
|
|
222
|
+
line-height: 1.4;
|
|
223
|
+
overflow-wrap: anywhere;
|
|
224
|
+
}
|
|
225
|
+
|
|
213
226
|
.body {
|
|
214
227
|
margin: 0;
|
|
215
228
|
color: var(--paragraph-color, #2b2b2b);
|
|
@@ -518,10 +531,18 @@
|
|
|
518
531
|
cursor: pointer;
|
|
519
532
|
}
|
|
520
533
|
|
|
521
|
-
/* The consequence in words, always present
|
|
534
|
+
/* The consequence in words, always present — and since it now NAMES the people
|
|
535
|
+
a reply reaches, it has to survive a Cc of five. `min-width: 0` is the
|
|
536
|
+
load-bearing half: a flex item's default `min-width: auto` refuses to shrink
|
|
537
|
+
below its longest unbreakable run, which would push the sentence out of the
|
|
538
|
+
bar rather than wrapping it. */
|
|
522
539
|
.consequence {
|
|
540
|
+
flex: 1 1 auto;
|
|
541
|
+
min-width: 0;
|
|
523
542
|
color: var(--muted-color, #6b7280);
|
|
524
543
|
font-size: 0.76rem;
|
|
544
|
+
line-height: 1.4;
|
|
545
|
+
overflow-wrap: anywhere;
|
|
525
546
|
}
|
|
526
547
|
|
|
527
548
|
.composerInput {
|
|
@@ -25,6 +25,44 @@ const CHANNEL_ICON = {
|
|
|
25
25
|
Web: Globe,
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Who an email went to, or is about to go to.
|
|
30
|
+
*
|
|
31
|
+
* A reply on a ticket goes to everybody on the chain, not to the one person
|
|
32
|
+
* whose message is on screen — so the composer has to SAY so before the button
|
|
33
|
+
* is pressed, and a sent entry has to say who it reached. The same two
|
|
34
|
+
* readings are used in both places, which is why they live here rather than
|
|
35
|
+
* once in the Composer and again in Entry:
|
|
36
|
+
*
|
|
37
|
+
* names() — what is drawn. "Dana Reid, accounts@acme.test" — the name
|
|
38
|
+
* where there is one, the address where there is not.
|
|
39
|
+
* addresses() — what is HOVERED. The full `Name <address>` list, because
|
|
40
|
+
* two people called Dana is exactly the case where the name
|
|
41
|
+
* alone is not enough to check a send against.
|
|
42
|
+
*
|
|
43
|
+
* Both take a possibly-absent list: `recipients` is null on an internal note
|
|
44
|
+
* and on every row imported before the server knew any of this.
|
|
45
|
+
*/
|
|
46
|
+
const names = (list) =>
|
|
47
|
+
(Array.isArray(list) ? list : [])
|
|
48
|
+
.map((r) => r.name || r.address)
|
|
49
|
+
.join(', ');
|
|
50
|
+
|
|
51
|
+
const addresses = (list) =>
|
|
52
|
+
(Array.isArray(list) ? list : [])
|
|
53
|
+
.map((r) => (r.name ? `${r.name} <${r.address}>` : r.address))
|
|
54
|
+
.join(', ');
|
|
55
|
+
|
|
56
|
+
/** The `title` for a To/Cc line — undefined rather than an empty attribute. */
|
|
57
|
+
const recipientTitle = (to, cc) => {
|
|
58
|
+
const lines = [];
|
|
59
|
+
|
|
60
|
+
if (to.length) lines.push(`To: ${addresses(to)}`);
|
|
61
|
+
if (cc.length) lines.push(`Cc: ${addresses(cc)}`);
|
|
62
|
+
|
|
63
|
+
return lines.length ? lines.join('\n') : undefined;
|
|
64
|
+
};
|
|
65
|
+
|
|
28
66
|
/**
|
|
29
67
|
* A support ticket's conversation.
|
|
30
68
|
*
|
|
@@ -49,6 +87,10 @@ const TicketConversation = ({
|
|
|
49
87
|
}) => {
|
|
50
88
|
const [entries, setEntries] = useState([]);
|
|
51
89
|
const [ticket, setTicket] = useState(null);
|
|
90
|
+
// Who a reply would go to. null = the server does not know, or the ticket
|
|
91
|
+
// cannot be answered by email — the composer falls back to the old
|
|
92
|
+
// sentence rather than naming nobody.
|
|
93
|
+
const [audience, setAudience] = useState(null);
|
|
52
94
|
const [loading, setLoading] = useState(true);
|
|
53
95
|
const [failed, setFailed] = useState(false);
|
|
54
96
|
const mountedRef = useRef(true);
|
|
@@ -72,6 +114,7 @@ const TicketConversation = ({
|
|
|
72
114
|
|
|
73
115
|
setEntries(Array.isArray(result?.data) ? result.data : []);
|
|
74
116
|
setTicket(result?.ticket ?? null);
|
|
117
|
+
setAudience(result?.reply_audience ?? null);
|
|
75
118
|
setLoading(false);
|
|
76
119
|
onLoaded?.(result?.ticket ?? null);
|
|
77
120
|
},
|
|
@@ -128,6 +171,7 @@ const TicketConversation = ({
|
|
|
128
171
|
<Composer
|
|
129
172
|
base={base}
|
|
130
173
|
ticketId={ticketId}
|
|
174
|
+
audience={audience}
|
|
131
175
|
onPosted={(entry) => setEntries((prev) => [entry, ...prev])}
|
|
132
176
|
/>
|
|
133
177
|
)}
|
|
@@ -155,8 +199,15 @@ const TicketConversation = ({
|
|
|
155
199
|
* this" / "only staff can see this") every time. An agent sending an internal
|
|
156
200
|
* note to a customer is the expensive mistake here, and a toggle whose state
|
|
157
201
|
* you have to remember is how it happens.
|
|
202
|
+
*
|
|
203
|
+
* `audience` is the second half of that argument. A reply goes to everybody on
|
|
204
|
+
* the email chain — which on a forwarded ticket is regularly somebody the agent
|
|
205
|
+
* has never seen — so the consequence NAMES them rather than saying "the
|
|
206
|
+
* customer". Nothing is truncated to "+3 more": the whole point of the line is
|
|
207
|
+
* to be read before sending, and the name that would be hidden is the one worth
|
|
208
|
+
* noticing. `null` means the server could not say, and the old sentence stands.
|
|
158
209
|
*/
|
|
159
|
-
const Composer = ({ base, ticketId, onPosted }) => {
|
|
210
|
+
const Composer = ({ base, ticketId, audience, onPosted }) => {
|
|
160
211
|
const [body, setBody] = useState('');
|
|
161
212
|
const [internal, setInternal] = useState(false);
|
|
162
213
|
const [sending, setSending] = useState(false);
|
|
@@ -189,6 +240,22 @@ const Composer = ({ base, ticketId, onPosted }) => {
|
|
|
189
240
|
});
|
|
190
241
|
};
|
|
191
242
|
|
|
243
|
+
const to = audience?.to ?? [];
|
|
244
|
+
const cc = audience?.cc ?? [];
|
|
245
|
+
const named = !internal && to.length > 0;
|
|
246
|
+
|
|
247
|
+
let consequence;
|
|
248
|
+
|
|
249
|
+
if (internal) {
|
|
250
|
+
consequence = 'Only staff can see this.';
|
|
251
|
+
} else if (named) {
|
|
252
|
+
consequence = `Emailed to ${names(to)}`;
|
|
253
|
+
|
|
254
|
+
if (cc.length) consequence += ` · Cc ${names(cc)}`;
|
|
255
|
+
} else {
|
|
256
|
+
consequence = 'This is emailed to the customer.';
|
|
257
|
+
}
|
|
258
|
+
|
|
192
259
|
return (
|
|
193
260
|
<div className={`${styles.composer} ${internal ? styles.composerNote : ''}`}>
|
|
194
261
|
<div className={styles.composerBar}>
|
|
@@ -201,10 +268,11 @@ const Composer = ({ base, ticketId, onPosted }) => {
|
|
|
201
268
|
Internal note
|
|
202
269
|
</label>
|
|
203
270
|
|
|
204
|
-
<span
|
|
205
|
-
{
|
|
206
|
-
|
|
207
|
-
|
|
271
|
+
<span
|
|
272
|
+
className={styles.consequence}
|
|
273
|
+
title={named ? recipientTitle(to, cc) : undefined}
|
|
274
|
+
>
|
|
275
|
+
{consequence}
|
|
208
276
|
</span>
|
|
209
277
|
</div>
|
|
210
278
|
|
|
@@ -408,6 +476,20 @@ const Entry = ({ entry }) => {
|
|
|
408
476
|
? styles.entryIn
|
|
409
477
|
: styles.entryOut;
|
|
410
478
|
|
|
479
|
+
/* Who else was on this message. Drawn for BOTH directions, and they answer
|
|
480
|
+
different questions: on our reply it is who we sent to, on the
|
|
481
|
+
customer's it is who else at their end has already read it — which is
|
|
482
|
+
what tells an agent whether the answer needs to reach anybody new.
|
|
483
|
+
Absent on notes, and on every row that predates the server sending it. */
|
|
484
|
+
const to = entry.recipients?.to ?? [];
|
|
485
|
+
const cc = entry.recipients?.cc ?? [];
|
|
486
|
+
const parts = [];
|
|
487
|
+
|
|
488
|
+
if (to.length) parts.push(`To ${names(to)}`);
|
|
489
|
+
if (cc.length) parts.push(`Cc ${names(cc)}`);
|
|
490
|
+
|
|
491
|
+
const showRecipients = !isNote && parts.length > 0;
|
|
492
|
+
|
|
411
493
|
return (
|
|
412
494
|
<li className={`${styles.entry} ${cls}`}>
|
|
413
495
|
<div className={styles.entryHead}>
|
|
@@ -452,6 +534,15 @@ const Entry = ({ entry }) => {
|
|
|
452
534
|
)}
|
|
453
535
|
</div>
|
|
454
536
|
|
|
537
|
+
{showRecipients && (
|
|
538
|
+
<p
|
|
539
|
+
className={styles.recipients}
|
|
540
|
+
title={recipientTitle(to, cc)}
|
|
541
|
+
>
|
|
542
|
+
{parts.join(' · ')}
|
|
543
|
+
</p>
|
|
544
|
+
)}
|
|
545
|
+
|
|
455
546
|
<EntryBody entry={entry} />
|
|
456
547
|
</li>
|
|
457
548
|
);
|
package/src/index.js
CHANGED
|
@@ -141,12 +141,30 @@ import {
|
|
|
141
141
|
import CallQueuePop from './components/callQueue/CallQueuePop';
|
|
142
142
|
import CallQueueSettings from './components/callQueue/CallQueueSettings';
|
|
143
143
|
import CallQueueDiagnostics from './components/callQueue/CallQueueDiagnostics';
|
|
144
|
+
// The client-block field list and its two readers, so a host can build its own
|
|
145
|
+
// `clientDetailFields` beside the default rather than restating the shape.
|
|
146
|
+
import {
|
|
147
|
+
CLIENT_DETAIL_FIELDS,
|
|
148
|
+
clientDetails,
|
|
149
|
+
demoClientDetails,
|
|
150
|
+
} from './components/callQueue/callQueueHelpers';
|
|
144
151
|
import {
|
|
145
152
|
CALL_POP_STATUS_EVENT,
|
|
146
153
|
getCallPopStatus,
|
|
154
|
+
noteCallPopChannelPresent,
|
|
147
155
|
updateCallPopStatus,
|
|
148
156
|
} from './components/callQueue/callPopStatus';
|
|
149
157
|
|
|
158
|
+
// Ref-counted `echo.private()`. Exported because any host component that
|
|
159
|
+
// listens on a channel one of ours also listens on must go through it —
|
|
160
|
+
// laravel-echo's `leave()` destroys a channel for every holder, not just the
|
|
161
|
+
// caller, which is how the call pop lost its subscription to the header badge.
|
|
162
|
+
import {
|
|
163
|
+
acquirePrivateChannel,
|
|
164
|
+
hasPrivateChannel,
|
|
165
|
+
releasePrivateChannel,
|
|
166
|
+
} from './components/echo/sharedPrivateChannel';
|
|
167
|
+
|
|
150
168
|
// Vault — the staff password manager. VaultQuickSearch mounts through
|
|
151
169
|
// Navigation's `renderers` slot; VaultManager is the page behind it.
|
|
152
170
|
import OtpChip from './components/vault/OtpChip';
|
|
@@ -365,8 +383,15 @@ export {
|
|
|
365
383
|
CallQueueDiagnostics,
|
|
366
384
|
CallQueuePop,
|
|
367
385
|
CallQueueSettings,
|
|
386
|
+
CLIENT_DETAIL_FIELDS,
|
|
387
|
+
clientDetails,
|
|
388
|
+
demoClientDetails,
|
|
368
389
|
getCallPopStatus,
|
|
390
|
+
noteCallPopChannelPresent,
|
|
369
391
|
updateCallPopStatus,
|
|
392
|
+
acquirePrivateChannel,
|
|
393
|
+
hasPrivateChannel,
|
|
394
|
+
releasePrivateChannel,
|
|
370
395
|
cleanBase32,
|
|
371
396
|
decodeFromBlob,
|
|
372
397
|
decodeFromSource,
|