@visns-studio/visns-components 6.21.0 → 6.23.1
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/package.json +1 -1
- package/src/components/DataGrid.jsx +15 -2
- package/src/components/JsonView.jsx +299 -0
- package/src/components/Navigation.jsx +10 -1
- package/src/components/columns/SelectFilterEditor.jsx +372 -0
- package/src/components/controls/DataGridSearch.jsx +6 -1
- package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
- package/src/components/phone/phonePresenceEndpoints.js +34 -0
- package/src/components/phone/phonePresenceHelpers.js +298 -0
- package/src/components/phone/useZoomPhoneLive.js +165 -0
- package/src/components/sms/SmsClientConversations.jsx +8 -0
- package/src/components/sms/SmsInbox.jsx +11 -0
- package/src/components/sms/SmsThreadPanel.jsx +258 -29
- package/src/components/sms/smsHelpers.js +37 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +12 -2
- package/src/components/styles/JsonView.module.scss +233 -0
- package/src/components/styles/Sms.module.scss +131 -0
- package/src/components/styles/Vault.module.scss +114 -0
- package/src/components/styles/ZoomPhone.module.scss +550 -0
- package/src/components/styles/global-datagrid.css +23 -0
- package/src/components/vault/VaultAccessLog.jsx +24 -0
- package/src/components/vault/VaultEntryForm.jsx +239 -8
- package/src/components/vault/VaultManager.jsx +14 -2
- package/src/components/vault/VaultShareModal.jsx +126 -5
- package/src/components/vault/vaultShares.js +67 -3
- package/src/index.js +49 -2
|
@@ -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={() => {
|