@visns-studio/visns-components 6.28.0 → 6.31.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.
@@ -0,0 +1,217 @@
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 — and, when the caller says what
172
+ * it is holding, is THAT the subscription the registry currently has?
173
+ *
174
+ * True only when the registry has a live entry AND — when the connector
175
+ * exposes its cache — Echo still holds `private-<name>`. The second half is
176
+ * the whole point: an external `leave()` leaves our ref count untouched but
177
+ * kills the channel, and that mismatch is precisely the failure this module
178
+ * was written for. Callers use it to notice and resubscribe.
179
+ *
180
+ * The optional `held` argument closes a race between two self-healing
181
+ * holders. When holder A notices the channel gone and rebuilds it, the
182
+ * registry (and Echo's cache) hold a fresh subscription again — so holder B's
183
+ * check, a moment later, would see "present" and keep listening on the dead
184
+ * object A just replaced. Passing what you hold makes the answer "no" until
185
+ * you have re-acquired, so both holders end up on the live subscription.
186
+ *
187
+ * @param {object} instance Echo instance.
188
+ * @param {string} name Channel name, unprefixed.
189
+ * @param {object} [held] The subscription this caller is listening on.
190
+ *
191
+ * @returns {boolean}
192
+ */
193
+ export const hasPrivateChannel = (instance, name, held = undefined) => {
194
+ if (!instance || !name) {
195
+ return false;
196
+ }
197
+
198
+ const entry = registries.get(instance)?.get(name);
199
+
200
+ if (!entry) {
201
+ return false;
202
+ }
203
+
204
+ if (held !== undefined && entry.subscription !== held) {
205
+ return false;
206
+ }
207
+
208
+ // `null` = the connector cannot tell us; the registry is then all we have.
209
+ return echoHolds(instance, name) !== false;
210
+ };
211
+
212
+ /** Drop every entry. Used by tests; never call this from application code. */
213
+ export const _resetSharedPrivateChannels = () => {
214
+ registries = new WeakMap();
215
+ };
216
+
217
+ export default acquirePrivateChannel;
@@ -90,6 +90,10 @@ const ZoomPhoneBadgeInner = ({
90
90
  // somebody is looking at is worth more than one behind a closed chip.
91
91
  pollInterval = 60_000,
92
92
  openPollInterval = 20_000,
93
+ // How often the subscription confirms it still has its channel. Left
94
+ // undefined so useZoomPhoneLive's own default (60s) applies; a host only
95
+ // passes this to slow the check down, or to switch it off with 0.
96
+ resubscribeCheckMs,
93
97
  // ⌘⇧U / Ctrl+Shift+U. Not ⌘⇧P: browsers open a private window on that.
94
98
  shortcut = 'mod+shift+u',
95
99
  navigate,
@@ -194,6 +198,7 @@ const ZoomPhoneBadgeInner = ({
194
198
  echo,
195
199
  channel,
196
200
  onPresence: handlePresence,
201
+ resubscribeCheckMs,
197
202
  });
198
203
 
199
204
  /*
@@ -15,6 +15,7 @@
15
15
  // `node --test`, before tests/jsxHooks.mjs (which is what resolves the
16
16
  // library's extensionless imports) has been registered.
17
17
  import { initialsFor, normaliseNumberForDisplay } from '../sms/smsHelpers.js';
18
+ import { isDeadConnectionState } from '../sms/smsLiveState.js';
18
19
 
19
20
  export { initialsFor, normaliseNumberForDisplay };
20
21
 
@@ -296,3 +297,33 @@ export const freshnessLabel = (fetchedAt, live, now = Date.now()) => {
296
297
 
297
298
  return `Updated ${Math.floor(seconds / 3600)}h ago`;
298
299
  };
300
+
301
+ /**
302
+ * Has the roster's channel been taken away, and is now the moment to rebuild it?
303
+ *
304
+ * The failure being watched for is silent: another component calling
305
+ * `echo.leave()` on the shared `call-queue-monitor.{env}` channel unsubscribes
306
+ * everybody without raising a single event, so `live` stays true and the
307
+ * presence events simply stop. Nothing announces it — the only way to find out
308
+ * is to look, which is why the hook looks on a timer.
309
+ *
310
+ * `present` is what the shared-channel registry says (`hasPrivateChannel`), and
311
+ * `state` is the pusher connection state. A dead socket is deliberately NOT a
312
+ * reason to resubscribe: nothing can be subscribed while it is down, pusher-js
313
+ * restores what it still holds when it returns, and the hook re-checks on every
314
+ * `connected` transition anyway. An unknown state (`null` — the host's Echo may
315
+ * expose no reachable connection object) is not treated as dead.
316
+ *
317
+ * @param {object} options
318
+ * @param {boolean} options.present Does the registry still hold the channel?
319
+ * @param {string|null} [options.state] Pusher connection state, when known.
320
+ *
321
+ * @returns {boolean}
322
+ */
323
+ export const shouldResubscribe = ({ present, state = null } = {}) => {
324
+ if (present) {
325
+ return false;
326
+ }
327
+
328
+ return !isDeadConnectionState(state);
329
+ };
@@ -6,9 +6,30 @@ 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
+ hasPrivateChannel,
15
+ releasePrivateChannel,
16
+ } from '../echo/sharedPrivateChannel';
17
+
18
+ import { shouldResubscribe } from './phonePresenceHelpers';
19
+
9
20
  /** The event the backend broadcasts when one extension's state changes. */
10
21
  export const EVENT_PRESENCE = '.phone.presence';
11
22
 
23
+ /**
24
+ * How often the hook confirms its channel is still subscribed.
25
+ *
26
+ * A socket that drops announces itself; a channel destroyed by somebody else's
27
+ * `echo.leave()` announces nothing at all, so the only way to notice is to
28
+ * look. Once a minute costs a property read and bounds the blind window at a
29
+ * minute — the same figure, for the same reason, as the call pop's own check.
30
+ */
31
+ export const DEFAULT_RESUBSCRIBE_CHECK_MS = 60000;
32
+
12
33
  /**
13
34
  * Live phone presence: Pusher when it is wired up, polling when it is not.
14
35
  *
@@ -28,11 +49,20 @@ export const EVENT_PRESENCE = '.phone.presence';
28
49
  * user without the permission never opens a Pusher connection at all. The
29
50
  * account has a hard concurrent-connection quota.
30
51
  *
52
+ * The channel is shared, and a shared channel can be taken away without a
53
+ * word: `echo.leave()` anywhere else destroys it for every holder. So this
54
+ * hook does what the call pop does — rechecks the channel on every socket
55
+ * reconnect and every `resubscribeCheckMs` while the tab is visible, and
56
+ * rebuilds its subscription when it finds the channel gone. Without that the
57
+ * roster stays on a green `Live` stamp and never receives another event.
58
+ *
31
59
  * @param {object} options
32
60
  * @param {object|Function|null} options.echo Echo instance, or `() => echo`.
33
61
  * @param {string|null} options.channel Private channel name.
34
62
  * @param {boolean} [options.enabled] Off entirely when false.
35
63
  * @param {Function} [options.onPresence] `({cleared, keys, call}) => void`.
64
+ * @param {number} [options.resubscribeCheckMs] How often to confirm the
65
+ * channel is still there (ms, visible tabs only). 0 turns the check off.
36
66
  *
37
67
  * @returns {{live: boolean}}
38
68
  */
@@ -41,6 +71,7 @@ const useZoomPhoneLive = ({
41
71
  channel = null,
42
72
  enabled = true,
43
73
  onPresence,
74
+ resubscribeCheckMs = DEFAULT_RESUBSCRIBE_CHECK_MS,
44
75
  } = {}) => {
45
76
  const [live, setLive] = useState(false);
46
77
 
@@ -78,15 +109,24 @@ const useZoomPhoneLive = ({
78
109
  let subscription = null;
79
110
  let unmonitor = () => {};
80
111
  let unbindState = () => {};
112
+ let resubscribeTimer = null;
81
113
 
82
- try {
83
- subscription = instance.private(channel);
84
-
85
- subscription.listen(EVENT_PRESENCE, (event) => {
114
+ /**
115
+ * Bind the presence listener and the confirmation monitor to a channel.
116
+ *
117
+ * Broken out of the effect body because a resubscribe has to do it all
118
+ * again against a brand new subscription object — the old one is a
119
+ * corpse once Echo has dropped the channel.
120
+ *
121
+ * @param {object} target The subscription to bind to.
122
+ * @returns {Function} Stops the monitor again.
123
+ */
124
+ const attachListener = (target) => {
125
+ target.listen(EVENT_PRESENCE, (event) => {
86
126
  presenceRef.current?.(event || {});
87
127
  });
88
128
 
89
- unmonitor = subscriptionMonitor(subscription, {
129
+ return subscriptionMonitor(target, {
90
130
  onSuccess: () => {
91
131
  confirmed = true;
92
132
  sync();
@@ -96,6 +136,13 @@ const useZoomPhoneLive = ({
96
136
  sync();
97
137
  },
98
138
  });
139
+ };
140
+
141
+ try {
142
+ // Ref-counted: the call pop is very likely already on this
143
+ // channel, and the two must share one subscription.
144
+ subscription = acquirePrivateChannel(instance, channel);
145
+ unmonitor = attachListener(subscription);
99
146
  } catch (error) {
100
147
  // A channel that will not subscribe leaves the poller in charge,
101
148
  // which is the whole point of the poller.
@@ -104,6 +151,67 @@ const useZoomPhoneLive = ({
104
151
  return undefined;
105
152
  }
106
153
 
154
+ /**
155
+ * Confirm the channel is still there, and rebuild it when it is not.
156
+ *
157
+ * Never throws: this runs from a socket callback and from an interval,
158
+ * and a roster that cannot resubscribe must fall back to its poll, not
159
+ * take the header down with it.
160
+ *
161
+ * @returns {boolean} True when a resubscribe was performed.
162
+ */
163
+ const ensureSubscribed = () => {
164
+ let present = false;
165
+
166
+ try {
167
+ present = hasPrivateChannel(instance, channel, subscription);
168
+ } catch (error) {
169
+ present = false;
170
+ }
171
+
172
+ if (!shouldResubscribe({ present, state: connectionState })) {
173
+ return false;
174
+ }
175
+
176
+ try {
177
+ unmonitor();
178
+ } catch (error) {
179
+ // Already gone.
180
+ }
181
+
182
+ unmonitor = () => {};
183
+
184
+ try {
185
+ subscription?.stopListening(EVENT_PRESENCE);
186
+ } catch (error) {
187
+ // A dead channel has nothing left to unbind.
188
+ }
189
+
190
+ // Release before acquiring so the ref count nets out unchanged:
191
+ // this hook held one reference before and holds one after.
192
+ releasePrivateChannel(instance, channel);
193
+
194
+ // Nothing has arrived on the new subscription yet, so `live` goes
195
+ // back to false until the monitor confirms it. A green stamp over
196
+ // an unconfirmed channel is the exact lie this hook exists to
197
+ // avoid telling.
198
+ confirmed = false;
199
+
200
+ try {
201
+ subscription = acquirePrivateChannel(instance, channel);
202
+ unmonitor = attachListener(subscription);
203
+ } catch (error) {
204
+ subscription = null;
205
+ sync();
206
+
207
+ return false;
208
+ }
209
+
210
+ sync();
211
+
212
+ return true;
213
+ };
214
+
107
215
  // The socket underneath. When it goes, the channel goes with it whatever
108
216
  // the confirmation said; pusher-js resubscribes on its own once it is
109
217
  // back, and the subscribed callback re-confirms.
@@ -118,6 +226,11 @@ const useZoomPhoneLive = ({
118
226
 
119
227
  if (isDeadConnectionState(current)) confirmed = false;
120
228
 
229
+ // A socket can come back without our channel: pusher-js
230
+ // resubscribes what it still holds, and a channel someone
231
+ // else `leave()`d is no longer among them.
232
+ if (current === 'connected') ensureSubscribed();
233
+
121
234
  sync();
122
235
  };
123
236
 
@@ -137,9 +250,32 @@ const useZoomPhoneLive = ({
137
250
  // decides, which is the same answer we gave before.
138
251
  }
139
252
 
253
+ // The backstop. `state_change` covers a socket that visibly went away;
254
+ // this covers the case with no signal at all — the channel removed
255
+ // underneath a perfectly healthy socket. Visible tabs only: a roster
256
+ // nobody is looking at has nothing to catch up for, and the badge's own
257
+ // poll refills it either way.
258
+ if (resubscribeCheckMs > 0) {
259
+ resubscribeTimer = setInterval(() => {
260
+ if (
261
+ typeof document !== 'undefined' &&
262
+ document.visibilityState !== 'visible'
263
+ ) {
264
+ return;
265
+ }
266
+
267
+ ensureSubscribed();
268
+ }, resubscribeCheckMs);
269
+ }
270
+
140
271
  sync();
141
272
 
142
273
  return () => {
274
+ if (resubscribeTimer !== null) {
275
+ clearInterval(resubscribeTimer);
276
+ resubscribeTimer = null;
277
+ }
278
+
143
279
  unbindState();
144
280
 
145
281
  try {
@@ -150,14 +286,18 @@ const useZoomPhoneLive = ({
150
286
 
151
287
  try {
152
288
  subscription?.stopListening(EVENT_PRESENCE);
153
- instance.leave(channel);
289
+ // Our own listener goes, the channel only if nobody else (the
290
+ // call pop) is still holding it. The header hides itself on
291
+ // record pages, so this cleanup runs while the pop is very
292
+ // much still watching.
293
+ releasePrivateChannel(instance, channel);
154
294
  } catch (error) {
155
295
  // Nothing to clean up.
156
296
  }
157
297
 
158
298
  setLive(false);
159
299
  };
160
- }, [enabled, channel]);
300
+ }, [enabled, channel, resubscribeCheckMs]);
161
301
 
162
302
  return { live };
163
303
  };
@@ -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 {