@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/index.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, { message }, message.id)) });
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: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
1562
- className: "clivly-chat-widget__title",
1563
- children: panelTitle
1564
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1565
- className: "clivly-chat-widget__subtitle",
1566
- children: sessionSummary
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.jsxs)("form", {
1618
- className: "clivly-chat-widget__composer",
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
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
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", {