@visns-studio/visns-components 6.6.4 → 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.
- package/README.md +1623 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +171 -37
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- 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;
|