@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 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.28.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 {
@@ -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' — logged, so the
894
- * 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.
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
- try {
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
- subscription.listen(EVENT_ANSWERED, (event) => {
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
- subscription.listen(EVENT_ENDED, (event) => {
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
- subscription.listen(EVENT_MISSED, (event) => {
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
- subscription.listen(EVENT_PING, (event) => {
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
- stopMonitoring = subscriptionMonitor(subscription, {
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
- return () => {
1331
- 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
+ }
1332
1497
 
1333
- try {
1334
- stopMonitoring();
1335
- } catch (error) {
1336
- // Already gone.
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
- try {
1340
- if (subscription) {
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
- instance.leave(activeChannel);
1349
- } catch (error) {
1350
- // Nothing to clean up.
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
- 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 });
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
- 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
@@ -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,