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