@visns-studio/visns-components 6.21.0 → 6.23.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,298 @@
1
+ /**
2
+ * The Zoom Phone roster's logic, with no React and no DOM in it.
3
+ *
4
+ * Split out for the same reason smsHelpers.js was: the interesting parts of a
5
+ * live roster are "what does this state mean" and "how does an event change the
6
+ * list", and both are much easier to be sure about when `node --test` can call
7
+ * them directly.
8
+ *
9
+ * The number formatting and the avatar initials are imported from the messaging
10
+ * module rather than written again — a phone number should read identically
11
+ * whether it is above a text message or beside an extension.
12
+ */
13
+
14
+ // The extension is spelled out because this module is imported directly by
15
+ // `node --test`, before tests/jsxHooks.mjs (which is what resolves the
16
+ // library's extensionless imports) has been registered.
17
+ import { initialsFor, normaliseNumberForDisplay } from '../sms/smsHelpers.js';
18
+
19
+ export { initialsFor, normaliseNumberForDisplay };
20
+
21
+ /**
22
+ * The four states a roster row can be in.
23
+ *
24
+ * `available` is worth reading carefully: it is an INFERENCE, not something
25
+ * Zoom said. It means "no live call leg for this extension", which is only as
26
+ * true as the webhook subscription is healthy — which is why the popover always
27
+ * shows the age of its snapshot next to the dots rather than letting a row of
28
+ * green stand on its own.
29
+ */
30
+ export const STATUS = {
31
+ ON_CALL: 'on_call',
32
+ RINGING: 'ringing',
33
+ AVAILABLE: 'available',
34
+ INACTIVE: 'inactive',
35
+ };
36
+
37
+ const STATUS_LABELS = {
38
+ [STATUS.ON_CALL]: 'On a call',
39
+ [STATUS.RINGING]: 'Ringing',
40
+ [STATUS.AVAILABLE]: 'Available',
41
+ [STATUS.INACTIVE]: 'No handset',
42
+ };
43
+
44
+ /** Busy first: a roster is read to find out who is unavailable. */
45
+ const STATUS_RANK = {
46
+ [STATUS.ON_CALL]: 0,
47
+ [STATUS.RINGING]: 1,
48
+ [STATUS.AVAILABLE]: 2,
49
+ [STATUS.INACTIVE]: 3,
50
+ };
51
+
52
+ export const statusLabel = (status) => STATUS_LABELS[status] ?? 'Unknown';
53
+
54
+ /**
55
+ * The dot's tone. Four words rather than colours, so the stylesheet owns the
56
+ * palette and this module stays testable.
57
+ */
58
+ export const statusTone = (status) => {
59
+ if (status === STATUS.ON_CALL) return 'busy';
60
+ if (status === STATUS.RINGING) return 'ringing';
61
+ if (status === STATUS.AVAILABLE) return 'free';
62
+
63
+ return 'muted';
64
+ };
65
+
66
+ const parseDate = (value) => {
67
+ if (!value) return null;
68
+
69
+ const date = value instanceof Date ? value : new Date(value);
70
+
71
+ return Number.isNaN(date.getTime()) ? null : date;
72
+ };
73
+
74
+ /**
75
+ * How long this leg has been going, as `m:ss` (or `h:mm:ss` past an hour).
76
+ *
77
+ * Measured from the answer where there is one and from the first ring where
78
+ * there is not, because those are the two questions being asked: "how long have
79
+ * they been talking" and "how long has that been ringing".
80
+ *
81
+ * Empty string, never "0:00", when there is no usable timestamp — a duration
82
+ * that is quietly wrong is worse than one that is absent.
83
+ */
84
+ export const callDuration = (call, now = Date.now()) => {
85
+ const from = parseDate(call?.answered_at) ?? parseDate(call?.started_at);
86
+
87
+ if (!from) return '';
88
+
89
+ const seconds = Math.floor((now - from.getTime()) / 1000);
90
+
91
+ if (!Number.isFinite(seconds) || seconds < 0) return '';
92
+
93
+ const hours = Math.floor(seconds / 3600);
94
+ const minutes = Math.floor((seconds % 3600) / 60);
95
+ const rest = seconds % 60;
96
+ const pad = (value) => String(value).padStart(2, '0');
97
+
98
+ return hours > 0
99
+ ? `${hours}:${pad(minutes)}:${pad(rest)}`
100
+ : `${minutes}:${pad(rest)}`;
101
+ };
102
+
103
+ /**
104
+ * The line under a name while somebody is on a call.
105
+ *
106
+ * Direction first, because "called Cleo Client" and "call from Cleo Client" are
107
+ * different facts and the roster is read at a glance.
108
+ */
109
+ export const callSummary = (call) => {
110
+ if (!call) return '';
111
+
112
+ const who = call.client?.name || call.peer_name || '';
113
+ const number = normaliseNumberForDisplay(call.peer_number);
114
+ const outbound = call.direction === 'outbound';
115
+
116
+ const subject = who || number || 'Unknown number';
117
+
118
+ if (call.status === 'ringing') {
119
+ return outbound ? `Calling ${subject}` : `Ringing — ${subject}`;
120
+ }
121
+
122
+ return outbound ? `Calling ${subject}` : `Call from ${subject}`;
123
+ };
124
+
125
+ /**
126
+ * The number to show beside the name, when it adds something.
127
+ *
128
+ * Suppressed when the summary is already showing the same digits — the row is
129
+ * one line of a popover, not a record card.
130
+ */
131
+ export const peerNumberLabel = (call) => {
132
+ if (!call?.peer_number) return '';
133
+
134
+ const number = normaliseNumberForDisplay(call.peer_number);
135
+ const named = Boolean(call.client?.name || call.peer_name);
136
+
137
+ return named ? number : '';
138
+ };
139
+
140
+ /**
141
+ * Busy people first, then the server's order (which is by extension).
142
+ *
143
+ * Stable: `sort` is stable in every engine this library supports, so two
144
+ * extensions in the same state keep the order the roster gave them, and a row
145
+ * does not jump around under the cursor while the list refreshes.
146
+ */
147
+ export const orderRoster = (users) => {
148
+ if (!Array.isArray(users)) return [];
149
+
150
+ return [...users].sort((a, b) => {
151
+ const left = STATUS_RANK[a?.status] ?? STATUS_RANK[STATUS.AVAILABLE];
152
+ const right = STATUS_RANK[b?.status] ?? STATUS_RANK[STATUS.AVAILABLE];
153
+
154
+ return left - right;
155
+ });
156
+ };
157
+
158
+ /**
159
+ * How many people are on the phone right now — the number on the header badge.
160
+ *
161
+ * Counts ringing legs too: "somebody's phone is ringing" is exactly the thing
162
+ * the badge exists to catch. `unmatched` are live calls on extensions the
163
+ * cached directory has not heard of; they are people too.
164
+ */
165
+ export const onCallCount = (users, unmatched = []) => {
166
+ const roster = Array.isArray(users) ? users : [];
167
+ const extra = Array.isArray(unmatched) ? unmatched : [];
168
+
169
+ return roster.filter((user) => Boolean(user?.call)).length + extra.length;
170
+ };
171
+
172
+ /** '' below one, the count up to the ceiling, then '9+'. */
173
+ export const badgeCount = (count, ceiling = 9) => {
174
+ const value = Number(count) || 0;
175
+
176
+ if (value <= 0) return '';
177
+
178
+ return value > ceiling ? `${ceiling}+` : String(value);
179
+ };
180
+
181
+ /**
182
+ * Does this roster row own the leg an event is about?
183
+ *
184
+ * The server publishes the identifier list it matched on — the Zoom user id,
185
+ * the extension number, the extension id — because `/phone/users` and the
186
+ * webhook payloads do not name these the same way and neither side should have
187
+ * to guess which one Zoom actually supplied.
188
+ */
189
+ export const matchesUser = (user, keys) => {
190
+ if (!user || !Array.isArray(keys) || keys.length === 0) return false;
191
+
192
+ const mine = [user.id, user.extension_number]
193
+ .map((value) => String(value ?? '').trim())
194
+ .filter(Boolean);
195
+
196
+ return keys.some((key) => mine.includes(String(key ?? '').trim()));
197
+ };
198
+
199
+ /**
200
+ * Fold one broadcast into the roster.
201
+ *
202
+ * Returns a NEW array when something changed and the SAME array when nothing
203
+ * did, so React can skip the re-render for an event about somebody who is not
204
+ * on this list — which, on a broadcast channel shared with the call pop, is a
205
+ * good proportion of them.
206
+ *
207
+ * A cleared leg puts the row back to `available` rather than to whatever it was
208
+ * before: `inactive` is a property of the extension, not of the call, and the
209
+ * next poll re-reads it in the unlikely case it changed while a call was open.
210
+ */
211
+ export const applyPresenceEvent = (users, event) => {
212
+ const list = Array.isArray(users) ? users : [];
213
+
214
+ if (!event || !Array.isArray(event.keys) || event.keys.length === 0) {
215
+ return list;
216
+ }
217
+
218
+ let changed = false;
219
+
220
+ const next = list.map((user) => {
221
+ if (!matchesUser(user, event.keys)) return user;
222
+
223
+ changed = true;
224
+
225
+ if (event.cleared || !event.call) {
226
+ return {
227
+ ...user,
228
+ status:
229
+ user.active === false ? STATUS.INACTIVE : STATUS.AVAILABLE,
230
+ call: null,
231
+ };
232
+ }
233
+
234
+ return {
235
+ ...user,
236
+ status:
237
+ event.call.status === 'ringing'
238
+ ? STATUS.RINGING
239
+ : STATUS.ON_CALL,
240
+ call: event.call,
241
+ };
242
+ });
243
+
244
+ return changed ? next : list;
245
+ };
246
+
247
+ /**
248
+ * The one-line note under the list.
249
+ *
250
+ * Three different sentences for three different situations, because "no
251
+ * credentials", "Zoom refused us" and "nobody is on the phone" look identical
252
+ * from the browser and mean entirely different things. The house style is to
253
+ * say which one it is on the face of the popover rather than in a console
254
+ * somewhere.
255
+ */
256
+ export const presenceNote = (status) => {
257
+ if (!status) return null;
258
+
259
+ if (status.configured === false) {
260
+ return {
261
+ tone: 'bad',
262
+ text: 'Zoom Phone is not connected — no extensions to show yet',
263
+ };
264
+ }
265
+
266
+ if (status.error) {
267
+ return { tone: 'bad', text: `Zoom: ${status.error}` };
268
+ }
269
+
270
+ if ((status.users?.length ?? 0) === 0) {
271
+ return {
272
+ tone: 'warn',
273
+ text: 'Zoom returned no phone extensions for this account',
274
+ };
275
+ }
276
+
277
+ return null;
278
+ };
279
+
280
+ /**
281
+ * "Updated just now" / "Updated 2m ago" — the freshness stamp that keeps a row
282
+ * of green dots honest. Live subscriptions get their own wording, because a
283
+ * subscribed tab is not relying on the stamp at all.
284
+ */
285
+ export const freshnessLabel = (fetchedAt, live, now = Date.now()) => {
286
+ if (live) return 'Live';
287
+
288
+ const stamp = parseDate(fetchedAt);
289
+
290
+ if (!stamp) return '';
291
+
292
+ const seconds = Math.floor((now - stamp.getTime()) / 1000);
293
+
294
+ if (seconds < 0 || seconds < 45) return 'Updated just now';
295
+ if (seconds < 3600) return `Updated ${Math.floor(seconds / 60)}m ago`;
296
+
297
+ return `Updated ${Math.floor(seconds / 3600)}h ago`;
298
+ };
@@ -0,0 +1,165 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+
3
+ import {
4
+ deriveLiveState,
5
+ isDeadConnectionState,
6
+ subscriptionMonitor,
7
+ } from '../sms/smsLiveState';
8
+
9
+ /** The event the backend broadcasts when one extension's state changes. */
10
+ export const EVENT_PRESENCE = '.phone.presence';
11
+
12
+ /**
13
+ * Live phone presence: Pusher when it is wired up, polling when it is not.
14
+ *
15
+ * A thin sibling of `useSmsLive`, and deliberately not a copy of it — the SMS
16
+ * inbox listens on one channel per line and has to reconcile a set; this
17
+ * listens on the ONE channel the call queue pop already uses, so all the set
18
+ * bookkeeping falls away.
19
+ *
20
+ * What is kept is the part that matters: "live" means a channel has actually
21
+ * reported `pusher:subscription_succeeded`, not merely that `echo.private()`
22
+ * returned an object. Treating the latter as live is how a roster ends up
23
+ * subscribed to nothing, polling nothing, and showing a green dot beside
24
+ * somebody who has been on the phone for half an hour. See smsLiveState.js.
25
+ *
26
+ * The `echo` prop is an instance OR a factory, matching CallQueuePop and the
27
+ * SMS badge: the factory is called inside the effect rather than at mount, so a
28
+ * user without the permission never opens a Pusher connection at all. The
29
+ * account has a hard concurrent-connection quota.
30
+ *
31
+ * @param {object} options
32
+ * @param {object|Function|null} options.echo Echo instance, or `() => echo`.
33
+ * @param {string|null} options.channel Private channel name.
34
+ * @param {boolean} [options.enabled] Off entirely when false.
35
+ * @param {Function} [options.onPresence] `({cleared, keys, call}) => void`.
36
+ *
37
+ * @returns {{live: boolean}}
38
+ */
39
+ const useZoomPhoneLive = ({
40
+ echo = null,
41
+ channel = null,
42
+ enabled = true,
43
+ onPresence,
44
+ } = {}) => {
45
+ const [live, setLive] = useState(false);
46
+
47
+ // Read through refs so a consumer passing an inline arrow — which is the
48
+ // normal way to write both of these — does not tear the subscription down
49
+ // and rebuild it on every render. Only the channel name does that.
50
+ const echoRef = useRef(echo);
51
+ echoRef.current = echo;
52
+
53
+ const presenceRef = useRef(onPresence);
54
+ presenceRef.current = onPresence;
55
+
56
+ useEffect(() => {
57
+ if (!enabled || !channel || typeof window === 'undefined') {
58
+ setLive(false);
59
+
60
+ return undefined;
61
+ }
62
+
63
+ const source = echoRef.current;
64
+ const instance = typeof source === 'function' ? source() : source;
65
+
66
+ if (!instance) {
67
+ setLive(false);
68
+
69
+ return undefined;
70
+ }
71
+
72
+ let confirmed = false;
73
+ let connectionState = null;
74
+
75
+ const sync = () =>
76
+ setLive(deriveLiveState(confirmed ? 1 : 0, connectionState));
77
+
78
+ let subscription = null;
79
+ let unmonitor = () => {};
80
+ let unbindState = () => {};
81
+
82
+ try {
83
+ subscription = instance.private(channel);
84
+
85
+ subscription.listen(EVENT_PRESENCE, (event) => {
86
+ presenceRef.current?.(event || {});
87
+ });
88
+
89
+ unmonitor = subscriptionMonitor(subscription, {
90
+ onSuccess: () => {
91
+ confirmed = true;
92
+ sync();
93
+ },
94
+ onError: () => {
95
+ confirmed = false;
96
+ sync();
97
+ },
98
+ });
99
+ } catch (error) {
100
+ // A channel that will not subscribe leaves the poller in charge,
101
+ // which is the whole point of the poller.
102
+ setLive(false);
103
+
104
+ return undefined;
105
+ }
106
+
107
+ // The socket underneath. When it goes, the channel goes with it whatever
108
+ // the confirmation said; pusher-js resubscribes on its own once it is
109
+ // back, and the subscribed callback re-confirms.
110
+ try {
111
+ const connection = instance.connector?.pusher?.connection;
112
+
113
+ if (connection && typeof connection.bind === 'function') {
114
+ const onStateChange = (states) => {
115
+ const current = states?.current ?? states;
116
+
117
+ connectionState = current;
118
+
119
+ if (isDeadConnectionState(current)) confirmed = false;
120
+
121
+ sync();
122
+ };
123
+
124
+ connection.bind('state_change', onStateChange);
125
+ connectionState = connection.state ?? null;
126
+
127
+ unbindState = () => {
128
+ try {
129
+ connection.unbind?.('state_change', onStateChange);
130
+ } catch (error) {
131
+ // Already gone.
132
+ }
133
+ };
134
+ }
135
+ } catch (error) {
136
+ // No reachable connection object: the channel confirmation alone
137
+ // decides, which is the same answer we gave before.
138
+ }
139
+
140
+ sync();
141
+
142
+ return () => {
143
+ unbindState();
144
+
145
+ try {
146
+ unmonitor();
147
+ } catch (error) {
148
+ // Already gone.
149
+ }
150
+
151
+ try {
152
+ subscription?.stopListening(EVENT_PRESENCE);
153
+ instance.leave(channel);
154
+ } catch (error) {
155
+ // Nothing to clean up.
156
+ }
157
+
158
+ setLive(false);
159
+ };
160
+ }, [enabled, channel]);
161
+
162
+ return { live };
163
+ };
164
+
165
+ export default useZoomPhoneLive;
@@ -74,6 +74,12 @@ const SmsClientConversations = ({
74
74
  templates: templatesProp = null,
75
75
  canManage = false,
76
76
  clientUrl = (id) => `/clients/${id}`,
77
+ // Both opt-in and both handed straight to the conversation pane — see
78
+ // SmsThreadPanel for the shapes. They are here because the client page is
79
+ // where a host has a reason to file a client's messages against one of its
80
+ // own records; the card itself has no opinion about what that record is.
81
+ messageSelection = null,
82
+ messageAnnotations = null,
77
83
  title = 'Messages',
78
84
  // The card is a fixed-height box with the conversation scrolling inside
79
85
  // it: a timeline that grows the page pushes the composer off the bottom of
@@ -408,6 +414,8 @@ const SmsClientConversations = ({
408
414
  lines={lines}
409
415
  canManage={canManage}
410
416
  clientUrl={clientUrl}
417
+ messageSelection={messageSelection}
418
+ messageAnnotations={messageAnnotations}
411
419
  onThreadChange={applyThread}
412
420
  onMessage={applyThread}
413
421
  showTransportBanner={false}
@@ -104,6 +104,15 @@ const SmsInboxInner = ({
104
104
  channelFor = defaultChannelFor,
105
105
  settingsUrl = '/settings/sms',
106
106
  clientUrl = (id) => `/clients/${id}`,
107
+ // Both opt-in, both handed straight to the conversation pane — see
108
+ // SmsThreadPanel for the shapes. Absent, this page is exactly what it was.
109
+ //
110
+ // The pane, not the page, is what knows which conversation is open. That is
111
+ // why `onSelect` is handed the thread and why `messageAnnotations` may be a
112
+ // function of it: an inbox host cannot know either in advance, and cannot
113
+ // hold a chip map for every conversation in the practice.
114
+ messageSelection = null,
115
+ messageAnnotations = null,
107
116
  pageTitle = 'Messages',
108
117
  navigate,
109
118
  locationSearch,
@@ -523,6 +532,8 @@ const SmsInboxInner = ({
523
532
  lines={lines}
524
533
  canManage={canManage}
525
534
  clientUrl={clientUrl}
535
+ messageSelection={messageSelection}
536
+ messageAnnotations={messageAnnotations}
526
537
  onThreadChange={onThreadChange}
527
538
  onMessage={onThreadChange}
528
539
  onBack={() => {