@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.
- package/README.md +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- 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/AutoGrowCell.jsx +141 -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/DataGrid.module.scss +10 -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/styles/global-datagrid.css +29 -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,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;
|