@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.
@@ -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
- const lazyButtonClass = "text-xs text-blue-600 underline flex items-center gap-1";
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("button", { onClick: lazy.load, className: lazyButtonClass, children: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage });
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("button", { onClick: lazy.load, className: lazyButtonClass, children: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo });
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("button", { onClick: lazy.load, className: lazyButtonClass, children: lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio });
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?.split("/")[1]?.toUpperCase() ?? "FILE";
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-64 items-center gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800",
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("audio", { src: pending.objectURL, controls: true, className: "h-8 min-w-0 flex-1" }),
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-SOWV4264.js";
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
- className: cn("flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium", classNames?.toggle),
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
  ),
@@ -8,7 +8,7 @@ import {
8
8
  DocumentsLibrary,
9
9
  MessageBubble,
10
10
  MessageComposer
11
- } from "../chunk-SOWV4264.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.15",
3
+ "version": "0.1.0-rc.17",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
- className="absolute bottom-full right-0 z-20 mb-2 flex w-64 items-center gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
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
- <audio src={pending.objectURL} controls className="h-8 min-w-0 flex-1" />
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
- className={cn('flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium', classNames?.toggle)}
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 ? (
@@ -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
- const lazyButtonClass = 'text-xs text-blue-600 underline flex items-center gap-1'
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
- <button onClick={lazy.load} className={lazyButtonClass}>
76
- {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
77
- </button>
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
- <button onClick={lazy.load} className={lazyButtonClass}>
96
- {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
97
- </button>
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
- <button onClick={lazy.load} className={lazyButtonClass}>
116
- {lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
117
- </button>
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?.split('/')[1]?.toUpperCase() ?? 'FILE'
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
- bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
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])