@visns-studio/visns-components 6.6.3 → 6.15.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 (100) hide show
  1. package/README.md +1658 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +619 -158
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/AutoGrowCell.jsx +141 -0
  32. package/src/components/columns/ColumnRenderers.jsx +53 -7
  33. package/src/components/generic/GenericAuth.jsx +163 -96
  34. package/src/components/generic/GenericDetail.jsx +215 -90
  35. package/src/components/generic/GenericIndex.jsx +20 -47
  36. package/src/components/generic/GenericMain.jsx +5 -0
  37. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  38. package/src/components/generic/StandardModal.jsx +17 -5
  39. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  40. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  41. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  42. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  44. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  45. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  46. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  47. package/src/components/navActive.js +60 -0
  48. package/src/components/notify/desktopNotifications.js +256 -0
  49. package/src/components/sketch/SketchField.jsx +12 -2
  50. package/src/components/sms/SmsComposeModal.jsx +495 -0
  51. package/src/components/sms/SmsInbox.jsx +596 -0
  52. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  53. package/src/components/sms/SmsLineSettings.jsx +854 -0
  54. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  55. package/src/components/sms/smsEndpoints.js +56 -0
  56. package/src/components/sms/smsHelpers.js +1075 -0
  57. package/src/components/sms/smsLiveState.js +132 -0
  58. package/src/components/sms/useSmsLive.js +307 -0
  59. package/src/components/styles/CallQueuePop.module.scss +646 -0
  60. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  61. package/src/components/styles/ClientAuth.module.scss +229 -0
  62. package/src/components/styles/DataGrid.module.scss +10 -0
  63. package/src/components/styles/GenericDetail.module.scss +153 -26
  64. package/src/components/styles/GenericIndex.module.scss +13 -0
  65. package/src/components/styles/GenericMain.module.scss +19 -2
  66. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  67. package/src/components/styles/Login.module.scss +13 -122
  68. package/src/components/styles/LogoutScreen.module.scss +89 -0
  69. package/src/components/styles/Navigation.module.scss +509 -223
  70. package/src/components/styles/Notification.module.scss +103 -0
  71. package/src/components/styles/Reset.module.scss +52 -186
  72. package/src/components/styles/Sms.module.scss +2382 -0
  73. package/src/components/styles/TableFilter.module.scss +118 -98
  74. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  75. package/src/components/styles/Vault.module.scss +1983 -0
  76. package/src/components/styles/Verify.module.scss +57 -180
  77. package/src/components/styles/_authBrandPanel.scss +163 -0
  78. package/src/components/styles/_authShell.scss +369 -0
  79. package/src/components/styles/global-datagrid.css +29 -0
  80. package/src/components/utils/buildEnv.js +67 -0
  81. package/src/components/utils/displayValue.js +94 -0
  82. package/src/components/utils/useDensity.js +345 -9
  83. package/src/components/vault/OtpChip.jsx +208 -0
  84. package/src/components/vault/PasswordGenerator.jsx +145 -0
  85. package/src/components/vault/QrScanner.jsx +326 -0
  86. package/src/components/vault/VaultAccessLog.jsx +225 -0
  87. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  88. package/src/components/vault/VaultEntryForm.jsx +740 -0
  89. package/src/components/vault/VaultManager.jsx +1000 -0
  90. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  91. package/src/components/vault/qrDecode.js +257 -0
  92. package/src/components/vault/useDebouncedValue.js +22 -0
  93. package/src/components/vault/useVaultReveal.js +160 -0
  94. package/src/components/vault/vaultClipboard.js +33 -0
  95. package/src/components/vault/vaultEndpoints.js +40 -0
  96. package/src/components/vault/vaultFit.js +116 -0
  97. package/src/components/vault/vaultHelpers.js +547 -0
  98. package/src/components/vault/vaultNavigation.jsx +58 -0
  99. package/src/components/vault/vaultOtp.js +105 -0
  100. package/src/index.js +233 -1
