@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.
Files changed (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  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/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. package/src/index.js +233 -1
@@ -0,0 +1,1184 @@
1
+ import React, {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import {
10
+ Archive,
11
+ ArchiveRestore,
12
+ ArrowLeft,
13
+ ChevronUp,
14
+ ExternalLink,
15
+ FileText,
16
+ Link2,
17
+ Loader2,
18
+ MessageSquarePlus,
19
+ Send,
20
+ Unplug,
21
+ User,
22
+ X,
23
+ } from 'lucide-react';
24
+
25
+ import CustomFetch from '../Fetch';
26
+ import StandardModal from '../generic/StandardModal';
27
+ import styles from '../styles/Sms.module.scss';
28
+ import { resolveSmsEndpoints } from './smsEndpoints';
29
+ import useSmsLive, { defaultChannelFor } from './useSmsLive';
30
+ import useDebouncedValue from '../vault/useDebouncedValue';
31
+ import {
32
+ MAX_SEGMENTS,
33
+ clockTime,
34
+ composerPlaceholder,
35
+ counterLabel,
36
+ describeTransport,
37
+ fillTemplate,
38
+ groupMessagesByDay,
39
+ initialsFor,
40
+ isHeldTransport,
41
+ markRuns,
42
+ nextPlaceholder,
43
+ normaliseNumberForDisplay,
44
+ prependMessages,
45
+ segmentCount,
46
+ statusLabel,
47
+ threadDisplayName,
48
+ threadSubtitle,
49
+ upsertMessage,
50
+ } from './smsHelpers';
51
+
52
+ const ENTER_SENDS_KEY = 'visns.sms.enterSends';
53
+
54
+ /** Read the Enter-sends preference without letting a locked-down browser throw. */
55
+ const readEnterSends = () => {
56
+ try {
57
+ return window.localStorage.getItem(ENTER_SENDS_KEY) !== '0';
58
+ } catch (error) {
59
+ return true;
60
+ }
61
+ };
62
+
63
+ const writeEnterSends = (value) => {
64
+ try {
65
+ window.localStorage.setItem(ENTER_SENDS_KEY, value ? '1' : '0');
66
+ } catch (error) {
67
+ // A private window, or storage the browser has switched off. The
68
+ // preference simply does not persist; nothing else changes.
69
+ }
70
+ };
71
+
72
+ let temporaryId = 0;
73
+
74
+ /**
75
+ * One conversation: header, timeline, composer.
76
+ *
77
+ * Split out of SmsInbox so the CRM can drop a client's message history onto
78
+ * the client page later without the surrounding two-pane furniture — pass
79
+ * `compact` and it loses the page-level chrome but keeps every behaviour.
80
+ *
81
+ * It can run on its own (it fetches its own thread, status and templates, and
82
+ * subscribes to its own line) or under a parent that already has a live
83
+ * subscription, in which case the parent passes `subscribe={false}` and feeds
84
+ * it events. Two subscriptions to one channel is not an error, but it is two
85
+ * websocket authorisations and two copies of every message to reconcile.
86
+ */
87
+ const SmsThreadPanel = ({
88
+ threadId,
89
+ endpoints,
90
+ echo = null,
91
+ channelFor = defaultChannelFor,
92
+ subscribe = true,
93
+ // A parent that already holds a live subscription feeds events down
94
+ // instead: `{seq, payload}`, where `seq` changes on every event so the
95
+ // effect below fires even when two identical messages arrive.
96
+ liveEvent = null,
97
+ // Changing this re-reads the conversation. The inbox bumps it on each
98
+ // polling tick, so one timer serves both panes.
99
+ refreshToken = 0,
100
+ compact = false,
101
+ showTransportBanner = true,
102
+ status: statusProp = null,
103
+ templates: templatesProp = null,
104
+ // The lines this user can work, purely so a template's `{line}` can be
105
+ // filled: the thread carries a line_id and nothing else, and looking the
106
+ // label up is not worth a request of its own.
107
+ lines = null,
108
+ canManage = false,
109
+ clientUrl = (id) => `/clients/${id}`,
110
+ onThreadChange,
111
+ onMessage,
112
+ onBack = null,
113
+ className = '',
114
+ }) => {
115
+ const routes = useMemo(() => resolveSmsEndpoints(endpoints), [endpoints]);
116
+
117
+ const [thread, setThread] = useState(null);
118
+ const [messages, setMessages] = useState([]);
119
+ const [hasMore, setHasMore] = useState(false);
120
+ const [loading, setLoading] = useState(false);
121
+ const [loadingEarlier, setLoadingEarlier] = useState(false);
122
+ const [sending, setSending] = useState(false);
123
+ const [body, setBody] = useState('');
124
+ const [enterSends, setEnterSends] = useState(readEnterSends);
125
+
126
+ const [ownStatus, setOwnStatus] = useState(null);
127
+ const [ownTemplates, setOwnTemplates] = useState(null);
128
+ const [templatesOpen, setTemplatesOpen] = useState(false);
129
+
130
+ const [linkOpen, setLinkOpen] = useState(false);
131
+ const [simulateOpen, setSimulateOpen] = useState(false);
132
+
133
+ const mountedRef = useRef(true);
134
+ const timelineRef = useRef(null);
135
+ const composerRef = useRef(null);
136
+ const requestRef = useRef(0);
137
+ // Whether the user is reading the newest messages. A conversation someone
138
+ // has scrolled up into must not be yanked to the bottom by an arriving
139
+ // message.
140
+ const pinnedRef = useRef(true);
141
+
142
+ useEffect(
143
+ () => () => {
144
+ mountedRef.current = false;
145
+ },
146
+ []
147
+ );
148
+
149
+ const status = statusProp ?? ownStatus;
150
+ const templates = templatesProp ?? ownTemplates;
151
+ const transport = describeTransport(status);
152
+ const held = isHeldTransport(status);
153
+
154
+ /* -------------------------------------------------------------- loading */
155
+
156
+ useEffect(() => {
157
+ if (statusProp) return;
158
+
159
+ CustomFetch(
160
+ routes.status,
161
+ 'GET',
162
+ null,
163
+ (result) => {
164
+ if (mountedRef.current && result) setOwnStatus(result);
165
+ },
166
+ () => {}
167
+ ).catch(() => {});
168
+ }, [routes, statusProp]);
169
+
170
+ useEffect(() => {
171
+ if (!threadId) {
172
+ setThread(null);
173
+ setMessages([]);
174
+ setHasMore(false);
175
+
176
+ return;
177
+ }
178
+
179
+ const ticket = requestRef.current + 1;
180
+
181
+ requestRef.current = ticket;
182
+ setLoading(true);
183
+ pinnedRef.current = true;
184
+
185
+ // Fetching a thread is what marks it read — the contract says so, and
186
+ // it is why the inbox does not send a separate /read on selection.
187
+ CustomFetch(
188
+ routes.thread(threadId),
189
+ 'GET',
190
+ { limit: 50 },
191
+ (result) => {
192
+ if (!mountedRef.current || requestRef.current !== ticket) return;
193
+
194
+ setThread(result?.thread ?? null);
195
+ setMessages(Array.isArray(result?.messages) ? result.messages : []);
196
+ setHasMore(Boolean(result?.has_more));
197
+ setLoading(false);
198
+
199
+ if (result?.thread) {
200
+ onThreadChange?.({ ...result.thread, unread_count: 0 });
201
+ }
202
+ },
203
+ () => {
204
+ if (!mountedRef.current || requestRef.current !== ticket) return;
205
+
206
+ setLoading(false);
207
+ }
208
+ ).catch(() => {
209
+ if (!mountedRef.current || requestRef.current !== ticket) return;
210
+
211
+ setLoading(false);
212
+ });
213
+ // `onThreadChange` is deliberately not a dependency: a consumer that
214
+ // passes an inline arrow would otherwise re-fetch the thread on every
215
+ // render of the page above it.
216
+ // eslint-disable-next-line react-hooks/exhaustive-deps
217
+ }, [routes, threadId, refreshToken]);
218
+
219
+ const loadEarlier = useCallback(() => {
220
+ if (!threadId || messages.length === 0 || loadingEarlier) return;
221
+
222
+ const oldest = messages[0];
223
+ const node = timelineRef.current;
224
+ const anchor = node ? node.scrollHeight - node.scrollTop : 0;
225
+
226
+ setLoadingEarlier(true);
227
+
228
+ CustomFetch(
229
+ routes.thread(threadId),
230
+ 'GET',
231
+ { before: oldest.id, limit: 50 },
232
+ (result) => {
233
+ if (!mountedRef.current) return;
234
+
235
+ setMessages((prev) => prependMessages(prev, result?.messages));
236
+ setHasMore(Boolean(result?.has_more));
237
+ setLoadingEarlier(false);
238
+
239
+ // Keep the reader where they were rather than at the top of a
240
+ // page of history they have not read.
241
+ requestAnimationFrame(() => {
242
+ const target = timelineRef.current;
243
+
244
+ if (target) target.scrollTop = target.scrollHeight - anchor;
245
+ });
246
+ },
247
+ () => {
248
+ if (mountedRef.current) setLoadingEarlier(false);
249
+ }
250
+ ).catch(() => {
251
+ if (mountedRef.current) setLoadingEarlier(false);
252
+ });
253
+ }, [routes, threadId, messages, loadingEarlier]);
254
+
255
+ /* ----------------------------------------------------------------- live */
256
+
257
+ const handleEvent = useCallback(
258
+ (event) => {
259
+ if (!mountedRef.current) return;
260
+
261
+ const eventThreadId = event?.thread?.id ?? event?.message?.thread_id;
262
+
263
+ if (String(eventThreadId) !== String(threadId)) return;
264
+
265
+ if (event.thread) setThread((prev) => ({ ...(prev || {}), ...event.thread }));
266
+ if (event.message) {
267
+ setMessages((prev) => upsertMessage(prev, event.message));
268
+ }
269
+ },
270
+ [threadId]
271
+ );
272
+
273
+ // A parent's event, applied exactly once per event.
274
+ useEffect(() => {
275
+ if (!liveEvent?.payload) return;
276
+
277
+ handleEvent(liveEvent.payload);
278
+ // Keyed on `seq` alone: the payload object is rebuilt every render by
279
+ // the parent, and `handleEvent` changes with the thread id.
280
+ // eslint-disable-next-line react-hooks/exhaustive-deps
281
+ }, [liveEvent?.seq]);
282
+
283
+ // The one line this conversation is on, as useSmsLive wants it. Named apart
284
+ // from the `lines` prop: that one is the user's whole list, and is only
285
+ // here to give a template's {line} a label.
286
+ const liveLines = useMemo(
287
+ () => (thread?.line_id ? [{ id: thread.line_id }] : []),
288
+ [thread]
289
+ );
290
+
291
+ useSmsLive({
292
+ echo,
293
+ lines: liveLines,
294
+ channelFor,
295
+ endpoints,
296
+ enabled: subscribe,
297
+ onReceived: handleEvent,
298
+ onUpdated: handleEvent,
299
+ // The panel has no badge of its own, so the polling fallback only
300
+ // needs to re-read the conversation.
301
+ onPoll: useCallback(() => {
302
+ if (!threadId) return;
303
+
304
+ CustomFetch(
305
+ routes.thread(threadId),
306
+ 'GET',
307
+ { limit: 50 },
308
+ (result) => {
309
+ if (!mountedRef.current || !result) return;
310
+
311
+ setMessages((prev) =>
312
+ (Array.isArray(result.messages) ? result.messages : []).reduce(
313
+ upsertMessage,
314
+ prev
315
+ )
316
+ );
317
+ if (result.thread) setThread(result.thread);
318
+ },
319
+ () => {}
320
+ ).catch(() => {});
321
+ }, [routes, threadId]),
322
+ });
323
+
324
+ /* --------------------------------------------------------------- scroll */
325
+
326
+ const onTimelineScroll = useCallback((event) => {
327
+ const node = event.currentTarget;
328
+
329
+ pinnedRef.current =
330
+ node.scrollHeight - node.scrollTop - node.clientHeight < 60;
331
+ }, []);
332
+
333
+ useLayoutEffect(() => {
334
+ const node = timelineRef.current;
335
+
336
+ if (!node || !pinnedRef.current) return;
337
+
338
+ node.scrollTop = node.scrollHeight;
339
+ }, [messages, threadId]);
340
+
341
+ /* -------------------------------------------------------------- sending */
342
+
343
+ // Auto-grow, capped by the max-height in the stylesheet.
344
+ useLayoutEffect(() => {
345
+ const node = composerRef.current;
346
+
347
+ if (!node) return;
348
+
349
+ node.style.height = 'auto';
350
+ node.style.height = `${Math.min(node.scrollHeight, 160)}px`;
351
+ }, [body]);
352
+
353
+ const counts = segmentCount(body);
354
+ const overLimit = counts.segments > MAX_SEGMENTS;
355
+ const canSend = body.trim() !== '' && !overLimit && !sending && Boolean(threadId);
356
+
357
+ const send = useCallback(
358
+ (text) => {
359
+ const outgoing = String(text ?? '').trim();
360
+
361
+ if (outgoing === '' || !threadId) return;
362
+
363
+ temporaryId += 1;
364
+
365
+ const temp = {
366
+ id: `pending-${temporaryId}`,
367
+ client_id: `pending-${temporaryId}`,
368
+ direction: 'out',
369
+ body: outgoing,
370
+ status: 'queued',
371
+ pending: true,
372
+ user: null,
373
+ attachments: [],
374
+ created_at: new Date().toISOString(),
375
+ };
376
+
377
+ pinnedRef.current = true;
378
+ setMessages((prev) => [...prev, temp]);
379
+ setSending(true);
380
+
381
+ CustomFetch(
382
+ routes.messages(threadId),
383
+ 'POST',
384
+ { body: outgoing },
385
+ (result) => {
386
+ if (!mountedRef.current) return;
387
+
388
+ setSending(false);
389
+
390
+ const message = result?.message;
391
+
392
+ setMessages((prev) => {
393
+ const without = prev.filter((one) => one.id !== temp.id);
394
+
395
+ return message ? upsertMessage(without, message) : without;
396
+ });
397
+
398
+ if (message) onMessage?.(thread, message);
399
+ },
400
+ (error) => {
401
+ if (!mountedRef.current) return;
402
+
403
+ setSending(false);
404
+ setMessages((prev) =>
405
+ prev.map((one) =>
406
+ one.id === temp.id
407
+ ? { ...one, status: 'failed', error: error || 'Not sent' }
408
+ : one
409
+ )
410
+ );
411
+ }
412
+ ).catch(() => {
413
+ if (!mountedRef.current) return;
414
+
415
+ setSending(false);
416
+ });
417
+ },
418
+ [routes, threadId, thread, onMessage]
419
+ );
420
+
421
+ const submit = useCallback(() => {
422
+ if (!canSend) return;
423
+
424
+ send(body);
425
+ setBody('');
426
+ }, [canSend, send, body]);
427
+
428
+ const retry = useCallback(
429
+ (message) => {
430
+ setMessages((prev) => prev.filter((one) => one.id !== message.id));
431
+ send(message.body);
432
+ },
433
+ [send]
434
+ );
435
+
436
+ const onComposerKeyDown = useCallback(
437
+ (event) => {
438
+ if (event.key !== 'Enter') return;
439
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
440
+ if (!enterSends) return;
441
+
442
+ event.preventDefault();
443
+ submit();
444
+ },
445
+ [enterSends, submit]
446
+ );
447
+
448
+ /* -------------------------------------------------------------- actions */
449
+
450
+ const patchThread = useCallback(
451
+ (payload, done) => {
452
+ if (!threadId) return;
453
+
454
+ CustomFetch(
455
+ routes.updateThread(threadId),
456
+ 'PUT',
457
+ payload,
458
+ (result) => {
459
+ if (!mountedRef.current || !result?.thread) return;
460
+
461
+ setThread(result.thread);
462
+ onThreadChange?.(result.thread);
463
+ done?.(result.thread);
464
+ },
465
+ null
466
+ ).catch(() => {});
467
+ },
468
+ [routes, threadId, onThreadChange]
469
+ );
470
+
471
+ const toggleArchive = useCallback(() => {
472
+ if (!thread) return;
473
+
474
+ const archiving = !thread.archived_at;
475
+ const url = archiving ? routes.archive(thread.id) : routes.unarchive(thread.id);
476
+
477
+ CustomFetch(
478
+ url,
479
+ 'POST',
480
+ {},
481
+ () => {
482
+ if (!mountedRef.current) return;
483
+
484
+ const next = {
485
+ ...thread,
486
+ archived_at: archiving ? new Date().toISOString() : null,
487
+ };
488
+
489
+ setThread(next);
490
+ onThreadChange?.(next);
491
+ },
492
+ null
493
+ ).catch(() => {});
494
+ }, [routes, thread, onThreadChange]);
495
+
496
+ const loadTemplates = useCallback(() => {
497
+ if (templatesProp || ownTemplates) return;
498
+
499
+ CustomFetch(
500
+ routes.templates,
501
+ 'GET',
502
+ null,
503
+ (result) => {
504
+ if (!mountedRef.current) return;
505
+
506
+ setOwnTemplates(Array.isArray(result?.data) ? result.data : []);
507
+ },
508
+ () => {
509
+ if (mountedRef.current) setOwnTemplates([]);
510
+ }
511
+ ).catch(() => {
512
+ if (mountedRef.current) setOwnTemplates([]);
513
+ });
514
+ }, [routes, templatesProp, ownTemplates]);
515
+
516
+ /** The label of the line this conversation is on, when the parent knows it. */
517
+ const lineLabel = useMemo(() => {
518
+ const list = Array.isArray(lines) ? lines : [];
519
+ const line = list.find((one) => String(one?.id) === String(thread?.line_id));
520
+
521
+ return line?.label ?? null;
522
+ }, [lines, thread]);
523
+
524
+ const insertTemplate = useCallback(
525
+ (template) => {
526
+ setTemplatesOpen(false);
527
+
528
+ // "Hi {first_name}, a reminder of your review on {date}" is only
529
+ // worth storing if it arrives filled in. Whatever cannot be filled
530
+ // stays as written, which is the cue to type over it.
531
+ const filled = fillTemplate(template.body, {
532
+ client: thread?.client,
533
+ contact_name: thread?.contact_name,
534
+ number: thread?.display_number || thread?.external_number,
535
+ line: lineLabel,
536
+ });
537
+
538
+ let caret = null;
539
+
540
+ setBody((prev) => {
541
+ const next = prev.trim() === '' ? filled : `${prev}\n${filled}`;
542
+ const gap = next.length - filled.length;
543
+ const blank = nextPlaceholder(next, gap);
544
+
545
+ caret = blank;
546
+
547
+ return next;
548
+ });
549
+
550
+ requestAnimationFrame(() => {
551
+ const box = composerRef.current;
552
+
553
+ if (!box) return;
554
+
555
+ box.focus();
556
+
557
+ // Select the first blank rather than parking the caret at the
558
+ // end: the next keystroke then replaces the token instead of
559
+ // landing beside it.
560
+ if (caret) box.setSelectionRange(caret.start, caret.end);
561
+ });
562
+ },
563
+ [thread, lineLabel]
564
+ );
565
+
566
+ /* --------------------------------------------------------------- render */
567
+
568
+ if (!threadId) {
569
+ return (
570
+ <section className={`${styles.convPane} ${className}`.trim()}>
571
+ <div className={styles.timelineEmpty}>
572
+ <MessageSquarePlus size={22} strokeWidth={1.8} aria-hidden="true" />
573
+ <p className={styles.help}>
574
+ Pick a conversation on the left, or start a new message.
575
+ </p>
576
+ </div>
577
+ </section>
578
+ );
579
+ }
580
+
581
+ const name = threadDisplayName(thread);
582
+ const subtitle = threadSubtitle(thread);
583
+ const groups = groupMessagesByDay(messages);
584
+
585
+ const renderMessage = ({ message, lastOfRun }) => {
586
+ const outgoing = message.direction === 'out';
587
+ const failed = message.status === 'failed';
588
+ const heldMessage = message.status === 'not_connected';
589
+ const attachments = Array.isArray(message.attachments) ? message.attachments : [];
590
+ // A failure is never silent, whatever else the run is doing.
591
+ const showMeta = lastOfRun || failed || heldMessage;
592
+
593
+ return (
594
+ <div
595
+ key={message.id}
596
+ className={[
597
+ styles.bubbleRow,
598
+ outgoing ? styles.bubbleOut : styles.bubbleIn,
599
+ lastOfRun ? styles.bubbleRunEnd : '',
600
+ ]
601
+ .filter(Boolean)
602
+ .join(' ')}
603
+ >
604
+ <div
605
+ className={[
606
+ styles.bubble,
607
+ outgoing ? styles.bubbleBodyOut : styles.bubbleBodyIn,
608
+ heldMessage ? styles.bubbleHeld : '',
609
+ failed ? styles.bubbleFailed : '',
610
+ message.pending ? styles.bubblePending : '',
611
+ ]
612
+ .filter(Boolean)
613
+ .join(' ')}
614
+ >
615
+ {message.body}
616
+
617
+ {attachments.length > 0 && (
618
+ <div className={styles.attachments}>
619
+ {attachments.map((file) => (
620
+ <a
621
+ key={file.id}
622
+ className={styles.attachment}
623
+ href={file.download_url}
624
+ target="_blank"
625
+ rel="noopener noreferrer"
626
+ >
627
+ <FileText size={12} strokeWidth={2} aria-hidden="true" />
628
+ <span>{file.name}</span>
629
+ </a>
630
+ ))}
631
+ </div>
632
+ )}
633
+ </div>
634
+
635
+ {showMeta && (
636
+ <div
637
+ className={[
638
+ styles.bubbleMeta,
639
+ failed ? styles.metaFailed : '',
640
+ heldMessage ? styles.metaHeld : '',
641
+ ]
642
+ .filter(Boolean)
643
+ .join(' ')}
644
+ >
645
+ <span>{clockTime(message.created_at)}</span>
646
+ {outgoing && <span>· {statusLabel(message)}</span>}
647
+ {outgoing && message.user?.name && <span>· {message.user.name}</span>}
648
+ {outgoing && failed && (
649
+ <button
650
+ type="button"
651
+ className={styles.retryButton}
652
+ onClick={() => retry(message)}
653
+ >
654
+ Retry
655
+ </button>
656
+ )}
657
+ </div>
658
+ )}
659
+ </div>
660
+ );
661
+ };
662
+
663
+ return (
664
+ <section
665
+ className={`${styles.convPane} ${className}`.trim()}
666
+ aria-label={`Conversation with ${name}`}
667
+ >
668
+ <header className={styles.convHead}>
669
+ {onBack && (
670
+ <button
671
+ type="button"
672
+ className={`${styles.iconButton} ${styles.backButton}`}
673
+ onClick={onBack}
674
+ aria-label="Back to conversations"
675
+ >
676
+ <ArrowLeft size={16} strokeWidth={2} aria-hidden="true" />
677
+ </button>
678
+ )}
679
+
680
+ <div className={styles.convIdentity}>
681
+ <span className={`${styles.avatar} ${styles.avatarLg}`} aria-hidden="true">
682
+ {initialsFor(name)}
683
+ </span>
684
+ <span>
685
+ <strong className={styles.convName}>{name}</strong>
686
+ {subtitle !== '' && (
687
+ <span className={styles.convNumber}>{subtitle}</span>
688
+ )}
689
+ </span>
690
+ </div>
691
+
692
+ <div className={styles.convActions}>
693
+ {thread?.client?.id ? (
694
+ <a
695
+ className={styles.clientChip}
696
+ href={clientUrl(thread.client.id)}
697
+ target="_blank"
698
+ rel="noopener noreferrer"
699
+ >
700
+ <User size={12} strokeWidth={2.2} aria-hidden="true" />
701
+ {thread.client.name}
702
+ <ExternalLink size={11} strokeWidth={2.2} aria-hidden="true" />
703
+ </a>
704
+ ) : null}
705
+
706
+ <button
707
+ type="button"
708
+ className={styles.ghostButton}
709
+ onClick={() => setLinkOpen(true)}
710
+ >
711
+ <Link2 size={14} strokeWidth={2} aria-hidden="true" />
712
+ {thread?.client?.id ? 'Change link' : 'Link to client'}
713
+ </button>
714
+
715
+ {canManage && status && status.transport !== 'zoom' && (
716
+ <button
717
+ type="button"
718
+ className={styles.ghostButton}
719
+ onClick={() => setSimulateOpen(true)}
720
+ data-tooltip-id="system-tooltip"
721
+ data-tooltip-content="Write a reply as if it came from this number"
722
+ >
723
+ Simulate reply
724
+ </button>
725
+ )}
726
+
727
+ <button
728
+ type="button"
729
+ className={styles.iconButton}
730
+ onClick={toggleArchive}
731
+ data-tooltip-id="system-tooltip"
732
+ data-tooltip-content={thread?.archived_at ? 'Unarchive' : 'Archive'}
733
+ aria-label={thread?.archived_at ? 'Unarchive' : 'Archive'}
734
+ >
735
+ {thread?.archived_at ? (
736
+ <ArchiveRestore size={15} strokeWidth={2} aria-hidden="true" />
737
+ ) : (
738
+ <Archive size={15} strokeWidth={2} aria-hidden="true" />
739
+ )}
740
+ </button>
741
+ </div>
742
+ </header>
743
+
744
+ {showTransportBanner && held && (
745
+ <p className={`${styles.banner} ${styles.bannerWarn}`} role="status">
746
+ <Unplug size={14} strokeWidth={2} aria-hidden="true" />
747
+ <strong>{transport.title}</strong>
748
+ <span className={styles.bannerNote}>{transport.note}</span>
749
+ </p>
750
+ )}
751
+
752
+ <div
753
+ className={styles.timeline}
754
+ ref={timelineRef}
755
+ onScroll={onTimelineScroll}
756
+ aria-live="polite"
757
+ >
758
+ {hasMore && (
759
+ <div className={styles.timelineTop}>
760
+ <button
761
+ type="button"
762
+ className={styles.ghostButton}
763
+ onClick={loadEarlier}
764
+ disabled={loadingEarlier}
765
+ >
766
+ {loadingEarlier ? (
767
+ <Loader2 size={14} className={styles.spin} aria-hidden="true" />
768
+ ) : (
769
+ <ChevronUp size={14} strokeWidth={2} aria-hidden="true" />
770
+ )}
771
+ Load earlier
772
+ </button>
773
+ </div>
774
+ )}
775
+
776
+ {loading && messages.length === 0 && (
777
+ <div className={styles.timelineEmpty}>
778
+ <Loader2 size={18} className={styles.spin} aria-label="Loading" />
779
+ </div>
780
+ )}
781
+
782
+ {!loading && messages.length === 0 && (
783
+ <div className={styles.timelineEmpty}>
784
+ <p className={styles.help}>
785
+ No messages in this conversation yet. Write the first one below.
786
+ </p>
787
+ </div>
788
+ )}
789
+
790
+ {groups.map((group) => (
791
+ <React.Fragment key={group.key}>
792
+ {group.label !== '' && (
793
+ <div className={styles.dayHeading}>{group.label}</div>
794
+ )}
795
+ {markRuns(group.messages).map(renderMessage)}
796
+ </React.Fragment>
797
+ ))}
798
+ </div>
799
+
800
+ <div className={styles.composer}>
801
+ <div className={styles.composerBox}>
802
+ <textarea
803
+ ref={composerRef}
804
+ className={styles.composerInput}
805
+ rows={compact ? 1 : 2}
806
+ value={body}
807
+ placeholder={composerPlaceholder(status)}
808
+ aria-label="Message"
809
+ onChange={(event) => setBody(event.target.value)}
810
+ onKeyDown={onComposerKeyDown}
811
+ />
812
+
813
+ <div className={styles.composerBar}>
814
+ <div className={styles.composerTools}>
815
+ <div className={styles.menuWrap}>
816
+ <button
817
+ type="button"
818
+ className={styles.ghostButton}
819
+ onClick={() => {
820
+ loadTemplates();
821
+ setTemplatesOpen((prev) => !prev);
822
+ }}
823
+ aria-expanded={templatesOpen}
824
+ aria-haspopup="menu"
825
+ >
826
+ <FileText size={14} strokeWidth={2} aria-hidden="true" />
827
+ Templates
828
+ </button>
829
+
830
+ {templatesOpen && (
831
+ <div className={styles.menu} role="menu">
832
+ {(templates || []).filter((one) => one.active !== false)
833
+ .length === 0 ? (
834
+ <p className={styles.menuEmpty}>
835
+ {templates === null
836
+ ? 'Loading…'
837
+ : 'No templates yet.'}
838
+ </p>
839
+ ) : (
840
+ (templates || [])
841
+ .filter((one) => one.active !== false)
842
+ .map((template) => (
843
+ <button
844
+ key={template.id}
845
+ type="button"
846
+ className={styles.menuItem}
847
+ role="menuitem"
848
+ onClick={() => insertTemplate(template)}
849
+ >
850
+ <strong>{template.name}</strong>
851
+ <small>{template.body}</small>
852
+ </button>
853
+ ))
854
+ )}
855
+ </div>
856
+ )}
857
+ </div>
858
+
859
+ <span
860
+ className={`${styles.counter} ${overLimit ? styles.counterOver : ''}`}
861
+ >
862
+ {counterLabel(body)}
863
+ {overLimit ? ` · over the ${MAX_SEGMENTS}-part limit` : ''}
864
+ </span>
865
+ </div>
866
+
867
+ <div className={styles.composerActions}>
868
+ <button
869
+ type="button"
870
+ className={`${styles.toggle} ${enterSends ? styles.toggleOn : ''}`}
871
+ aria-pressed={enterSends}
872
+ onClick={() => {
873
+ const next = !enterSends;
874
+
875
+ setEnterSends(next);
876
+ writeEnterSends(next);
877
+ }}
878
+ data-tooltip-id="system-tooltip"
879
+ data-tooltip-content={
880
+ enterSends
881
+ ? 'Enter sends · Shift+Enter starts a new line'
882
+ : 'Enter starts a new line · use the Send button'
883
+ }
884
+ >
885
+ <span className={styles.toggleTrack} aria-hidden="true">
886
+ <span className={styles.toggleKnob} />
887
+ </span>
888
+ Enter sends
889
+ </button>
890
+
891
+ <button
892
+ type="button"
893
+ className={styles.sendButton}
894
+ onClick={submit}
895
+ disabled={!canSend}
896
+ >
897
+ {sending ? (
898
+ <Loader2 size={15} className={styles.spin} aria-hidden="true" />
899
+ ) : (
900
+ <Send size={15} strokeWidth={2.2} aria-hidden="true" />
901
+ )}
902
+ Send
903
+ </button>
904
+ </div>
905
+ </div>
906
+ </div>
907
+ </div>
908
+
909
+ <LinkClientModal
910
+ open={linkOpen}
911
+ onClose={() => setLinkOpen(false)}
912
+ routes={routes}
913
+ thread={thread}
914
+ onSave={patchThread}
915
+ />
916
+
917
+ <SimulateModal
918
+ open={simulateOpen}
919
+ onClose={() => setSimulateOpen(false)}
920
+ routes={routes}
921
+ threadId={threadId}
922
+ onCreated={(message) => {
923
+ setMessages((prev) => upsertMessage(prev, message));
924
+ pinnedRef.current = true;
925
+ }}
926
+ />
927
+ </section>
928
+ );
929
+ };
930
+
931
+ /* ------------------------------------------------------------------ modals */
932
+
933
+ /**
934
+ * Attach a conversation to a client — or to a name, when there is no client.
935
+ *
936
+ * A number arrives with nothing but digits attached. Until someone says whose
937
+ * it is, every list in the CRM shows it as digits; this is the one action that
938
+ * fixes that everywhere at once.
939
+ */
940
+ const LinkClientModal = ({ open, onClose, routes, thread, onSave }) => {
941
+ const [query, setQuery] = useState('');
942
+ const [results, setResults] = useState([]);
943
+ const [searching, setSearching] = useState(false);
944
+ const [contactName, setContactName] = useState('');
945
+
946
+ const debounced = useDebouncedValue(query, 250);
947
+
948
+ useEffect(() => {
949
+ if (!open) return;
950
+
951
+ setContactName(thread?.contact_name || '');
952
+ setQuery('');
953
+ setResults([]);
954
+ }, [open, thread]);
955
+
956
+ useEffect(() => {
957
+ if (!open) return;
958
+
959
+ const search = debounced.trim();
960
+
961
+ if (search.length < 2) {
962
+ setResults([]);
963
+
964
+ return;
965
+ }
966
+
967
+ setSearching(true);
968
+
969
+ CustomFetch(
970
+ routes.clientSearch,
971
+ 'GET',
972
+ { q: search },
973
+ (result) => {
974
+ setResults(Array.isArray(result?.data) ? result.data : []);
975
+ setSearching(false);
976
+ },
977
+ () => setSearching(false)
978
+ ).catch(() => setSearching(false));
979
+ }, [open, debounced, routes]);
980
+
981
+ if (!open) return null;
982
+
983
+ return (
984
+ <StandardModal isOpen={open} onClose={onClose} size="small">
985
+ <div className={styles.modalForm}>
986
+ <div className={styles.modalHead}>
987
+ <h3 className={styles.modalTitle}>
988
+ <Link2 size={16} strokeWidth={2.2} aria-hidden="true" />
989
+ Link this conversation
990
+ </h3>
991
+ <button
992
+ type="button"
993
+ className={styles.iconButton}
994
+ onClick={onClose}
995
+ aria-label="Close"
996
+ >
997
+ <X size={15} strokeWidth={2} aria-hidden="true" />
998
+ </button>
999
+ </div>
1000
+
1001
+ <div className={styles.field}>
1002
+ <span className={styles.label}>Contact name</span>
1003
+ <input
1004
+ type="text"
1005
+ className={styles.input}
1006
+ value={contactName}
1007
+ placeholder="What to call this number"
1008
+ onChange={(event) => setContactName(event.target.value)}
1009
+ />
1010
+ <p className={styles.help}>
1011
+ Used when the number is not a client — a spouse, an accountant, a
1012
+ supplier.
1013
+ </p>
1014
+ </div>
1015
+
1016
+ <div className={`${styles.field} ${styles.typeahead}`}>
1017
+ <span className={styles.label}>Client</span>
1018
+ <input
1019
+ type="text"
1020
+ className={styles.input}
1021
+ value={query}
1022
+ placeholder="Search clients by name…"
1023
+ onChange={(event) => setQuery(event.target.value)}
1024
+ />
1025
+
1026
+ {results.length > 0 && (
1027
+ <ul className={styles.typeaheadList}>
1028
+ {results.map((client) => (
1029
+ <li key={client.id}>
1030
+ <button
1031
+ type="button"
1032
+ className={styles.typeaheadRow}
1033
+ onClick={() =>
1034
+ onSave(
1035
+ {
1036
+ client_id: client.id,
1037
+ client_name: client.name,
1038
+ contact_name: contactName || null,
1039
+ },
1040
+ onClose
1041
+ )
1042
+ }
1043
+ >
1044
+ <span className={styles.avatar} aria-hidden="true">
1045
+ {initialsFor(client.name)}
1046
+ </span>
1047
+ <span>
1048
+ <strong>{client.name}</strong>
1049
+ {Array.isArray(client.numbers) &&
1050
+ client.numbers.length > 0 && (
1051
+ <small>
1052
+ {' '}
1053
+ {normaliseNumberForDisplay(
1054
+ client.numbers[0].number
1055
+ )}
1056
+ </small>
1057
+ )}
1058
+ </span>
1059
+ </button>
1060
+ </li>
1061
+ ))}
1062
+ </ul>
1063
+ )}
1064
+
1065
+ {searching && <p className={styles.help}>Searching…</p>}
1066
+ </div>
1067
+
1068
+ <div className={styles.modalActions}>
1069
+ {thread?.client?.id && (
1070
+ <button
1071
+ type="button"
1072
+ className={styles.dangerLink}
1073
+ onClick={() =>
1074
+ onSave(
1075
+ { client_id: null, client_name: null },
1076
+ onClose
1077
+ )
1078
+ }
1079
+ >
1080
+ Remove the client link
1081
+ </button>
1082
+ )}
1083
+ <button type="button" className={styles.ghostButton} onClick={onClose}>
1084
+ Cancel
1085
+ </button>
1086
+ <button
1087
+ type="button"
1088
+ className={styles.primaryButton}
1089
+ onClick={() => onSave({ contact_name: contactName || null }, onClose)}
1090
+ >
1091
+ Save the name
1092
+ </button>
1093
+ </div>
1094
+ </div>
1095
+ </StandardModal>
1096
+ );
1097
+ };
1098
+
1099
+ /**
1100
+ * A reply that never came from a phone.
1101
+ *
1102
+ * Only ever offered to an administrator, and only while the transport is not
1103
+ * Zoom — the entire point of it is to exercise the inbound half of the UI
1104
+ * before there is anything on the other end to send one.
1105
+ */
1106
+ const SimulateModal = ({ open, onClose, routes, threadId, onCreated }) => {
1107
+ const [body, setBody] = useState('');
1108
+ const [busy, setBusy] = useState(false);
1109
+
1110
+ useEffect(() => {
1111
+ if (open) setBody('');
1112
+ }, [open]);
1113
+
1114
+ if (!open) return null;
1115
+
1116
+ const submit = () => {
1117
+ if (body.trim() === '') return;
1118
+
1119
+ setBusy(true);
1120
+
1121
+ CustomFetch(
1122
+ routes.simulateInbound(threadId),
1123
+ 'POST',
1124
+ { body: body.trim() },
1125
+ (result) => {
1126
+ setBusy(false);
1127
+
1128
+ if (result?.message) onCreated(result.message);
1129
+
1130
+ onClose();
1131
+ },
1132
+ () => setBusy(false)
1133
+ ).catch(() => setBusy(false));
1134
+ };
1135
+
1136
+ return (
1137
+ <StandardModal isOpen={open} onClose={onClose} size="small">
1138
+ <div className={styles.modalForm}>
1139
+ <div className={styles.modalHead}>
1140
+ <h3 className={styles.modalTitle}>Simulate a reply</h3>
1141
+ <button
1142
+ type="button"
1143
+ className={styles.iconButton}
1144
+ onClick={onClose}
1145
+ aria-label="Close"
1146
+ >
1147
+ <X size={15} strokeWidth={2} aria-hidden="true" />
1148
+ </button>
1149
+ </div>
1150
+
1151
+ <p className={styles.help}>
1152
+ This writes an incoming message onto the conversation as though the
1153
+ number had texted back. It is a testing tool, and it is only here
1154
+ because Zoom is not connected yet.
1155
+ </p>
1156
+
1157
+ <textarea
1158
+ className={styles.textarea}
1159
+ rows={3}
1160
+ value={body}
1161
+ placeholder="Thanks, got it."
1162
+ aria-label="Reply body"
1163
+ onChange={(event) => setBody(event.target.value)}
1164
+ />
1165
+
1166
+ <div className={styles.modalActions}>
1167
+ <button type="button" className={styles.ghostButton} onClick={onClose}>
1168
+ Cancel
1169
+ </button>
1170
+ <button
1171
+ type="button"
1172
+ className={styles.primaryButton}
1173
+ onClick={submit}
1174
+ disabled={busy || body.trim() === ''}
1175
+ >
1176
+ Add the reply
1177
+ </button>
1178
+ </div>
1179
+ </div>
1180
+ </StandardModal>
1181
+ );
1182
+ };
1183
+
1184
+ export default SmsThreadPanel;