@adatechnology/conversations-ui 0.1.0-rc.5 → 0.1.0-rc.6

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.
@@ -3,8 +3,21 @@ import { Avatar } from './Avatar'
3
3
  import { contactFlag, formatContactHandle } from './conversationChannel'
4
4
  import type { ConversationSummary } from './providers/types'
5
5
 
6
+ export interface ConversationListItemLabels {
7
+ /** Tooltip do ponto vermelho: a janela de atendimento de 24h já fechou. */
8
+ expiredWindow: string
9
+ /** Tooltip do ponto laranja: a janela de atendimento está perto de fechar. */
10
+ warningWindow: string
11
+ }
12
+
13
+ export const DEFAULT_CONVERSATION_LIST_ITEM_LABELS: ConversationListItemLabels = {
14
+ expiredWindow: 'Janela expirada',
15
+ warningWindow: 'Janela próxima do fim',
16
+ }
17
+
6
18
  export interface ConversationListItemProps {
7
19
  conversation: ConversationSummary
20
+ labels?: Partial<ConversationListItemLabels>
8
21
  active?: boolean
9
22
  selected?: boolean
10
23
  onClick?: () => void
@@ -92,7 +105,10 @@ export const ConversationListItem = ({
92
105
  onSelect,
93
106
  showDivider = true,
94
107
  highlightActive = true,
108
+ labels,
95
109
  }: ConversationListItemProps) => {
110
+ const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
111
+ const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
96
112
  const isActive = active || selected
97
113
  const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
98
114
  const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
@@ -134,10 +150,10 @@ export const ConversationListItem = ({
134
150
  {!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
135
151
  <span className="text-[16px] text-[#111b21] truncate">{name}</span>
136
152
  {windowStatus && windowStatus.label === 'expired' && (
137
- <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
153
+ <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title={expiredWindowLabel} />
138
154
  )}
139
155
  {windowStatus && windowStatus.label === 'warning' && (
140
- <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title="Janela próxima do fim" />
156
+ <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title={warningWindowLabel} />
141
157
  )}
142
158
  </div>
143
159
  {timestamp && (
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Biblioteca de arquivos de TODAS as conversas — a tela de Documentos do painel, fora do
3
+ * atendimento.
4
+ *
5
+ * Distinta do `ConversationDocumentsPanel`: aquele parte de uma conversa aberta e vive dentro dela;
6
+ * esta varre a empresa e por isso mostra de qual conversa cada arquivo veio, com o número
7
+ * clicável. Sem essa referência, uma lista global de anexos não responde nenhuma pergunta.
8
+ */
9
+
10
+ import { useEffect, useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Users } from 'lucide-react'
12
+ import { useConversations } from './providers/ConversationsProvider'
13
+ import { DOCUMENT_SOURCE_FILTER, type DocumentSourceFilter } from './ConversationDocumentsPanel'
14
+ import { FileIcon } from './FileIcon'
15
+ import { cn } from './lib/cn'
16
+ import { formatDateTime, formatFileSize } from './lib/format'
17
+ import { formatPhone } from './lib/phone'
18
+ import type { CompanyDocument } from './providers/types'
19
+
20
+ export interface DocumentsLibraryLabels {
21
+ title: string
22
+ searchPlaceholder: string
23
+ empty: string
24
+ noResults: string
25
+ loading: string
26
+ failure: string
27
+ view: string
28
+ download: string
29
+ openConversation: string
30
+ sourceFilterAll: string
31
+ sourceFilterCustomer: string
32
+ sourceFilterTeam: string
33
+ sortMostRecent: string
34
+ sortOldest: string
35
+ clearFilters: string
36
+ total: (count: number) => string
37
+ page: (current: number, last: number) => string
38
+ }
39
+
40
+ export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
41
+ title: 'Documentos',
42
+ searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
43
+ empty: 'Nenhum arquivo trocado ainda.',
44
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
45
+ loading: 'Carregando arquivos…',
46
+ failure: 'Não foi possível carregar os arquivos.',
47
+ view: 'Visualizar',
48
+ download: 'Baixar',
49
+ openConversation: 'Abrir conversa',
50
+ sourceFilterAll: 'Todas as origens',
51
+ sourceFilterCustomer: 'Cliente',
52
+ sourceFilterTeam: 'Equipe',
53
+ sortMostRecent: 'Mais recentes',
54
+ sortOldest: 'Mais antigos',
55
+ clearFilters: 'Limpar filtros',
56
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
57
+ page: (current: number, last: number) => `${current} / ${last}`,
58
+ }
59
+
60
+ export interface DocumentsLibraryClassNames {
61
+ root: string
62
+ title: string
63
+ filters: string
64
+ search: string
65
+ sourceSelect: string
66
+ sortButton: string
67
+ clearButton: string
68
+ status: string
69
+ list: string
70
+ item: string
71
+ conversationLink: string
72
+ filename: string
73
+ meta: string
74
+ pagination: string
75
+ }
76
+
77
+ export interface DocumentsLibraryProps {
78
+ /** Itens por página. O total vem do servidor. */
79
+ perPage?: number
80
+ /** Abrir a conversa de origem. Ausente, o número aparece como texto e não como link. */
81
+ onOpenConversation?: (conversationId: string) => void
82
+ labels?: Partial<DocumentsLibraryLabels>
83
+ className?: string
84
+ classNames?: Partial<DocumentsLibraryClassNames>
85
+ }
86
+
87
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
88
+ const DEFAULT_PER_PAGE = 20
89
+
90
+ export function DocumentsLibrary({
91
+ perPage = DEFAULT_PER_PAGE,
92
+ onOpenConversation,
93
+ labels: labelsOverride,
94
+ className,
95
+ classNames,
96
+ }: DocumentsLibraryProps) {
97
+ const labels = { ...DEFAULT_DOCUMENTS_LIBRARY_LABELS, ...labelsOverride }
98
+ const context = useConversations()
99
+ const [search, setSearch] = useState('')
100
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
101
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
102
+ const [page, setPage] = useState(1)
103
+ const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
104
+ const [total, setTotal] = useState(0)
105
+ const [loading, setLoading] = useState(false)
106
+ const [failed, setFailed] = useState(false)
107
+
108
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
109
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
110
+ const fetchAll = context?.api.getAllDocuments
111
+
112
+ useEffect(() => {
113
+ if (!fetchAll) return
114
+ let active = true
115
+ setLoading(true)
116
+ setFailed(false)
117
+
118
+ void fetchAll({
119
+ search,
120
+ page,
121
+ limit: perPage,
122
+ sortDirection,
123
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
124
+ })
125
+ .then((result) => {
126
+ // `active` evita que uma resposta antiga sobrescreva a nova: digitar rápido na busca dispara
127
+ // várias chamadas e a ordem de retorno não é garantida.
128
+ if (!active) return
129
+ setDocuments(result.documents)
130
+ setTotal(result.total)
131
+ })
132
+ .catch(() => {
133
+ if (active) setFailed(true)
134
+ })
135
+ .finally(() => {
136
+ if (active) setLoading(false)
137
+ })
138
+
139
+ return () => {
140
+ active = false
141
+ }
142
+ }, [fetchAll, search, sourceFilter, sortDirection, page, perPage])
143
+
144
+ function applyFilter(change: () => void): void {
145
+ change()
146
+ setPage(1)
147
+ }
148
+
149
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
150
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
151
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
152
+ }
153
+
154
+ // Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
155
+ // sempre e fazer parecer que a empresa não tem arquivo nenhum.
156
+ if (!fetchAll) return null
157
+
158
+ return (
159
+ <div className={cn('space-y-3', classNames?.root, className)}>
160
+ <h2 className={cn('text-lg font-semibold', classNames?.title)}>{labels.title}</h2>
161
+
162
+ <div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
163
+ <input
164
+ type="search"
165
+ value={search}
166
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
167
+ placeholder={labels.searchPlaceholder}
168
+ aria-label={labels.searchPlaceholder}
169
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-64', classNames?.search)}
170
+ />
171
+
172
+ <select
173
+ value={sourceFilter}
174
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
175
+ aria-label={labels.sourceFilterAll}
176
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-40', classNames?.sourceSelect)}
177
+ >
178
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
179
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
180
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
181
+ </select>
182
+
183
+ <button
184
+ type="button"
185
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
186
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
187
+ >
188
+ <ArrowUpDown size={14} />
189
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
190
+ </button>
191
+
192
+ {hasFilters ? (
193
+ <button
194
+ type="button"
195
+ onClick={() =>
196
+ applyFilter(() => {
197
+ setSearch('')
198
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
199
+ setSortDirection('desc')
200
+ })
201
+ }
202
+ className={cn('cv-header-action', classNames?.clearButton)}
203
+ >
204
+ {labels.clearFilters}
205
+ </button>
206
+ ) : null}
207
+ </div>
208
+
209
+ {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
210
+ {failed ? (
211
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
212
+ {labels.failure}
213
+ </p>
214
+ ) : null}
215
+ {!loading && !failed && documents.length === 0 ? (
216
+ <p className={cn('text-sm text-gray-500', classNames?.status)}>
217
+ {hasFilters ? labels.noResults : labels.empty}
218
+ </p>
219
+ ) : null}
220
+
221
+ <ul className={cn('space-y-2', classNames?.list)}>
222
+ {documents.map((document) => {
223
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
224
+ const SourceIcon = isFromCustomer ? Users : Bot
225
+
226
+ return (
227
+ <li
228
+ key={`${document.conversationId}:${document.id}`}
229
+ className={cn(
230
+ 'flex items-center justify-between gap-3 rounded-lg border px-3 py-2 dark:border-gray-700',
231
+ classNames?.item,
232
+ )}
233
+ >
234
+ <div className="flex min-w-0 flex-1 items-center gap-3">
235
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
236
+ <div className="min-w-0 flex-1">
237
+ <div className={cn('truncate text-sm font-medium', classNames?.filename)} title={document.filename}>
238
+ {document.filename}
239
+ </div>
240
+ <div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
241
+ <span className="inline-flex items-center gap-1">
242
+ <SourceIcon size={11} />
243
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
244
+ </span>
245
+ <span>·</span>
246
+ <span>{formatDateTime(document.linkedAt)}</span>
247
+ <span>·</span>
248
+ <span>{formatFileSize(document.sizeBytes)}</span>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ {/* A conversa de origem é o dado que só esta tela tem. Vira botão quando o host sabe
254
+ navegar; sem handler, fica texto — link que não leva a lugar nenhum é pior. */}
255
+ {onOpenConversation ? (
256
+ <button
257
+ type="button"
258
+ onClick={() => onOpenConversation(document.conversationId)}
259
+ title={labels.openConversation}
260
+ className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
261
+ >
262
+ <MessageSquare size={12} />
263
+ {formatPhone(document.conversationId)}
264
+ </button>
265
+ ) : (
266
+ <span className={cn('shrink-0 text-xs text-gray-500', classNames?.conversationLink)}>
267
+ {formatPhone(document.conversationId)}
268
+ </span>
269
+ )}
270
+
271
+ <div className="flex shrink-0 gap-1">
272
+ <button
273
+ type="button"
274
+ onClick={() => void handleOpen(document.id, 'inline')}
275
+ title={labels.view}
276
+ aria-label={`${labels.view}: ${document.filename}`}
277
+ className="cv-header-icon"
278
+ >
279
+ <Eye size={14} />
280
+ </button>
281
+ <button
282
+ type="button"
283
+ onClick={() => void handleOpen(document.id, 'attachment')}
284
+ title={labels.download}
285
+ aria-label={`${labels.download}: ${document.filename}`}
286
+ className="cv-header-icon"
287
+ >
288
+ <Download size={14} />
289
+ </button>
290
+ </div>
291
+ </li>
292
+ )
293
+ })}
294
+ </ul>
295
+
296
+ {total > perPage ? (
297
+ <div className={cn('flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700', classNames?.pagination)}>
298
+ <span className="text-gray-400">{labels.total(total)}</span>
299
+ <div className="flex items-center gap-2">
300
+ <button
301
+ type="button"
302
+ onClick={() => setPage(page - 1)}
303
+ disabled={page <= 1}
304
+ className="cv-header-icon disabled:opacity-40"
305
+ >
306
+ ‹
307
+ </button>
308
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
309
+ <button
310
+ type="button"
311
+ onClick={() => setPage(page + 1)}
312
+ disabled={page >= lastPage}
313
+ className="cv-header-icon disabled:opacity-40"
314
+ >
315
+ ›
316
+ </button>
317
+ </div>
318
+ </div>
319
+ ) : null}
320
+ </div>
321
+ )
322
+ }
@@ -33,6 +33,51 @@ describe('resolveFileIconExtension', () => {
33
33
  })
34
34
 
35
35
  it('devolve algo fora do mapa para tipo desconhecido, caindo no ícone genérico', () => {
36
- expect(resolveFileIconExtension('lista-compras.txt', 'text/plain')).toBe('plain')
36
+ expect(resolveFileIconExtension('backup', 'application/octet-stream')).toBe('octet-stream')
37
+ })
38
+
39
+ // Imagem, vídeo e áudio entram na biblioteca junto dos documentos — o backend linka as cinco
40
+ // espécies de mídia (image/audio/video/document/sticker), não só `document`.
41
+ const MEDIA_TYPES = [
42
+ { filename: 'foto.jpg', mimeType: 'image/jpeg', expected: 'jpg' },
43
+ { filename: 'prateleira.png', mimeType: 'image/png', expected: 'png' },
44
+ { filename: 'video-do-produto.mp4', mimeType: 'video/mp4', expected: 'mp4' },
45
+ { filename: 'antigo.3gp', mimeType: 'video/3gp', expected: '3gp' },
46
+ { filename: 'musica.mp3', mimeType: 'audio/mpeg', expected: 'mp3' },
47
+ { filename: 'recado.m4a', mimeType: 'audio/mp4', expected: 'm4a' },
48
+ { filename: 'lista-compras.txt', mimeType: 'text/plain', expected: 'txt' },
49
+ { filename: 'planilha.csv', mimeType: 'text/csv', expected: 'csv' },
50
+ {
51
+ filename: 'apresentacao.pptx',
52
+ mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
53
+ expected: 'pptx',
54
+ },
55
+ ]
56
+
57
+ for (const testCase of MEDIA_TYPES) {
58
+ it(`resolve ${testCase.expected}`, () => {
59
+ expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
60
+ })
61
+ }
62
+
63
+ // Áudio e sticker chegam da Meta sem nome de arquivo: o backend salva o id da mídia como rótulo,
64
+ // então a única pista de tipo é o mimeType.
65
+ it('resolve pela família quando o nome é o id da mídia, sem extensão', () => {
66
+ expect(resolveFileIconExtension('wamid-abc123', 'image/webp')).toBe('image')
67
+ expect(resolveFileIconExtension('wamid-abc123', 'video/mp4')).toBe('video')
68
+ expect(resolveFileIconExtension('wamid-abc123', 'audio/aac')).toBe('audio')
69
+ })
70
+
71
+ // `audio/ogg; codecs=opus` é o formato do áudio de WhatsApp. Com o parâmetro colado, o subtipo
72
+ // viria "ogg; codecs=opus" e o áudio cairia no ícone genérico.
73
+ it('descarta parâmetro do mimeType', () => {
74
+ expect(resolveFileIconExtension('wamid-audio', 'audio/ogg; codecs=opus')).toBe('audio')
75
+ })
76
+
77
+ // Pelo subtipo, `audio/mp4` casaria a chave `mp4` — que é vídeo. A família vem primeiro
78
+ // justamente para um m4a não aparecer com ícone de filme.
79
+ it('não confunde audio/mp4 com vídeo', () => {
80
+ expect(resolveFileIconExtension(undefined, 'audio/mp4')).toBe('audio')
81
+ expect(resolveFileIconExtension(undefined, 'video/mp4')).toBe('video')
37
82
  })
38
83
  })
package/src/FileIcon.tsx CHANGED
@@ -1,4 +1,13 @@
1
- import { FileArchive, FileSpreadsheet, FileText, File as FileGeneric } from 'lucide-react'
1
+ import {
2
+ FileArchive,
3
+ FileAudio,
4
+ FileImage,
5
+ FileSpreadsheet,
6
+ FileText,
7
+ FileVideo,
8
+ File as FileGeneric,
9
+ Presentation,
10
+ } from 'lucide-react'
2
11
 
3
12
  import { cn } from './lib/cn'
4
13
 
@@ -9,26 +18,83 @@ export interface FileIconProps {
9
18
  className?: string
10
19
  }
11
20
 
12
- const EXTENSION_STYLE: Record<string, { Icon: typeof FileText; colorClass: string }> = {
21
+ type IconStyle = { Icon: typeof FileText; colorClass: string }
22
+
23
+ // Um estilo por família, reaproveitado por todas as extensões dela: assim o ícone de um `.jpg`
24
+ // resolvido pelo nome é o mesmo de um `image/jpeg` resolvido pelo mimeType. Com cor por extensão, a
25
+ // mesma foto trocaria de cor conforme o dado que chegou junto.
26
+ const IMAGE_STYLE: IconStyle = { Icon: FileImage, colorClass: 'text-violet-500' }
27
+ const VIDEO_STYLE: IconStyle = { Icon: FileVideo, colorClass: 'text-fuchsia-500' }
28
+ const AUDIO_STYLE: IconStyle = { Icon: FileAudio, colorClass: 'text-amber-500' }
29
+ const SHEET_STYLE: IconStyle = { Icon: FileSpreadsheet, colorClass: 'text-green-600' }
30
+ const WORD_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-blue-500' }
31
+ const SLIDES_STYLE: IconStyle = { Icon: Presentation, colorClass: 'text-orange-600' }
32
+ const TEXT_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-gray-500' }
33
+
34
+ const EXTENSION_STYLE: Record<string, IconStyle> = {
13
35
  pdf: { Icon: FileText, colorClass: 'text-red-500' },
14
- doc: { Icon: FileText, colorClass: 'text-blue-500' },
15
- docx: { Icon: FileText, colorClass: 'text-blue-500' },
16
- xls: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
17
- xlsx: { Icon: FileSpreadsheet, colorClass: 'text-green-600' },
36
+ doc: WORD_STYLE,
37
+ docx: WORD_STYLE,
38
+ xls: SHEET_STYLE,
39
+ xlsx: SHEET_STYLE,
40
+ csv: SHEET_STYLE,
41
+ ppt: SLIDES_STYLE,
42
+ pptx: SLIDES_STYLE,
18
43
  zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
44
+ txt: TEXT_STYLE,
45
+ plain: TEXT_STYLE,
46
+
47
+ image: IMAGE_STYLE,
48
+ jpg: IMAGE_STYLE,
49
+ jpeg: IMAGE_STYLE,
50
+ png: IMAGE_STYLE,
51
+ webp: IMAGE_STYLE,
52
+ gif: IMAGE_STYLE,
53
+ heic: IMAGE_STYLE,
54
+
55
+ video: VIDEO_STYLE,
56
+ mp4: VIDEO_STYLE,
57
+ '3gp': VIDEO_STYLE,
58
+ '3gpp': VIDEO_STYLE,
59
+ mov: VIDEO_STYLE,
60
+ webm: VIDEO_STYLE,
61
+
62
+ audio: AUDIO_STYLE,
63
+ mp3: AUDIO_STYLE,
64
+ mpeg: AUDIO_STYLE,
65
+ ogg: AUDIO_STYLE,
66
+ oga: AUDIO_STYLE,
67
+ opus: AUDIO_STYLE,
68
+ aac: AUDIO_STYLE,
69
+ amr: AUDIO_STYLE,
70
+ m4a: AUDIO_STYLE,
71
+ wav: AUDIO_STYLE,
19
72
  }
20
73
 
74
+ const MEDIA_FAMILIES = new Set(['image', 'video', 'audio'])
75
+
21
76
  /**
22
- * O nome do arquivo tem precedência sobre o mimeType porque o mapa é indexado por extensão curta:
23
- * o mimeType do Office é longo (`…wordprocessingml.document`) e nunca casaria, então quem passa só
24
- * mimeType perde o ícone de Word e de Excel.
77
+ * A chave de estilo do arquivo, na ordem em que cada dado é confiável.
78
+ *
79
+ * 1. **extensão do nome** — o mimeType do Office é longo (`…wordprocessingml.document`) e nunca
80
+ * casaria, então quem olhasse só o mimeType perderia o ícone de Word e de Excel;
81
+ * 2. **família do mimeType** (`image/`, `video/`, `audio/`) — tem de vir ANTES do subtipo por causa
82
+ * de `audio/mp4`: pelo subtipo, um áudio m4a ganharia o ícone de vídeo;
83
+ * 3. **subtipo** — cobre `application/pdf` e `application/zip`, que chegam sem nome de arquivo.
84
+ *
85
+ * Áudio de WhatsApp chega como `audio/ogg; codecs=opus`; o parâmetro depois do `;` é descartado,
86
+ * senão o subtipo viria `ogg; codecs=opus` e não casaria nada.
25
87
  *
26
88
  * Exportada para teste: é a regra que já regrediu uma vez no painel de documentos.
27
89
  */
28
90
  export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
29
91
  const fromFilename = filename?.split('.').pop()?.toLowerCase()
30
92
  if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
31
- return mimeType?.split('/')[1]?.toLowerCase() ?? ''
93
+
94
+ const [family, subtype] = (mimeType ?? '').split(';')[0]!.toLowerCase().split('/')
95
+ if (family && MEDIA_FAMILIES.has(family)) return family
96
+
97
+ return subtype ?? ''
32
98
  }
33
99
 
34
100
  // Melhoria sobre a paridade da bolha de documento (T6.7 usava um único ícone genérico
package/src/Lightbox.tsx CHANGED
@@ -1,16 +1,31 @@
1
+ export interface LightboxLabels {
2
+ /** Texto alternativo quando a imagem não tem legenda — sem ele o leitor de tela anuncia a URL. */
3
+ imageAlt: string
4
+ close: string
5
+ }
6
+
1
7
  export interface LightboxProps {
2
8
  imageUrl: string
3
9
  caption?: string
4
10
  onClose: () => void
11
+ labels?: Partial<LightboxLabels>
5
12
  }
6
13
 
7
- export function Lightbox({ imageUrl, caption, onClose }: LightboxProps) {
14
+ export const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {
15
+ imageAlt: 'Imagem',
16
+ close: 'Fechar',
17
+ }
18
+
19
+ export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps) {
20
+ const imageAltLabel = labels?.imageAlt ?? DEFAULT_LIGHTBOX_LABELS.imageAlt
21
+ const closeLabel = labels?.close ?? DEFAULT_LIGHTBOX_LABELS.close
22
+
8
23
  return (
9
24
  <div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
10
25
  <div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
11
- <img src={imageUrl} alt={caption ?? 'Image'} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
26
+ <img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
12
27
  {caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
13
- <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">Fechar</button>
28
+ <button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
14
29
  </div>
15
30
  </div>
16
31
  )
@@ -1,4 +1,4 @@
1
- import { useState } from 'react'
1
+ import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
3
  import type { MessagePayload } from './types'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
@@ -7,6 +7,8 @@ import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
7
7
  import { Lightbox } from './Lightbox'
8
8
  import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
9
9
  import { cn } from './lib/cn'
10
+ import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
11
+ import { useConversations } from './providers/ConversationsProvider'
10
12
  import { formatTimestamp, formatDateTime } from './lib/format'
11
13
 
12
14
  export interface MessageBubbleProps {
@@ -17,6 +19,10 @@ export interface MessageBubbleProps {
17
19
  isSelecting?: boolean
18
20
  isSelected?: boolean
19
21
  onToggleSelect?: () => void
22
+ /**
23
+ * Como buscar a mídia da mensagem. Ausente, o balão usa o `ConversationsApi` do
24
+ * `ConversationsProvider` — passe apenas para sobrescrever (cache próprio, CDN, proxy do host).
25
+ */
20
26
  onResolveMediaUrl?: ResolveMediaUrl
21
27
  className?: string
22
28
  }
@@ -43,6 +49,16 @@ export function MessageBubble({
43
49
  }: MessageBubbleProps) {
44
50
  const { bubble, selection } = useConversationLocales()
45
51
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
52
+ const context = useConversations()
53
+
54
+ // Resolvedor pelo contexto quando o host não passa um: o contrato já declara `getDocumentUrl` e
55
+ // `getMediaProxyUrl`, então exigir que cada projeto ligasse esse fio só garantia que a mídia não
56
+ // carregasse em quem esquecesse. `useMemo` porque o resolvedor é a identidade de que o
57
+ // `MediaRenderer` depende para não refazer busca a cada render.
58
+ const resolveMediaUrl = useMemo(
59
+ () => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
60
+ [onResolveMediaUrl, context?.api],
61
+ )
46
62
 
47
63
  const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
48
64
  const hasError = message.status === 'failed'
@@ -115,7 +131,7 @@ export function MessageBubble({
115
131
  )}
116
132
 
117
133
  {isMedia ? (
118
- <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
134
+ <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
119
135
  ) : (
120
136
  <>
121
137
  {isTemplate && (
@@ -3,7 +3,20 @@ import type { ConversationsFeatures } from './types'
3
3
  import { cn } from './lib/cn'
4
4
  import { EmojiPicker } from './EmojiPicker'
5
5
 
6
+ export interface MessageComposerLabels {
7
+ emoji: string
8
+ attach: string
9
+ send: string
10
+ }
11
+
12
+ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
13
+ emoji: 'Emoji',
14
+ attach: 'Anexar',
15
+ send: 'Enviar',
16
+ }
17
+
6
18
  export interface MessageComposerProps {
19
+ labels?: Partial<MessageComposerLabels>
7
20
  onSend: (text: string) => void
8
21
  onAttach?: (file: File) => void
9
22
  value?: string
@@ -42,7 +55,11 @@ export const MessageComposer = ({
42
55
  acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
43
56
  className,
44
57
  classNames,
58
+ labels,
45
59
  }: MessageComposerProps) => {
60
+ const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
61
+ const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
62
+ const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
46
63
  const [internalText, setInternalText] = useState('')
47
64
  const [showEmoji, setShowEmoji] = useState(false)
48
65
  const [attachments, setAttachments] = useState<FilePreview[]>([])
@@ -168,7 +185,7 @@ export const MessageComposer = ({
168
185
  <div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
169
186
  {showEmojiButton && (
170
187
  <div className="relative flex-shrink-0">
171
- <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label="Emoji">
188
+ <button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
172
189
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
173
190
  </button>
174
191
  {showEmoji && (
@@ -194,7 +211,7 @@ export const MessageComposer = ({
194
211
  {showAttachButton && (
195
212
  <>
196
213
  <input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
197
- <button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label="Anexar">
214
+ <button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
198
215
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
199
216
  </button>
200
217
  </>
@@ -208,7 +225,7 @@ export const MessageComposer = ({
208
225
  ? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
209
226
  : 'bg-gray-200 text-gray-400 cursor-not-allowed'
210
227
  }`}
211
- aria-label="Enviar"
228
+ aria-label={sendLabel}
212
229
  >
213
230
  <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
214
231
  </button>