@@ -0,0 +1,596 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ Inbox,
4
+ Loader2,
5
+ MessageSquare,
6
+ PenSquare,
7
+ Unplug,
8
+ Search,
9
+ Settings,
10
+ } from 'lucide-react';
11
+
12
+ import CustomFetch from '../Fetch';
13
+ import styles from '../styles/Sms.module.scss';
14
+ import withVaultNavigation from '../vault/vaultNavigation';
15
+ import useDebouncedValue from '../vault/useDebouncedValue';
16
+ import SmsComposeModal from './SmsComposeModal';
17
+ import SmsThreadPanel from './SmsThreadPanel';
18
+ import { resolveSmsEndpoints } from './smsEndpoints';
19
+ import useSmsLive, { defaultChannelFor } from './useSmsLive';
20
+ import {
21
+ describeTransport,
22
+ initialsFor,
23
+ lineNumberForDisplay,
24
+ lineOptionLabel,
25
+ relativeTime,
26
+ threadDisplayName,
27
+ threadIdFromSearch,
28
+ upsertThread,
29
+ } from './smsHelpers';
30
+
31
+ const PER_PAGE = 25;
32
+
33
+ const FILTERS = [
34
+ { id: 'all', label: 'All' },
35
+ { id: 'unread', label: 'Unread' },
36
+ { id: 'archived', label: 'Archived' },
37
+ ];
38
+
39
+ /** Keep the address bar in step without pushing a history entry per click. */
40
+ const syncThreadInUrl = (threadId) => {
41
+ if (typeof window === 'undefined' || !window.history?.replaceState) return;
42
+
43
+ const url = new URL(window.location.href);
44
+
45
+ if (threadId) url.searchParams.set('thread', threadId);
46
+ else url.searchParams.delete('thread');
47
+
48
+ window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
49
+ };
50
+
51
+ /**
52
+ * What the page says when the user can see no lines at all.
53
+ *
54
+ * Exported because it is the state a new deployment starts in — nothing works
55
+ * and nothing is broken — and because it is the one screen a person is most
56
+ * likely to hit before anybody has written a line of backend.
57
+ */
58
+ export const SmsNoLines = ({ canManage = false, onOpenSettings }) => (
59
+ <div className={`${styles.listPane} ${styles.tableEmpty}`}>
60
+ <p>
61
+ No lines yet — ask an administrator to set one up in Settings →
62
+ Messaging.
63
+ </p>
64
+ {canManage && (
65
+ <button type="button" className={styles.primaryButton} onClick={onOpenSettings}>
66
+ <Settings size={14} strokeWidth={2} aria-hidden="true" />
67
+ Open messaging settings
68
+ </button>
69
+ )}
70
+ </div>
71
+ );
72
+
73
+ const previewOf = (thread) => {
74
+ const message = thread?.last_message;
75
+
76
+ if (!message || !message.body) return 'No messages yet';
77
+
78
+ const body = String(message.body).replace(/\s+/g, ' ').trim();
79
+
80
+ return message.direction === 'out' ? `You: ${body}` : body;
81
+ };
82
+
83
+ /**
84
+ * The messages page: lines and conversations on the left, one conversation on
85
+ * the right.
86
+ *
87
+ * Two panes because that is what a messaging inbox is, and because the two
88
+ * halves scroll independently — a timeline that scrolls the page takes the
89
+ * composer off the bottom of the screen with it. Below 900px they stop being
90
+ * two panes and become a list that opens into a conversation with a back
91
+ * button, which is the same content and one decision fewer on a phone.
92
+ *
93
+ * This page owns the live subscription for every line the user can see, and
94
+ * hands events down to the conversation pane. One subscription per channel,
95
+ * one poller when there is no Echo at all.
96
+ */
97
+ const SmsInboxInner = ({
98
+ endpoints,
99
+ // Accepted so the page matches the prop shape every other CRM screen is
100
+ // mounted with, even though the two-pane layout does not read it.
101
+ userProfile: _userProfile = null,
102
+ canManage = false,
103
+ echo = null,
104
+ channelFor = defaultChannelFor,
105
+ settingsUrl = '/settings/sms',
106
+ clientUrl = (id) => `/clients/${id}`,
107
+ pageTitle = 'Messages',
108
+ navigate,
109
+ locationSearch,
110
+ }) => {
111
+ const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
112
+
113
+ const [status, setStatus] = useState(null);
114
+ const [lines, setLines] = useState([]);
115
+ const [linesLoaded, setLinesLoaded] = useState(false);
116
+ const [unreadByLine, setUnreadByLine] = useState({});
117
+
118
+ const [lineId, setLineId] = useState('all');
119
+ const [filter, setFilter] = useState('all');
120
+ const [search, setSearch] = useState('');
121
+
122
+ const [threads, setThreads] = useState([]);
123
+ const [page, setPage] = useState(1);
124
+ const [lastPage, setLastPage] = useState(1);
125
+ const [loading, setLoading] = useState(false);
126
+
127
+ const [selectedId, setSelectedId] = useState(() => threadIdFromSearch(locationSearch));
128
+ const [showConversation, setShowConversation] = useState(
129
+ () => Boolean(threadIdFromSearch(locationSearch))
130
+ );
131
+
132
+ const [composeOpen, setComposeOpen] = useState(false);
133
+ const [liveEvent, setLiveEvent] = useState(null);
134
+ const [refreshToken, setRefreshToken] = useState(0);
135
+
136
+ const mountedRef = useRef(true);
137
+ const requestRef = useRef(0);
138
+ const seqRef = useRef(0);
139
+
140
+ const debouncedSearch = useDebouncedValue(search, 300);
141
+
142
+ useEffect(
143
+ () => () => {
144
+ mountedRef.current = false;
145
+ },
146
+ []
147
+ );
148
+
149
+ /* -------------------------------------------------------------- loading */
150
+
151
+ useEffect(() => {
152
+ CustomFetch(
153
+ routes.status,
154
+ 'GET',
155
+ null,
156
+ (result) => {
157
+ if (mountedRef.current && result) setStatus(result);
158
+ },
159
+ () => {}
160
+ ).catch(() => {});
161
+
162
+ CustomFetch(
163
+ routes.lines,
164
+ 'GET',
165
+ null,
166
+ (result) => {
167
+ if (!mountedRef.current) return;
168
+
169
+ setLines(Array.isArray(result?.data) ? result.data : []);
170
+ setLinesLoaded(true);
171
+ },
172
+ () => {
173
+ if (mountedRef.current) setLinesLoaded(true);
174
+ }
175
+ ).catch(() => {
176
+ if (mountedRef.current) setLinesLoaded(true);
177
+ });
178
+ }, [routes]);
179
+
180
+ const fetchThreads = useCallback(
181
+ (targetPage = 1, { append = false } = {}) => {
182
+ const ticket = requestRef.current + 1;
183
+
184
+ requestRef.current = ticket;
185
+ setLoading(true);
186
+
187
+ const params = { page: targetPage, per_page: PER_PAGE };
188
+
189
+ if (lineId !== 'all') params.line_id = lineId;
190
+ if (debouncedSearch.trim() !== '') params.search = debouncedSearch.trim();
191
+ if (filter === 'unread') params.unread_only = 1;
192
+ if (filter === 'archived') params.archived = 1;
193
+
194
+ CustomFetch(
195
+ routes.threads,
196
+ 'GET',
197
+ params,
198
+ (result) => {
199
+ if (!mountedRef.current || requestRef.current !== ticket) return;
200
+
201
+ const rows = Array.isArray(result?.data) ? result.data : [];
202
+
203
+ setThreads((prev) => (append ? [...prev, ...rows] : rows));
204
+ setPage(Number(result?.current_page) || targetPage);
205
+ setLastPage(Number(result?.last_page) || 1);
206
+ setLoading(false);
207
+ },
208
+ () => {
209
+ if (!mountedRef.current || requestRef.current !== ticket) return;
210
+
211
+ setLoading(false);
212
+ }
213
+ ).catch(() => {
214
+ if (!mountedRef.current || requestRef.current !== ticket) return;
215
+
216
+ setLoading(false);
217
+ });
218
+ },
219
+ [routes, lineId, debouncedSearch, filter]
220
+ );
221
+
222
+ useEffect(() => {
223
+ fetchThreads(1);
224
+ }, [fetchThreads]);
225
+
226
+ /* ----------------------------------------------------------------- live */
227
+
228
+ const pushEvent = useCallback((event) => {
229
+ if (!mountedRef.current) return;
230
+
231
+ if (event?.thread) {
232
+ setThreads((prev) => upsertThread(prev, event.thread));
233
+ }
234
+
235
+ seqRef.current += 1;
236
+ setLiveEvent({ seq: seqRef.current, payload: event });
237
+ }, []);
238
+
239
+ const handleUnread = useCallback((payload) => {
240
+ if (!mountedRef.current) return;
241
+
242
+ setUnreadByLine(payload?.by_line || {});
243
+ }, []);
244
+
245
+ useSmsLive({
246
+ echo,
247
+ lines,
248
+ channelFor,
249
+ endpoints,
250
+ onReceived: pushEvent,
251
+ onUpdated: pushEvent,
252
+ onUnread: handleUnread,
253
+ // Only ticks while there is no Echo instance: refresh the list, and
254
+ // let the conversation pane know to re-read itself.
255
+ onPoll: useCallback(() => {
256
+ fetchThreads(1);
257
+ setRefreshToken((prev) => prev + 1);
258
+ }, [fetchThreads]),
259
+ });
260
+
261
+ /* ------------------------------------------------------------ selection */
262
+
263
+ const selectThread = useCallback((thread) => {
264
+ const id = thread?.id ?? null;
265
+
266
+ setSelectedId(id);
267
+ setShowConversation(Boolean(id));
268
+ syncThreadInUrl(id);
269
+
270
+ // Optimistically clear the badge: the GET that opens the thread is
271
+ // what marks it read on the server, and it is already in flight.
272
+ if (id) {
273
+ setThreads((prev) =>
274
+ prev.map((one) =>
275
+ String(one.id) === String(id) ? { ...one, unread_count: 0 } : one
276
+ )
277
+ );
278
+ }
279
+ }, []);
280
+
281
+ const onThreadChange = useCallback((thread) => {
282
+ if (!mountedRef.current || !thread) return;
283
+
284
+ setThreads((prev) =>
285
+ prev.some((one) => String(one.id) === String(thread.id))
286
+ ? prev.map((one) =>
287
+ String(one.id) === String(thread.id) ? { ...one, ...thread } : one
288
+ )
289
+ : upsertThread(prev, thread)
290
+ );
291
+ }, []);
292
+
293
+ /* --------------------------------------------------------------- render */
294
+
295
+ const transport = describeTransport(status);
296
+ const noLines = linesLoaded && lines.length === 0;
297
+
298
+ const lineUnread = (line) =>
299
+ Number(unreadByLine[line.id] ?? line.unread_count ?? 0) || 0;
300
+
301
+ const totalUnread = lines.reduce((total, line) => total + lineUnread(line), 0);
302
+
303
+ return (
304
+ <div className={styles.inbox}>
305
+ <div className={styles.inboxHead}>
306
+ <div className={styles.inboxHeading}>
307
+ <h1 className={styles.pageTitle}>
308
+ <MessageSquare size={22} strokeWidth={2.2} aria-hidden="true" />
309
+ {pageTitle}
310
+ </h1>
311
+ <p className={styles.pageIntro}>
312
+ Text messages sent and received on the practice's phone lines.
313
+ Replies come back to the line they were sent from.
314
+ </p>
315
+ {lines.length > 0 && (
316
+ <ul className={styles.lineNumbers} aria-label="Numbers clients can text">
317
+ {lines.map((line) => (
318
+ <li key={line.id} className={styles.lineNumber}>
319
+ <span className={styles.lineNumberLabel}>
320
+ {line.label || 'Line'}
321
+ </span>
322
+ <strong className={styles.lineNumberValue}>
323
+ {lineNumberForDisplay(line) || 'No number'}
324
+ </strong>
325
+ </li>
326
+ ))}
327
+ </ul>
328
+ )}
329
+ </div>
330
+
331
+ <div className={styles.headActions}>
332
+ {canManage && (
333
+ <button
334
+ type="button"
335
+ className={styles.ghostButton}
336
+ onClick={() => navigate(settingsUrl)}
337
+ >
338
+ <Settings size={14} strokeWidth={2} aria-hidden="true" />
339
+ Messaging settings
340
+ </button>
341
+ )}
342
+ <button
343
+ type="button"
344
+ className={styles.primaryButton}
345
+ onClick={() => setComposeOpen(true)}
346
+ disabled={noLines}
347
+ >
348
+ <PenSquare size={14} strokeWidth={2} aria-hidden="true" />
349
+ New message
350
+ </button>
351
+ </div>
352
+ </div>
353
+
354
+ {/* One banner, three possible readings of it: not connected, dev
355
+ transport, or Zoom refusing to answer. Only a healthy Zoom
356
+ connection says nothing at all. */}
357
+ {status && transport.tone !== 'ok' && (
358
+ <p
359
+ className={`${styles.banner} ${
360
+ transport.tone === 'bad' ? styles.bannerBad : styles.bannerWarn
361
+ }`}
362
+ role="status"
363
+ >
364
+ <Unplug size={14} strokeWidth={2} aria-hidden="true" />
365
+ <strong>{transport.title}</strong>
366
+ <span className={styles.bannerNote}>{transport.note}</span>
367
+ </p>
368
+ )}
369
+
370
+ {noLines ? (
371
+ <SmsNoLines
372
+ canManage={canManage}
373
+ onOpenSettings={() => navigate(settingsUrl)}
374
+ />
375
+ ) : (
376
+ <div className={styles.panes}>
377
+ <section
378
+ className={`${styles.listPane} ${
379
+ showConversation ? styles.paneHiddenNarrow : ''
380
+ }`}
381
+ aria-label="Conversations"
382
+ >
383
+ <div className={styles.listHead}>
384
+ <label className={styles.field}>
385
+ <span className={styles.visuallyHidden}>Line</span>
386
+ <select
387
+ className={styles.select}
388
+ value={lineId}
389
+ onChange={(event) => setLineId(event.target.value)}
390
+ >
391
+ <option value="all">
392
+ {totalUnread > 0
393
+ ? `All lines · ${totalUnread} unread`
394
+ : 'All lines'}
395
+ </option>
396
+ {lines.map((line) => (
397
+ <option key={line.id} value={line.id}>
398
+ {lineOptionLabel(line, lineUnread(line))}
399
+ </option>
400
+ ))}
401
+ </select>
402
+ </label>
403
+
404
+ <div className={styles.searchBar}>
405
+ <Search size={15} strokeWidth={2} aria-hidden="true" />
406
+ <input
407
+ type="text"
408
+ className={styles.searchInput}
409
+ value={search}
410
+ placeholder="Search messages and numbers…"
411
+ aria-label="Search conversations"
412
+ autoComplete="off"
413
+ onChange={(event) => setSearch(event.target.value)}
414
+ />
415
+ {loading && (
416
+ <Loader2
417
+ size={14}
418
+ className={styles.spin}
419
+ aria-label="Loading"
420
+ />
421
+ )}
422
+ </div>
423
+
424
+ <div className={styles.chips}>
425
+ {FILTERS.map((one) => (
426
+ <button
427
+ key={one.id}
428
+ type="button"
429
+ className={`${styles.chip} ${
430
+ filter === one.id ? styles.chipActive : ''
431
+ }`}
432
+ aria-pressed={filter === one.id}
433
+ onClick={() => setFilter(one.id)}
434
+ >
435
+ {one.label}
436
+ {one.id === 'unread' && totalUnread > 0 && (
437
+ <span className={styles.chipCount}>
438
+ {totalUnread}
439
+ </span>
440
+ )}
441
+ </button>
442
+ ))}
443
+ </div>
444
+ </div>
445
+
446
+ <ul className={styles.threadList}>
447
+ {threads.map((thread) => {
448
+ const unread = Number(thread.unread_count) || 0;
449
+ const name = threadDisplayName(thread);
450
+ const active = String(thread.id) === String(selectedId);
451
+
452
+ return (
453
+ <li key={thread.id}>
454
+ <button
455
+ type="button"
456
+ className={[
457
+ styles.threadRow,
458
+ active ? styles.threadRowActive : '',
459
+ unread > 0 ? styles.threadUnread : '',
460
+ ]
461
+ .filter(Boolean)
462
+ .join(' ')}
463
+ aria-current={active ? 'true' : undefined}
464
+ onClick={() => selectThread(thread)}
465
+ >
466
+ {/* The unread/selected marker, in
467
+ its own fixed track — see
468
+ Sms.module.scss `.threadRow`. */}
469
+ <span
470
+ className={styles.threadAccent}
471
+ aria-hidden="true"
472
+ />
473
+
474
+ <span
475
+ className={styles.avatar}
476
+ aria-hidden="true"
477
+ >
478
+ {initialsFor(name)}
479
+ </span>
480
+
481
+ <span className={styles.threadBody}>
482
+ <span className={styles.threadTop}>
483
+ <span className={styles.threadName}>
484
+ {name}
485
+ </span>
486
+ <span className={styles.threadTime}>
487
+ {relativeTime(
488
+ thread.last_message?.at ??
489
+ thread.updated_at
490
+ )}
491
+ </span>
492
+ </span>
493
+ <span className={styles.threadPreview}>
494
+ <span>{previewOf(thread)}</span>
495
+ {unread > 0 && (
496
+ <span
497
+ className={styles.threadCount}
498
+ >
499
+ {unread}
500
+ </span>
501
+ )}
502
+ </span>
503
+ </span>
504
+ </button>
505
+ </li>
506
+ );
507
+ })}
508
+
509
+ {threads.length === 0 && !loading && (
510
+ <li>
511
+ <div className={styles.tableEmpty}>
512
+ <p>
513
+ {filter === 'archived'
514
+ ? 'Nothing archived.'
515
+ : filter === 'unread'
516
+ ? 'Nothing unread — you are all caught up.'
517
+ : search.trim() !== ''
518
+ ? `No conversations match "${search.trim()}".`
519
+ : 'No conversations yet.'}
520
+ </p>
521
+ </div>
522
+ </li>
523
+ )}
524
+ </ul>
525
+
526
+ {page < lastPage && (
527
+ <div className={styles.listFoot}>
528
+ <button
529
+ type="button"
530
+ className={styles.ghostButton}
531
+ onClick={() => fetchThreads(page + 1, { append: true })}
532
+ disabled={loading}
533
+ >
534
+ {loading ? (
535
+ <Loader2
536
+ size={14}
537
+ className={styles.spin}
538
+ aria-hidden="true"
539
+ />
540
+ ) : (
541
+ <Inbox size={14} strokeWidth={2} aria-hidden="true" />
542
+ )}
543
+ Load more
544
+ </button>
545
+ </div>
546
+ )}
547
+ </section>
548
+
549
+ <SmsThreadPanel
550
+ threadId={selectedId}
551
+ endpoints={endpoints}
552
+ // The page holds the subscription; the pane is fed from
553
+ // it. Two subscriptions to one channel is two websocket
554
+ // authorisations and two copies of every message.
555
+ subscribe={false}
556
+ liveEvent={liveEvent}
557
+ refreshToken={refreshToken}
558
+ status={status}
559
+ // Only so a template's {line} has a label to fill with.
560
+ lines={lines}
561
+ canManage={canManage}
562
+ clientUrl={clientUrl}
563
+ onThreadChange={onThreadChange}
564
+ onMessage={onThreadChange}
565
+ onBack={() => {
566
+ setShowConversation(false);
567
+ syncThreadInUrl(null);
568
+ }}
569
+ showTransportBanner={false}
570
+ className={showConversation ? '' : styles.paneHiddenNarrow}
571
+ />
572
+ </div>
573
+ )}
574
+
575
+ <SmsComposeModal
576
+ open={composeOpen}
577
+ onClose={() => setComposeOpen(false)}
578
+ endpoints={endpoints}
579
+ lines={lines}
580
+ status={status}
581
+ onCreated={(thread) => {
582
+ setComposeOpen(false);
583
+
584
+ if (!thread) return;
585
+
586
+ setThreads((prev) => upsertThread(prev, thread));
587
+ selectThread(thread);
588
+ }}
589
+ />
590
+ </div>
591
+ );
592
+ };
593
+
594
+ const SmsInbox = withVaultNavigation(SmsInboxInner);
595
+
596
+ export default SmsInbox;