@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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +15 -2
- 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/Sms.module.scss +131 -0
- package/src/components/styles/Vault.module.scss +46 -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/VaultShareModal.jsx +126 -5
- package/src/components/vault/vaultShares.js +67 -3
- package/src/index.js +41 -2
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import {
|
|
9
|
+
Loader2,
|
|
10
|
+
PhoneCall,
|
|
11
|
+
RefreshCw,
|
|
12
|
+
TriangleAlert,
|
|
13
|
+
Unplug,
|
|
14
|
+
} from 'lucide-react';
|
|
15
|
+
|
|
16
|
+
import CustomFetch from '../Fetch';
|
|
17
|
+
import styles from '../styles/ZoomPhone.module.scss';
|
|
18
|
+
// Named for the vault because that is where it was written; nothing in it is
|
|
19
|
+
// vault-specific. It hands a component a `navigate` that works inside a Router
|
|
20
|
+
// and outside one — the same reason SmsInboxBadge imports it.
|
|
21
|
+
import withVaultNavigation from '../vault/vaultNavigation';
|
|
22
|
+
import {
|
|
23
|
+
matchesShortcut,
|
|
24
|
+
shortcutLabel,
|
|
25
|
+
shouldIgnoreShortcut,
|
|
26
|
+
} from '../vault/vaultHelpers';
|
|
27
|
+
import { resolvePhonePresenceEndpoints } from './phonePresenceEndpoints';
|
|
28
|
+
import useZoomPhoneLive from './useZoomPhoneLive';
|
|
29
|
+
import {
|
|
30
|
+
applyPresenceEvent,
|
|
31
|
+
badgeCount,
|
|
32
|
+
callDuration,
|
|
33
|
+
callSummary,
|
|
34
|
+
freshnessLabel,
|
|
35
|
+
initialsFor,
|
|
36
|
+
onCallCount,
|
|
37
|
+
orderRoster,
|
|
38
|
+
peerNumberLabel,
|
|
39
|
+
presenceNote,
|
|
40
|
+
statusLabel,
|
|
41
|
+
statusTone,
|
|
42
|
+
} from './phonePresenceHelpers';
|
|
43
|
+
|
|
44
|
+
/** Ticks the on-screen call timers without re-fetching anything. */
|
|
45
|
+
const TICK_MS = 1000;
|
|
46
|
+
|
|
47
|
+
/** The same clock with nothing counting: it only drives the freshness stamp. */
|
|
48
|
+
const IDLE_TICK_MS = 15_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Zoom Phone, from the header.
|
|
52
|
+
*
|
|
53
|
+
* "Who is on the phone right now" — every extension in the account, who is
|
|
54
|
+
* free, who is ringing, who is talking, to what number, and which client that
|
|
55
|
+
* number belongs to. Mounted as an account action through Navigation's
|
|
56
|
+
* `renderers` slot, so it sits in the chip row beside Messages and the vault
|
|
57
|
+
* and wears the same trigger.
|
|
58
|
+
*
|
|
59
|
+
* The data is a join of two things the server does, not one thing Zoom offers:
|
|
60
|
+
* the roster comes from `GET /phone/users` (cached), and the live state is
|
|
61
|
+
* derived from the Zoom Phone webhooks the application already receives. Zoom
|
|
62
|
+
* has no per-extension "is this handset busy" endpoint — `/users/{id}/
|
|
63
|
+
* presence_status` is Zoom MEETINGS presence, and reports nothing about calls.
|
|
64
|
+
*
|
|
65
|
+
* That has one consequence the UI is honest about: "Available" means "no live
|
|
66
|
+
* call leg for this extension", which is only as true as the webhook is
|
|
67
|
+
* healthy. So the footer always says how old the snapshot is, or `Live` when a
|
|
68
|
+
* subscription has actually been confirmed — never a row of green dots with
|
|
69
|
+
* nothing to date them.
|
|
70
|
+
*
|
|
71
|
+
* The two states this was built around are the ones that do not exist on a
|
|
72
|
+
* fresh install: Zoom is not connected, and Zoom refused the credentials. Both
|
|
73
|
+
* say so on the face of the popover, the same way the messaging module does.
|
|
74
|
+
*/
|
|
75
|
+
const ZoomPhoneBadgeInner = ({
|
|
76
|
+
setting,
|
|
77
|
+
// Accepted so the component matches the shape Navigation's renderers hand
|
|
78
|
+
// over, even though the popover does not read them.
|
|
79
|
+
userProfile: _userProfile,
|
|
80
|
+
setSystemAuth: _setSystemAuth,
|
|
81
|
+
endpoints,
|
|
82
|
+
echo = null,
|
|
83
|
+
// The private channel to listen on. Null — never a guess — leaves the
|
|
84
|
+
// popover on its poll, which is a working roster, just a slower one.
|
|
85
|
+
channel = null,
|
|
86
|
+
// How a client name becomes a link. A function or a string template, the
|
|
87
|
+
// same contract CallQueuePop's `clientUrl` uses.
|
|
88
|
+
clientUrl = null,
|
|
89
|
+
// The fallback poll. Halved while the popover is open, because a roster
|
|
90
|
+
// somebody is looking at is worth more than one behind a closed chip.
|
|
91
|
+
pollInterval = 60_000,
|
|
92
|
+
openPollInterval = 20_000,
|
|
93
|
+
// ⌘⇧U / Ctrl+Shift+U. Not ⌘⇧P: browsers open a private window on that.
|
|
94
|
+
shortcut = 'mod+shift+u',
|
|
95
|
+
navigate,
|
|
96
|
+
}) => {
|
|
97
|
+
const routes = useMemo(
|
|
98
|
+
() => resolvePhonePresenceEndpoints(endpoints),
|
|
99
|
+
[endpoints]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const [open, setOpen] = useState(false);
|
|
103
|
+
const [roster, setRoster] = useState(null);
|
|
104
|
+
const [loading, setLoading] = useState(false);
|
|
105
|
+
const [loaded, setLoaded] = useState(false);
|
|
106
|
+
// Drives the call timers. Cheap: one state write a second, and only while
|
|
107
|
+
// there is actually a call on screen.
|
|
108
|
+
const [tick, setTick] = useState(() => Date.now());
|
|
109
|
+
|
|
110
|
+
const wrapperRef = useRef(null);
|
|
111
|
+
const triggerRef = useRef(null);
|
|
112
|
+
const listRef = useRef(null);
|
|
113
|
+
const mountedRef = useRef(true);
|
|
114
|
+
// Guards against an early response overwriting a later one.
|
|
115
|
+
const requestRef = useRef(0);
|
|
116
|
+
|
|
117
|
+
useEffect(
|
|
118
|
+
() => () => {
|
|
119
|
+
mountedRef.current = false;
|
|
120
|
+
},
|
|
121
|
+
[]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/* -------------------------------------------------------------- loading */
|
|
125
|
+
|
|
126
|
+
const load = useCallback(
|
|
127
|
+
(refresh = false) => {
|
|
128
|
+
const ticket = requestRef.current + 1;
|
|
129
|
+
|
|
130
|
+
requestRef.current = ticket;
|
|
131
|
+
setLoading(true);
|
|
132
|
+
|
|
133
|
+
CustomFetch(
|
|
134
|
+
routes.presence,
|
|
135
|
+
'GET',
|
|
136
|
+
refresh ? { refresh: 1 } : null,
|
|
137
|
+
(result) => {
|
|
138
|
+
if (!mountedRef.current || requestRef.current !== ticket)
|
|
139
|
+
return;
|
|
140
|
+
|
|
141
|
+
setRoster(
|
|
142
|
+
result && typeof result === 'object' ? result : null
|
|
143
|
+
);
|
|
144
|
+
setLoading(false);
|
|
145
|
+
setLoaded(true);
|
|
146
|
+
},
|
|
147
|
+
// Silent: a header chip must not toast at someone because a
|
|
148
|
+
// background poll failed. The popover shows the last good
|
|
149
|
+
// snapshot and its age, which says the same thing usefully.
|
|
150
|
+
() => {
|
|
151
|
+
if (!mountedRef.current || requestRef.current !== ticket)
|
|
152
|
+
return;
|
|
153
|
+
|
|
154
|
+
setLoading(false);
|
|
155
|
+
setLoaded(true);
|
|
156
|
+
}
|
|
157
|
+
).catch(() => {
|
|
158
|
+
if (!mountedRef.current || requestRef.current !== ticket)
|
|
159
|
+
return;
|
|
160
|
+
|
|
161
|
+
setLoading(false);
|
|
162
|
+
setLoaded(true);
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
[routes]
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/* ----------------------------------------------------------------- live */
|
|
169
|
+
|
|
170
|
+
const handlePresence = useCallback((event) => {
|
|
171
|
+
if (!mountedRef.current) return;
|
|
172
|
+
|
|
173
|
+
setRoster((prev) => {
|
|
174
|
+
if (!prev) return prev;
|
|
175
|
+
|
|
176
|
+
const users = applyPresenceEvent(prev.users, event);
|
|
177
|
+
|
|
178
|
+
// Nobody on this roster owns that leg. It is either an extension the
|
|
179
|
+
// cached directory has not heard of or — much more likely, since
|
|
180
|
+
// this channel is shared with the call pop — simply not our event.
|
|
181
|
+
// Re-fetch rather than guess: `unmatched_calls` is the server's
|
|
182
|
+
// answer to give, not ours to invent.
|
|
183
|
+
if (users === prev.users) return prev;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
...prev,
|
|
187
|
+
users,
|
|
188
|
+
on_call_count: onCallCount(users, prev.unmatched_calls),
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const { live } = useZoomPhoneLive({
|
|
194
|
+
echo,
|
|
195
|
+
channel,
|
|
196
|
+
onPresence: handlePresence,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/*
|
|
200
|
+
* The poll. It is the only source when there is no confirmed subscription,
|
|
201
|
+
* and the safety net when there is one — a dropped frame then costs a
|
|
202
|
+
* minute of staleness rather than the rest of the session.
|
|
203
|
+
*
|
|
204
|
+
* It also covers the case `handlePresence` deliberately does not: a call
|
|
205
|
+
* arriving on an extension the roster does not list. Only the server can
|
|
206
|
+
* say who that is.
|
|
207
|
+
*/
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const interval = open ? openPollInterval : pollInterval;
|
|
210
|
+
|
|
211
|
+
load();
|
|
212
|
+
|
|
213
|
+
if (!(interval > 0)) return undefined;
|
|
214
|
+
|
|
215
|
+
const timer = setInterval(() => load(), interval);
|
|
216
|
+
|
|
217
|
+
return () => clearInterval(timer);
|
|
218
|
+
}, [open, load, pollInterval, openPollInterval]);
|
|
219
|
+
|
|
220
|
+
/* --------------------------------------------------------------- timers */
|
|
221
|
+
|
|
222
|
+
const users = useMemo(() => orderRoster(roster?.users), [roster]);
|
|
223
|
+
const unmatched = useMemo(
|
|
224
|
+
() =>
|
|
225
|
+
Array.isArray(roster?.unmatched_calls)
|
|
226
|
+
? roster.unmatched_calls
|
|
227
|
+
: [],
|
|
228
|
+
[roster]
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const busy = onCallCount(roster?.users, unmatched);
|
|
232
|
+
|
|
233
|
+
/*
|
|
234
|
+
* The clock behind the call timers and the "updated 2m ago" stamp.
|
|
235
|
+
*
|
|
236
|
+
* Only while the popover is open, and only at one-second resolution while
|
|
237
|
+
* there is actually a duration ticking on screen — a closed chip has no
|
|
238
|
+
* reason to re-render once a second all day.
|
|
239
|
+
*/
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
if (!open) return undefined;
|
|
242
|
+
|
|
243
|
+
setTick(Date.now());
|
|
244
|
+
|
|
245
|
+
const timer = setInterval(
|
|
246
|
+
() => setTick(Date.now()),
|
|
247
|
+
busy > 0 ? TICK_MS : IDLE_TICK_MS
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return () => clearInterval(timer);
|
|
251
|
+
}, [open, busy]);
|
|
252
|
+
|
|
253
|
+
/* -------------------------------------------------------------- opening */
|
|
254
|
+
|
|
255
|
+
const close = useCallback(() => setOpen(false), []);
|
|
256
|
+
|
|
257
|
+
// Global, captured: same reasoning as the vault and the SMS badge — the
|
|
258
|
+
// point of a chord is not reaching for the mouse, and a data grid or rich
|
|
259
|
+
// editor that stops keydown propagation must not swallow it.
|
|
260
|
+
const shortcutRef = useRef(shortcut);
|
|
261
|
+
shortcutRef.current = shortcut;
|
|
262
|
+
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
const onKeyDown = (event) => {
|
|
265
|
+
if (!matchesShortcut(event, shortcutRef.current)) return;
|
|
266
|
+
if (shouldIgnoreShortcut(event)) return;
|
|
267
|
+
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
event.stopPropagation();
|
|
270
|
+
setOpen((prev) => !prev);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
274
|
+
|
|
275
|
+
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!open) return undefined;
|
|
280
|
+
|
|
281
|
+
const onMouseDown = (event) => {
|
|
282
|
+
if (wrapperRef.current?.contains(event.target)) return;
|
|
283
|
+
if (triggerRef.current?.contains(event.target)) return;
|
|
284
|
+
|
|
285
|
+
close();
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
document.addEventListener('mousedown', onMouseDown);
|
|
289
|
+
|
|
290
|
+
return () => document.removeEventListener('mousedown', onMouseDown);
|
|
291
|
+
}, [open, close]);
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
if (!open) return undefined;
|
|
295
|
+
|
|
296
|
+
// After paint, or the list is not in the document yet.
|
|
297
|
+
const handle = setTimeout(() => listRef.current?.focus(), 0);
|
|
298
|
+
|
|
299
|
+
return () => clearTimeout(handle);
|
|
300
|
+
}, [open]);
|
|
301
|
+
|
|
302
|
+
const onKeyDown = useCallback(
|
|
303
|
+
(event) => {
|
|
304
|
+
if (event.key !== 'Escape') return;
|
|
305
|
+
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
close();
|
|
308
|
+
triggerRef.current?.focus();
|
|
309
|
+
},
|
|
310
|
+
[close]
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
/* --------------------------------------------------------------- render */
|
|
314
|
+
|
|
315
|
+
const label = setting?.label || 'Zoom Phone';
|
|
316
|
+
const hint = shortcutLabel(shortcut);
|
|
317
|
+
const count = badgeCount(busy);
|
|
318
|
+
const note = presenceNote(roster);
|
|
319
|
+
|
|
320
|
+
const linkFor = useCallback(
|
|
321
|
+
(client) => {
|
|
322
|
+
if (!client || !clientUrl) return null;
|
|
323
|
+
|
|
324
|
+
return typeof clientUrl === 'function'
|
|
325
|
+
? clientUrl(client)
|
|
326
|
+
: clientUrl;
|
|
327
|
+
},
|
|
328
|
+
[clientUrl]
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const openClient = useCallback(
|
|
332
|
+
(client) => {
|
|
333
|
+
const url = linkFor(client);
|
|
334
|
+
|
|
335
|
+
if (!url) return;
|
|
336
|
+
|
|
337
|
+
close();
|
|
338
|
+
navigate(url);
|
|
339
|
+
},
|
|
340
|
+
[linkFor, close, navigate]
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
/** The client chip on a busy row: a link when we know where to send it. */
|
|
344
|
+
const renderClient = (call) => {
|
|
345
|
+
const client = call?.client;
|
|
346
|
+
|
|
347
|
+
if (!client?.name) return null;
|
|
348
|
+
|
|
349
|
+
const url = linkFor(client);
|
|
350
|
+
|
|
351
|
+
if (!url) {
|
|
352
|
+
return <span className={styles.clientFlat}>{client.name}</span>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<button
|
|
357
|
+
type="button"
|
|
358
|
+
className={styles.clientLink}
|
|
359
|
+
onClick={(event) => {
|
|
360
|
+
event.stopPropagation();
|
|
361
|
+
openClient(client);
|
|
362
|
+
}}
|
|
363
|
+
title={`Open ${client.name}`}
|
|
364
|
+
>
|
|
365
|
+
{client.name}
|
|
366
|
+
</button>
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const renderCall = (call) => {
|
|
371
|
+
if (!call) return null;
|
|
372
|
+
|
|
373
|
+
const duration = callDuration(call, tick);
|
|
374
|
+
const number = peerNumberLabel(call);
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<span className={styles.callLine}>
|
|
378
|
+
<span className={styles.callSummary}>{callSummary(call)}</span>
|
|
379
|
+
{number !== '' && (
|
|
380
|
+
<span className={styles.callNumber}>{number}</span>
|
|
381
|
+
)}
|
|
382
|
+
{call.queue_name && (
|
|
383
|
+
<span className={styles.queueTag}>{call.queue_name}</span>
|
|
384
|
+
)}
|
|
385
|
+
{duration !== '' && (
|
|
386
|
+
<span className={styles.duration}>{duration}</span>
|
|
387
|
+
)}
|
|
388
|
+
</span>
|
|
389
|
+
);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const renderRow = (user) => {
|
|
393
|
+
const tone = statusTone(user.status);
|
|
394
|
+
const client = user.call?.client;
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<li key={user.id || user.extension_number} className={styles.row}>
|
|
398
|
+
<span className={styles.avatar} aria-hidden="true">
|
|
399
|
+
{initialsFor(user.name)}
|
|
400
|
+
</span>
|
|
401
|
+
|
|
402
|
+
<span className={styles.rowMain}>
|
|
403
|
+
<span className={styles.rowTop}>
|
|
404
|
+
<strong className={styles.rowName}>{user.name}</strong>
|
|
405
|
+
{user.extension_number && (
|
|
406
|
+
<span className={styles.extension}>
|
|
407
|
+
ext {user.extension_number}
|
|
408
|
+
</span>
|
|
409
|
+
)}
|
|
410
|
+
</span>
|
|
411
|
+
|
|
412
|
+
{user.call ? (
|
|
413
|
+
<>
|
|
414
|
+
{renderCall(user.call)}
|
|
415
|
+
{client?.name && (
|
|
416
|
+
<span className={styles.clientLine}>
|
|
417
|
+
{renderClient(user.call)}
|
|
418
|
+
{client.adviser && (
|
|
419
|
+
<span className={styles.clientMeta}>
|
|
420
|
+
{client.adviser}
|
|
421
|
+
</span>
|
|
422
|
+
)}
|
|
423
|
+
</span>
|
|
424
|
+
)}
|
|
425
|
+
</>
|
|
426
|
+
) : (
|
|
427
|
+
<span className={styles.idleLine}>
|
|
428
|
+
{statusLabel(user.status)}
|
|
429
|
+
</span>
|
|
430
|
+
)}
|
|
431
|
+
</span>
|
|
432
|
+
|
|
433
|
+
<span
|
|
434
|
+
className={[styles.status, styles[`status_${tone}`]]
|
|
435
|
+
.filter(Boolean)
|
|
436
|
+
.join(' ')}
|
|
437
|
+
>
|
|
438
|
+
<span className={styles.dot} aria-hidden="true" />
|
|
439
|
+
{statusLabel(user.status)}
|
|
440
|
+
</span>
|
|
441
|
+
</li>
|
|
442
|
+
);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A live call on an extension the cached directory does not list — a
|
|
447
|
+
* common-area handset, a meeting room, or somebody hired since the roster
|
|
448
|
+
* was last fetched. Shown rather than dropped: swallowing it would make the
|
|
449
|
+
* popover say the office is quiet during a call.
|
|
450
|
+
*/
|
|
451
|
+
const renderUnmatched = (entry, index) => (
|
|
452
|
+
<li key={`unmatched-${index}`} className={styles.row}>
|
|
453
|
+
<span className={styles.avatar} aria-hidden="true">
|
|
454
|
+
{initialsFor(entry.name)}
|
|
455
|
+
</span>
|
|
456
|
+
|
|
457
|
+
<span className={styles.rowMain}>
|
|
458
|
+
<span className={styles.rowTop}>
|
|
459
|
+
<strong className={styles.rowName}>
|
|
460
|
+
{entry.name || 'Unknown extension'}
|
|
461
|
+
</strong>
|
|
462
|
+
{entry.extension_number && (
|
|
463
|
+
<span className={styles.extension}>
|
|
464
|
+
ext {entry.extension_number}
|
|
465
|
+
</span>
|
|
466
|
+
)}
|
|
467
|
+
</span>
|
|
468
|
+
{renderCall(entry.call)}
|
|
469
|
+
</span>
|
|
470
|
+
|
|
471
|
+
<span
|
|
472
|
+
className={[styles.status, styles.status_busy]
|
|
473
|
+
.filter(Boolean)
|
|
474
|
+
.join(' ')}
|
|
475
|
+
>
|
|
476
|
+
<span className={styles.dot} aria-hidden="true" />
|
|
477
|
+
{statusLabel(
|
|
478
|
+
entry.call?.status === 'ringing' ? 'ringing' : 'on_call'
|
|
479
|
+
)}
|
|
480
|
+
</span>
|
|
481
|
+
</li>
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
const empty = users.length === 0 && unmatched.length === 0;
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
<div className={styles.zoomPhone}>
|
|
488
|
+
<button
|
|
489
|
+
ref={triggerRef}
|
|
490
|
+
type="button"
|
|
491
|
+
className={styles.trigger}
|
|
492
|
+
onClick={() => (open ? close() : setOpen(true))}
|
|
493
|
+
data-tooltip-id="system-tooltip"
|
|
494
|
+
data-tooltip-place="bottom"
|
|
495
|
+
data-tooltip-content={hint ? `${label} · ${hint}` : label}
|
|
496
|
+
aria-label={busy > 0 ? `${label} (${busy} on a call)` : label}
|
|
497
|
+
aria-keyshortcuts="Meta+Shift+U Control+Shift+U"
|
|
498
|
+
aria-expanded={open}
|
|
499
|
+
aria-haspopup="dialog"
|
|
500
|
+
>
|
|
501
|
+
<PhoneCall size={20} strokeWidth={2} />
|
|
502
|
+
{count !== '' && (
|
|
503
|
+
<span className={styles.badge} aria-hidden="true">
|
|
504
|
+
{count}
|
|
505
|
+
</span>
|
|
506
|
+
)}
|
|
507
|
+
</button>
|
|
508
|
+
|
|
509
|
+
{open && (
|
|
510
|
+
<div
|
|
511
|
+
className={styles.popoverWrapper}
|
|
512
|
+
ref={wrapperRef}
|
|
513
|
+
/* Tells the navigation's action pill to stop clipping while
|
|
514
|
+
this is open. See Navigation.module.scss `.hactions > ul`,
|
|
515
|
+
and the vault and messages popovers, which do the same. */
|
|
516
|
+
data-nav-overlay="open"
|
|
517
|
+
role="dialog"
|
|
518
|
+
aria-label={label}
|
|
519
|
+
>
|
|
520
|
+
<div className={styles.popover}>
|
|
521
|
+
<div className={styles.popoverHead}>
|
|
522
|
+
<span className={styles.popoverTitle}>
|
|
523
|
+
<PhoneCall
|
|
524
|
+
size={15}
|
|
525
|
+
strokeWidth={2.2}
|
|
526
|
+
aria-hidden="true"
|
|
527
|
+
/>
|
|
528
|
+
{label}
|
|
529
|
+
</span>
|
|
530
|
+
|
|
531
|
+
<button
|
|
532
|
+
type="button"
|
|
533
|
+
className={styles.iconButton}
|
|
534
|
+
onClick={() => load(true)}
|
|
535
|
+
title="Refresh the extension list from Zoom"
|
|
536
|
+
aria-label="Refresh"
|
|
537
|
+
>
|
|
538
|
+
{loading ? (
|
|
539
|
+
<Loader2
|
|
540
|
+
size={15}
|
|
541
|
+
className={styles.spin}
|
|
542
|
+
/>
|
|
543
|
+
) : (
|
|
544
|
+
<RefreshCw size={15} strokeWidth={2} />
|
|
545
|
+
)}
|
|
546
|
+
</button>
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{!empty && (
|
|
550
|
+
<ul
|
|
551
|
+
className={styles.rows}
|
|
552
|
+
ref={listRef}
|
|
553
|
+
tabIndex={-1}
|
|
554
|
+
onKeyDown={onKeyDown}
|
|
555
|
+
aria-label="Zoom Phone extensions"
|
|
556
|
+
>
|
|
557
|
+
{unmatched.map(renderUnmatched)}
|
|
558
|
+
{users.map(renderRow)}
|
|
559
|
+
</ul>
|
|
560
|
+
)}
|
|
561
|
+
|
|
562
|
+
{empty && (
|
|
563
|
+
<div className={styles.empty}>
|
|
564
|
+
{loading || !loaded ? (
|
|
565
|
+
<p>
|
|
566
|
+
<Loader2
|
|
567
|
+
size={16}
|
|
568
|
+
className={styles.spin}
|
|
569
|
+
aria-label="Loading extensions"
|
|
570
|
+
/>
|
|
571
|
+
</p>
|
|
572
|
+
) : (
|
|
573
|
+
<p>No Zoom Phone extensions to show.</p>
|
|
574
|
+
)}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
|
|
578
|
+
{note && (
|
|
579
|
+
<p
|
|
580
|
+
className={[
|
|
581
|
+
styles.statusNote,
|
|
582
|
+
note.tone === 'bad'
|
|
583
|
+
? styles.statusNoteBad
|
|
584
|
+
: '',
|
|
585
|
+
]
|
|
586
|
+
.filter(Boolean)
|
|
587
|
+
.join(' ')}
|
|
588
|
+
>
|
|
589
|
+
{note.tone === 'bad' ? (
|
|
590
|
+
<Unplug
|
|
591
|
+
size={14}
|
|
592
|
+
strokeWidth={2}
|
|
593
|
+
aria-hidden="true"
|
|
594
|
+
/>
|
|
595
|
+
) : (
|
|
596
|
+
<TriangleAlert
|
|
597
|
+
size={14}
|
|
598
|
+
strokeWidth={2}
|
|
599
|
+
aria-hidden="true"
|
|
600
|
+
/>
|
|
601
|
+
)}
|
|
602
|
+
{note.text}
|
|
603
|
+
</p>
|
|
604
|
+
)}
|
|
605
|
+
|
|
606
|
+
<div className={styles.footer}>
|
|
607
|
+
<span className={styles.footerHint}>
|
|
608
|
+
{busy === 0
|
|
609
|
+
? 'Nobody is on a call'
|
|
610
|
+
: `${busy} on a call`}
|
|
611
|
+
</span>
|
|
612
|
+
<span className={styles.footerStamp}>
|
|
613
|
+
{freshnessLabel(roster?.fetched_at, live, tick)}
|
|
614
|
+
</span>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
);
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const ZoomPhoneBadge = withVaultNavigation(ZoomPhoneBadgeInner);
|
|
624
|
+
|
|
625
|
+
export default ZoomPhoneBadge;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL the Zoom Phone roster talks to.
|
|
3
|
+
*
|
|
4
|
+
* One route today, and an object rather than a string anyway — for the same
|
|
5
|
+
* reason smsEndpoints.js and vaultEndpoints.js are objects: a consumer that
|
|
6
|
+
* moves the route must not have to re-derive anything, and a second route added
|
|
7
|
+
* later must not change this module's shape.
|
|
8
|
+
*
|
|
9
|
+
* Pure and React-free so `node --test` can import it. This default is the URL a
|
|
10
|
+
* consumer that passes no `endpoints` prop will hit, so a change here is a
|
|
11
|
+
* change to every deployment.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const makePhonePresenceEndpoints = (base = '/ajax/call-queue') => ({
|
|
15
|
+
base,
|
|
16
|
+
|
|
17
|
+
// The roster: every Zoom Phone extension, each with its live call if it has
|
|
18
|
+
// one. Served by the call queue module, because it is the same webhook, the
|
|
19
|
+
// same Zoom credentials and the same number -> client hook.
|
|
20
|
+
presence: `${base}/presence`,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_ENDPOINTS = makePhonePresenceEndpoints();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Merge a partial `endpoints` prop over the defaults, so a consumer overriding
|
|
27
|
+
* one route does not lose the others.
|
|
28
|
+
*/
|
|
29
|
+
export const resolvePhonePresenceEndpoints = (endpoints) => ({
|
|
30
|
+
...DEFAULT_ENDPOINTS,
|
|
31
|
+
...(endpoints || {}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default DEFAULT_ENDPOINTS;
|