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