@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 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.27.0",
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
- // Every field the server's client block can carry, so the
684
- // rich card can be reviewed without a real match. id 0
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
- adviser: 'Alina Bailey',
691
- coding: 'Early Retirement',
692
- age: 66,
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
- city: 'Fremantle',
737
+ ...demoClientDetails(clientDetailFields),
695
738
  next_event: {
696
- title: 'Client - Annual Review',
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 rollover paperwork',
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: 'Superannuation',
755
+ task_type: 'Ticket',
713
756
  },
714
757
  {
715
758
  id: 90002,
716
- label: 'Send updated risk profile questionnaire',
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: 'Insurance',
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' — logged, so the
879
- * diagnostics panel says why each refresh happened.
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
- try {
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
- subscription.listen(EVENT_ANSWERED, (event) => {
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
- subscription.listen(EVENT_ENDED, (event) => {
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
- subscription.listen(EVENT_MISSED, (event) => {
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
- subscription.listen(EVENT_PING, (event) => {
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
- stopMonitoring = subscriptionMonitor(subscription, {
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
- return () => {
1316
- unbindState();
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
- try {
1319
- stopMonitoring();
1320
- } catch (error) {
1321
- // Already gone.
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
- try {
1325
- if (subscription) {
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
- instance.leave(activeChannel);
1334
- } catch (error) {
1335
- // Nothing to clean up.
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
- updateCallPopStatus({ subscribed: false });
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
- /** The client fields shown as label/value rows, in card order. */
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
- return CLIENT_DETAIL_FIELDS.map(({ key, label }) => ({
231
- label,
232
- value: client[key],
233
- })).filter(
234
- (row) =>
235
- row.value !== null &&
236
- row.value !== undefined &&
237
- String(row.value).trim() !== ''
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
- subscription = instance.private(channel);
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
- instance.leave(channel);
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 className={styles.consequence}>
205
- {internal
206
- ? 'Only staff can see this.'
207
- : 'This is emailed to the customer.'}
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,