@visns-studio/visns-components 6.28.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 +76 -0
- package/package.json +1 -1
- package/src/components/callQueue/CallQueueDiagnostics.jsx +13 -0
- package/src/components/callQueue/CallQueuePop.jsx +199 -33
- package/src/components/callQueue/callPopStatus.js +23 -0
- 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 +15 -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`
|
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 {
|
|
@@ -119,6 +128,15 @@ export {
|
|
|
119
128
|
* and reconciles both ways, so a call that started or ended while this browser
|
|
120
129
|
* was deaf is not stuck on (or missing from) the screen.
|
|
121
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
|
+
*
|
|
122
140
|
* The caller -> client match arrives already resolved, as each call's `client`
|
|
123
141
|
* block (computed once by the Zoom webhook, server-side). This component never
|
|
124
142
|
* looks a caller up itself — every watching member of staff sees every pop, and
|
|
@@ -209,6 +227,16 @@ const DEFAULT_MISSED_GRACE_MS = 10000;
|
|
|
209
227
|
*/
|
|
210
228
|
const SNAPSHOT_MIN_GAP_MS = 5000;
|
|
211
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
|
+
|
|
212
240
|
/** Title shown on alternate ticks while a call rings in a hidden tab. */
|
|
213
241
|
const FLASH_TITLE = '📞 Incoming call…';
|
|
214
242
|
|
|
@@ -300,6 +328,7 @@ const CallQueuePop = ({
|
|
|
300
328
|
clientDetailFields = CLIENT_DETAIL_FIELDS,
|
|
301
329
|
demoEnabled = true,
|
|
302
330
|
missedGraceMs = DEFAULT_MISSED_GRACE_MS,
|
|
331
|
+
resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
|
|
303
332
|
}) => {
|
|
304
333
|
const [calls, setCalls] = useState([]);
|
|
305
334
|
const [now, setNow] = useState(() => Date.now());
|
|
@@ -890,8 +919,9 @@ const CallQueuePop = ({
|
|
|
890
919
|
* reconcileSnapshot for the two exceptions), so a call that started while
|
|
891
920
|
* the socket was down appears, and one that ended while it was down goes.
|
|
892
921
|
*
|
|
893
|
-
* @param {string} reason 'mount' | 'reconnect' | 'visible'
|
|
894
|
-
* 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.
|
|
895
925
|
*/
|
|
896
926
|
const refreshSnapshot = useCallback(
|
|
897
927
|
(reason) => {
|
|
@@ -1148,14 +1178,24 @@ const CallQueuePop = ({
|
|
|
1148
1178
|
let subscription = null;
|
|
1149
1179
|
let stopMonitoring = () => {};
|
|
1150
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
|
+
}
|
|
1151
1197
|
|
|
1152
|
-
|
|
1153
|
-
// Until /broadcasting/auth knows this channel the subscription just
|
|
1154
|
-
// fails auth — pusher-js logs a warning, nothing of ours throws.
|
|
1155
|
-
// That silence is exactly what the monitor below breaks.
|
|
1156
|
-
subscription = instance.private(activeChannel);
|
|
1157
|
-
|
|
1158
|
-
subscription.listen(EVENT_RINGING, (event) => {
|
|
1198
|
+
target.listen(EVENT_RINGING, (event) => {
|
|
1159
1199
|
const call = event?.call ?? event;
|
|
1160
1200
|
|
|
1161
1201
|
noteCallPopEvent(EVENT_RINGING);
|
|
@@ -1163,7 +1203,7 @@ const CallQueuePop = ({
|
|
|
1163
1203
|
ingestCall(call);
|
|
1164
1204
|
});
|
|
1165
1205
|
|
|
1166
|
-
|
|
1206
|
+
target.listen(EVENT_ANSWERED, (event) => {
|
|
1167
1207
|
const callId =
|
|
1168
1208
|
event?.callId ?? event?.call_id ?? event?.call?.callId;
|
|
1169
1209
|
|
|
@@ -1175,7 +1215,7 @@ const CallQueuePop = ({
|
|
|
1175
1215
|
}
|
|
1176
1216
|
});
|
|
1177
1217
|
|
|
1178
|
-
|
|
1218
|
+
target.listen(EVENT_ENDED, (event) => {
|
|
1179
1219
|
const callId =
|
|
1180
1220
|
event?.callId ?? event?.call_id ?? event?.call?.callId;
|
|
1181
1221
|
|
|
@@ -1190,7 +1230,7 @@ const CallQueuePop = ({
|
|
|
1190
1230
|
// One leg said no. The call is not over — it may be ringing on
|
|
1191
1231
|
// three other devices — so this starts a grace period rather than
|
|
1192
1232
|
// removing anything.
|
|
1193
|
-
|
|
1233
|
+
target.listen(EVENT_MISSED, (event) => {
|
|
1194
1234
|
const callId =
|
|
1195
1235
|
event?.callId ?? event?.call_id ?? event?.call?.callId;
|
|
1196
1236
|
|
|
@@ -1204,7 +1244,7 @@ const CallQueuePop = ({
|
|
|
1204
1244
|
|
|
1205
1245
|
// The diagnostics panel's round-trip probe. It settles nothing and
|
|
1206
1246
|
// pops nothing — it only proves this browser is being delivered to.
|
|
1207
|
-
|
|
1247
|
+
target.listen(EVENT_PING, (event) => {
|
|
1208
1248
|
const nonce = event?.nonce ?? event?.id ?? null;
|
|
1209
1249
|
|
|
1210
1250
|
noteCallPopEvent(EVENT_PING, {
|
|
@@ -1216,7 +1256,7 @@ const CallQueuePop = ({
|
|
|
1216
1256
|
trace('event .queue.diagnostic-ping', nonce);
|
|
1217
1257
|
});
|
|
1218
1258
|
|
|
1219
|
-
|
|
1259
|
+
return subscriptionMonitor(target, {
|
|
1220
1260
|
onSuccess: () => {
|
|
1221
1261
|
appendCallPopLog(`Subscribed to ${activeChannel}`, 'info', {
|
|
1222
1262
|
subscribed: true,
|
|
@@ -1258,6 +1298,113 @@ const CallQueuePop = ({
|
|
|
1258
1298
|
);
|
|
1259
1299
|
},
|
|
1260
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
|
+
);
|
|
1261
1408
|
} catch (error) {
|
|
1262
1409
|
subscription = null;
|
|
1263
1410
|
appendCallPopLog(
|
|
@@ -1265,6 +1412,7 @@ const CallQueuePop = ({
|
|
|
1265
1412
|
'error',
|
|
1266
1413
|
{
|
|
1267
1414
|
subscribed: false,
|
|
1415
|
+
channelPresent: false,
|
|
1268
1416
|
subscriptionError: {
|
|
1269
1417
|
status: null,
|
|
1270
1418
|
message: String(error?.message ?? error),
|
|
@@ -1293,6 +1441,11 @@ const CallQueuePop = ({
|
|
|
1293
1441
|
// only when the socket actually went away, otherwise every
|
|
1294
1442
|
// ordinary connecting -> connected on load would refetch.
|
|
1295
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
|
+
|
|
1296
1449
|
if (sawDisconnect.current) {
|
|
1297
1450
|
requestSnapshot('reconnect');
|
|
1298
1451
|
}
|
|
@@ -1327,30 +1480,42 @@ const CallQueuePop = ({
|
|
|
1327
1480
|
// says whether this browser is live, which is what it said before.
|
|
1328
1481
|
}
|
|
1329
1482
|
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
+
}
|
|
1332
1497
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1498
|
+
ensureSubscribed();
|
|
1499
|
+
requestSnapshot('poll');
|
|
1500
|
+
}, resubscribeCheckMs);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
return () => {
|
|
1504
|
+
if (resubscribeTimer !== null) {
|
|
1505
|
+
clearInterval(resubscribeTimer);
|
|
1506
|
+
resubscribeTimer = null;
|
|
1337
1507
|
}
|
|
1338
1508
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
subscription.stopListening(EVENT_RINGING);
|
|
1342
|
-
subscription.stopListening(EVENT_ANSWERED);
|
|
1343
|
-
subscription.stopListening(EVENT_ENDED);
|
|
1344
|
-
subscription.stopListening(EVENT_MISSED);
|
|
1345
|
-
subscription.stopListening(EVENT_PING);
|
|
1346
|
-
}
|
|
1509
|
+
unbindState();
|
|
1510
|
+
detachListeners(subscription);
|
|
1347
1511
|
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
}
|
|
1512
|
+
// Only really leaves the channel when nobody else (the header's
|
|
1513
|
+
// phone badge) is still holding it.
|
|
1514
|
+
releasePrivateChannel(instance, activeChannel);
|
|
1352
1515
|
|
|
1353
|
-
|
|
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 });
|
|
1354
1519
|
};
|
|
1355
1520
|
}, [
|
|
1356
1521
|
activeChannel,
|
|
@@ -1359,6 +1524,7 @@ const CallQueuePop = ({
|
|
|
1359
1524
|
markMissed,
|
|
1360
1525
|
removeCall,
|
|
1361
1526
|
requestSnapshot,
|
|
1527
|
+
resubscribeCheckMs,
|
|
1362
1528
|
]);
|
|
1363
1529
|
|
|
1364
1530
|
/**
|
|
@@ -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.
|
|
@@ -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
|
@@ -151,9 +151,20 @@ import {
|
|
|
151
151
|
import {
|
|
152
152
|
CALL_POP_STATUS_EVENT,
|
|
153
153
|
getCallPopStatus,
|
|
154
|
+
noteCallPopChannelPresent,
|
|
154
155
|
updateCallPopStatus,
|
|
155
156
|
} from './components/callQueue/callPopStatus';
|
|
156
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
|
+
|
|
157
168
|
// Vault — the staff password manager. VaultQuickSearch mounts through
|
|
158
169
|
// Navigation's `renderers` slot; VaultManager is the page behind it.
|
|
159
170
|
import OtpChip from './components/vault/OtpChip';
|
|
@@ -376,7 +387,11 @@ export {
|
|
|
376
387
|
clientDetails,
|
|
377
388
|
demoClientDetails,
|
|
378
389
|
getCallPopStatus,
|
|
390
|
+
noteCallPopChannelPresent,
|
|
379
391
|
updateCallPopStatus,
|
|
392
|
+
acquirePrivateChannel,
|
|
393
|
+
hasPrivateChannel,
|
|
394
|
+
releasePrivateChannel,
|
|
380
395
|
cleanBase32,
|
|
381
396
|
decodeFromBlob,
|
|
382
397
|
decodeFromSource,
|