@clivly/chat-widget 0.18.0 → 0.19.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/dist/browser-mount.cjs +1191 -71
- package/dist/browser-mount.d.cts +36 -0
- package/dist/browser-mount.d.mts +36 -0
- package/dist/browser-mount.mjs +1191 -71
- package/dist/browser.cjs +1191 -71
- package/dist/browser.d.cts +36 -0
- package/dist/browser.d.mts +36 -0
- package/dist/browser.mjs +1191 -71
- package/dist/index.cjs +1191 -71
- package/dist/index.d.cts +37 -1
- package/dist/index.d.mts +37 -1
- package/dist/index.mjs +1191 -71
- package/dist/widget.global.js +284 -9
- package/package.json +2 -2
package/dist/browser-mount.mjs
CHANGED
|
@@ -1,6 +1,856 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
2
|
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
// src/attachments.ts
|
|
5
|
+
/**
|
|
6
|
+
* Files in the widget: what may be picked, uploading with progress, and
|
|
7
|
+
* fetching a sent file with the attachment token.
|
|
8
|
+
*
|
|
9
|
+
* The server's session says which types and sizes it takes; these checks only
|
|
10
|
+
* spare the visitor an upload that would be refused. The server checks the
|
|
11
|
+
* bytes themselves again on upload. File contents are never logged.
|
|
12
|
+
*/
|
|
13
|
+
const KB = 1024;
|
|
14
|
+
const MB = KB * KB;
|
|
15
|
+
/** "820 KB", "2.4 MB". */
|
|
16
|
+
function formatFileSize(bytes) {
|
|
17
|
+
if (bytes >= MB) return `${(bytes / MB).toFixed(1)} MB`;
|
|
18
|
+
return `${Math.max(1, Math.round(bytes / KB))} KB`;
|
|
19
|
+
}
|
|
20
|
+
function extensionOf(name) {
|
|
21
|
+
const dot = name.lastIndexOf(".");
|
|
22
|
+
return dot === -1 ? "" : name.slice(dot).toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
/** The extensions a config accepts, as `.pdf`-style strings. */
|
|
25
|
+
function acceptedExtensions(config) {
|
|
26
|
+
return new Set(config.accept.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.startsWith(".")));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Which of `picked` may join a composer already holding `heldCount` files,
|
|
30
|
+
* in order, and a reason for each one that may not.
|
|
31
|
+
*/
|
|
32
|
+
function admitFiles(config, heldCount, picked) {
|
|
33
|
+
const extensions = acceptedExtensions(config);
|
|
34
|
+
const accepted = [];
|
|
35
|
+
const refused = [];
|
|
36
|
+
for (const file of picked) {
|
|
37
|
+
if (!extensions.has(extensionOf(file.name))) {
|
|
38
|
+
refused.push(`${file.name} can't be sent here. ${config.hint}.`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (file.size <= 0) {
|
|
42
|
+
refused.push(`${file.name} is empty.`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (file.size > config.maxFileBytes) {
|
|
46
|
+
refused.push(`${file.name} is ${formatFileSize(file.size)}; the limit is ${formatFileSize(config.maxFileBytes)}.`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (heldCount + accepted.length >= config.maxFiles) {
|
|
50
|
+
refused.push(`You can send up to ${config.maxFiles} files in one message.`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
accepted.push(file);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
accepted,
|
|
57
|
+
refused
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** How a picked file should be previewed before it is sent. */
|
|
61
|
+
function localKindOf(file) {
|
|
62
|
+
if (file.type.startsWith("image/")) return "image";
|
|
63
|
+
if (file.type.startsWith("audio/")) return "audio";
|
|
64
|
+
return "document";
|
|
65
|
+
}
|
|
66
|
+
/** An upload the server refused or that never arrived, and whether to offer a retry. */
|
|
67
|
+
var UploadError = class extends Error {
|
|
68
|
+
retryable;
|
|
69
|
+
constructor(message, retryable) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "UploadError";
|
|
72
|
+
this.retryable = retryable;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const HTTP_CREATED = 201;
|
|
76
|
+
const HTTP_TOO_MANY = 429;
|
|
77
|
+
const HTTP_SERVER_ERROR = 500;
|
|
78
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
79
|
+
function refusalMessage(xhr) {
|
|
80
|
+
try {
|
|
81
|
+
const body = JSON.parse(xhr.responseText);
|
|
82
|
+
if (typeof body.message === "string" && body.message) return body.message;
|
|
83
|
+
} catch {}
|
|
84
|
+
if (xhr.status === HTTP_UNAUTHORIZED) return "This chat session has expired. Reload the page to attach files.";
|
|
85
|
+
return "The file couldn't be uploaded.";
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Upload one file as the raw request body (no multipart: the server reads the
|
|
89
|
+
* bytes and takes the name from the query). XMLHttpRequest rather than fetch
|
|
90
|
+
* because only it reports upload progress.
|
|
91
|
+
*/
|
|
92
|
+
function uploadAttachment(config, file, onProgress) {
|
|
93
|
+
const xhr = new XMLHttpRequest();
|
|
94
|
+
return {
|
|
95
|
+
abort: () => xhr.abort(),
|
|
96
|
+
done: new Promise((resolve, reject) => {
|
|
97
|
+
const url = `${config.url}?filename=${encodeURIComponent(file.name)}`;
|
|
98
|
+
xhr.open("POST", url);
|
|
99
|
+
xhr.setRequestHeader("Authorization", `Bearer ${config.token}`);
|
|
100
|
+
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
|
101
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
102
|
+
if (event.lengthComputable && event.total > 0) onProgress(event.loaded / event.total);
|
|
103
|
+
});
|
|
104
|
+
xhr.addEventListener("load", () => {
|
|
105
|
+
if (xhr.status === HTTP_CREATED) {
|
|
106
|
+
try {
|
|
107
|
+
resolve(JSON.parse(xhr.responseText));
|
|
108
|
+
} catch {
|
|
109
|
+
reject(new UploadError("The upload's answer was unreadable.", true));
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const retryable = xhr.status === HTTP_TOO_MANY || xhr.status >= HTTP_SERVER_ERROR;
|
|
114
|
+
reject(new UploadError(refusalMessage(xhr), retryable));
|
|
115
|
+
});
|
|
116
|
+
xhr.addEventListener("error", () => {
|
|
117
|
+
reject(new UploadError("The upload was interrupted. Check your connection and retry.", true));
|
|
118
|
+
});
|
|
119
|
+
xhr.addEventListener("abort", () => {
|
|
120
|
+
reject(new UploadError("Upload cancelled.", false));
|
|
121
|
+
});
|
|
122
|
+
xhr.send(file);
|
|
123
|
+
})
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Take an unsent upload back off the server. Best effort: the hourly purge is the backstop. */
|
|
127
|
+
function discardUpload(config, id) {
|
|
128
|
+
fetch(`${config.url}/${encodeURIComponent(id)}`, {
|
|
129
|
+
method: "DELETE",
|
|
130
|
+
headers: { Authorization: `Bearer ${config.token}` }
|
|
131
|
+
}).catch(() => void 0);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Object URLs for files already fetched (or picked locally and then sent), by
|
|
135
|
+
* attachment id, so a re-render or a reconnect's snapshot never downloads
|
|
136
|
+
* the same file twice. Lives as long as the page.
|
|
137
|
+
*/
|
|
138
|
+
const objectUrls = /* @__PURE__ */ new Map();
|
|
139
|
+
/** Seed the cache with a local preview, so the sender's own file is not re-fetched. */
|
|
140
|
+
function rememberObjectUrl(id, url) {
|
|
141
|
+
objectUrls.set(id, Promise.resolve(url));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* A sent file as an object URL. The bytes come through the access-checked
|
|
145
|
+
* route with the bearer token: there is no public link to the file.
|
|
146
|
+
*/
|
|
147
|
+
function attachmentObjectUrl(config, id) {
|
|
148
|
+
const cached = objectUrls.get(id);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
const pending = fetch(`${config.url}/${encodeURIComponent(id)}`, { headers: { Authorization: `Bearer ${config.token}` } }).then(async (response) => {
|
|
151
|
+
if (!response.ok) throw new Error(response.status === HTTP_UNAUTHORIZED ? "This chat session has expired. Reload the page to open files." : "This file isn't available.");
|
|
152
|
+
return URL.createObjectURL(await response.blob());
|
|
153
|
+
});
|
|
154
|
+
objectUrls.set(id, pending);
|
|
155
|
+
pending.catch(() => objectUrls.delete(id));
|
|
156
|
+
return pending;
|
|
157
|
+
}
|
|
158
|
+
/** Save a sent file to the visitor's device under its own name. */
|
|
159
|
+
async function downloadAttachment(config, attachment) {
|
|
160
|
+
const url = await attachmentObjectUrl(config, attachment.id);
|
|
161
|
+
const anchor = document.createElement("a");
|
|
162
|
+
anchor.href = url;
|
|
163
|
+
anchor.download = attachment.filename;
|
|
164
|
+
anchor.rel = "noopener";
|
|
165
|
+
anchor.click();
|
|
166
|
+
}
|
|
167
|
+
/** A short type label for a file card: "PDF", "DOCX", "Audio". */
|
|
168
|
+
function fileTypeLabel(attachment) {
|
|
169
|
+
const extension = extensionOf(attachment.filename).slice(1).toUpperCase();
|
|
170
|
+
if (extension) return extension;
|
|
171
|
+
return attachment.kind === "audio" ? "Audio" : "File";
|
|
172
|
+
}
|
|
173
|
+
/** "1:05" from seconds; empty for an unknown or endless duration. */
|
|
174
|
+
function formatDuration(seconds) {
|
|
175
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return "";
|
|
176
|
+
const whole = Math.round(seconds);
|
|
177
|
+
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
|
178
|
+
}
|
|
179
|
+
// src/attachment-views.tsx
|
|
180
|
+
/**
|
|
181
|
+
* Everything the visitor sees of files: the paperclip and its tray in the
|
|
182
|
+
* composer, and the images, audio players and file cards in the thread.
|
|
183
|
+
* Styles use the widget's own tokens, so a host's brand colour and dark mode
|
|
184
|
+
* carry over.
|
|
185
|
+
*/
|
|
186
|
+
/** Size hints for a thumbnail; CSS lets the image keep its own proportions. */
|
|
187
|
+
const THUMB_WIDTH_PX = 288;
|
|
188
|
+
const THUMB_HEIGHT_PX = 192;
|
|
189
|
+
const ATTACHMENT_STYLES = `
|
|
190
|
+
.clivly-chat-widget__attach {
|
|
191
|
+
align-items: center;
|
|
192
|
+
background: transparent;
|
|
193
|
+
border: 1px solid var(--clivly-chat-border);
|
|
194
|
+
border-radius: 999px;
|
|
195
|
+
color: inherit;
|
|
196
|
+
cursor: pointer;
|
|
197
|
+
display: inline-flex;
|
|
198
|
+
flex: 0 0 auto;
|
|
199
|
+
height: 2.75rem;
|
|
200
|
+
justify-content: center;
|
|
201
|
+
padding: 0;
|
|
202
|
+
width: 2.75rem;
|
|
203
|
+
}
|
|
204
|
+
.clivly-chat-widget__attach svg {
|
|
205
|
+
height: 1.2rem;
|
|
206
|
+
width: 1.2rem;
|
|
207
|
+
}
|
|
208
|
+
.clivly-chat-widget__attach[disabled] {
|
|
209
|
+
cursor: not-allowed;
|
|
210
|
+
opacity: 0.55;
|
|
211
|
+
}
|
|
212
|
+
.clivly-chat-widget__attach:focus-visible,
|
|
213
|
+
.clivly-chat-widget__file-action:focus-visible,
|
|
214
|
+
.clivly-chat-widget__thumb:focus-visible,
|
|
215
|
+
.clivly-chat-widget__viewer button:focus-visible {
|
|
216
|
+
outline: 2px solid var(--clivly-chat-accent);
|
|
217
|
+
outline-offset: 2px;
|
|
218
|
+
}
|
|
219
|
+
.clivly-chat-widget__hint {
|
|
220
|
+
color: var(--clivly-chat-muted);
|
|
221
|
+
font-size: 0.6875rem;
|
|
222
|
+
margin: 0;
|
|
223
|
+
padding: 0 0.25rem;
|
|
224
|
+
}
|
|
225
|
+
.clivly-chat-widget__tray {
|
|
226
|
+
display: flex;
|
|
227
|
+
gap: 0.375rem;
|
|
228
|
+
list-style: none;
|
|
229
|
+
margin: 0;
|
|
230
|
+
overflow-x: auto;
|
|
231
|
+
padding: 0 0 0.125rem;
|
|
232
|
+
}
|
|
233
|
+
.clivly-chat-widget__chip {
|
|
234
|
+
align-items: center;
|
|
235
|
+
background: var(--clivly-chat-input);
|
|
236
|
+
border: 1px solid var(--clivly-chat-border);
|
|
237
|
+
border-radius: 0.75rem;
|
|
238
|
+
display: grid;
|
|
239
|
+
flex: 0 0 auto;
|
|
240
|
+
gap: 0.125rem 0.5rem;
|
|
241
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
242
|
+
max-width: 14rem;
|
|
243
|
+
padding: 0.375rem 0.375rem 0.375rem 0.5rem;
|
|
244
|
+
position: relative;
|
|
245
|
+
}
|
|
246
|
+
.clivly-chat-widget__chip[data-status="failed"] {
|
|
247
|
+
border-color: var(--clivly-chat-error-border);
|
|
248
|
+
}
|
|
249
|
+
.clivly-chat-widget__chip-thumb,
|
|
250
|
+
.clivly-chat-widget__chip-icon {
|
|
251
|
+
border-radius: 0.375rem;
|
|
252
|
+
grid-row: span 2;
|
|
253
|
+
height: 2.25rem;
|
|
254
|
+
object-fit: cover;
|
|
255
|
+
width: 2.25rem;
|
|
256
|
+
}
|
|
257
|
+
.clivly-chat-widget__chip-icon {
|
|
258
|
+
align-items: center;
|
|
259
|
+
background: var(--clivly-chat-operator);
|
|
260
|
+
color: var(--clivly-chat-muted);
|
|
261
|
+
display: inline-flex;
|
|
262
|
+
font-size: 0.5625rem;
|
|
263
|
+
font-weight: 700;
|
|
264
|
+
justify-content: center;
|
|
265
|
+
letter-spacing: 0.02em;
|
|
266
|
+
}
|
|
267
|
+
.clivly-chat-widget__chip-name {
|
|
268
|
+
font-size: 0.75rem;
|
|
269
|
+
font-weight: 600;
|
|
270
|
+
overflow: hidden;
|
|
271
|
+
text-overflow: ellipsis;
|
|
272
|
+
white-space: nowrap;
|
|
273
|
+
}
|
|
274
|
+
.clivly-chat-widget__chip-meta {
|
|
275
|
+
color: var(--clivly-chat-muted);
|
|
276
|
+
font-size: 0.6875rem;
|
|
277
|
+
white-space: nowrap;
|
|
278
|
+
}
|
|
279
|
+
.clivly-chat-widget__chip[data-status="failed"] .clivly-chat-widget__chip-meta {
|
|
280
|
+
color: var(--clivly-chat-error-text);
|
|
281
|
+
white-space: normal;
|
|
282
|
+
}
|
|
283
|
+
.clivly-chat-widget__chip-actions {
|
|
284
|
+
display: flex;
|
|
285
|
+
gap: 0.125rem;
|
|
286
|
+
grid-column: 3;
|
|
287
|
+
grid-row: 1 / span 2;
|
|
288
|
+
}
|
|
289
|
+
/* Scoped under the root: the widget's base \`button { font: inherit }\`
|
|
290
|
+
rule is more specific than a bare class. */
|
|
291
|
+
.clivly-chat-widget .clivly-chat-widget__file-action {
|
|
292
|
+
align-items: center;
|
|
293
|
+
background: transparent;
|
|
294
|
+
border: 1px solid var(--clivly-chat-border);
|
|
295
|
+
border-radius: 999px;
|
|
296
|
+
color: inherit;
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
display: inline-flex;
|
|
299
|
+
font: inherit;
|
|
300
|
+
font-size: 0.75rem;
|
|
301
|
+
font-weight: 600;
|
|
302
|
+
height: 1.75rem;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
min-width: 1.75rem;
|
|
305
|
+
padding: 0 0.375rem;
|
|
306
|
+
}
|
|
307
|
+
.clivly-chat-widget__chip-actions .clivly-chat-widget__file-action {
|
|
308
|
+
border-color: transparent;
|
|
309
|
+
}
|
|
310
|
+
.clivly-chat-widget__file-action:hover {
|
|
311
|
+
background: var(--clivly-chat-operator);
|
|
312
|
+
}
|
|
313
|
+
.clivly-chat-widget__file-action svg {
|
|
314
|
+
height: 0.9rem;
|
|
315
|
+
width: 0.9rem;
|
|
316
|
+
}
|
|
317
|
+
.clivly-chat-widget__progress {
|
|
318
|
+
background: var(--clivly-chat-border);
|
|
319
|
+
border-radius: 999px;
|
|
320
|
+
bottom: 0.2rem;
|
|
321
|
+
height: 0.1875rem;
|
|
322
|
+
left: 0.5rem;
|
|
323
|
+
overflow: hidden;
|
|
324
|
+
position: absolute;
|
|
325
|
+
right: 0.5rem;
|
|
326
|
+
}
|
|
327
|
+
.clivly-chat-widget__progress span {
|
|
328
|
+
background: var(--clivly-chat-accent);
|
|
329
|
+
display: block;
|
|
330
|
+
height: 100%;
|
|
331
|
+
transition: width 120ms linear;
|
|
332
|
+
}
|
|
333
|
+
.clivly-chat-widget__composer[data-dragging] {
|
|
334
|
+
outline: 2px dashed var(--clivly-chat-accent);
|
|
335
|
+
outline-offset: -0.375rem;
|
|
336
|
+
}
|
|
337
|
+
.clivly-chat-widget__attachments {
|
|
338
|
+
display: grid;
|
|
339
|
+
gap: 0.375rem;
|
|
340
|
+
margin-top: 0.375rem;
|
|
341
|
+
white-space: normal;
|
|
342
|
+
}
|
|
343
|
+
.clivly-chat-widget__thumb {
|
|
344
|
+
background: transparent;
|
|
345
|
+
border: 0;
|
|
346
|
+
border-radius: 0.625rem;
|
|
347
|
+
cursor: zoom-in;
|
|
348
|
+
display: block;
|
|
349
|
+
max-width: 100%;
|
|
350
|
+
overflow: hidden;
|
|
351
|
+
padding: 0;
|
|
352
|
+
}
|
|
353
|
+
.clivly-chat-widget__thumb img {
|
|
354
|
+
display: block;
|
|
355
|
+
height: auto;
|
|
356
|
+
width: auto;
|
|
357
|
+
max-height: 12rem;
|
|
358
|
+
max-width: 100%;
|
|
359
|
+
object-fit: cover;
|
|
360
|
+
}
|
|
361
|
+
.clivly-chat-widget__thumb-placeholder {
|
|
362
|
+
align-items: center;
|
|
363
|
+
background: var(--clivly-chat-border);
|
|
364
|
+
border-radius: 0.625rem;
|
|
365
|
+
color: var(--clivly-chat-muted);
|
|
366
|
+
display: flex;
|
|
367
|
+
font-size: 0.75rem;
|
|
368
|
+
height: 6rem;
|
|
369
|
+
justify-content: center;
|
|
370
|
+
width: 9rem;
|
|
371
|
+
}
|
|
372
|
+
.clivly-chat-widget__file {
|
|
373
|
+
align-items: center;
|
|
374
|
+
background: var(--clivly-chat-panel);
|
|
375
|
+
border: 1px solid var(--clivly-chat-border);
|
|
376
|
+
border-radius: 0.75rem;
|
|
377
|
+
display: grid;
|
|
378
|
+
gap: 0 0.625rem;
|
|
379
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
380
|
+
padding: 0.5rem 0.5rem 0.5rem 0.625rem;
|
|
381
|
+
}
|
|
382
|
+
.clivly-chat-widget__file audio {
|
|
383
|
+
grid-column: 1 / -1;
|
|
384
|
+
margin-top: 0.5rem;
|
|
385
|
+
width: 100%;
|
|
386
|
+
}
|
|
387
|
+
.clivly-chat-widget__file-error {
|
|
388
|
+
color: var(--clivly-chat-error-text);
|
|
389
|
+
font-size: 0.6875rem;
|
|
390
|
+
grid-column: 1 / -1;
|
|
391
|
+
margin: 0.25rem 0 0;
|
|
392
|
+
}
|
|
393
|
+
.clivly-chat-widget__viewer {
|
|
394
|
+
align-items: center;
|
|
395
|
+
background: rgb(0 0 0 / 0.82);
|
|
396
|
+
display: flex;
|
|
397
|
+
flex-direction: column;
|
|
398
|
+
gap: 0.75rem;
|
|
399
|
+
inset: 0;
|
|
400
|
+
justify-content: center;
|
|
401
|
+
padding: 1rem;
|
|
402
|
+
position: absolute;
|
|
403
|
+
z-index: 2;
|
|
404
|
+
}
|
|
405
|
+
.clivly-chat-widget__viewer img {
|
|
406
|
+
border-radius: 0.5rem;
|
|
407
|
+
height: auto;
|
|
408
|
+
width: auto;
|
|
409
|
+
max-height: calc(100% - 4rem);
|
|
410
|
+
max-width: 100%;
|
|
411
|
+
object-fit: contain;
|
|
412
|
+
}
|
|
413
|
+
.clivly-chat-widget__viewer-bar {
|
|
414
|
+
align-items: center;
|
|
415
|
+
color: #fff;
|
|
416
|
+
display: flex;
|
|
417
|
+
gap: 0.5rem;
|
|
418
|
+
max-width: 100%;
|
|
419
|
+
}
|
|
420
|
+
.clivly-chat-widget__viewer-bar span {
|
|
421
|
+
font-size: 0.8125rem;
|
|
422
|
+
overflow: hidden;
|
|
423
|
+
text-overflow: ellipsis;
|
|
424
|
+
white-space: nowrap;
|
|
425
|
+
}
|
|
426
|
+
.clivly-chat-widget__viewer button {
|
|
427
|
+
background: rgb(255 255 255 / 0.14);
|
|
428
|
+
border: 0;
|
|
429
|
+
border-radius: 999px;
|
|
430
|
+
color: #fff;
|
|
431
|
+
cursor: pointer;
|
|
432
|
+
font: inherit;
|
|
433
|
+
font-size: 0.8125rem;
|
|
434
|
+
padding: 0.4rem 0.8rem;
|
|
435
|
+
}
|
|
436
|
+
@media (prefers-reduced-motion: reduce) {
|
|
437
|
+
.clivly-chat-widget__progress span {
|
|
438
|
+
transition: none;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
@media (forced-colors: active) {
|
|
442
|
+
.clivly-chat-widget__progress span {
|
|
443
|
+
background: Highlight;
|
|
444
|
+
}
|
|
445
|
+
.clivly-chat-widget__viewer {
|
|
446
|
+
background: Canvas;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
`;
|
|
450
|
+
function PaperclipIcon() {
|
|
451
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
452
|
+
"aria-hidden": "true",
|
|
453
|
+
fill: "none",
|
|
454
|
+
focusable: "false",
|
|
455
|
+
stroke: "currentColor",
|
|
456
|
+
strokeLinecap: "round",
|
|
457
|
+
strokeLinejoin: "round",
|
|
458
|
+
strokeWidth: 2,
|
|
459
|
+
viewBox: "0 0 24 24",
|
|
460
|
+
children: /* @__PURE__ */ jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" })
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function CloseIcon() {
|
|
464
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
465
|
+
"aria-hidden": "true",
|
|
466
|
+
fill: "none",
|
|
467
|
+
focusable: "false",
|
|
468
|
+
stroke: "currentColor",
|
|
469
|
+
strokeLinecap: "round",
|
|
470
|
+
strokeWidth: 2,
|
|
471
|
+
viewBox: "0 0 24 24",
|
|
472
|
+
children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" })
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The paperclip and its hidden file input. The input's `accept` comes from the
|
|
477
|
+
* server, so the picker offers only what will be taken.
|
|
478
|
+
*/
|
|
479
|
+
function AttachButton({ config, disabled, inputRef, onFiles }) {
|
|
480
|
+
const hintId = useId();
|
|
481
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
482
|
+
/* @__PURE__ */ jsx("button", {
|
|
483
|
+
"aria-describedby": hintId,
|
|
484
|
+
"aria-label": "Attach files",
|
|
485
|
+
className: "clivly-chat-widget__attach",
|
|
486
|
+
disabled,
|
|
487
|
+
onClick: () => inputRef.current?.click(),
|
|
488
|
+
type: "button",
|
|
489
|
+
children: /* @__PURE__ */ jsx(PaperclipIcon, {})
|
|
490
|
+
}),
|
|
491
|
+
/* @__PURE__ */ jsx("span", {
|
|
492
|
+
className: "clivly-chat-widget__visually-hidden",
|
|
493
|
+
id: hintId,
|
|
494
|
+
children: config.hint
|
|
495
|
+
}),
|
|
496
|
+
/* @__PURE__ */ jsx("input", {
|
|
497
|
+
accept: config.accept,
|
|
498
|
+
"aria-hidden": "true",
|
|
499
|
+
className: "clivly-chat-widget__visually-hidden",
|
|
500
|
+
multiple: true,
|
|
501
|
+
onChange: (event) => {
|
|
502
|
+
const picked = Array.from(event.target.files ?? []);
|
|
503
|
+
event.target.value = "";
|
|
504
|
+
onFiles(picked);
|
|
505
|
+
},
|
|
506
|
+
ref: inputRef,
|
|
507
|
+
tabIndex: -1,
|
|
508
|
+
type: "file"
|
|
509
|
+
})
|
|
510
|
+
] });
|
|
511
|
+
}
|
|
512
|
+
function chipMeta(item) {
|
|
513
|
+
if (item.status === "failed") return item.error ?? "Upload failed.";
|
|
514
|
+
if (item.status === "uploading") return `Uploading… ${Math.round(item.progress * 100)}%`;
|
|
515
|
+
return formatFileSize(item.file.size);
|
|
516
|
+
}
|
|
517
|
+
function ComposerChip({ item, onRemove, onRetry }) {
|
|
518
|
+
const name = item.file.name;
|
|
519
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
520
|
+
className: "clivly-chat-widget__chip",
|
|
521
|
+
"data-status": item.status,
|
|
522
|
+
children: [
|
|
523
|
+
item.previewUrl ? /* @__PURE__ */ jsx("img", {
|
|
524
|
+
alt: "",
|
|
525
|
+
className: "clivly-chat-widget__chip-thumb",
|
|
526
|
+
height: 36,
|
|
527
|
+
src: item.previewUrl,
|
|
528
|
+
width: 36
|
|
529
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
530
|
+
"aria-hidden": "true",
|
|
531
|
+
className: "clivly-chat-widget__chip-icon",
|
|
532
|
+
children: fileTypeLabel({
|
|
533
|
+
filename: name,
|
|
534
|
+
kind: item.kind
|
|
535
|
+
}).slice(0, 4)
|
|
536
|
+
}),
|
|
537
|
+
/* @__PURE__ */ jsx("span", {
|
|
538
|
+
className: "clivly-chat-widget__chip-name",
|
|
539
|
+
title: name,
|
|
540
|
+
children: name
|
|
541
|
+
}),
|
|
542
|
+
/* @__PURE__ */ jsx("span", {
|
|
543
|
+
className: "clivly-chat-widget__chip-meta",
|
|
544
|
+
children: chipMeta(item)
|
|
545
|
+
}),
|
|
546
|
+
/* @__PURE__ */ jsxs("span", {
|
|
547
|
+
className: "clivly-chat-widget__chip-actions",
|
|
548
|
+
children: [item.status === "failed" && item.retryable ? /* @__PURE__ */ jsx("button", {
|
|
549
|
+
"aria-label": `Retry uploading ${name}`,
|
|
550
|
+
className: "clivly-chat-widget__file-action",
|
|
551
|
+
onClick: onRetry,
|
|
552
|
+
type: "button",
|
|
553
|
+
children: "Retry"
|
|
554
|
+
}) : null, /* @__PURE__ */ jsx("button", {
|
|
555
|
+
"aria-label": item.status === "uploading" ? `Cancel uploading ${name}` : `Remove ${name}`,
|
|
556
|
+
className: "clivly-chat-widget__file-action",
|
|
557
|
+
onClick: onRemove,
|
|
558
|
+
type: "button",
|
|
559
|
+
children: /* @__PURE__ */ jsx(CloseIcon, {})
|
|
560
|
+
})]
|
|
561
|
+
}),
|
|
562
|
+
item.status === "uploading" ? /* @__PURE__ */ jsx("span", {
|
|
563
|
+
"aria-label": `Uploading ${name}`,
|
|
564
|
+
"aria-valuemax": 100,
|
|
565
|
+
"aria-valuemin": 0,
|
|
566
|
+
"aria-valuenow": Math.round(item.progress * 100),
|
|
567
|
+
className: "clivly-chat-widget__progress",
|
|
568
|
+
role: "progressbar",
|
|
569
|
+
children: /* @__PURE__ */ jsx("span", { style: { width: `${Math.round(item.progress * 100)}%` } })
|
|
570
|
+
}) : null
|
|
571
|
+
]
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
/** One sentence for screen readers about the tray as a whole. */
|
|
575
|
+
function trayAnnouncement(files, notice) {
|
|
576
|
+
if (notice) return notice;
|
|
577
|
+
const uploading = files.filter((item) => item.status === "uploading").length;
|
|
578
|
+
const failed = files.filter((item) => item.status === "failed").length;
|
|
579
|
+
const ready = files.filter((item) => item.status === "ready").length;
|
|
580
|
+
if (uploading > 0) return `Uploading ${uploading} ${uploading === 1 ? "file" : "files"}…`;
|
|
581
|
+
if (failed > 0) return `${failed} ${failed === 1 ? "file" : "files"} couldn't be uploaded.`;
|
|
582
|
+
if (ready > 0) return `${ready} ${ready === 1 ? "file" : "files"} ready to send.`;
|
|
583
|
+
return "";
|
|
584
|
+
}
|
|
585
|
+
function ComposerTray({ files, notice, onRemove, onRetry }) {
|
|
586
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
587
|
+
files.length > 0 ? /* @__PURE__ */ jsx("ul", {
|
|
588
|
+
"aria-label": "Files to send",
|
|
589
|
+
className: "clivly-chat-widget__tray",
|
|
590
|
+
children: files.map((item) => /* @__PURE__ */ jsx(ComposerChip, {
|
|
591
|
+
item,
|
|
592
|
+
onRemove: () => onRemove(item.key),
|
|
593
|
+
onRetry: () => onRetry(item.key)
|
|
594
|
+
}, item.key))
|
|
595
|
+
}) : null,
|
|
596
|
+
notice ? /* @__PURE__ */ jsx("p", {
|
|
597
|
+
className: "clivly-chat-widget__file-error",
|
|
598
|
+
role: "alert",
|
|
599
|
+
children: notice
|
|
600
|
+
}) : null,
|
|
601
|
+
/* @__PURE__ */ jsx("span", {
|
|
602
|
+
"aria-live": "polite",
|
|
603
|
+
className: "clivly-chat-widget__visually-hidden",
|
|
604
|
+
children: trayAnnouncement(files, null)
|
|
605
|
+
})
|
|
606
|
+
] });
|
|
607
|
+
}
|
|
608
|
+
/** Resolve a sent file to an object URL once, with loading and error states. */
|
|
609
|
+
function useObjectUrl(config, id, enabled) {
|
|
610
|
+
const [state, setState] = useState({
|
|
611
|
+
error: null,
|
|
612
|
+
url: null
|
|
613
|
+
});
|
|
614
|
+
useEffect(() => {
|
|
615
|
+
if (!(enabled && config)) return;
|
|
616
|
+
let live = true;
|
|
617
|
+
attachmentObjectUrl(config, id).then((url) => {
|
|
618
|
+
if (live) setState({
|
|
619
|
+
url,
|
|
620
|
+
error: null
|
|
621
|
+
});
|
|
622
|
+
}).catch((error) => {
|
|
623
|
+
if (live) setState({
|
|
624
|
+
url: null,
|
|
625
|
+
error: error instanceof Error ? error.message : "Unavailable."
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
return () => {
|
|
629
|
+
live = false;
|
|
630
|
+
};
|
|
631
|
+
}, [
|
|
632
|
+
config,
|
|
633
|
+
id,
|
|
634
|
+
enabled
|
|
635
|
+
]);
|
|
636
|
+
return state;
|
|
637
|
+
}
|
|
638
|
+
function ImageAttachment({ attachment, config, onOpen }) {
|
|
639
|
+
const { url, error } = useObjectUrl(config, attachment.id, true);
|
|
640
|
+
if (!url) return /* @__PURE__ */ jsx("span", {
|
|
641
|
+
"aria-label": attachment.filename,
|
|
642
|
+
className: "clivly-chat-widget__thumb-placeholder",
|
|
643
|
+
role: "img",
|
|
644
|
+
children: error ?? (config ? "Loading image…" : "Image")
|
|
645
|
+
});
|
|
646
|
+
return /* @__PURE__ */ jsx("button", {
|
|
647
|
+
"aria-label": `Open image ${attachment.filename}`,
|
|
648
|
+
className: "clivly-chat-widget__thumb",
|
|
649
|
+
onClick: () => onOpen(url),
|
|
650
|
+
type: "button",
|
|
651
|
+
children: /* @__PURE__ */ jsx("img", {
|
|
652
|
+
alt: attachment.filename,
|
|
653
|
+
height: THUMB_HEIGHT_PX,
|
|
654
|
+
src: url,
|
|
655
|
+
width: THUMB_WIDTH_PX
|
|
656
|
+
})
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function AudioAttachment({ attachment, config }) {
|
|
660
|
+
const [requested, setRequested] = useState(false);
|
|
661
|
+
const [duration, setDuration] = useState("");
|
|
662
|
+
const { url, error } = useObjectUrl(config, attachment.id, requested);
|
|
663
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
664
|
+
className: "clivly-chat-widget__file",
|
|
665
|
+
children: [
|
|
666
|
+
/* @__PURE__ */ jsx("span", {
|
|
667
|
+
"aria-hidden": "true",
|
|
668
|
+
className: "clivly-chat-widget__chip-icon",
|
|
669
|
+
children: "♪"
|
|
670
|
+
}),
|
|
671
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
672
|
+
/* @__PURE__ */ jsx("span", {
|
|
673
|
+
className: "clivly-chat-widget__chip-name",
|
|
674
|
+
title: attachment.filename,
|
|
675
|
+
children: attachment.filename
|
|
676
|
+
}),
|
|
677
|
+
/* @__PURE__ */ jsx("br", {}),
|
|
678
|
+
/* @__PURE__ */ jsxs("span", {
|
|
679
|
+
className: "clivly-chat-widget__chip-meta",
|
|
680
|
+
children: [
|
|
681
|
+
"Audio · ",
|
|
682
|
+
formatFileSize(attachment.sizeBytes),
|
|
683
|
+
duration ? ` · ${duration}` : ""
|
|
684
|
+
]
|
|
685
|
+
})
|
|
686
|
+
] }),
|
|
687
|
+
url ? null : /* @__PURE__ */ jsx("button", {
|
|
688
|
+
"aria-label": `Play ${attachment.filename}`,
|
|
689
|
+
className: "clivly-chat-widget__file-action",
|
|
690
|
+
disabled: !config || requested && !error,
|
|
691
|
+
onClick: () => setRequested(true),
|
|
692
|
+
type: "button",
|
|
693
|
+
children: requested && !error ? "Loading…" : "Play"
|
|
694
|
+
}),
|
|
695
|
+
url ? /* @__PURE__ */ jsx("audio", {
|
|
696
|
+
"aria-label": attachment.filename,
|
|
697
|
+
autoPlay: true,
|
|
698
|
+
controls: true,
|
|
699
|
+
onLoadedMetadata: (event) => setDuration(formatDuration(event.currentTarget.duration)),
|
|
700
|
+
preload: "metadata",
|
|
701
|
+
src: url
|
|
702
|
+
}) : null,
|
|
703
|
+
error ? /* @__PURE__ */ jsx("p", {
|
|
704
|
+
className: "clivly-chat-widget__file-error",
|
|
705
|
+
children: error
|
|
706
|
+
}) : null
|
|
707
|
+
]
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
function DocumentAttachment({ attachment, config }) {
|
|
711
|
+
const [state, setState] = useState("idle");
|
|
712
|
+
const [error, setError] = useState(null);
|
|
713
|
+
const save = async () => {
|
|
714
|
+
if (!config) return;
|
|
715
|
+
setState("saving");
|
|
716
|
+
try {
|
|
717
|
+
await downloadAttachment(config, attachment);
|
|
718
|
+
setState("idle");
|
|
719
|
+
setError(null);
|
|
720
|
+
} catch (caught) {
|
|
721
|
+
setState("failed");
|
|
722
|
+
setError(caught instanceof Error ? caught.message : "Download failed.");
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
726
|
+
className: "clivly-chat-widget__file",
|
|
727
|
+
children: [
|
|
728
|
+
/* @__PURE__ */ jsx("span", {
|
|
729
|
+
"aria-hidden": "true",
|
|
730
|
+
className: "clivly-chat-widget__chip-icon",
|
|
731
|
+
children: fileTypeLabel(attachment).slice(0, 4)
|
|
732
|
+
}),
|
|
733
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
734
|
+
/* @__PURE__ */ jsx("span", {
|
|
735
|
+
className: "clivly-chat-widget__chip-name",
|
|
736
|
+
title: attachment.filename,
|
|
737
|
+
children: attachment.filename
|
|
738
|
+
}),
|
|
739
|
+
/* @__PURE__ */ jsx("br", {}),
|
|
740
|
+
/* @__PURE__ */ jsxs("span", {
|
|
741
|
+
className: "clivly-chat-widget__chip-meta",
|
|
742
|
+
children: [
|
|
743
|
+
fileTypeLabel(attachment),
|
|
744
|
+
" · ",
|
|
745
|
+
formatFileSize(attachment.sizeBytes)
|
|
746
|
+
]
|
|
747
|
+
})
|
|
748
|
+
] }),
|
|
749
|
+
/* @__PURE__ */ jsx("button", {
|
|
750
|
+
"aria-label": `Download ${attachment.filename} (${formatFileSize(attachment.sizeBytes)})`,
|
|
751
|
+
className: "clivly-chat-widget__file-action",
|
|
752
|
+
disabled: !config || state === "saving",
|
|
753
|
+
onClick: save,
|
|
754
|
+
type: "button",
|
|
755
|
+
children: state === "saving" ? "Saving…" : "Download"
|
|
756
|
+
}),
|
|
757
|
+
error ? /* @__PURE__ */ jsx("p", {
|
|
758
|
+
className: "clivly-chat-widget__file-error",
|
|
759
|
+
children: error
|
|
760
|
+
}) : null
|
|
761
|
+
]
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
function MessageAttachments({ attachments, config, onOpenImage }) {
|
|
765
|
+
return /* @__PURE__ */ jsx("div", {
|
|
766
|
+
className: "clivly-chat-widget__attachments",
|
|
767
|
+
children: attachments.map((attachment) => {
|
|
768
|
+
if (attachment.kind === "image") return /* @__PURE__ */ jsx(ImageAttachment, {
|
|
769
|
+
attachment,
|
|
770
|
+
config,
|
|
771
|
+
onOpen: (url) => onOpenImage({
|
|
772
|
+
filename: attachment.filename,
|
|
773
|
+
id: attachment.id,
|
|
774
|
+
url
|
|
775
|
+
})
|
|
776
|
+
}, attachment.id);
|
|
777
|
+
if (attachment.kind === "audio") return /* @__PURE__ */ jsx(AudioAttachment, {
|
|
778
|
+
attachment,
|
|
779
|
+
config
|
|
780
|
+
}, attachment.id);
|
|
781
|
+
return /* @__PURE__ */ jsx(DocumentAttachment, {
|
|
782
|
+
attachment,
|
|
783
|
+
config
|
|
784
|
+
}, attachment.id);
|
|
785
|
+
})
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* The full-size image, over the panel. Escape or Close returns focus to the
|
|
790
|
+
* thumbnail that opened it.
|
|
791
|
+
*/
|
|
792
|
+
function ImageViewer({ image, onClose, onDownload }) {
|
|
793
|
+
const closeRef = useRef(null);
|
|
794
|
+
const dialogRef = useRef(null);
|
|
795
|
+
const onCloseRef = useRef(onClose);
|
|
796
|
+
onCloseRef.current = onClose;
|
|
797
|
+
useEffect(() => {
|
|
798
|
+
const opener = document.activeElement;
|
|
799
|
+
closeRef.current?.focus();
|
|
800
|
+
const onKey = (event) => {
|
|
801
|
+
if (event.key === "Escape") {
|
|
802
|
+
event.stopPropagation();
|
|
803
|
+
onCloseRef.current();
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (event.key !== "Tab") return;
|
|
807
|
+
const buttons = Array.from(dialogRef.current?.querySelectorAll("button") ?? []);
|
|
808
|
+
const first = buttons[0];
|
|
809
|
+
const last = buttons.at(-1);
|
|
810
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
811
|
+
event.preventDefault();
|
|
812
|
+
last?.focus();
|
|
813
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
814
|
+
event.preventDefault();
|
|
815
|
+
first?.focus();
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
document.addEventListener("keydown", onKey, true);
|
|
819
|
+
return () => {
|
|
820
|
+
document.removeEventListener("keydown", onKey, true);
|
|
821
|
+
opener?.focus?.();
|
|
822
|
+
};
|
|
823
|
+
}, []);
|
|
824
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
825
|
+
"aria-label": image.filename,
|
|
826
|
+
"aria-modal": "true",
|
|
827
|
+
className: "clivly-chat-widget__viewer",
|
|
828
|
+
ref: dialogRef,
|
|
829
|
+
role: "dialog",
|
|
830
|
+
children: [/* @__PURE__ */ jsx("img", {
|
|
831
|
+
alt: image.filename,
|
|
832
|
+
height: THUMB_HEIGHT_PX,
|
|
833
|
+
src: image.url,
|
|
834
|
+
width: THUMB_WIDTH_PX
|
|
835
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
836
|
+
className: "clivly-chat-widget__viewer-bar",
|
|
837
|
+
children: [
|
|
838
|
+
/* @__PURE__ */ jsx("span", { children: image.filename }),
|
|
839
|
+
/* @__PURE__ */ jsx("button", {
|
|
840
|
+
onClick: onDownload,
|
|
841
|
+
type: "button",
|
|
842
|
+
children: "Download"
|
|
843
|
+
}),
|
|
844
|
+
/* @__PURE__ */ jsx("button", {
|
|
845
|
+
onClick: onClose,
|
|
846
|
+
ref: closeRef,
|
|
847
|
+
type: "button",
|
|
848
|
+
children: "Close"
|
|
849
|
+
})
|
|
850
|
+
]
|
|
851
|
+
})]
|
|
852
|
+
});
|
|
853
|
+
}
|
|
4
854
|
// src/theme.ts
|
|
5
855
|
const DARK_MEDIA_QUERY = "(prefers-color-scheme: dark)";
|
|
6
856
|
/** The attribute set by next-themes, daisyUI, and most `data-*` theme setups. */
|
|
@@ -109,6 +959,127 @@ function parseTheme(value) {
|
|
|
109
959
|
if (value === "auto" || value === "dark" || value === "light") return value;
|
|
110
960
|
return "auto";
|
|
111
961
|
}
|
|
962
|
+
// src/use-composer-attachments.ts
|
|
963
|
+
let keySeed = 0;
|
|
964
|
+
const nextKey = () => {
|
|
965
|
+
keySeed += 1;
|
|
966
|
+
return `file-${keySeed}`;
|
|
967
|
+
};
|
|
968
|
+
function useComposerAttachments(config) {
|
|
969
|
+
const [files, setFiles] = useState([]);
|
|
970
|
+
const [notice, setNotice] = useState(null);
|
|
971
|
+
const handles = useRef(/* @__PURE__ */ new Map());
|
|
972
|
+
const filesRef = useRef(files);
|
|
973
|
+
filesRef.current = files;
|
|
974
|
+
const patch = useCallback((key, change) => {
|
|
975
|
+
setFiles((current) => current.map((item) => item.key === key ? {
|
|
976
|
+
...item,
|
|
977
|
+
...change
|
|
978
|
+
} : item));
|
|
979
|
+
}, []);
|
|
980
|
+
const start = useCallback((key, file) => {
|
|
981
|
+
if (!config) return;
|
|
982
|
+
const handle = uploadAttachment(config, file, (progress) => {
|
|
983
|
+
patch(key, { progress });
|
|
984
|
+
});
|
|
985
|
+
handles.current.set(key, handle);
|
|
986
|
+
handle.done.then((uploaded) => {
|
|
987
|
+
patch(key, {
|
|
988
|
+
status: "ready",
|
|
989
|
+
id: uploaded.id,
|
|
990
|
+
kind: uploaded.kind,
|
|
991
|
+
progress: 1,
|
|
992
|
+
error: null
|
|
993
|
+
});
|
|
994
|
+
}).catch((error) => {
|
|
995
|
+
if (error instanceof UploadError && error.message === "Upload cancelled.") return;
|
|
996
|
+
patch(key, {
|
|
997
|
+
status: "failed",
|
|
998
|
+
error: error instanceof Error ? error.message : "The upload failed.",
|
|
999
|
+
retryable: error instanceof UploadError ? error.retryable : true
|
|
1000
|
+
});
|
|
1001
|
+
}).finally(() => {
|
|
1002
|
+
handles.current.delete(key);
|
|
1003
|
+
});
|
|
1004
|
+
}, [config, patch]);
|
|
1005
|
+
const add = useCallback((picked) => {
|
|
1006
|
+
if (!config || picked.length === 0) return;
|
|
1007
|
+
const { accepted, refused } = admitFiles(config, filesRef.current.length, picked);
|
|
1008
|
+
setNotice(refused.length > 0 ? refused.join(" ") : null);
|
|
1009
|
+
const added = accepted.map((file) => {
|
|
1010
|
+
const kind = localKindOf(file);
|
|
1011
|
+
return {
|
|
1012
|
+
key: nextKey(),
|
|
1013
|
+
file,
|
|
1014
|
+
kind,
|
|
1015
|
+
previewUrl: kind === "image" ? URL.createObjectURL(file) : null,
|
|
1016
|
+
status: "uploading",
|
|
1017
|
+
progress: 0,
|
|
1018
|
+
id: null,
|
|
1019
|
+
error: null,
|
|
1020
|
+
retryable: false
|
|
1021
|
+
};
|
|
1022
|
+
});
|
|
1023
|
+
setFiles((current) => [...current, ...added]);
|
|
1024
|
+
for (const item of added) start(item.key, item.file);
|
|
1025
|
+
}, [config, start]);
|
|
1026
|
+
const remove = useCallback((key) => {
|
|
1027
|
+
const item = filesRef.current.find((candidate) => candidate.key === key);
|
|
1028
|
+
if (!item) return;
|
|
1029
|
+
handles.current.get(key)?.abort();
|
|
1030
|
+
if (item.id && config) discardUpload(config, item.id);
|
|
1031
|
+
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
|
1032
|
+
setFiles((current) => current.filter((candidate) => candidate.key !== key));
|
|
1033
|
+
}, [config]);
|
|
1034
|
+
const retry = useCallback((key) => {
|
|
1035
|
+
const item = filesRef.current.find((candidate) => candidate.key === key);
|
|
1036
|
+
if (item?.status !== "failed") return;
|
|
1037
|
+
patch(key, {
|
|
1038
|
+
status: "uploading",
|
|
1039
|
+
progress: 0,
|
|
1040
|
+
error: null
|
|
1041
|
+
});
|
|
1042
|
+
start(key, item.file);
|
|
1043
|
+
}, [patch, start]);
|
|
1044
|
+
const clearSent = useCallback(() => {
|
|
1045
|
+
const sent = filesRef.current.flatMap((item) => {
|
|
1046
|
+
if (item.status !== "ready" || !item.id) return [];
|
|
1047
|
+
if (item.previewUrl) rememberObjectUrl(item.id, item.previewUrl);
|
|
1048
|
+
return [{
|
|
1049
|
+
id: item.id,
|
|
1050
|
+
filename: item.file.name,
|
|
1051
|
+
contentType: item.file.type,
|
|
1052
|
+
sizeBytes: item.file.size,
|
|
1053
|
+
kind: item.kind
|
|
1054
|
+
}];
|
|
1055
|
+
});
|
|
1056
|
+
setFiles((current) => current.filter((item) => item.status !== "ready"));
|
|
1057
|
+
setNotice(null);
|
|
1058
|
+
return sent;
|
|
1059
|
+
}, []);
|
|
1060
|
+
const isUploading = files.some((item) => item.status === "uploading");
|
|
1061
|
+
useEffect(() => {
|
|
1062
|
+
if (!isUploading) return;
|
|
1063
|
+
const warn = (event) => {
|
|
1064
|
+
event.preventDefault();
|
|
1065
|
+
};
|
|
1066
|
+
window.addEventListener("beforeunload", warn);
|
|
1067
|
+
return () => window.removeEventListener("beforeunload", warn);
|
|
1068
|
+
}, [isUploading]);
|
|
1069
|
+
useEffect(() => () => {
|
|
1070
|
+
for (const handle of handles.current.values()) handle.abort();
|
|
1071
|
+
}, []);
|
|
1072
|
+
return {
|
|
1073
|
+
add,
|
|
1074
|
+
clearSent,
|
|
1075
|
+
files,
|
|
1076
|
+
isUploading,
|
|
1077
|
+
notice,
|
|
1078
|
+
readyIds: files.flatMap((item) => item.status === "ready" && item.id ? [item.id] : []),
|
|
1079
|
+
remove,
|
|
1080
|
+
retry
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
112
1083
|
// src/visitor-token-storage.ts
|
|
113
1084
|
/** Normalised the way the server matches contacts: trimmed, lower-case. */
|
|
114
1085
|
function normalizeVisitorEmail(email) {
|
|
@@ -480,6 +1451,21 @@ const WIDGET_STYLES = `
|
|
|
480
1451
|
align-self: flex-start;
|
|
481
1452
|
background: var(--clivly-chat-operator);
|
|
482
1453
|
}
|
|
1454
|
+
.clivly-chat-widget__reply-time {
|
|
1455
|
+
color: var(--clivly-chat-muted);
|
|
1456
|
+
font-size: 0.75rem;
|
|
1457
|
+
margin: 0.125rem 0 0;
|
|
1458
|
+
}
|
|
1459
|
+
.clivly-chat-widget__reply-time::before {
|
|
1460
|
+
background: #22c55e;
|
|
1461
|
+
border-radius: 999px;
|
|
1462
|
+
content: "";
|
|
1463
|
+
display: inline-block;
|
|
1464
|
+
height: 0.4rem;
|
|
1465
|
+
margin-right: 0.375rem;
|
|
1466
|
+
vertical-align: middle;
|
|
1467
|
+
width: 0.4rem;
|
|
1468
|
+
}
|
|
483
1469
|
.clivly-chat-widget__message-meta {
|
|
484
1470
|
color: var(--clivly-chat-muted);
|
|
485
1471
|
display: flex;
|
|
@@ -884,9 +1870,12 @@ function applyServerEvent(event, actions) {
|
|
|
884
1870
|
case "conversation.snapshot":
|
|
885
1871
|
setMessages(sortMessages(event.messages.map((message) => createLocalMessage(message))));
|
|
886
1872
|
return;
|
|
887
|
-
case "error":
|
|
1873
|
+
case "error": {
|
|
888
1874
|
setError(event.message);
|
|
1875
|
+
const refused = event.clientMessageId;
|
|
1876
|
+
if (refused) setMessages((currentMessages) => markMessageFailed(currentMessages, refused));
|
|
889
1877
|
return;
|
|
1878
|
+
}
|
|
890
1879
|
case "message.ack":
|
|
891
1880
|
setMessages((currentMessages) => sortMessages(replacePendingMessage(currentMessages, event.clientMessageId, event.message)));
|
|
892
1881
|
return;
|
|
@@ -966,7 +1955,7 @@ function LauncherIcon({ open }) {
|
|
|
966
1955
|
children: open ? /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) : /* @__PURE__ */ jsx("path", { d: "M7.9 20A9 9 0 1 0 4 16.1L2 22Z" })
|
|
967
1956
|
});
|
|
968
1957
|
}
|
|
969
|
-
function MessageBubble({ message }) {
|
|
1958
|
+
function MessageBubble({ attachmentDisplay, message }) {
|
|
970
1959
|
const badge = automationBadge(message);
|
|
971
1960
|
const author = badge ? (message.author ?? "").replace(AUTHOR_LABEL_SUFFIX, "") : message.author;
|
|
972
1961
|
return /* @__PURE__ */ jsxs("article", {
|
|
@@ -985,7 +1974,12 @@ function MessageBubble({ message }) {
|
|
|
985
1974
|
/* @__PURE__ */ jsx("span", { children: formatTimestamp(message.createdAt) })
|
|
986
1975
|
]
|
|
987
1976
|
}),
|
|
988
|
-
/* @__PURE__ */ jsx("div", { children: message.body }),
|
|
1977
|
+
message.body ? /* @__PURE__ */ jsx("div", { children: message.body }) : null,
|
|
1978
|
+
message.attachments && message.attachments.length > 0 ? /* @__PURE__ */ jsx(MessageAttachments, {
|
|
1979
|
+
attachments: message.attachments,
|
|
1980
|
+
config: attachmentDisplay.config,
|
|
1981
|
+
onOpenImage: attachmentDisplay.onOpenImage
|
|
1982
|
+
}) : null,
|
|
989
1983
|
message.delivery === "sent" ? null : /* @__PURE__ */ jsx("div", {
|
|
990
1984
|
className: "clivly-chat-widget__status",
|
|
991
1985
|
children: DELIVERY_LABELS[message.delivery]
|
|
@@ -998,7 +1992,7 @@ function MessageBubble({ message }) {
|
|
|
998
1992
|
* (no session, empty thread, messages) were a nested ternary in the render tree
|
|
999
1993
|
* — early returns make them readable and keep the parent's complexity down.
|
|
1000
1994
|
*/
|
|
1001
|
-
function MessageList({ greeting, hasSession, messages }) {
|
|
1995
|
+
function MessageList({ attachmentDisplay, greeting, hasSession, messages }) {
|
|
1002
1996
|
if (!hasSession) return /* @__PURE__ */ jsx("p", {
|
|
1003
1997
|
className: "clivly-chat-widget__empty",
|
|
1004
1998
|
children: greeting
|
|
@@ -1007,7 +2001,10 @@ function MessageList({ greeting, hasSession, messages }) {
|
|
|
1007
2001
|
className: "clivly-chat-widget__empty",
|
|
1008
2002
|
children: "No messages yet. Say hello to get the conversation started."
|
|
1009
2003
|
});
|
|
1010
|
-
return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, {
|
|
2004
|
+
return /* @__PURE__ */ jsx(Fragment, { children: messages.map((message) => /* @__PURE__ */ jsx(MessageBubble, {
|
|
2005
|
+
attachmentDisplay,
|
|
2006
|
+
message
|
|
2007
|
+
}, message.id)) });
|
|
1011
2008
|
}
|
|
1012
2009
|
/**
|
|
1013
2010
|
* How often an open, visible widget re-sends its read receipt. The room drops
|
|
@@ -1173,7 +2170,7 @@ function composerPlaceholder(hasTopics) {
|
|
|
1173
2170
|
* when the transport supports it (the server resolves the answer), otherwise
|
|
1174
2171
|
* as the label, typed. Throws what the transport throws.
|
|
1175
2172
|
*/
|
|
1176
|
-
function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
|
|
2173
|
+
function dispatchVisitorMessage(transport, clientMessageId, body, topic, attachmentIds = []) {
|
|
1177
2174
|
if (topic && transport.selectTopic) {
|
|
1178
2175
|
transport.selectTopic({
|
|
1179
2176
|
id: clientMessageId,
|
|
@@ -1182,7 +2179,12 @@ function dispatchVisitorMessage(transport, clientMessageId, body, topic) {
|
|
|
1182
2179
|
});
|
|
1183
2180
|
return;
|
|
1184
2181
|
}
|
|
1185
|
-
transport.sendMessage({
|
|
2182
|
+
transport.sendMessage(attachmentIds.length > 0 ? {
|
|
2183
|
+
body,
|
|
2184
|
+
id: clientMessageId,
|
|
2185
|
+
type: "message.create",
|
|
2186
|
+
attachmentIds: [...attachmentIds]
|
|
2187
|
+
} : {
|
|
1186
2188
|
body,
|
|
1187
2189
|
id: clientMessageId,
|
|
1188
2190
|
type: "message.create"
|
|
@@ -1321,6 +2323,146 @@ function useResetOnHostIdentityChange({ generationRef, hostIdentity, identityRef
|
|
|
1321
2323
|
resetRef.current();
|
|
1322
2324
|
}, [hostIdentity]);
|
|
1323
2325
|
}
|
|
2326
|
+
/**
|
|
2327
|
+
* Whether the composer holds something to send: text or a ready file, and no
|
|
2328
|
+
* file still uploading — a message must not go without a file the visitor
|
|
2329
|
+
* can see attached.
|
|
2330
|
+
*/
|
|
2331
|
+
function hasSendable(draft, files) {
|
|
2332
|
+
if (files.isUploading) return false;
|
|
2333
|
+
return draft.trim() !== "" || files.readyIds.length > 0;
|
|
2334
|
+
}
|
|
2335
|
+
function withAttachments(message, attachments) {
|
|
2336
|
+
return attachments.length > 0 ? {
|
|
2337
|
+
...message,
|
|
2338
|
+
attachments
|
|
2339
|
+
} : message;
|
|
2340
|
+
}
|
|
2341
|
+
/** The reply time the team promises, under the title; nothing when none. */
|
|
2342
|
+
function ReplyTime({ text }) {
|
|
2343
|
+
return text ? /* @__PURE__ */ jsx("p", {
|
|
2344
|
+
className: "clivly-chat-widget__reply-time",
|
|
2345
|
+
children: text
|
|
2346
|
+
}) : null;
|
|
2347
|
+
}
|
|
2348
|
+
function ImageViewerLayer({ config, image, onClose }) {
|
|
2349
|
+
if (!image) return null;
|
|
2350
|
+
return /* @__PURE__ */ jsx(ImageViewer, {
|
|
2351
|
+
image,
|
|
2352
|
+
onClose,
|
|
2353
|
+
onDownload: () => {
|
|
2354
|
+
if (config) downloadAttachment(config, image).catch(() => void 0);
|
|
2355
|
+
}
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* The message box: files tray, paperclip, text and Send. Files can also be
|
|
2360
|
+
* dropped on it or pasted into the text. Split out of `ChatWidget` to keep
|
|
2361
|
+
* that component inside its complexity budget.
|
|
2362
|
+
*/
|
|
2363
|
+
function ConversationComposer({ attachments, attachmentsConfig, canSend, canSubmit, composerRef, hasTopics, messageDraft, onDraftChange, onSubmit, statusText }) {
|
|
2364
|
+
const [dragging, setDragging] = useState(false);
|
|
2365
|
+
const fileInputRef = useRef(null);
|
|
2366
|
+
const acceptFiles = (files) => {
|
|
2367
|
+
if (attachmentsConfig && files.length > 0) attachments.add(files);
|
|
2368
|
+
};
|
|
2369
|
+
const showHint = attachmentsConfig !== null && (dragging || attachments.files.length > 0);
|
|
2370
|
+
return /* @__PURE__ */ jsxs("form", {
|
|
2371
|
+
className: "clivly-chat-widget__composer",
|
|
2372
|
+
"data-dragging": dragging || void 0,
|
|
2373
|
+
onDragLeave: () => setDragging(false),
|
|
2374
|
+
onDragOver: (event) => {
|
|
2375
|
+
if (attachmentsConfig && event.dataTransfer.types.includes("Files")) {
|
|
2376
|
+
event.preventDefault();
|
|
2377
|
+
setDragging(true);
|
|
2378
|
+
}
|
|
2379
|
+
},
|
|
2380
|
+
onDrop: (event) => {
|
|
2381
|
+
if (!attachmentsConfig) return;
|
|
2382
|
+
event.preventDefault();
|
|
2383
|
+
setDragging(false);
|
|
2384
|
+
acceptFiles(Array.from(event.dataTransfer.files));
|
|
2385
|
+
},
|
|
2386
|
+
onSubmit,
|
|
2387
|
+
children: [
|
|
2388
|
+
attachmentsConfig ? /* @__PURE__ */ jsx(ComposerTray, {
|
|
2389
|
+
files: attachments.files,
|
|
2390
|
+
notice: attachments.notice,
|
|
2391
|
+
onRemove: attachments.remove,
|
|
2392
|
+
onRetry: attachments.retry
|
|
2393
|
+
}) : null,
|
|
2394
|
+
showHint ? /* @__PURE__ */ jsxs("p", {
|
|
2395
|
+
className: "clivly-chat-widget__hint",
|
|
2396
|
+
children: [dragging ? "Drop to attach · " : "", attachmentsConfig?.hint]
|
|
2397
|
+
}) : null,
|
|
2398
|
+
/* @__PURE__ */ jsxs("div", {
|
|
2399
|
+
className: "clivly-chat-widget__composer-row",
|
|
2400
|
+
children: [
|
|
2401
|
+
attachmentsConfig ? /* @__PURE__ */ jsx(AttachButton, {
|
|
2402
|
+
config: attachmentsConfig,
|
|
2403
|
+
disabled: !canSend,
|
|
2404
|
+
inputRef: fileInputRef,
|
|
2405
|
+
onFiles: acceptFiles
|
|
2406
|
+
}) : null,
|
|
2407
|
+
/* @__PURE__ */ jsxs("label", {
|
|
2408
|
+
className: "clivly-chat-widget__field clivly-chat-widget__composer-field",
|
|
2409
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
2410
|
+
className: "clivly-chat-widget__label clivly-chat-widget__visually-hidden",
|
|
2411
|
+
children: "Message"
|
|
2412
|
+
}), /* @__PURE__ */ jsx("textarea", {
|
|
2413
|
+
className: "clivly-chat-widget__textarea",
|
|
2414
|
+
onChange: (event) => {
|
|
2415
|
+
onDraftChange(event.target.value);
|
|
2416
|
+
},
|
|
2417
|
+
onKeyDown: (event) => {
|
|
2418
|
+
if (!isSendKey(event)) return;
|
|
2419
|
+
event.preventDefault();
|
|
2420
|
+
if (canSubmit) onSubmit();
|
|
2421
|
+
},
|
|
2422
|
+
onPaste: (event) => {
|
|
2423
|
+
const pasted = Array.from(event.clipboardData.files);
|
|
2424
|
+
if (attachmentsConfig && pasted.length > 0) {
|
|
2425
|
+
event.preventDefault();
|
|
2426
|
+
acceptFiles(pasted);
|
|
2427
|
+
}
|
|
2428
|
+
},
|
|
2429
|
+
placeholder: composerPlaceholder(hasTopics),
|
|
2430
|
+
ref: composerRef,
|
|
2431
|
+
rows: 1,
|
|
2432
|
+
value: messageDraft
|
|
2433
|
+
})]
|
|
2434
|
+
}),
|
|
2435
|
+
/* @__PURE__ */ jsx("button", {
|
|
2436
|
+
"aria-label": "Send",
|
|
2437
|
+
className: "clivly-chat-widget__button clivly-chat-widget__send",
|
|
2438
|
+
disabled: !canSubmit,
|
|
2439
|
+
type: "submit",
|
|
2440
|
+
children: /* @__PURE__ */ jsx("svg", {
|
|
2441
|
+
"aria-hidden": "true",
|
|
2442
|
+
fill: "none",
|
|
2443
|
+
focusable: "false",
|
|
2444
|
+
stroke: "currentColor",
|
|
2445
|
+
strokeLinecap: "round",
|
|
2446
|
+
strokeLinejoin: "round",
|
|
2447
|
+
strokeWidth: 2,
|
|
2448
|
+
viewBox: "0 0 24 24",
|
|
2449
|
+
children: /* @__PURE__ */ jsx("path", { d: "M12 19V5M5 12l7-7 7 7" })
|
|
2450
|
+
})
|
|
2451
|
+
})
|
|
2452
|
+
]
|
|
2453
|
+
}),
|
|
2454
|
+
/* @__PURE__ */ jsx("div", {
|
|
2455
|
+
className: "clivly-chat-widget__actions",
|
|
2456
|
+
"data-ready": canSend || void 0,
|
|
2457
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
2458
|
+
"aria-live": "polite",
|
|
2459
|
+
className: "clivly-chat-widget__subtitle",
|
|
2460
|
+
children: statusText
|
|
2461
|
+
})
|
|
2462
|
+
})
|
|
2463
|
+
]
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
1324
2466
|
function ChatWidget({ className, createTransport, defaultVisitor, getSession, initiallyOpen = false, launcher = "auto", launcherLabel = "Chat with us", placement = "bottom-right", storage = DEFAULT_STORAGE, style, theme = "auto", widgetId }) {
|
|
1325
2467
|
const [isOpen, setIsOpen] = useState(initiallyOpen);
|
|
1326
2468
|
const panelId = useId();
|
|
@@ -1341,6 +2483,9 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1341
2483
|
name: defaultVisitor?.name ?? ""
|
|
1342
2484
|
});
|
|
1343
2485
|
const transportRef = useRef(null);
|
|
2486
|
+
const attachmentsConfig = session?.attachments ?? null;
|
|
2487
|
+
const composerFiles = useComposerAttachments(attachmentsConfig);
|
|
2488
|
+
const [viewerImage, setViewerImage] = useState(null);
|
|
1344
2489
|
const listRef = useRef(null);
|
|
1345
2490
|
const composerRef = useRef(null);
|
|
1346
2491
|
useEffect(() => {
|
|
@@ -1490,25 +2635,25 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1490
2635
|
* click goes as `topic.select` when the transport supports it, so the
|
|
1491
2636
|
* server resolves the answer; otherwise as the label, typed.
|
|
1492
2637
|
*/
|
|
1493
|
-
const sendVisitorMessage = (body, topic) => {
|
|
2638
|
+
const sendVisitorMessage = (body, topic, attachments = []) => {
|
|
1494
2639
|
const transport = transportRef.current;
|
|
1495
2640
|
if (!(session && transport)) {
|
|
1496
2641
|
setError("Chat is not connected yet.");
|
|
1497
2642
|
return;
|
|
1498
2643
|
}
|
|
1499
2644
|
const clientMessageId = generateId();
|
|
1500
|
-
const optimisticMessage = {
|
|
2645
|
+
const optimisticMessage = withAttachments({
|
|
1501
2646
|
author: sessionDisplayName(session),
|
|
1502
2647
|
automation: topic && transport.selectTopic ? "topic_choice" : null,
|
|
1503
2648
|
body,
|
|
1504
2649
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1505
2650
|
id: clientMessageId,
|
|
1506
2651
|
senderType: "visitor"
|
|
1507
|
-
};
|
|
2652
|
+
}, attachments);
|
|
1508
2653
|
setMessages((currentMessages) => sortMessages([...currentMessages, createLocalMessage(optimisticMessage, "pending")]));
|
|
1509
2654
|
setError(null);
|
|
1510
2655
|
try {
|
|
1511
|
-
dispatchVisitorMessage(transport, clientMessageId, body, topic);
|
|
2656
|
+
dispatchVisitorMessage(transport, clientMessageId, body, topic, attachments.map((file) => file.id));
|
|
1512
2657
|
} catch (transportError) {
|
|
1513
2658
|
const resolved = transportError instanceof Error ? transportError : /* @__PURE__ */ new Error("Failed to send your message.");
|
|
1514
2659
|
setMessages((currentMessages) => markMessageFailed(currentMessages, clientMessageId));
|
|
@@ -1517,12 +2662,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1517
2662
|
};
|
|
1518
2663
|
const submitMessage = (event) => {
|
|
1519
2664
|
event?.preventDefault();
|
|
2665
|
+
if (!hasSendable(messageDraft, composerFiles)) return;
|
|
1520
2666
|
const body = messageDraft.trim();
|
|
1521
|
-
if (!body) return;
|
|
1522
2667
|
setMessageDraft("");
|
|
1523
|
-
sendVisitorMessage(body);
|
|
2668
|
+
sendVisitorMessage(body, void 0, composerFiles.clearSent());
|
|
1524
2669
|
};
|
|
1525
2670
|
const canSend = Boolean(createTransport) && isReady;
|
|
2671
|
+
const canSubmit = canSend && hasSendable(messageDraft, composerFiles);
|
|
2672
|
+
const attachmentDisplay = {
|
|
2673
|
+
config: attachmentsConfig,
|
|
2674
|
+
onOpenImage: setViewerImage
|
|
2675
|
+
};
|
|
1526
2676
|
const { awaitingHostSession, greeting, panelTitle, sessionSummary, showComposer, topics } = panelView({
|
|
1527
2677
|
cachedPresentation,
|
|
1528
2678
|
defaultVisitor,
|
|
@@ -1538,7 +2688,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1538
2688
|
"data-clivly-theme": resolvedTheme,
|
|
1539
2689
|
style: themeStyle,
|
|
1540
2690
|
children: [
|
|
1541
|
-
/* @__PURE__ */ jsx("style", { children: WIDGET_STYLES }),
|
|
2691
|
+
/* @__PURE__ */ jsx("style", { children: `${WIDGET_STYLES}${ATTACHMENT_STYLES}` }),
|
|
1542
2692
|
/* @__PURE__ */ jsx(Launcher, {
|
|
1543
2693
|
label: launcherLabel,
|
|
1544
2694
|
onIntent: prefetchSession,
|
|
@@ -1557,13 +2707,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1557
2707
|
className: "clivly-chat-widget__header",
|
|
1558
2708
|
children: [/* @__PURE__ */ jsxs("div", {
|
|
1559
2709
|
className: "clivly-chat-widget__heading",
|
|
1560
|
-
children: [
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
2710
|
+
children: [
|
|
2711
|
+
/* @__PURE__ */ jsx("h2", {
|
|
2712
|
+
className: "clivly-chat-widget__title",
|
|
2713
|
+
children: panelTitle
|
|
2714
|
+
}),
|
|
2715
|
+
/* @__PURE__ */ jsx("p", {
|
|
2716
|
+
className: "clivly-chat-widget__subtitle",
|
|
2717
|
+
children: sessionSummary
|
|
2718
|
+
}),
|
|
2719
|
+
/* @__PURE__ */ jsx(ReplyTime, { text: session?.support?.responseTime })
|
|
2720
|
+
]
|
|
1567
2721
|
}), /* @__PURE__ */ jsx("button", {
|
|
1568
2722
|
"aria-label": "Close chat",
|
|
1569
2723
|
className: "clivly-chat-widget__close",
|
|
@@ -1595,6 +2749,7 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1595
2749
|
children: error
|
|
1596
2750
|
}) : null,
|
|
1597
2751
|
/* @__PURE__ */ jsx(MessageList, {
|
|
2752
|
+
attachmentDisplay,
|
|
1598
2753
|
greeting,
|
|
1599
2754
|
hasSession: Boolean(session),
|
|
1600
2755
|
messages
|
|
@@ -1613,57 +2768,17 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1613
2768
|
},
|
|
1614
2769
|
topics
|
|
1615
2770
|
}) : null,
|
|
1616
|
-
showComposer ? /* @__PURE__ */
|
|
1617
|
-
|
|
2771
|
+
showComposer ? /* @__PURE__ */ jsx(ConversationComposer, {
|
|
2772
|
+
attachments: composerFiles,
|
|
2773
|
+
attachmentsConfig,
|
|
2774
|
+
canSend,
|
|
2775
|
+
canSubmit,
|
|
2776
|
+
composerRef,
|
|
2777
|
+
hasTopics: topics.length > 0,
|
|
2778
|
+
messageDraft,
|
|
2779
|
+
onDraftChange: setMessageDraft,
|
|
1618
2780
|
onSubmit: submitMessage,
|
|
1619
|
-
|
|
1620
|
-
className: "clivly-chat-widget__composer-row",
|
|
1621
|
-
children: [/* @__PURE__ */ jsxs("label", {
|
|
1622
|
-
className: "clivly-chat-widget__field clivly-chat-widget__composer-field",
|
|
1623
|
-
children: [/* @__PURE__ */ jsx("span", {
|
|
1624
|
-
className: "clivly-chat-widget__label clivly-chat-widget__visually-hidden",
|
|
1625
|
-
children: "Message"
|
|
1626
|
-
}), /* @__PURE__ */ jsx("textarea", {
|
|
1627
|
-
className: "clivly-chat-widget__textarea",
|
|
1628
|
-
onChange: (event) => {
|
|
1629
|
-
setMessageDraft(event.target.value);
|
|
1630
|
-
},
|
|
1631
|
-
onKeyDown: (event) => {
|
|
1632
|
-
if (!isSendKey(event)) return;
|
|
1633
|
-
event.preventDefault();
|
|
1634
|
-
if (messageDraft.trim() && canSend) submitMessage();
|
|
1635
|
-
},
|
|
1636
|
-
placeholder: composerPlaceholder(topics.length > 0),
|
|
1637
|
-
ref: composerRef,
|
|
1638
|
-
rows: 1,
|
|
1639
|
-
value: messageDraft
|
|
1640
|
-
})]
|
|
1641
|
-
}), /* @__PURE__ */ jsx("button", {
|
|
1642
|
-
"aria-label": "Send",
|
|
1643
|
-
className: "clivly-chat-widget__button clivly-chat-widget__send",
|
|
1644
|
-
disabled: !(messageDraft.trim() && canSend),
|
|
1645
|
-
type: "submit",
|
|
1646
|
-
children: /* @__PURE__ */ jsx("svg", {
|
|
1647
|
-
"aria-hidden": "true",
|
|
1648
|
-
fill: "none",
|
|
1649
|
-
focusable: "false",
|
|
1650
|
-
stroke: "currentColor",
|
|
1651
|
-
strokeLinecap: "round",
|
|
1652
|
-
strokeLinejoin: "round",
|
|
1653
|
-
strokeWidth: 2,
|
|
1654
|
-
viewBox: "0 0 24 24",
|
|
1655
|
-
children: /* @__PURE__ */ jsx("path", { d: "M12 19V5M5 12l7-7 7 7" })
|
|
1656
|
-
})
|
|
1657
|
-
})]
|
|
1658
|
-
}), /* @__PURE__ */ jsx("div", {
|
|
1659
|
-
className: "clivly-chat-widget__actions",
|
|
1660
|
-
"data-ready": canSend || void 0,
|
|
1661
|
-
children: /* @__PURE__ */ jsx("span", {
|
|
1662
|
-
"aria-live": "polite",
|
|
1663
|
-
className: "clivly-chat-widget__subtitle",
|
|
1664
|
-
children: composerStatusText(Boolean(createTransport), isReady, reconnecting)
|
|
1665
|
-
})
|
|
1666
|
-
})]
|
|
2781
|
+
statusText: composerStatusText(Boolean(createTransport), isReady, reconnecting)
|
|
1667
2782
|
}) : /* @__PURE__ */ jsxs("form", {
|
|
1668
2783
|
className: "clivly-chat-widget__form",
|
|
1669
2784
|
onSubmit: submitVisitor,
|
|
@@ -1719,6 +2834,11 @@ function ChatWidget({ className, createTransport, defaultVisitor, getSession, in
|
|
|
1719
2834
|
})
|
|
1720
2835
|
]
|
|
1721
2836
|
}),
|
|
2837
|
+
/* @__PURE__ */ jsx(ImageViewerLayer, {
|
|
2838
|
+
config: attachmentsConfig,
|
|
2839
|
+
image: viewerImage,
|
|
2840
|
+
onClose: () => setViewerImage(null)
|
|
2841
|
+
}),
|
|
1722
2842
|
/* @__PURE__ */ jsx("p", {
|
|
1723
2843
|
className: "clivly-chat-widget__powered",
|
|
1724
2844
|
children: /* @__PURE__ */ jsxs("a", {
|