@visns-studio/visns-components 6.20.1 → 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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +15 -2
  3. package/src/components/Navigation.jsx +10 -1
  4. package/src/components/columns/SelectFilterEditor.jsx +372 -0
  5. package/src/components/controls/DataGridSearch.jsx +6 -1
  6. package/src/components/layout/AnchoredMenu.jsx +131 -0
  7. package/src/components/layout/BandedList.jsx +158 -0
  8. package/src/components/layout/FactsBand.jsx +150 -0
  9. package/src/components/layout/PageHeader.jsx +90 -0
  10. package/src/components/layout/ScrollRegion.jsx +53 -0
  11. package/src/components/layout/StatStrip.jsx +170 -0
  12. package/src/components/layout/useContainedHeight.js +82 -0
  13. package/src/components/navigation/TabStrip.jsx +156 -0
  14. package/src/components/phone/ZoomPhoneBadge.jsx +625 -0
  15. package/src/components/phone/phonePresenceEndpoints.js +34 -0
  16. package/src/components/phone/phonePresenceHelpers.js +298 -0
  17. package/src/components/phone/useZoomPhoneLive.js +165 -0
  18. package/src/components/sms/SmsClientConversations.jsx +8 -0
  19. package/src/components/sms/SmsInbox.jsx +11 -0
  20. package/src/components/sms/SmsThreadPanel.jsx +258 -29
  21. package/src/components/sms/smsHelpers.js +95 -0
  22. package/src/components/styles/AnchoredMenu.module.scss +43 -0
  23. package/src/components/styles/BandedList.module.scss +278 -0
  24. package/src/components/styles/DataGrid.module.scss +10 -0
  25. package/src/components/styles/FactsBand.module.scss +108 -0
  26. package/src/components/styles/GenericDetail.module.scss +12 -2
  27. package/src/components/styles/PageHeader.module.scss +98 -0
  28. package/src/components/styles/ScrollRegion.module.scss +20 -0
  29. package/src/components/styles/Sms.module.scss +131 -0
  30. package/src/components/styles/StatStrip.module.scss +183 -0
  31. package/src/components/styles/TabStrip.module.scss +96 -0
  32. package/src/components/styles/Vault.module.scss +46 -0
  33. package/src/components/styles/ZoomPhone.module.scss +550 -0
  34. package/src/components/styles/_surface.scss +49 -0
  35. package/src/components/styles/global-datagrid.css +23 -0
  36. package/src/components/vault/VaultAccessLog.jsx +24 -0
  37. package/src/components/vault/VaultShareModal.jsx +126 -5
  38. package/src/components/vault/vaultShares.js +67 -3
  39. package/src/index.js +88 -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;