@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
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { MessagePayload } from '../types'
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CompanyDocumentPage,
|
|
13
|
+
ConversationDocumentPage,
|
|
14
|
+
ConversationPage,
|
|
15
|
+
ConversationTemplate,
|
|
16
|
+
ConversationsApi,
|
|
17
|
+
ListConversationsParams,
|
|
18
|
+
} from '../providers/types'
|
|
19
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
20
|
+
import { previewFileBase64, previewFileUrl } from './previewMediaSource'
|
|
12
21
|
import type { PreviewStore } from './previewStore'
|
|
13
22
|
|
|
14
|
-
// PNG 1x1 transparente: o suficiente para o MediaRenderer ter algo válido para desenhar.
|
|
15
|
-
const PREVIEW_IMAGE_BASE64 =
|
|
16
|
-
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYGD4DwABBAEAX+XyEgAAAABJRU5ErkJggg=='
|
|
17
|
-
|
|
18
23
|
export type CreateMockConversationsApiParams = {
|
|
19
24
|
readonly store: PreviewStore
|
|
20
25
|
readonly latencyMs?: number
|
|
@@ -23,7 +28,24 @@ export type CreateMockConversationsApiParams = {
|
|
|
23
28
|
|
|
24
29
|
const DEFAULT_LATENCY_MS = 120
|
|
25
30
|
|
|
26
|
-
|
|
31
|
+
const PREVIEW_AGENT_ID = 'preview-agent'
|
|
32
|
+
|
|
33
|
+
const PREVIEW_TEMPLATES: readonly ConversationTemplate[] = [
|
|
34
|
+
{ name: 'retomada_atendimento', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
35
|
+
{ name: 'lembrete_documentos', language: 'pt_BR', status: 'APPROVED', category: 'UTILITY' },
|
|
36
|
+
{ name: 'promocao_taxa', language: 'pt_BR', status: 'PENDING', category: 'MARKETING' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* O mock satisfaz `ConversationsApi`, mas com o retorno de `fetchConversations` ESTREITADO para a
|
|
41
|
+
* forma paginada. Sem isto o contrato — que aceita array ou página — obrigaria todo consumidor do
|
|
42
|
+
* preview a desempacotar uma união que aqui nunca varia.
|
|
43
|
+
*/
|
|
44
|
+
export type MockConversationsApi = Omit<ConversationsApi, 'fetchConversations'> & {
|
|
45
|
+
fetchConversations(params?: ListConversationsParams): Promise<ConversationPage>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createMockConversationsApi(params: CreateMockConversationsApiParams): MockConversationsApi {
|
|
27
49
|
const latencyMs = params.latencyMs ?? DEFAULT_LATENCY_MS
|
|
28
50
|
|
|
29
51
|
async function withLatency<TResult>(produce: () => TResult): Promise<TResult> {
|
|
@@ -32,7 +54,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
return {
|
|
35
|
-
|
|
57
|
+
// Devolve a forma paginada, não o array puro: é a que o contrato passou a oferecer e a que
|
|
58
|
+
// permite o preview desenhar controles de página. O total é contado ANTES do corte — depois
|
|
59
|
+
// dele seria sempre o tamanho da página, e a paginação nunca sairia da primeira.
|
|
60
|
+
fetchConversations(fetchParams): Promise<ConversationPage> {
|
|
36
61
|
return withLatency(() => {
|
|
37
62
|
const conversations = params.store.listConversations({
|
|
38
63
|
waitingHuman: fetchParams?.waitingHuman,
|
|
@@ -41,7 +66,10 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
41
66
|
|
|
42
67
|
const limit = fetchParams?.limit ?? conversations.length
|
|
43
68
|
const page = fetchParams?.page ?? 1
|
|
44
|
-
return
|
|
69
|
+
return {
|
|
70
|
+
conversations: conversations.slice((page - 1) * limit, page * limit),
|
|
71
|
+
total: conversations.length,
|
|
72
|
+
}
|
|
45
73
|
})
|
|
46
74
|
},
|
|
47
75
|
|
|
@@ -74,7 +102,9 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
74
102
|
return withLatency(() => {
|
|
75
103
|
params.store.appendMessage({
|
|
76
104
|
conversationId,
|
|
77
|
-
|
|
105
|
+
// Sem nome, o host está pedindo o template padrão do backend — o mock representa isso
|
|
106
|
+
// pelo que o atendente veria, não por um nome inventado.
|
|
107
|
+
content: `[template] ${data.templateName ?? PREVIEW_TEMPLATES[0]?.name ?? 'padrao'}`,
|
|
78
108
|
direction: 'outbound',
|
|
79
109
|
sender: 'agent',
|
|
80
110
|
})
|
|
@@ -96,16 +126,146 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
|
|
|
96
126
|
})
|
|
97
127
|
},
|
|
98
128
|
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Espelha o backend em busca, filtro de origem, ordenação E paginação. Mock que ignora params
|
|
131
|
+
* faz o painel parecer quebrado aqui e, pior, esconde o caso em que o backend também os ignora
|
|
132
|
+
* — foi exatamente assim que o filtro de origem passou a existir só no contrato.
|
|
133
|
+
*/
|
|
134
|
+
getDocuments(conversationId, documentParams): Promise<ConversationDocumentPage> {
|
|
135
|
+
return withLatency(() => {
|
|
136
|
+
let documents = [...(PREVIEW_DOCUMENTS[conversationId] ?? [])]
|
|
137
|
+
|
|
138
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
139
|
+
if (search) {
|
|
140
|
+
documents = documents.filter((document) => document.filename.toLowerCase().includes(search))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 'team' agrupa agent + bot, como o painel apresenta.
|
|
144
|
+
const source = documentParams?.source
|
|
145
|
+
if (source === 'team') {
|
|
146
|
+
documents = documents.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
147
|
+
} else if (source) {
|
|
148
|
+
documents = documents.filter((document) => document.source === source)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
documents.sort((left, right) =>
|
|
152
|
+
documentParams?.sortDirection === 'asc'
|
|
153
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
154
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// Total contado ANTES do corte — depois seria sempre o tamanho da página, e a paginação
|
|
158
|
+
// nunca sairia da primeira.
|
|
159
|
+
const total = documents.length
|
|
160
|
+
const limit = documentParams?.limit ?? total
|
|
161
|
+
const page = documentParams?.page ?? 1
|
|
162
|
+
|
|
163
|
+
return { documents: documents.slice((page - 1) * limit, page * limit), total }
|
|
164
|
+
})
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Zip de mentira: um texto listando o que entraria. Basta para exercitar seleção, botão e o
|
|
169
|
+
* caminho de download no preview, sem arrastar uma lib de compactação para o pacote.
|
|
170
|
+
*/
|
|
171
|
+
downloadDocumentsArchive(conversationId, uploadIds): Promise<Blob> {
|
|
172
|
+
return withLatency(() => {
|
|
173
|
+
const known = PREVIEW_DOCUMENTS[conversationId] ?? []
|
|
174
|
+
const names = uploadIds.map((id) => known.find((document) => document.id === id)?.filename ?? id)
|
|
175
|
+
return new Blob([`preview: ${names.length} arquivo(s)\n${names.join('\n')}`], { type: 'application/zip' })
|
|
176
|
+
})
|
|
101
177
|
},
|
|
102
178
|
|
|
103
|
-
|
|
104
|
-
|
|
179
|
+
/** Junta as bibliotecas de todas as conversas do fixture, com a origem de cada arquivo. */
|
|
180
|
+
getAllDocuments(documentParams): Promise<CompanyDocumentPage> {
|
|
181
|
+
return withLatency(() => {
|
|
182
|
+
let all = Object.entries(PREVIEW_DOCUMENTS).flatMap(([conversationId, docs]) =>
|
|
183
|
+
docs.map((document) => ({ ...document, conversationId })),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Mesma regra do backend (`companyDocumentSearch`): o termo casa nome do arquivo OU
|
|
187
|
+
// telefone da conversa, e o telefone só pelos dígitos — o preview mostra o número
|
|
188
|
+
// formatado, então é assim que o atendente vai colá-lo na busca.
|
|
189
|
+
const search = documentParams?.search?.trim().toLowerCase()
|
|
190
|
+
if (search) {
|
|
191
|
+
const digits = search.replace(/\D/g, '')
|
|
192
|
+
all = all.filter(
|
|
193
|
+
(document) =>
|
|
194
|
+
document.filename.toLowerCase().includes(search) ||
|
|
195
|
+
(digits !== '' && document.conversationId.includes(digits)),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const source = documentParams?.source
|
|
200
|
+
if (source === 'team') {
|
|
201
|
+
all = all.filter((document) => document.source === 'agent' || document.source === 'bot')
|
|
202
|
+
} else if (source) {
|
|
203
|
+
all = all.filter((document) => document.source === source)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
all.sort((left, right) =>
|
|
207
|
+
documentParams?.sortDirection === 'asc'
|
|
208
|
+
? left.linkedAt.localeCompare(right.linkedAt)
|
|
209
|
+
: right.linkedAt.localeCompare(left.linkedAt),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const total = all.length
|
|
213
|
+
const limit = documentParams?.limit ?? total
|
|
214
|
+
const page = documentParams?.page ?? 1
|
|
215
|
+
return { documents: all.slice((page - 1) * limit, page * limit), total }
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Devolve os bytes DO TIPO do documento, não uma imagem para tudo: antes, abrir um PDF entregava
|
|
220
|
+
// um PNG rotulado `application/pdf` e o leitor recusava o arquivo. O `uploadId` é a única pista
|
|
221
|
+
// que o contrato dá, então o tipo vem da própria biblioteca.
|
|
222
|
+
getDocumentUrl(uploadId): Promise<string> {
|
|
223
|
+
return withLatency(() => {
|
|
224
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
225
|
+
.flat()
|
|
226
|
+
.find((document) => document.id === uploadId)
|
|
227
|
+
return previewFileUrl(found?.mimeType, found?.filename)
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
// Caminho da mídia ainda não ingerida: o backend busca na Meta e devolve base64. Resolve pelo
|
|
232
|
+
// id para a bolha receber os bytes DO TIPO dela — devolvendo um PNG para todo id, vídeo e áudio
|
|
233
|
+
// apareciam quebrados na thread mesmo havendo amostra válida do formato.
|
|
234
|
+
getMediaProxyUrl(mediaId): Promise<{ mimeType: string; data: string }> {
|
|
235
|
+
return withLatency(() => {
|
|
236
|
+
const found = Object.values(PREVIEW_DOCUMENTS)
|
|
237
|
+
.flat()
|
|
238
|
+
.find((document) => document.id === `preview/inbound/${mediaId}`)
|
|
239
|
+
return previewFileBase64(found?.mimeType, found?.filename)
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
takeover(conversationId): Promise<void> {
|
|
244
|
+
return withLatency(() =>
|
|
245
|
+
params.store.setMode({ conversationId, mode: 'human', assignedUserId: PREVIEW_AGENT_ID }),
|
|
246
|
+
)
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
release(conversationId): Promise<void> {
|
|
250
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
// Encerrar devolve ao bot como o release, e é de propósito: a diferença entre os dois é a
|
|
254
|
+
// despedida, que o host manda antes de chamar aqui. O mock não a inventa.
|
|
255
|
+
finalize(conversationId): Promise<void> {
|
|
256
|
+
return withLatency(() => params.store.setMode({ conversationId, mode: 'bot' }))
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
markAllRead(): Promise<void> {
|
|
260
|
+
return withLatency(() => {
|
|
261
|
+
for (const conversation of params.store.listConversations()) {
|
|
262
|
+
params.store.markRead(conversation.id)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
105
265
|
},
|
|
106
266
|
|
|
107
|
-
|
|
108
|
-
return withLatency(() =>
|
|
267
|
+
listTemplates(): Promise<ConversationTemplate[]> {
|
|
268
|
+
return withLatency(() => [...PREVIEW_TEMPLATES])
|
|
109
269
|
},
|
|
110
270
|
}
|
|
111
271
|
}
|
package/src/preview/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type { CreateMockConversationsApiParams } from './createMockConversations
|
|
|
23
23
|
export { createMockSSEProvider } from './createMockSSEProvider'
|
|
24
24
|
export type { CreateMockSSEProviderParams } from './createMockSSEProvider'
|
|
25
25
|
|
|
26
|
-
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
26
|
+
export { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES, PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
27
27
|
|
|
28
28
|
export { ConversationPreview } from './ConversationPreview'
|
|
29
29
|
export type { ConversationPreviewProps } from './ConversationPreview'
|
|
@@ -38,3 +38,8 @@ export type { PreviewWebhookClient, CreatePreviewWebhookClientParams } from './c
|
|
|
38
38
|
|
|
39
39
|
export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
|
|
40
40
|
export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
|
|
41
|
+
export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
42
|
+
export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
|
|
43
|
+
export { previewFileBase64 } from './previewMediaSource'
|
|
44
|
+
export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
|
|
45
|
+
export type { MediaTypesPreviewProps } from './MediaTypesPreview'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a busca da biblioteca no mock — que é a implementação de referência da UI do SDK e tem de
|
|
3
|
+
* casar com o `companyDocumentSearch` do backend, senão o preview ensina um comportamento que a
|
|
4
|
+
* tela real não entrega.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
|
|
9
|
+
import { createMockConversationsApi } from './createMockConversationsApi'
|
|
10
|
+
import { createPreviewStore } from './previewStore'
|
|
11
|
+
import { PREVIEW_CONVERSATIONS, PREVIEW_MESSAGES } from './previewFixtures'
|
|
12
|
+
|
|
13
|
+
function buildApi() {
|
|
14
|
+
const store = createPreviewStore({ conversations: PREVIEW_CONVERSATIONS, messages: PREVIEW_MESSAGES })
|
|
15
|
+
return createMockConversationsApi({ store })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function search(term: string): Promise<{ total: number; conversations: string[] }> {
|
|
19
|
+
const page = await buildApi().getAllDocuments!({ search: term })
|
|
20
|
+
return { total: page.total, conversations: [...new Set(page.documents.map((document) => document.conversationId))] }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('busca da biblioteca no mock', () => {
|
|
24
|
+
it('acha por nome do arquivo', async () => {
|
|
25
|
+
const result = await search('contrato')
|
|
26
|
+
|
|
27
|
+
expect(result.total).toBe(1)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('acha por telefone da conversa', async () => {
|
|
31
|
+
const porNumero = await search('94444')
|
|
32
|
+
|
|
33
|
+
expect(porNumero.conversations).toEqual(['5511944443333'])
|
|
34
|
+
expect(porNumero.total).toBeGreaterThan(0)
|
|
35
|
+
// Discrimina de verdade: 98888 é conversa que existe nas fixtures, mas sem arquivo — se o
|
|
36
|
+
// predicado do telefone não estivesse valendo, esta busca devolveria a biblioteca inteira.
|
|
37
|
+
expect((await search('98888')).total).toBe(0)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// O caso real: o atendente copia o número como a tela o mostra.
|
|
41
|
+
it('acha com o telefone formatado, como aparece na tela', async () => {
|
|
42
|
+
const cru = await search('5511944443333')
|
|
43
|
+
const formatado = await search('+55 (11) 94444-3333')
|
|
44
|
+
|
|
45
|
+
expect(formatado.total).toBe(cru.total)
|
|
46
|
+
expect(formatado.total).toBeGreaterThan(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('não devolve nada para número de outra conversa', async () => {
|
|
50
|
+
expect((await search('99999999999')).total).toBe(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Sem dígito, o predicado do telefone não existe — senão qualquer palavra casaria toda conversa.
|
|
54
|
+
it('termo sem dígito filtra só por nome', async () => {
|
|
55
|
+
expect((await search('zzz-inexistente')).total).toBe(0)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -145,8 +145,10 @@ describe('createMockConversationsApi', () => {
|
|
|
145
145
|
const firstPage = await api.fetchConversations({ page: 1, limit: 2 })
|
|
146
146
|
const waiting = await api.fetchConversations({ waitingHuman: true })
|
|
147
147
|
|
|
148
|
-
expect(firstPage).toHaveLength(2)
|
|
149
|
-
|
|
148
|
+
expect(firstPage.conversations).toHaveLength(2)
|
|
149
|
+
// O total conta o conjunto filtrado inteiro, não a fatia — é o que a paginação da UI lê.
|
|
150
|
+
expect(firstPage.total).toBeGreaterThan(2)
|
|
151
|
+
expect(waiting.conversations.every((conversation) => conversation.waitingHuman)).toBe(true)
|
|
150
152
|
})
|
|
151
153
|
|
|
152
154
|
it('markRead zera as não-lidas vistas pela lista', async () => {
|
|
@@ -156,7 +158,7 @@ describe('createMockConversationsApi', () => {
|
|
|
156
158
|
await api.markRead(BOT_CONVERSATION_ID)
|
|
157
159
|
const conversations = await api.fetchConversations()
|
|
158
160
|
|
|
159
|
-
expect(conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
161
|
+
expect(conversations.conversations.find((item) => item.id === BOT_CONVERSATION_ID)?.unread).toBe(0)
|
|
160
162
|
})
|
|
161
163
|
})
|
|
162
164
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda que o preview entrega arquivo do tipo que promete.
|
|
3
|
+
*
|
|
4
|
+
* O defeito que motivou o arquivo: `getDocumentUrl` devolvia sempre a mesma imagem PNG, então
|
|
5
|
+
* "visualizar" num PDF abria um PNG rotulado `application/pdf` e o leitor dizia que o arquivo era
|
|
6
|
+
* inválido. Aqui os bytes são decodificados e conferidos pela assinatura de cada formato — teste que
|
|
7
|
+
* só olhasse o prefixo da data URL passaria com o bug de volta.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
|
|
12
|
+
import { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
|
|
13
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
14
|
+
|
|
15
|
+
function decode(dataUrl: string): { mimeType: string; bytes: Uint8Array } {
|
|
16
|
+
const [head, payload] = dataUrl.split(',')
|
|
17
|
+
const mimeType = head!.replace(/^data:/, '').replace(/;base64$/, '')
|
|
18
|
+
if (!head!.endsWith(';base64')) {
|
|
19
|
+
return { mimeType, bytes: new TextEncoder().encode(decodeURIComponent(payload!)) }
|
|
20
|
+
}
|
|
21
|
+
return { mimeType, bytes: Uint8Array.from(atob(payload!), (char) => char.charCodeAt(0)) }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function startsWith(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
|
|
25
|
+
return signature.every((byte, index) => bytes[offset + index] === byte)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ascii = (text: string): number[] => [...text].map((char) => char.charCodeAt(0))
|
|
29
|
+
|
|
30
|
+
// Assinatura real de cada formato, não o rótulo do mimeType.
|
|
31
|
+
const SIGNATURES: Record<string, (bytes: Uint8Array) => boolean> = {
|
|
32
|
+
'application/pdf': (bytes) => startsWith(bytes, ascii('%PDF-')),
|
|
33
|
+
'image/png': (bytes) => startsWith(bytes, [0x89, 0x50, 0x4e, 0x47]),
|
|
34
|
+
'image/jpeg': (bytes) => startsWith(bytes, [0xff, 0xd8, 0xff]),
|
|
35
|
+
'image/webp': (bytes) => startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8),
|
|
36
|
+
'video/mp4': (bytes) => startsWith(bytes, ascii('ftyp'), 4),
|
|
37
|
+
'audio/mp4': (bytes) => startsWith(bytes, ascii('ftyp'), 4),
|
|
38
|
+
// ID3 quando há tag, ou o sync do primeiro frame MPEG.
|
|
39
|
+
'audio/mpeg': (bytes) => startsWith(bytes, ascii('ID3')) || (bytes[0] === 0xff && (bytes[1]! & 0xe0) === 0xe0),
|
|
40
|
+
'audio/ogg': (bytes) => startsWith(bytes, ascii('OggS')),
|
|
41
|
+
'audio/aac': (bytes) => bytes[0] === 0xff && (bytes[1]! & 0xf0) === 0xf0,
|
|
42
|
+
'text/plain': (bytes) => bytes.length > 0,
|
|
43
|
+
'text/csv': (bytes) => bytes.length > 0,
|
|
44
|
+
// Todo pacote Office é zip: assinatura PK\x03\x04.
|
|
45
|
+
'application/zip': (bytes) => startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
46
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': (bytes) =>
|
|
47
|
+
startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
48
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': (bytes) =>
|
|
49
|
+
startsWith(bytes, [0x50, 0x4b, 0x03, 0x04]),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('amostras de arquivo do preview', () => {
|
|
53
|
+
it('toda amostra declara o mimeType que anuncia', () => {
|
|
54
|
+
for (const [mimeType, dataUrl] of Object.entries(PREVIEW_FILE_SAMPLES)) {
|
|
55
|
+
expect(decode(dataUrl).mimeType).toBe(mimeType)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
for (const [mimeType, isValid] of Object.entries(SIGNATURES)) {
|
|
60
|
+
it(`os bytes de ${mimeType} são realmente desse formato`, () => {
|
|
61
|
+
const sample = PREVIEW_FILE_SAMPLES[mimeType]
|
|
62
|
+
expect(sample).toBeDefined()
|
|
63
|
+
expect(isValid(decode(sample!).bytes)).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// O PDF sem xref/startxref é o caso que alguns leitores recusam de cara.
|
|
68
|
+
it('o PDF tem tabela xref e termina em %%EOF', () => {
|
|
69
|
+
const text = new TextDecoder().decode(decode(PREVIEW_FILE_SAMPLES['application/pdf']!).bytes)
|
|
70
|
+
|
|
71
|
+
expect(text).toContain('\nxref\n')
|
|
72
|
+
expect(text).toContain('startxref')
|
|
73
|
+
expect(text.trimEnd().endsWith('%%EOF')).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('resolvePreviewFileSample', () => {
|
|
78
|
+
it('descarta parâmetro do mimeType — áudio de WhatsApp chega com codecs', () => {
|
|
79
|
+
expect(resolvePreviewFileSample('audio/ogg; codecs=opus')).toBe(PREVIEW_FILE_SAMPLES['audio/ogg'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Tipos aceitos pela Meta sem amostra local (sem encoder amr/3gp nesta máquina): tocam pela
|
|
83
|
+
// família, em vez de devolver bytes de outro formato.
|
|
84
|
+
it('cai na família quando não há amostra do tipo exato', () => {
|
|
85
|
+
expect(resolvePreviewFileSample('audio/amr')).toBe(PREVIEW_FILE_SAMPLES['audio/mpeg'])
|
|
86
|
+
expect(resolvePreviewFileSample('video/3gp')).toBe(PREVIEW_FILE_SAMPLES['video/mp4'])
|
|
87
|
+
expect(resolvePreviewFileSample('image/heic')).toBe(PREVIEW_FILE_SAMPLES['image/png'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Office legado é OLE2, não zip: servir o docx aqui daria arquivo corrompido no Word. Melhor um
|
|
91
|
+
// texto que abre e se explica.
|
|
92
|
+
it('explica em texto quando não há como forjar o binário', () => {
|
|
93
|
+
const resolved = resolvePreviewFileSample('application/msword', 'procuracao.doc')
|
|
94
|
+
|
|
95
|
+
expect(resolved.startsWith('data:text/plain')).toBe(true)
|
|
96
|
+
expect(decodeURIComponent(resolved)).toContain('procuracao.doc')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('não devolve vazio para tipo desconhecido', () => {
|
|
100
|
+
expect(resolvePreviewFileSample(undefined).length).toBeGreaterThan(0)
|
|
101
|
+
expect(resolvePreviewFileSample('application/octet-stream').length).toBeGreaterThan(0)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('biblioteca do preview', () => {
|
|
106
|
+
// A infidelidade que existia: a biblioteca listava só `document`, enquanto o backend linka as
|
|
107
|
+
// cinco espécies de mídia. Foto, vídeo, áudio e sticker apareciam na tela real e não no preview.
|
|
108
|
+
it('lista foto, vídeo, áudio e sticker junto dos documentos', () => {
|
|
109
|
+
const mimeTypes = (PREVIEW_DOCUMENTS['5511944443333'] ?? []).map((document) => document.mimeType)
|
|
110
|
+
|
|
111
|
+
expect(mimeTypes.some((mimeType) => mimeType.startsWith('image/'))).toBe(true)
|
|
112
|
+
expect(mimeTypes.some((mimeType) => mimeType.startsWith('video/'))).toBe(true)
|
|
113
|
+
expect(mimeTypes.some((mimeType) => mimeType.startsWith('audio/'))).toBe(true)
|
|
114
|
+
expect(mimeTypes).toContain('image/webp')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Cada tipo que a Cloud API da Meta aceita para envio de mídia.
|
|
118
|
+
const ACCEPTED_BY_META = [
|
|
119
|
+
'image/jpeg',
|
|
120
|
+
'image/png',
|
|
121
|
+
'image/webp',
|
|
122
|
+
'video/mp4',
|
|
123
|
+
'video/3gp',
|
|
124
|
+
'audio/aac',
|
|
125
|
+
'audio/amr',
|
|
126
|
+
'audio/mpeg',
|
|
127
|
+
'audio/mp4',
|
|
128
|
+
'audio/ogg',
|
|
129
|
+
'application/pdf',
|
|
130
|
+
'application/msword',
|
|
131
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
132
|
+
'application/vnd.ms-excel',
|
|
133
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
134
|
+
'application/vnd.ms-powerpoint',
|
|
135
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
136
|
+
'text/plain',
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
it('cobre todo tipo aceito pela Meta', () => {
|
|
140
|
+
const present = new Set((PREVIEW_DOCUMENTS['5511944443333'] ?? []).map((document) => document.mimeType))
|
|
141
|
+
const faltando = ACCEPTED_BY_META.filter((mimeType) => !present.has(mimeType))
|
|
142
|
+
|
|
143
|
+
expect(faltando).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('todo item da biblioteca abre com bytes não vazios', () => {
|
|
147
|
+
for (const document of PREVIEW_DOCUMENTS['5511944443333'] ?? []) {
|
|
148
|
+
expect(decode(resolvePreviewFileSample(document.mimeType, document.filename)).bytes.length).toBeGreaterThan(0)
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bytes de verdade para o preview abrir.
|
|
3
|
+
*
|
|
4
|
+
* O mock antes devolvia a MESMA imagem PNG para qualquer documento, então clicar em "visualizar" num
|
|
5
|
+
* PDF entregava um PNG rotulado `application/pdf` e o leitor dizia que o arquivo era inválido. Cada
|
|
6
|
+
* amostra aqui é um arquivo mínimo porém legítimo do seu tipo: as de mídia foram geradas com ffmpeg
|
|
7
|
+
* e conferidas com ffprobe, o PDF tem xref/startxref/%%EOF, e os pacotes Office são zips com as
|
|
8
|
+
* partes obrigatórias (`[Content_Types].xml`, `_rels/.rels`, o documento).
|
|
9
|
+
*
|
|
10
|
+
* Não há amostra de `audio/amr`, `video/3gp` nem dos formatos binários legados do Office
|
|
11
|
+
* (`application/msword`, `application/vnd.ms-excel`, `application/vnd.ms-powerpoint`): não há
|
|
12
|
+
* encoder para eles nesta máquina e forjar OLE2 à mão daria arquivo corrompido — exatamente o que
|
|
13
|
+
* este módulo existe para evitar. Esses caem no texto explicativo de `resolvePreviewFileSample`,
|
|
14
|
+
* que abre sem erro e diz o que é.
|
|
15
|
+
*
|
|
16
|
+
* GERADO — não editar à mão.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const PREVIEW_FILE_SAMPLES: Readonly<Record<string, string>> = {
|
|
20
|
+
'application/pdf':
|
|
21
|
+
'data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjYwIDExMF0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjI+PnN0cmVhbQpCVCAvRjEgMTMgVGYgMTggNTUgVGQgKERvY3VtZW50byBkZSBleGVtcGxvIGRvIHByZXZpZXcpIFRqIEVUCmVuZHN0cmVhbWVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMTcgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM3OAolJUVPRgo=',
|
|
22
|
+
'image/png':
|
|
23
|
+
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAACXBIWXMAAAABAAAAAQBPJcTWAAAATElEQVR4nO3OMRHAIAAAMbhDWo2hCYPdu/xIh0RB5rPP+JN1O/AlVISKUBEqQkWoCBWhIlSEilARKkJFqAgVoSJUhIpQESpCRagIlRcgDwI25HmPQAAAAABJRU5ErkJggg==',
|
|
24
|
+
'image/jpeg':
|
|
25
|
+
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD//gAQTGF2YzYyLjExLjEwMAD/2wBDAAgEBAQEBAUFBQUFBQYGBgYGBgYGBgYGBgYHBwcICAgHBwcGBgcHCAgICAkJCQgICAgJCQoKCgwMCwsODg4RERT/xABMAAEBAAAAAAAAAAAAAAAAAAAABAEBAQAAAAAAAAAAAAAAAAAAAAYQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAAwADADASIAAhEAAxEA/9oADAMBAAIRAxEAPwCUBRpwAAAAAAAAAAAAAAAB/9k=',
|
|
26
|
+
'image/webp': 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
|
|
27
|
+
'video/mp4':
|
|
28
|
+
'data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAANdbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAASwAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAoh0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAASwAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAEsAAAIAAABAAAAAAIAbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAEABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABq21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAWtzdGJsAAAAv3N0c2QAAAAAAAAAAQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2Mi4xMS4xMDAgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQAAAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAABO2gAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAwAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAChjdHRzAAAAAAAAAAMAAAABAAAIAAAAAAEAAAwAAAAAAQAABAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAMAAAABAAAAIHN0c3oAAAAAAAAAAAAAAAMAAALbAAAADgAAAAwAAAAUc3RjbwAAAAAAAAABAAADjQAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZmNjIuMy4xMDAAAAAIZnJlZQAAAv1tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE2NSByMzIyMiBiMzU2MDVhIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAyNSAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3VibWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGljZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2JpYXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBrZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAgcmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAJWWIhAA///7mdfgUoKykhh+DdK6xVynnAWznTQ4fVSgMZ1FwY28AAAAKQZoibEN//qePiAAAAAgBnkF5Df8HfQ==',
|
|
29
|
+
'audio/mpeg':
|
|
30
|
+
'data:audio/mpeg;base64,SUQzBAAAAAAAIlRTU0UAAAAOAAADTGF2ZjYyLjMuMTAwAAAAAAAAAAAAAAD/40jAAAAAAAAAAAAASW5mbwAAAA8AAAAFAAAGwABVVVVVVVVVVVVVVVVVVVVVVVVVgICAgICAgICAgICAgICAgICAgICqqqqqqqqqqqqqqqqqqqqqqqqqqtXV1dXV1dXV1dXV1dXV1dXV1dXV//////////////////////////8AAAAATGF2YzYyLjExAAAAAAAAAAAAAAAAJALAAAAAAAAABsDR4nNFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/40jEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/40jEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/40jEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/40jEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVX/40jEAAAAA0gAAAAATEFNRTMuMTAwVVVVVVVVVVVVVVVMQU1FMy4xMDBVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVU=',
|
|
31
|
+
'audio/ogg':
|
|
32
|
+
'data:audio/ogg;base64,T2dnUwACAAAAAAAAAABXDTmmAAAAAKMnDKEBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAFcNOaYBAAAAZlPuKwE9T3B1c1RhZ3MMAAAATGF2ZjYyLjMuMTAwAQAAAB0AAABlbmNvZGVyPUxhdmM2Mi4xMS4xMDAgbGlib3B1c09nZ1MABLgmAAAAAAAAVw05pgIAAAA3uL6lCw8REhISEhISEhISeAvkwTbsxY2MSUaZpCaweAfJcifhROpV8fDA1d3pUmB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOB4B8l5yMlXwKISI/rvZ/NkwOA=',
|
|
33
|
+
'audio/mp4':
|
|
34
|
+
'data:audio/mp4;base64,AAAAHGZ0eXBNNEEgAAACAE00QSBpc29taXNvMgAAAAhmcmVlAAAAQW1kYXTeAgBMYXZjNjIuMTEuMTAwAAIwQA4BGCAHARggBwEYIAcBGCAHARggBwEYIAcBGCAHARggBwEYIAcAAAMibW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAMgAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAk10cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAMgAAAAAAAAAAAAAAAEBAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAADIAAAEAAABAAAAAAHFbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAACsRAAAJnRVxAAAAAAALWhkbHIAAAAAAAAAAHNvdW4AAAAAAAAAAAAAAABTb3VuZEhhbmRsZXIAAAABcG1pbmYAAAAQc21oZAAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAABNHN0YmwAAABqc3RzZAAAAAAAAAABAAAAWm1wNGEAAAAAAAAAAQAAAAAAAAAAAAEAEAAAAACsRAAAAAAANmVzZHMAAAAAA4CAgCUAAQAEgICAF0AVAAAAAAB9AAAAB/oFgICABRIIVuUABoCAgAECAAAAIHN0dHMAAAAAAAAAAgAAAAkAAAQAAAAAAQAAAnQAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAoAAAABAAAAPHN0c3oAAAAAAAAAAAAAAAoAAAAVAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAABAAAAAQAAAAEAAAAFHN0Y28AAAAAAAAAAQAAACwAAAAac2dwZAEAAAByb2xsAAAAAgAAAAH//wAAABxzYmdwAAAAAHJvbGwAAAABAAAACgAAAAEAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYyLjMuMTAw',
|
|
35
|
+
'audio/aac':
|
|
36
|
+
'data:audio/aac;base64,//FQQAOf/N4CAExhdmM2Mi4xMS4xMDAAAjBADv/xUEABf/wBGCAH//FQQAF//AEYIAf/8VBAAX/8ARggB//xUEABf/wBGCAH//FQQAF//AEYIAf/8VBAAX/8ARggB//xUEABf/wBGCAH//FQQAF//AEYIAf/8VBAAX/8ARggBw==',
|
|
37
|
+
'text/plain': 'data:text/plain;base64,TGlzdGEgZGUgY29tcHJhcwotIGFycm96IDJrZwotIG9sZW8gMXVuCg==',
|
|
38
|
+
'text/csv': 'data:text/csv;base64,cHJvZHV0byxxdWFudGlkYWRlLHByZWNvCmFycm96LDIsMjQuOTAKb2xlbywxLDcuNTAK',
|
|
39
|
+
'application/zip':
|
|
40
|
+
'data:application/zip;base64,UEsDBBQAAAAIANFO/FwtmGOxGwAAABkAAAARAAAAY29tcHJvdmFudGUtMS50eHRLzs8tKMovS8wrSVVISVUoSExPzE3NK8nnAgBQSwMEFAAAAAgA0U78XCftV8YWAAAAFAAAABEAAABjb21wcm92YW50ZS0yLnR4dCtOTS/NS8lXSM7PLSjKL0vMK0nlAgBQSwECFAMUAAAACADRTvxcLZhjsRsAAAAZAAAAEQAAAAAAAAAAAAAAgAEAAAAAY29tcHJvdmFudGUtMS50eHRQSwECFAMUAAAACADRTvxcJ+1XxhYAAAAUAAAAEQAAAAAAAAAAAAAAgAFKAAAAY29tcHJvdmFudGUtMi50eHRQSwUGAAAAAAIAAgB+AAAAjwAAAAAA',
|
|
41
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
|
42
|
+
'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,UEsDBBQAAAAIANFO/Fx5bjPX6AAAAK0BAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH1QyU7DMBD9FWuuKHHggBCK0wPLETiUDxjZk8SqN3nc0v49Tlt6QIXjzFv1+tXeO7GjzDYGBbdtB4KCjsaGScHn+rV5AMEFg0EXAyk4EMNq6NeHRCyqNrCCuZT0KCXrmTxyGxOFiowxeyz1zJNMqDc4kbzrunupYygUSlMWDxj6Zxpx64p42df3qUcmxyCeTsQlSwGm5KzGUnG5C+ZXSnNOaKvyyOHZJr6pBJBXExbk74Cz7r0Ok60h8YG5vKGvLPkVs5Em6q2vyvZ/mys94zhaTRf94pZy1MRcF/euvSAebfjpL49zD99QSwMEFAAAAAgA0U78XJv9N+qtAAAAKQEAAAsAAABfcmVscy8ucmVsc43POw7CMAwG4KtE3mlaBoRQ0y4IqSsqB7ASN61oHkrCo7cnAwNFDIy2f3+W6/ZpZnanECdnBVRFCYysdGqyWsClP232wGJCq3B2lgQsFKFt6jPNmPJKHCcfWTZsFDCm5A+cRzmSwVg4TzZPBhcMplwGzT3KK2ri27Lc8fBpwNpknRIQOlUB6xdP/9huGCZJRydvhmz6ceIrkWUMmpKAhwuKq3e7yCzwpuarF5sXUEsDBBQAAAAIANFO/FyraIuoqgAAAOcAAAARAAAAd29yZC9kb2N1bWVudC54bWxFjksOwjAMBa8SZU9TWCBUNWWBxAngAKExbaTGjpzQz+1JyoLNPFt+Grm9rn4SM3B0hFoeq1oKwJ6sw0HL5+N+uEgRk0FrJkLQcoMor127NJb6jwdMIgswNouWY0qhUSr2I3gTKwqA+fYm9ibllQe1ENvA1EOM2e8ndarrs/LGoSzKF9mtZCjggtTdCBObRMKCgBV8mPJIIjDMDpZWlU4h7ww7fx71/7H7AlBLAQIUAxQAAAAIANFO/Fx5bjPX6AAAAK0BAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQDFAAAAAgA0U78XJv9N+qtAAAAKQEAAAsAAAAAAAAAAAAAAIABGQEAAF9yZWxzLy5yZWxzUEsBAhQDFAAAAAgA0U78XKtoi6iqAAAA5wAAABEAAAAAAAAAAAAAAIAB7wEAAHdvcmQvZG9jdW1lbnQueG1sUEsFBgAAAAADAAMAuQAAAMgCAAAAAA==',
|
|
43
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
|
|
44
|
+
'data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,UEsDBBQAAAAIANFO/FxuYbgN/gAAAC0CAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbK2RzU7DMBCEX8XytYqdckAIJe2BnyNwKA+w2JvEiv/kdUv69jhp4YAKXDit7JnZb2Q328lZdsBEJviWr0XNGXoVtPF9y193j9UNZ5TBa7DBY8uPSHy7aXbHiMRK1lPLh5zjrZSkBnRAIkT0RelCcpDLMfUyghqhR3lV19dSBZ/R5yrPO/imuccO9jazh6lcn3oktMTZ3ck4s1oOMVqjIBddHrz+RqnOBFGSi4cGE2lVDFxeJMzKz4Bz7rk8TDIa2Quk/ASuuORk5XtI41sIo/h9yYWWoeuMQh3U3pWIoJgQNA2I2VmxTOHA+NXf/MVMchnrfy7ytf+zh1y+e/MBUEsDBBQAAAAIANFO/FyY2uuLrgAAACcBAAALAAAAX3JlbHMvLnJlbHONz8EOgjAMBuBXWXqXgQdjDIOLMeFq8AHmVgYB1mWbCm/vjmI8eGz69/vTsl7miT3Rh4GsgCLLgaFVpAdrBNzay+4ILERptZzIooAVA9RVecVJxnQS+sEFlgwbBPQxuhPnQfU4y5CRQ5s2HflZxjR6w51UozTI93l+4P7TgK3JGi3AN7oA1q4O/7Gp6waFZ1KPGW38UfGVSLL0BqOAZeIv8uOdaMwSCrwq+ebB6g1QSwMEFAAAAAgA0U78XEkKfby5AAAAGwEAAA8AAAB4bC93b3JrYm9vay54bWyNj7sOwjAMRX8l8g4pDAhVbVkQEhsDfEBoXBrR2JUdXn9PeO1Mfuke31ut7nEwVxQNTDXMpgUYpJZ9oFMNh/1msgSjyZF3AxPW8ECFVVPdWM5H5rPJctIa+pTG0lpte4xOpzwi5UvHEl3Ko5ysjoLOa4+Y4mDnRbGw0QWCD6GUfxjcdaHFNbeXiJQ+EMHBpWxe+zAqNNX7g36rIRez6R364DkHee22PucEI2XIjWz9DGxT2Z/M/pI1T1BLAwQUAAAACADRTvxcWv2Ca7EAAAAoAQAAGgAAAHhsL19yZWxzL3dvcmtib29rLnhtbC5yZWxzjc/JCsJADAbgVxlyt2k9iEinXkToVeoDDNN0oZ2Fybj07R08iAUPnkLyky+kPD7NLO4UeHRWQpHlIMhq1462l3Btzps9CI7Ktmp2liQsxHCsygvNKqYVHkbPIhmWJQwx+gMi64GM4sx5sinpXDAqpjb06JWeVE+4zfMdhm8D1qaoWwmhbgsQzeLpH9t13ajp5PTNkI0/TuDDhYkHophQFXqKEj4jxncpsqQCViWuPqxeUEsDBBQAAAAIANFO/FwcNOTnuQAAAP8AAAAYAAAAeGwvd29ya3NoZWV0cy9zaGVldDEueG1sTY5RTsMwDIavEvmdudsDmlCSaQhxAdgBotasEY1T2VYHO/3SgRAvlv1Zn/37w1eZ3EKiuXKA7aYDR9zXIfM5wOn99WEPTi3xkKbKFOCbFA7RX6p86khkrvmsAUaz+QlR+5FK0k2didvmo0pJ1kY5o85CabhLZcJd1z1iSZkh+jt7SZail3px0nI02q/NcQvOAmSeMtObSeNZo7eYROrVo0WPK8D+V3he1SXuPC4/FNvJVv/9wL/w8QZQSwECFAMUAAAACADRTvxcbmG4Df4AAAAtAgAAEwAAAAAAAAAAAAAAgAEAAAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLAQIUAxQAAAAIANFO/FyY2uuLrgAAACcBAAALAAAAAAAAAAAAAACAAS8BAABfcmVscy8ucmVsc1BLAQIUAxQAAAAIANFO/FxJCn28uQAAABsBAAAPAAAAAAAAAAAAAACAAQYCAAB4bC93b3JrYm9vay54bWxQSwECFAMUAAAACADRTvxcWv2Ca7EAAAAoAQAAGgAAAAAAAAAAAAAAgAHsAgAAeGwvX3JlbHMvd29ya2Jvb2sueG1sLnJlbHNQSwECFAMUAAAACADRTvxcHDTk57kAAAD/AAAAGAAAAAAAAAAAAAAAgAHVAwAAeGwvd29ya3NoZWV0cy9zaGVldDEueG1sUEsFBgAAAAAFAAUARQEAAMQEAAAAAA==',
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FAMILY_FALLBACK: Readonly<Record<string, string>> = {
|
|
48
|
+
image: 'image/png',
|
|
49
|
+
video: 'video/mp4',
|
|
50
|
+
audio: 'audio/mpeg',
|
|
51
|
+
text: 'text/plain',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A data URL que o preview abre para um documento.
|
|
56
|
+
*
|
|
57
|
+
* Ordem: tipo exato → família (`audio/amr` toca como mp3, `video/3gp` como mp4) → nota em texto.
|
|
58
|
+
* A nota fecha o caso dos tipos sem amostra: abre num leitor de texto e explica, em vez de baixar
|
|
59
|
+
* um binário quebrado com extensão de Word.
|
|
60
|
+
*/
|
|
61
|
+
export function resolvePreviewFileSample(mimeType: string | undefined, filename?: string): string {
|
|
62
|
+
const normalized = (mimeType ?? '').split(';')[0]!.trim().toLowerCase()
|
|
63
|
+
const exact = PREVIEW_FILE_SAMPLES[normalized]
|
|
64
|
+
if (exact) return exact
|
|
65
|
+
|
|
66
|
+
const family = FAMILY_FALLBACK[normalized.split('/')[0] ?? '']
|
|
67
|
+
const byFamily = family ? PREVIEW_FILE_SAMPLES[family] : undefined
|
|
68
|
+
if (byFamily) return byFamily
|
|
69
|
+
|
|
70
|
+
const note = `Preview do SDK: nao existe amostra binaria valida para ${normalized || 'tipo desconhecido'}${
|
|
71
|
+
filename ? ` (${filename})` : ''
|
|
72
|
+
}. Na tela real este arquivo vem do storage.`
|
|
73
|
+
return `data:text/plain;charset=utf-8,${encodeURIComponent(note)}`
|
|
74
|
+
}
|