@adatechnology/conversations-ui 0.1.0-rc.15 → 0.1.0-rc.17
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/{chunk-SOWV4264.js → chunk-OIDAIVCH.js} +54 -8
- package/dist/index.d.ts +10 -1
- package/dist/index.js +13 -5
- package/dist/preview/index.js +2 -2
- package/package.json +1 -1
- package/src/AudioRecorderButton.tsx +9 -2
- package/src/ConversationContextPanel.tsx +23 -2
- package/src/MediaRenderer.tsx +53 -13
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/preview/ConversationPreview.tsx +4 -1
|
@@ -276,6 +276,14 @@ function formatFileSize(bytes) {
|
|
|
276
276
|
// src/MediaRenderer.tsx
|
|
277
277
|
import { useState as useState2 } from "react";
|
|
278
278
|
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
279
|
+
function documentTypeLabel(filename, mimeType) {
|
|
280
|
+
const extension = filename?.split(".").pop();
|
|
281
|
+
if (extension && extension.length <= 5 && extension !== filename) return extension.toUpperCase();
|
|
282
|
+
const subtype = mimeType?.split(";")[0]?.split("/")[1];
|
|
283
|
+
if (!subtype) return "FILE";
|
|
284
|
+
const compacto = subtype.split(".").pop() ?? subtype;
|
|
285
|
+
return compacto.slice(0, 12).toUpperCase();
|
|
286
|
+
}
|
|
279
287
|
function resolveMediaSource(message) {
|
|
280
288
|
if (message.mediaUrl) return message.mediaUrl;
|
|
281
289
|
if (message.base64) {
|
|
@@ -313,12 +321,35 @@ function MediaRenderer({ message, onLightbox, onResolveUrl, className }) {
|
|
|
313
321
|
const lazy = useLazyMediaUrl(message, onResolveUrl);
|
|
314
322
|
const src = eagerSrc ?? lazy.url;
|
|
315
323
|
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl);
|
|
316
|
-
|
|
324
|
+
function LazyMediaButton({ icon, label }) {
|
|
325
|
+
return /* @__PURE__ */ jsxs3(
|
|
326
|
+
"button",
|
|
327
|
+
{
|
|
328
|
+
onClick: lazy.load,
|
|
329
|
+
disabled: lazy.loading,
|
|
330
|
+
className: "flex min-w-[180px] items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-left transition-colors hover:bg-black/10 disabled:opacity-60 dark:bg-white/10 dark:hover:bg-white/15",
|
|
331
|
+
children: [
|
|
332
|
+
/* @__PURE__ */ jsx5("span", { className: "flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white", children: icon }),
|
|
333
|
+
/* @__PURE__ */ jsx5("span", { className: "truncate text-xs text-gray-600 dark:text-gray-300", children: label })
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
317
338
|
switch (message.type) {
|
|
318
339
|
case "image":
|
|
319
340
|
case "sticker": {
|
|
320
341
|
if (!src && canLazyLoad) {
|
|
321
|
-
return /* @__PURE__ */ jsx5(
|
|
342
|
+
return /* @__PURE__ */ jsx5(
|
|
343
|
+
LazyMediaButton,
|
|
344
|
+
{
|
|
345
|
+
icon: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
|
|
346
|
+
/* @__PURE__ */ jsx5("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }),
|
|
347
|
+
/* @__PURE__ */ jsx5("circle", { cx: "8.5", cy: "8.5", r: "1.5" }),
|
|
348
|
+
/* @__PURE__ */ jsx5("polyline", { points: "21 15 16 10 5 21" })
|
|
349
|
+
] }),
|
|
350
|
+
label: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage
|
|
351
|
+
}
|
|
352
|
+
);
|
|
322
353
|
}
|
|
323
354
|
return /* @__PURE__ */ jsx5("div", { className: "min-w-[200px]", children: src ? /* @__PURE__ */ jsx5("img", { src, alt: message.caption ?? bubble.imageAlt, className: "w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity", onClick: () => onLightbox(src), loading: "lazy" }) : /* @__PURE__ */ jsx5("div", { className: "w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400", children: /* @__PURE__ */ jsxs3("svg", { width: "32", height: "32", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: [
|
|
324
355
|
/* @__PURE__ */ jsx5("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2", ry: "2" }),
|
|
@@ -328,7 +359,16 @@ function MediaRenderer({ message, onLightbox, onResolveUrl, className }) {
|
|
|
328
359
|
}
|
|
329
360
|
case "video": {
|
|
330
361
|
if (!src && canLazyLoad) {
|
|
331
|
-
return /* @__PURE__ */ jsx5(
|
|
362
|
+
return /* @__PURE__ */ jsx5(
|
|
363
|
+
LazyMediaButton,
|
|
364
|
+
{
|
|
365
|
+
icon: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
|
|
366
|
+
/* @__PURE__ */ jsx5("polygon", { points: "23 7 16 12 23 17 23 7" }),
|
|
367
|
+
/* @__PURE__ */ jsx5("rect", { x: "1", y: "5", width: "15", height: "14", rx: "2", ry: "2" })
|
|
368
|
+
] }),
|
|
369
|
+
label: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo
|
|
370
|
+
}
|
|
371
|
+
);
|
|
332
372
|
}
|
|
333
373
|
return /* @__PURE__ */ jsx5("div", { className: "min-w-[200px]", children: src ? /* @__PURE__ */ jsx5("video", { src, className: "w-full max-h-80 rounded-lg", controls: true, preload: "metadata", children: /* @__PURE__ */ jsx5("track", { kind: "captions" }) }) : /* @__PURE__ */ jsx5("div", { className: "w-full h-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400", children: /* @__PURE__ */ jsxs3("svg", { width: "32", height: "32", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", children: [
|
|
334
374
|
/* @__PURE__ */ jsx5("polygon", { points: "23 7 16 12 23 17 23 7" }),
|
|
@@ -337,18 +377,24 @@ function MediaRenderer({ message, onLightbox, onResolveUrl, className }) {
|
|
|
337
377
|
}
|
|
338
378
|
case "audio": {
|
|
339
379
|
if (!src && canLazyLoad) {
|
|
340
|
-
return /* @__PURE__ */ jsx5(
|
|
380
|
+
return /* @__PURE__ */ jsx5(
|
|
381
|
+
LazyMediaButton,
|
|
382
|
+
{
|
|
383
|
+
icon: /* @__PURE__ */ jsx5("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "translate-x-0.5", children: /* @__PURE__ */ jsx5("polygon", { points: "5 3 19 12 5 21 5 3" }) }),
|
|
384
|
+
label: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio
|
|
385
|
+
}
|
|
386
|
+
);
|
|
341
387
|
}
|
|
342
388
|
return /* @__PURE__ */ jsx5("div", { className: "min-w-[200px]", children: src ? /* @__PURE__ */ jsx5(AudioPlayer, { src, isMine: message.direction === "outbound" }) : /* @__PURE__ */ jsx5("div", { className: "h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs", children: bubble.mediaUnavailable }) });
|
|
343
389
|
}
|
|
344
390
|
case "document": {
|
|
345
|
-
const typeLabel = message.mimeType
|
|
391
|
+
const typeLabel = documentTypeLabel(message.filename, message.mimeType);
|
|
346
392
|
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null;
|
|
347
393
|
return /* @__PURE__ */ jsxs3("div", { className: cn("flex items-center gap-3 min-w-[200px]", className), children: [
|
|
348
394
|
/* @__PURE__ */ jsx5("div", { className: "w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0", children: /* @__PURE__ */ jsx5(FileIcon, { filename: message.filename, mimeType: message.mimeType }) }),
|
|
349
395
|
/* @__PURE__ */ jsxs3("div", { className: "flex-1 min-w-0", children: [
|
|
350
396
|
/* @__PURE__ */ jsx5("p", { className: "text-sm font-medium truncate", children: message.filename ?? bubble.untitledDocument }),
|
|
351
|
-
/* @__PURE__ */ jsx5("p", { className: "text-xs text-gray-500", children: sizeLabel ? `${typeLabel} \xB7 ${sizeLabel}` : typeLabel })
|
|
397
|
+
/* @__PURE__ */ jsx5("p", { className: "truncate text-xs text-gray-500", children: sizeLabel ? `${typeLabel} \xB7 ${sizeLabel}` : typeLabel })
|
|
352
398
|
] }),
|
|
353
399
|
src ? /* @__PURE__ */ jsx5("a", { href: src, download: message.filename, className: "w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors", "aria-label": bubble.downloadFile, children: /* @__PURE__ */ jsxs3("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", className: "text-gray-600", children: [
|
|
354
400
|
/* @__PURE__ */ jsx5("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }),
|
|
@@ -1187,9 +1233,9 @@ function AudioRecorderButton({
|
|
|
1187
1233
|
{
|
|
1188
1234
|
role: "group",
|
|
1189
1235
|
"aria-label": labelOf("review"),
|
|
1190
|
-
className: "absolute bottom-full right-0 z-20 mb-2 flex w-
|
|
1236
|
+
className: "absolute bottom-full right-0 z-20 mb-2 flex w-[calc(100vw-2rem)] max-w-[20rem] flex-wrap items-center justify-end gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800",
|
|
1191
1237
|
children: [
|
|
1192
|
-
/* @__PURE__ */ jsx13("
|
|
1238
|
+
/* @__PURE__ */ jsx13("div", { className: "min-w-0 flex-1", children: /* @__PURE__ */ jsx13(AudioPlayer, { src: pending.objectURL }) }),
|
|
1193
1239
|
/* @__PURE__ */ jsx13(
|
|
1194
1240
|
"button",
|
|
1195
1241
|
{
|
package/dist/index.d.ts
CHANGED
|
@@ -560,6 +560,8 @@ interface ConversationContextEntry {
|
|
|
560
560
|
interface ConversationContextPanelLabels {
|
|
561
561
|
title: string;
|
|
562
562
|
empty: string;
|
|
563
|
+
collapse: string;
|
|
564
|
+
expand: string;
|
|
563
565
|
}
|
|
564
566
|
declare const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels;
|
|
565
567
|
interface ConversationContextPanelClassNames {
|
|
@@ -570,11 +572,18 @@ interface ConversationContextPanelClassNames {
|
|
|
570
572
|
}
|
|
571
573
|
interface ConversationContextPanelProps {
|
|
572
574
|
entries: readonly ConversationContextEntry[];
|
|
575
|
+
/**
|
|
576
|
+
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
577
|
+
*
|
|
578
|
+
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
579
|
+
* painel ocupa altura mostrando quase só travessões, e empurra a conversa — que é o que se veio ver.
|
|
580
|
+
*/
|
|
581
|
+
defaultOpen?: boolean;
|
|
573
582
|
labels?: Partial<ConversationContextPanelLabels>;
|
|
574
583
|
className?: string;
|
|
575
584
|
classNames?: Partial<ConversationContextPanelClassNames>;
|
|
576
585
|
}
|
|
577
|
-
declare function ConversationContextPanel({ entries, labels: labelsOverride, className, classNames, }: ConversationContextPanelProps): react.JSX.Element;
|
|
586
|
+
declare function ConversationContextPanel({ entries, defaultOpen, labels: labelsOverride, className, classNames, }: ConversationContextPanelProps): react.JSX.Element;
|
|
578
587
|
|
|
579
588
|
interface WindowExpiredNoticeLabels {
|
|
580
589
|
title: string;
|
package/dist/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
useConversationDocuments,
|
|
45
45
|
useConversationLocales,
|
|
46
46
|
useConversations
|
|
47
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-OIDAIVCH.js";
|
|
48
48
|
import {
|
|
49
49
|
htmlToWA,
|
|
50
50
|
parseWhatsAppFormatting,
|
|
@@ -1337,10 +1337,13 @@ function useIsNarrow() {
|
|
|
1337
1337
|
import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
1338
1338
|
var DEFAULT_CONVERSATION_CONTEXT_LABELS = {
|
|
1339
1339
|
title: "\u{1F4CB} Suas Sele\xE7\xF5es",
|
|
1340
|
-
empty: "Nada coletado ainda nesta conversa."
|
|
1340
|
+
empty: "Nada coletado ainda nesta conversa.",
|
|
1341
|
+
collapse: "fechar",
|
|
1342
|
+
expand: "abrir"
|
|
1341
1343
|
};
|
|
1342
1344
|
function ConversationContextPanel({
|
|
1343
1345
|
entries,
|
|
1346
|
+
defaultOpen,
|
|
1344
1347
|
labels: labelsOverride,
|
|
1345
1348
|
className,
|
|
1346
1349
|
classNames
|
|
@@ -1349,7 +1352,7 @@ function ConversationContextPanel({
|
|
|
1349
1352
|
const filled = entries.filter((entry) => Boolean(entry.value));
|
|
1350
1353
|
const isNarrow = useIsNarrow();
|
|
1351
1354
|
const [manualOpen, setManualOpen] = useState7(void 0);
|
|
1352
|
-
const open = manualOpen ?? (!isNarrow && filled.length > 0);
|
|
1355
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0);
|
|
1353
1356
|
return /* @__PURE__ */ jsxs11("section", { className: cn("border-b", classNames?.root, className), children: [
|
|
1354
1357
|
/* @__PURE__ */ jsxs11(
|
|
1355
1358
|
"button",
|
|
@@ -1357,7 +1360,11 @@ function ConversationContextPanel({
|
|
|
1357
1360
|
type: "button",
|
|
1358
1361
|
onClick: () => setManualOpen(!open),
|
|
1359
1362
|
"aria-expanded": open,
|
|
1360
|
-
|
|
1363
|
+
title: open ? labels.collapse : labels.expand,
|
|
1364
|
+
className: cn(
|
|
1365
|
+
"flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-gray-50 dark:hover:bg-gray-800",
|
|
1366
|
+
classNames?.toggle
|
|
1367
|
+
),
|
|
1361
1368
|
children: [
|
|
1362
1369
|
/* @__PURE__ */ jsx13("span", { "aria-hidden": true, className: "text-xs", children: open ? "\u25BE" : "\u25B8" }),
|
|
1363
1370
|
/* @__PURE__ */ jsx13("span", { children: labels.title }),
|
|
@@ -1365,7 +1372,8 @@ function ConversationContextPanel({
|
|
|
1365
1372
|
filled.length,
|
|
1366
1373
|
"/",
|
|
1367
1374
|
entries.length
|
|
1368
|
-
] })
|
|
1375
|
+
] }),
|
|
1376
|
+
/* @__PURE__ */ jsx13("span", { "aria-hidden": true, className: "ml-auto text-xs text-gray-500", children: open ? labels.collapse : labels.expand })
|
|
1369
1377
|
]
|
|
1370
1378
|
}
|
|
1371
1379
|
),
|
package/dist/preview/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
DocumentsLibrary,
|
|
9
9
|
MessageBubble,
|
|
10
10
|
MessageComposer
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-OIDAIVCH.js";
|
|
12
12
|
import "../chunk-2AYDBWNE.js";
|
|
13
13
|
|
|
14
14
|
// src/preview/previewStore.ts
|
|
@@ -938,7 +938,7 @@ function ConversationPreview({
|
|
|
938
938
|
return () => clearInterval(timer);
|
|
939
939
|
}, [pollIntervalMs, refresh]);
|
|
940
940
|
useEffect(() => {
|
|
941
|
-
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
941
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
942
942
|
}, [messages]);
|
|
943
943
|
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal]);
|
|
944
944
|
async function refreshWithFollowUps() {
|
package/package.json
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
12
|
|
|
13
|
+
import { AudioPlayer } from './AudioPlayer'
|
|
14
|
+
|
|
13
15
|
export interface AudioRecorderButtonLabels {
|
|
14
16
|
start: string
|
|
15
17
|
stop: string
|
|
@@ -192,9 +194,14 @@ export function AudioRecorderButton({
|
|
|
192
194
|
<div
|
|
193
195
|
role="group"
|
|
194
196
|
aria-label={labelOf('review')}
|
|
195
|
-
|
|
197
|
+
/* Largura presa à viewport, não ao conteúdo: ancorado à direita do microfone, qualquer
|
|
198
|
+
largura fixa maior que a tela sangra para fora no celular. `flex-wrap` é a segunda
|
|
199
|
+
rede — se o player e os dois botões não couberem lado a lado, os botões descem. */
|
|
200
|
+
className="absolute bottom-full right-0 z-20 mb-2 flex w-[calc(100vw-2rem)] max-w-[20rem] flex-wrap items-center justify-end gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
|
196
201
|
>
|
|
197
|
-
<
|
|
202
|
+
<div className="min-w-0 flex-1">
|
|
203
|
+
<AudioPlayer src={pending.objectURL} />
|
|
204
|
+
</div>
|
|
198
205
|
<button
|
|
199
206
|
type="button"
|
|
200
207
|
onClick={discard}
|
|
@@ -21,11 +21,15 @@ export interface ConversationContextEntry {
|
|
|
21
21
|
export interface ConversationContextPanelLabels {
|
|
22
22
|
title: string
|
|
23
23
|
empty: string
|
|
24
|
+
collapse: string
|
|
25
|
+
expand: string
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
|
|
27
29
|
title: '📋 Suas Seleções',
|
|
28
30
|
empty: 'Nada coletado ainda nesta conversa.',
|
|
31
|
+
collapse: 'fechar',
|
|
32
|
+
expand: 'abrir',
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export interface ConversationContextPanelClassNames {
|
|
@@ -37,6 +41,13 @@ export interface ConversationContextPanelClassNames {
|
|
|
37
41
|
|
|
38
42
|
export interface ConversationContextPanelProps {
|
|
39
43
|
entries: readonly ConversationContextEntry[]
|
|
44
|
+
/**
|
|
45
|
+
* Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
|
|
46
|
+
*
|
|
47
|
+
* Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
|
|
48
|
+
* painel ocupa altura mostrando quase só travessões, e empurra a conversa — que é o que se veio ver.
|
|
49
|
+
*/
|
|
50
|
+
defaultOpen?: boolean
|
|
40
51
|
labels?: Partial<ConversationContextPanelLabels>
|
|
41
52
|
className?: string
|
|
42
53
|
classNames?: Partial<ConversationContextPanelClassNames>
|
|
@@ -44,6 +55,7 @@ export interface ConversationContextPanelProps {
|
|
|
44
55
|
|
|
45
56
|
export function ConversationContextPanel({
|
|
46
57
|
entries,
|
|
58
|
+
defaultOpen,
|
|
47
59
|
labels: labelsOverride,
|
|
48
60
|
className,
|
|
49
61
|
classNames,
|
|
@@ -61,7 +73,7 @@ export function ConversationContextPanel({
|
|
|
61
73
|
const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
|
|
62
74
|
// No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa. O
|
|
63
75
|
// contador no cabeçalho já entrega a informação de relance.
|
|
64
|
-
const open = manualOpen ?? (!isNarrow && filled.length > 0)
|
|
76
|
+
const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0)
|
|
65
77
|
|
|
66
78
|
return (
|
|
67
79
|
<section className={cn('border-b', classNames?.root, className)}>
|
|
@@ -69,7 +81,11 @@ export function ConversationContextPanel({
|
|
|
69
81
|
type="button"
|
|
70
82
|
onClick={() => setManualOpen(!open)}
|
|
71
83
|
aria-expanded={open}
|
|
72
|
-
|
|
84
|
+
title={open ? labels.collapse : labels.expand}
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-gray-50 dark:hover:bg-gray-800',
|
|
87
|
+
classNames?.toggle,
|
|
88
|
+
)}
|
|
73
89
|
>
|
|
74
90
|
<span aria-hidden className="text-xs">
|
|
75
91
|
{open ? '▾' : '▸'}
|
|
@@ -78,6 +94,11 @@ export function ConversationContextPanel({
|
|
|
78
94
|
<span className={cn('rounded-full bg-gray-200 px-2 text-xs dark:bg-gray-700', classNames?.counter)}>
|
|
79
95
|
{filled.length}/{entries.length}
|
|
80
96
|
</span>
|
|
97
|
+
{/* Rótulo escrito na ponta direita: o caret sozinho não dizia que a linha inteira fecha o
|
|
98
|
+
painel — a pergunta "cadê o botão de fechar?" veio daí. */}
|
|
99
|
+
<span aria-hidden className="ml-auto text-xs text-gray-500">
|
|
100
|
+
{open ? labels.collapse : labels.expand}
|
|
101
|
+
</span>
|
|
81
102
|
</button>
|
|
82
103
|
|
|
83
104
|
{open ? (
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
2
|
import { AudioPlayer } from './AudioPlayer'
|
|
3
3
|
import { FileIcon } from './FileIcon'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
@@ -6,6 +6,25 @@ import { formatFileSize } from './lib/format'
|
|
|
6
6
|
import { cn } from './lib/cn'
|
|
7
7
|
import type { MessagePayload } from './types'
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Rótulo curto do tipo, para a linha de baixo da bolha de documento.
|
|
11
|
+
*
|
|
12
|
+
* O subtipo cru do Office é gigantesco — `vnd.openxmlformats-officedocument.wordprocessingml.document`
|
|
13
|
+
* vira uma linha de 58 caracteres em caixa alta que estica a bolha, empurra a coluna da conversa e
|
|
14
|
+
* quebra o layout de três painéis. A extensão do arquivo diz a mesma coisa em quatro letras.
|
|
15
|
+
*/
|
|
16
|
+
export function documentTypeLabel(filename?: string, mimeType?: string): string {
|
|
17
|
+
const extension = filename?.split('.').pop()
|
|
18
|
+
if (extension && extension.length <= 5 && extension !== filename) return extension.toUpperCase()
|
|
19
|
+
|
|
20
|
+
const subtype = mimeType?.split(';')[0]?.split('/')[1]
|
|
21
|
+
if (!subtype) return 'FILE'
|
|
22
|
+
// Sem extensão e com subtipo longo (áudio/vídeo sem nome), fica o sufixo depois do último ponto:
|
|
23
|
+
// `…wordprocessingml.document` -> `DOCUMENT`, que ainda informa e não estoura.
|
|
24
|
+
const compacto = subtype.split('.').pop() ?? subtype
|
|
25
|
+
return compacto.slice(0, 12).toUpperCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
9
28
|
function resolveMediaSource(message: MessagePayload): string | null {
|
|
10
29
|
if (message.mediaUrl) return message.mediaUrl
|
|
11
30
|
if (message.base64) {
|
|
@@ -65,16 +84,35 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
65
84
|
const src = eagerSrc ?? lazy.url
|
|
66
85
|
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
|
|
67
86
|
|
|
68
|
-
|
|
87
|
+
/**
|
|
88
|
+
* O carregamento sob demanda continua sendo um botão, mas com a forma da mídia que ele vai virar
|
|
89
|
+
* — um link sublinhado no meio da conversa lê como texto da mensagem, não como controle, e é a
|
|
90
|
+
* única bolha que não se parece com o que contém.
|
|
91
|
+
*/
|
|
92
|
+
function LazyMediaButton({ icon, label }: { icon: ReactNode; label: string }) {
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
onClick={lazy.load}
|
|
96
|
+
disabled={lazy.loading}
|
|
97
|
+
className="flex min-w-[180px] items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-left transition-colors hover:bg-black/10 disabled:opacity-60 dark:bg-white/10 dark:hover:bg-white/15"
|
|
98
|
+
>
|
|
99
|
+
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white">
|
|
100
|
+
{icon}
|
|
101
|
+
</span>
|
|
102
|
+
<span className="truncate text-xs text-gray-600 dark:text-gray-300">{label}</span>
|
|
103
|
+
</button>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
69
106
|
|
|
70
107
|
switch (message.type) {
|
|
71
108
|
case 'image':
|
|
72
109
|
case 'sticker': {
|
|
73
110
|
if (!src && canLazyLoad) {
|
|
74
111
|
return (
|
|
75
|
-
<
|
|
76
|
-
{
|
|
77
|
-
|
|
112
|
+
<LazyMediaButton
|
|
113
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg>}
|
|
114
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
|
|
115
|
+
/>
|
|
78
116
|
)
|
|
79
117
|
}
|
|
80
118
|
return (
|
|
@@ -92,9 +130,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
92
130
|
case 'video': {
|
|
93
131
|
if (!src && canLazyLoad) {
|
|
94
132
|
return (
|
|
95
|
-
<
|
|
96
|
-
{
|
|
97
|
-
|
|
133
|
+
<LazyMediaButton
|
|
134
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" /></svg>}
|
|
135
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
|
|
136
|
+
/>
|
|
98
137
|
)
|
|
99
138
|
}
|
|
100
139
|
return (
|
|
@@ -112,9 +151,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
112
151
|
case 'audio': {
|
|
113
152
|
if (!src && canLazyLoad) {
|
|
114
153
|
return (
|
|
115
|
-
<
|
|
116
|
-
{
|
|
117
|
-
|
|
154
|
+
<LazyMediaButton
|
|
155
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
|
|
156
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
|
|
157
|
+
/>
|
|
118
158
|
)
|
|
119
159
|
}
|
|
120
160
|
return (
|
|
@@ -126,7 +166,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
126
166
|
)
|
|
127
167
|
}
|
|
128
168
|
case 'document': {
|
|
129
|
-
const typeLabel = message.mimeType
|
|
169
|
+
const typeLabel = documentTypeLabel(message.filename, message.mimeType)
|
|
130
170
|
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
|
|
131
171
|
return (
|
|
132
172
|
<div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
|
|
@@ -135,7 +175,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
135
175
|
</div>
|
|
136
176
|
<div className="flex-1 min-w-0">
|
|
137
177
|
<p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
|
|
138
|
-
<p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
|
|
178
|
+
<p className="truncate text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
|
|
139
179
|
</div>
|
|
140
180
|
{src ? (
|
|
141
181
|
<a href={src} download={message.filename} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors" aria-label={bubble.downloadFile}>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda o rótulo de tipo da bolha de documento.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou: `mimeType.split('/')[1].toUpperCase()` transformava um `.docx` em
|
|
5
|
+
* `VND.OPENXMLFORMATS-OFFICEDOCUMENT.WORDPROCESSINGML.DOCUMENT` — 58 caracteres numa linha que não
|
|
6
|
+
* quebra, esticando a bolha até empurrar a coluna da conversa e estourar o layout de três painéis.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import { documentTypeLabel } from './MediaRenderer'
|
|
12
|
+
|
|
13
|
+
describe('documentTypeLabel', () => {
|
|
14
|
+
// O caso que quebrou a tela.
|
|
15
|
+
it('usa a extensão do arquivo em vez do subtipo gigante do Office', () => {
|
|
16
|
+
const rotulo = documentTypeLabel(
|
|
17
|
+
'contrato.docx',
|
|
18
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
expect(rotulo).toBe('DOCX')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('serve para os tipos curtos também', () => {
|
|
25
|
+
expect(documentTypeLabel('nota-fiscal.pdf', 'application/pdf')).toBe('PDF')
|
|
26
|
+
expect(documentTypeLabel('lista.txt', 'text/plain')).toBe('TXT')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Áudio e sticker chegam sem nome de arquivo: o rótulo vem do mimeType, ainda curto.
|
|
30
|
+
it('cai no sufixo do subtipo quando não há nome', () => {
|
|
31
|
+
expect(documentTypeLabel(undefined, 'application/vnd.ms-excel')).toBe('MS-EXCEL')
|
|
32
|
+
expect(documentTypeLabel(undefined, 'application/pdf')).toBe('PDF')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('descarta parâmetro do mimeType', () => {
|
|
36
|
+
expect(documentTypeLabel(undefined, 'audio/ogg; codecs=opus')).toBe('OGG')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Nome sem ponto não tem extensão — o id da mídia, por exemplo.
|
|
40
|
+
it('não confunde nome sem extensão com extensão', () => {
|
|
41
|
+
expect(documentTypeLabel('seed-media-03', 'application/zip')).toBe('ZIP')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('tem rótulo para o caso sem nome e sem tipo', () => {
|
|
45
|
+
expect(documentTypeLabel()).toBe('FILE')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Teto de tamanho: nenhum rótulo pode voltar a esticar a bolha.
|
|
49
|
+
it('nunca passa de 12 caracteres', () => {
|
|
50
|
+
const longo = documentTypeLabel(
|
|
51
|
+
undefined,
|
|
52
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(longo.length).toBeLessThanOrEqual(12)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -188,7 +188,10 @@ export function ConversationPreview({
|
|
|
188
188
|
}, [pollIntervalMs, refresh])
|
|
189
189
|
|
|
190
190
|
useEffect(() => {
|
|
191
|
-
|
|
191
|
+
// `block: 'nearest'` e não o padrão ('start'): o padrão alinha o elemento ao topo da área
|
|
192
|
+
// visível MAIS PRÓXIMA que role — e quando o container do preview não tem altura limitada, essa
|
|
193
|
+
// área é a PÁGINA. O efeito era a tela inteira saltar para baixo ao abrir/usar o simulador.
|
|
194
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
192
195
|
}, [messages])
|
|
193
196
|
|
|
194
197
|
const rendered = useMemo(() => decorate([...messages, ...pendingLocal]), [messages, pendingLocal])
|