@adatechnology/conversations-ui 0.1.0-rc.4 → 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.
- package/dist/chunk-LITPZCWW.js +1465 -0
- package/dist/index.d.ts +223 -29
- package/dist/index.js +316 -366
- package/dist/preview/index.d.ts +92 -3
- package/dist/preview/index.js +627 -103
- package/dist/types-CeixG2Z9.d.ts +324 -0
- package/package.json +2 -2
- package/src/Avatar.tsx +13 -2
- package/src/ConversationDocumentsPanel.tsx +342 -24
- package/src/ConversationListItem.tsx +18 -2
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +5 -2
- package/src/MessageBubble.tsx +18 -2
- package/src/MessageComposer.tsx +20 -3
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +32 -11
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- package/src/preview/index.ts +6 -1
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/providers/types.ts +127 -9
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveFileIconExtension } from './FileIcon'
|
|
4
|
+
|
|
5
|
+
describe('resolveFileIconExtension', () => {
|
|
6
|
+
// Regressão: o painel de documentos passava só o mimeType, e o do Office é longo
|
|
7
|
+
// (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`), então docx, doc,
|
|
8
|
+
// xlsx e xls apareciam todos com o ícone genérico cinza.
|
|
9
|
+
const OFFICE_MIME_TYPES = [
|
|
10
|
+
{
|
|
11
|
+
filename: 'contrato.docx',
|
|
12
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
13
|
+
expected: 'docx',
|
|
14
|
+
},
|
|
15
|
+
{ filename: 'procuracao.doc', mimeType: 'application/msword', expected: 'doc' },
|
|
16
|
+
{
|
|
17
|
+
filename: 'pedido.xlsx',
|
|
18
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
19
|
+
expected: 'xlsx',
|
|
20
|
+
},
|
|
21
|
+
{ filename: 'tabela.xls', mimeType: 'application/vnd.ms-excel', expected: 'xls' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
for (const testCase of OFFICE_MIME_TYPES) {
|
|
25
|
+
it(`resolve ${testCase.expected} pelo nome, já que o mimeType do Office não bate`, () => {
|
|
26
|
+
expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('resolve pelo mimeType quando o nome não tem extensão conhecida', () => {
|
|
31
|
+
expect(resolveFileIconExtension(undefined, 'application/pdf')).toBe('pdf')
|
|
32
|
+
expect(resolveFileIconExtension('recibo', 'application/zip')).toBe('zip')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('devolve algo fora do mapa para tipo desconhecido, caindo no ícone genérico', () => {
|
|
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')
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/FileIcon.tsx
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
FileArchive,
|
|
3
|
+
FileAudio,
|
|
4
|
+
FileImage,
|
|
5
|
+
FileSpreadsheet,
|
|
6
|
+
FileText,
|
|
7
|
+
FileVideo,
|
|
8
|
+
File as FileGeneric,
|
|
9
|
+
Presentation,
|
|
10
|
+
} from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
import { cn } from './lib/cn'
|
|
2
13
|
|
|
3
14
|
export interface FileIconProps {
|
|
4
15
|
filename?: string
|
|
@@ -7,27 +18,93 @@ export interface FileIconProps {
|
|
|
7
18
|
className?: string
|
|
8
19
|
}
|
|
9
20
|
|
|
10
|
-
|
|
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> = {
|
|
11
35
|
pdf: { Icon: FileText, colorClass: 'text-red-500' },
|
|
12
|
-
doc:
|
|
13
|
-
docx:
|
|
14
|
-
xls:
|
|
15
|
-
xlsx:
|
|
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,
|
|
16
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,
|
|
17
72
|
}
|
|
18
73
|
|
|
19
|
-
|
|
74
|
+
const MEDIA_FAMILIES = new Set(['image', 'video', 'audio'])
|
|
75
|
+
|
|
76
|
+
/**
|
|
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.
|
|
87
|
+
*
|
|
88
|
+
* Exportada para teste: é a regra que já regrediu uma vez no painel de documentos.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
|
|
20
91
|
const fromFilename = filename?.split('.').pop()?.toLowerCase()
|
|
21
92
|
if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
|
|
22
|
-
|
|
93
|
+
|
|
94
|
+
const [family, subtype] = (mimeType ?? '').split(';')[0]!.toLowerCase().split('/')
|
|
95
|
+
if (family && MEDIA_FAMILIES.has(family)) return family
|
|
96
|
+
|
|
97
|
+
return subtype ?? ''
|
|
23
98
|
}
|
|
24
99
|
|
|
25
100
|
// Melhoria sobre a paridade da bolha de documento (T6.7 usava um único ícone genérico
|
|
26
101
|
// para qualquer tipo de arquivo) — ícone e cor variam por extensão/mimeType.
|
|
27
|
-
export function FileIcon({ filename, mimeType, size = 20, className
|
|
28
|
-
const extension =
|
|
102
|
+
export function FileIcon({ filename, mimeType, size = 20, className }: FileIconProps) {
|
|
103
|
+
const extension = resolveFileIconExtension(filename, mimeType)
|
|
29
104
|
const style = EXTENSION_STYLE[extension] ?? { Icon: FileGeneric, colorClass: 'text-gray-500' }
|
|
30
105
|
const { Icon, colorClass } = style
|
|
31
106
|
|
|
32
|
-
|
|
107
|
+
// `cn` em vez de concatenar: a cor por extensão é a base, e produto que passa `text-*` precisa
|
|
108
|
+
// ganhar dela — concatenado, quem vence é a ordem no CSS gerado, não a intenção de quem chamou.
|
|
109
|
+
return <Icon size={size} className={cn(colorClass, className)} />
|
|
33
110
|
}
|
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
|
|
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 ??
|
|
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">
|
|
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
|
)
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { AudioPlayer } from './AudioPlayer'
|
|
|
3
3
|
import { FileIcon } from './FileIcon'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
import { formatFileSize } from './lib/format'
|
|
6
|
+
import { cn } from './lib/cn'
|
|
6
7
|
import type { MessagePayload } from './types'
|
|
7
8
|
|
|
8
9
|
function resolveMediaSource(message: MessagePayload): string | null {
|
|
@@ -30,6 +31,8 @@ export interface MediaRendererProps {
|
|
|
30
31
|
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
31
32
|
// porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
32
33
|
onResolveUrl?: ResolveMediaUrl
|
|
34
|
+
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
35
|
+
className?: string
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
|
|
@@ -55,7 +58,7 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
|
|
|
55
58
|
return { url, loading, error, load }
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
|
|
61
|
+
export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
|
|
59
62
|
const { bubble } = useConversationLocales()
|
|
60
63
|
const eagerSrc = resolveMediaSource(message)
|
|
61
64
|
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
@@ -126,7 +129,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
126
129
|
const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
|
|
127
130
|
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
|
|
128
131
|
return (
|
|
129
|
-
<div className=
|
|
132
|
+
<div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
|
|
130
133
|
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
131
134
|
<FileIcon filename={message.filename} mimeType={message.mimeType} />
|
|
132
135
|
</div>
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -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={
|
|
134
|
+
<MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
|
|
119
135
|
) : (
|
|
120
136
|
<>
|
|
121
137
|
{isTemplate && (
|