@adatechnology/conversations-ui 0.1.0-rc.3 → 0.1.0-rc.30

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.
Files changed (143) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/chunk-GY472G6E.js +2316 -0
  3. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  4. package/dist/flows/index.d.ts +311 -4
  5. package/dist/flows/index.js +1322 -55
  6. package/dist/index.d.ts +1074 -42
  7. package/dist/index.js +3571 -742
  8. package/dist/preview/index.d.ts +328 -8
  9. package/dist/preview/index.js +902 -115
  10. package/dist/styles.css +819 -0
  11. package/dist/types-De5aN-E_.d.ts +502 -0
  12. package/package.json +3 -3
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +14 -3
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +347 -24
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +163 -45
  23. package/src/ConversationListItem.tsx +19 -2
  24. package/src/ConversationLocalesProvider.tsx +42 -0
  25. package/src/ConversationRow.tsx +31 -7
  26. package/src/DocumentsLibrary.tsx +382 -0
  27. package/src/EmojiPicker.tsx +70 -55
  28. package/src/FileIcon.test.ts +83 -0
  29. package/src/FileIcon.tsx +88 -11
  30. package/src/InteractiveMessage.test.tsx +41 -0
  31. package/src/InteractiveMessage.tsx +146 -0
  32. package/src/Lightbox.tsx +18 -3
  33. package/src/MediaRenderer.tsx +98 -22
  34. package/src/MessageBubble.test.tsx +41 -0
  35. package/src/MessageBubble.tsx +75 -6
  36. package/src/MessageComposer.test.tsx +35 -0
  37. package/src/MessageComposer.tsx +155 -19
  38. package/src/RichMessageComposer.test.tsx +113 -0
  39. package/src/RichMessageComposer.tsx +551 -0
  40. package/src/SimpleEmojiPicker.tsx +5 -3
  41. package/src/StatusTicks.tsx +1 -1
  42. package/src/Toast.tsx +4 -0
  43. package/src/Tooltip.test.ts +42 -0
  44. package/src/Tooltip.tsx +164 -0
  45. package/src/Wallpaper.test.tsx +21 -0
  46. package/src/Wallpaper.tsx +67 -7
  47. package/src/WhatsAppMessageEditor.tsx +28 -4
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/audioRecorderFormat.test.ts +67 -0
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +543 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/emojiCatalog.test.ts +35 -0
  60. package/src/emojiCatalog.ts +189 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +35 -8
  65. package/src/flows/FlowNodePanel.tsx +149 -38
  66. package/src/flows/FlowPalette.tsx +13 -3
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1003 -0
  70. package/src/flows/flowCanvasModel.test.ts +293 -0
  71. package/src/flows/flowCanvasModel.ts +342 -0
  72. package/src/flows/flowEditorOps.test.ts +241 -0
  73. package/src/flows/flowEditorOps.ts +177 -0
  74. package/src/flows/flowGraph.ts +6 -6
  75. package/src/flows/index.ts +40 -1
  76. package/src/flows/labels.ts +141 -0
  77. package/src/flows/workspaceContract.test.ts +95 -0
  78. package/src/hooks/useContainerWidth.ts +35 -0
  79. package/src/hooks/useConversationActions.ts +56 -0
  80. package/src/hooks/useConversationDocuments.ts +11 -7
  81. package/src/hooks/useConversationList.ts +15 -9
  82. package/src/hooks/useConversationMessages.ts +2 -2
  83. package/src/hooks/useConversationRealtime.ts +10 -8
  84. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  85. package/src/hooks/useUrlFilterState.ts +107 -0
  86. package/src/icon.constant.ts +12 -0
  87. package/src/index.ts +114 -13
  88. package/src/lib/cn.test.ts +29 -0
  89. package/src/lib/composer-formatting.test.ts +78 -0
  90. package/src/lib/composer-formatting.ts +145 -0
  91. package/src/lib/createMediaUrlResolver.ts +33 -0
  92. package/src/lib/paginated.test.ts +33 -0
  93. package/src/lib/paginated.ts +26 -0
  94. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  95. package/src/lib/whatsapp-formatting.tsx +28 -3
  96. package/src/listing/index.tsx +202 -0
  97. package/src/pagination.constant.ts +10 -0
  98. package/src/preview/ConversationPreview.tsx +199 -13
  99. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  100. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  101. package/src/preview/MediaTypesPreview.tsx +87 -0
  102. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  103. package/src/preview/createMockConversationsApi.ts +175 -15
  104. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  105. package/src/preview/createPreviewBridgeClient.ts +124 -0
  106. package/src/preview/createPreviewMediaUploader.ts +82 -0
  107. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  108. package/src/preview/createPreviewWebhookClient.ts +127 -4
  109. package/src/preview/index.ts +33 -3
  110. package/src/preview/mediaTypeOf.test.ts +15 -0
  111. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  112. package/src/preview/preview.test.ts +5 -3
  113. package/src/preview/previewFileSamples.test.ts +151 -0
  114. package/src/preview/previewFileSamples.ts +74 -0
  115. package/src/preview/previewFixtures.ts +288 -1
  116. package/src/preview/previewMediaSource.test.ts +62 -0
  117. package/src/preview/previewMediaSource.ts +91 -0
  118. package/src/preview/previewMediaUploader.test.ts +61 -0
  119. package/src/providers/ConversationsProvider.tsx +8 -6
  120. package/src/providers/types.ts +185 -10
  121. package/src/quickReply.test.ts +58 -0
  122. package/src/settings/MessagesWorkspace.tsx +468 -0
  123. package/src/settings/TopicsForm.tsx +2 -0
  124. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  125. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  126. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  127. package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
  128. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  129. package/src/settings/WhatsAppTemplatesSettings.tsx +9 -1
  130. package/src/styles.css +783 -0
  131. package/src/types.ts +64 -1
  132. package/src/useWaitingNotifications.ts +74 -29
  133. package/src/workspace/BulkTemplateModal.tsx +132 -0
  134. package/src/workspace/ConversationPane.tsx +432 -0
  135. package/src/workspace/ConversationsInboxList.tsx +194 -0
  136. package/src/workspace/ConversationsWorkspace.tsx +346 -0
  137. package/src/workspace/index.ts +12 -0
  138. package/src/workspace/labels.test.ts +17 -0
  139. package/src/workspace/labels.ts +85 -0
  140. package/src/workspace/useConversationsInbox.ts +332 -0
  141. package/dist/chunk-N7B24WYD.js +0 -719
  142. package/dist/chunk-NV2RZ5KT.js +0 -56
  143. package/dist/types-C0PtaO7S.d.ts +0 -207
@@ -6,24 +6,59 @@
6
6
  * Assina com WebCrypto porque `node:crypto` não existe no navegador. Os builders vêm dos contratos
7
7
  * (isomórficos) justamente para que o mesmo payload seja montado nos dois runtimes.
8
8
  *
9
- * ⚠️ Isto carrega o app secret de DESENVOLVIMENTO no bundle. Só existe para o docker local, e
10
- * `assertPreviewEnvironment` recusa rodar em produção — um segredo de dev vazado é irrelevante,
11
- * mas o hábito de embarcar segredo em frontend não é.
9
+ * ⚠️ SOMENTE EXECUÇÃO LOCAL. Isto carrega o app secret no bundle, e bundle é público onde quer que
10
+ * seja servido — em qualquer ambiente com URL acessível (homologação inclusive) usar esta fábrica
11
+ * equivale a publicar o segredo, e quem o tiver forja webhooks válidos daquele app da Meta: injeta
12
+ * mensagem de qualquer número e dispara os fluxos. `assertPreviewEnvironment` barra produção, mas
13
+ * homologação passaria, então a barreira não basta.
14
+ *
15
+ * Para qualquer ambiente publicado use `createPreviewBridgeClient`: o navegador manda a intenção e
16
+ * o servidor assina com o segredo que ele já tem.
12
17
  */
13
18
 
14
19
  import {
15
20
  buildInboundAudioPayload,
16
21
  buildInboundInteractivePayload,
22
+ buildInboundMediaPayload,
17
23
  buildInboundTextPayload,
18
24
  serializeWebhookPayload,
25
+ type InboundMediaType,
19
26
  type InteractiveReplyOption,
20
27
  } from '@adatechnology/meta-whatsapp-contracts/testing'
28
+ import { createPreviewMediaUploader, type PreviewUploadedMedia } from './createPreviewMediaUploader'
21
29
 
22
30
  export type PreviewWebhookClient = {
23
31
  sendText(text: string): Promise<void>
24
32
  sendButtonReply(reply: InteractiveReplyOption): Promise<void>
25
33
  sendListReply(reply: InteractiveReplyOption): Promise<void>
26
34
  sendAudio(mediaId: string): Promise<void>
35
+ sendMedia(params: SendPreviewMediaParams): Promise<void>
36
+ /**
37
+ * Guarda um arquivo gravado e devolve o `mediaId` já prefixado, pronto para `sendMedia`.
38
+ *
39
+ * Existe no cliente, e não como prop de quem monta a tela, porque isto é exatamente o que ele já
40
+ * sabe fazer: falar com ESTE host usando ESTE segredo. Enquanto era responsabilidade do produto,
41
+ * o resultado prático foi um produto com microfone no simulador e outro sem — não por decisão,
42
+ * por esquecimento. Cliente montado, microfone na tela.
43
+ *
44
+ * Opcional porque o cliente-ponte só consegue oferecer isto quando sabe a rota de mídia (ou quando
45
+ * o host injeta a função): sem destino, gravar áudio seria falar para o vazio, e aí a tela
46
+ * corretamente não desenha o gravador.
47
+ */
48
+ uploadMedia?(file: File): Promise<PreviewUploadedMedia>
49
+ }
50
+
51
+ export type SendPreviewMediaParams = {
52
+ readonly mediaType: InboundMediaType
53
+ /**
54
+ * Id que o host já usa para buscar o arquivo. Não é bytes: o webhook da Meta entrega mídia por
55
+ * referência, e o consumidor baixa depois — mandar base64 aqui simularia um payload que a Meta
56
+ * nunca produz, e o caminho testado deixaria de ser o de produção.
57
+ */
58
+ readonly mediaId: string
59
+ readonly mimeType?: string
60
+ readonly filename?: string
61
+ readonly caption?: string
27
62
  }
28
63
 
29
64
  export type CreatePreviewWebhookClientParams = {
@@ -31,10 +66,25 @@ export type CreatePreviewWebhookClientParams = {
31
66
  readonly appSecret: string
32
67
  readonly from: string
33
68
  readonly phoneNumberId?: string
69
+ /**
70
+ * Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na mesma origem do webhook.
71
+ *
72
+ * O padrão cobre o caso normal — as duas rotas são do mesmo servidor — e a prop existe para quem
73
+ * publica a API em outro host ou versiona o caminho.
74
+ */
75
+ readonly mediaUploadUrl?: string
34
76
  // Escape hatch para teste; em runtime real é sempre o fetch global.
35
77
  readonly fetchImplementation?: typeof fetch
36
78
  }
37
79
 
80
+ /** Falha da rota de upload, separada da do webhook: os dois lados quebram por motivos diferentes. */
81
+ export class PreviewMediaUploadRejectedError extends Error {
82
+ constructor(readonly status: number) {
83
+ super(`A rota de mídia do simulador recusou o upload (HTTP ${status}).`)
84
+ this.name = 'PreviewMediaUploadRejectedError'
85
+ }
86
+ }
87
+
38
88
  export class PreviewInProductionError extends Error {
39
89
  constructor() {
40
90
  super('O preview de conversa carrega um app secret e não pode ser montado em produção.')
@@ -57,7 +107,15 @@ export function assertPreviewEnvironment(isProduction: boolean): void {
57
107
  if (isProduction) throw new PreviewInProductionError()
58
108
  }
59
109
 
60
- async function signWithWebCrypto(params: { rawBody: string; appSecret: string }): Promise<string> {
110
+ /**
111
+ * Assina um texto qualquer com o app secret, no mesmo formato do header da Meta.
112
+ *
113
+ * Exportada porque o preview precisa provar identidade em MAIS de um lugar: além de entregar a
114
+ * mensagem no webhook, ele lê o transcript de volta — e ler pela API de admin exigia uma sessão que
115
+ * a aba do simulador não tem. Assinar a leitura com o segredo que ele já carrega resolve sem token
116
+ * de admin e sem rota aberta.
117
+ */
118
+ export async function signPreviewPayload(params: { rawBody: string; appSecret: string }): Promise<string> {
61
119
  const encoder = new TextEncoder()
62
120
  const key = await globalThis.crypto.subtle.importKey(
63
121
  'raw',
@@ -71,6 +129,55 @@ async function signWithWebCrypto(params: { rawBody: string; appSecret: string })
71
129
  return `sha256=${[...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, '0')).join('')}`
72
130
  }
73
131
 
132
+ const signWithWebCrypto = signPreviewPayload
133
+
134
+ export const DEFAULT_MEDIA_UPLOAD_PATH = '/v1/preview/media'
135
+
136
+ /**
137
+ * Mesma origem do webhook: as duas rotas são do mesmo servidor no caso normal.
138
+ *
139
+ * Quando `webhookUrl` é relativa — que é o que sai de um `VITE_API_URL` vazio, com o front servido
140
+ * pela própria API — não há origem para resolver contra, e o caminho relativo já aponta para o
141
+ * lugar certo. `new URL` com base relativa lançaria, e o microfone morreria no `createClient`.
142
+ */
143
+ export function defaultMediaUploadUrl(webhookUrl: string): string {
144
+ try {
145
+ return new URL(DEFAULT_MEDIA_UPLOAD_PATH, webhookUrl).toString()
146
+ } catch {
147
+ return DEFAULT_MEDIA_UPLOAD_PATH
148
+ }
149
+ }
150
+
151
+ /**
152
+ * O POST de mídia, sem a parte de assinatura — para os dois clientes usarem o mesmo caminho.
153
+ *
154
+ * O cliente-ponte autentica por sessão e o de webhook por HMAC; o que não muda é a rota, o formato
155
+ * do corpo e a leitura do `uploadId`. Duas cópias disso é como o prefixo de mídia divergiu antes.
156
+ */
157
+ export function createPreviewMediaPoster(params: {
158
+ readonly url: string
159
+ readonly headers?: (mimeType: string) => Promise<Readonly<Record<string, string>>>
160
+ readonly fetchImplementation?: typeof fetch
161
+ }): (file: File) => Promise<PreviewUploadedMedia> {
162
+ return createPreviewMediaUploader({
163
+ upload: async (request) => {
164
+ const performRequest = params.fetchImplementation ?? fetch
165
+ const extraHeaders = (await params.headers?.(request.mimeType)) ?? {}
166
+
167
+ const response = await performRequest(params.url, {
168
+ method: 'POST',
169
+ headers: { 'content-type': 'application/json', ...extraHeaders },
170
+ body: JSON.stringify(request),
171
+ })
172
+
173
+ if (!response.ok) throw new PreviewMediaUploadRejectedError(response.status)
174
+
175
+ const body = (await response.json()) as { data: { uploadId: string } }
176
+ return { uploadId: body.data.uploadId }
177
+ },
178
+ })
179
+ }
180
+
74
181
  export function createPreviewWebhookClient(params: CreatePreviewWebhookClientParams): PreviewWebhookClient {
75
182
  const sendPayload = async (payload: ReturnType<typeof buildInboundTextPayload>): Promise<void> => {
76
183
  // Serializa uma vez só: assinar um texto e enviar outro (mesmo com o conteúdo igual) derruba a
@@ -88,6 +195,20 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
88
195
  if (!response.ok) throw new PreviewWebhookRejectedError(response.status)
89
196
  }
90
197
 
198
+ /**
199
+ * Assina o MIME, não o binário — mesmo contrato da rota.
200
+ *
201
+ * Passar megabytes de base64 pelo HMAC do navegador travaria a aba a cada nota de voz; o que a
202
+ * assinatura protege aqui é o acesso à rota, e o binário já tem teto de tamanho no servidor.
203
+ */
204
+ const uploadMedia = createPreviewMediaPoster({
205
+ url: params.mediaUploadUrl ?? defaultMediaUploadUrl(params.webhookUrl),
206
+ headers: async (mimeType) => ({
207
+ 'x-preview-signature': await signWithWebCrypto({ rawBody: mimeType, appSecret: params.appSecret }),
208
+ }),
209
+ ...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
210
+ })
211
+
91
212
  const envelope = { from: params.from, phoneNumberId: params.phoneNumberId }
92
213
 
93
214
  return {
@@ -95,5 +216,7 @@ export function createPreviewWebhookClient(params: CreatePreviewWebhookClientPar
95
216
  sendButtonReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, buttonReply: reply })),
96
217
  sendListReply: (reply) => sendPayload(buildInboundInteractivePayload({ ...envelope, listReply: reply })),
97
218
  sendAudio: (mediaId) => sendPayload(buildInboundAudioPayload({ ...envelope, mediaId })),
219
+ sendMedia: (media) => sendPayload(buildInboundMediaPayload({ ...envelope, ...media })),
220
+ uploadMedia,
98
221
  }
99
222
  }
@@ -23,18 +23,48 @@ 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
+
28
+ export { ConversationSimulatorPanel, DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS } from './ConversationSimulatorPanel'
29
+ export type { ConversationSimulatorPanelProps, ConversationSimulatorPanelLabels } from './ConversationSimulatorPanel'
27
30
 
28
31
  export { ConversationPreview } from './ConversationPreview'
29
- export type { ConversationPreviewProps } from './ConversationPreview'
32
+ export { mediaTypeOf } from './ConversationPreview'
33
+ export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
34
+
35
+ export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
36
+ export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
30
37
 
31
38
  export {
32
39
  createPreviewWebhookClient,
40
+ signPreviewPayload,
33
41
  assertPreviewEnvironment,
34
42
  PreviewInProductionError,
35
43
  PreviewWebhookRejectedError,
44
+ PreviewMediaUploadRejectedError,
45
+ createPreviewMediaPoster,
46
+ DEFAULT_MEDIA_UPLOAD_PATH,
47
+ } from './createPreviewWebhookClient'
48
+ export type {
49
+ PreviewWebhookClient,
50
+ CreatePreviewWebhookClientParams,
51
+ SendPreviewMediaParams,
36
52
  } from './createPreviewWebhookClient'
37
- export type { PreviewWebhookClient, CreatePreviewWebhookClientParams } from './createPreviewWebhookClient'
53
+
54
+ export { createPreviewBridgeClient, PreviewBridgeRejectedError } from './createPreviewBridgeClient'
55
+ export type {
56
+ CreatePreviewBridgeClientParams,
57
+ PreviewInboundCommand,
58
+ SendPreviewInboundCommand,
59
+ } from './createPreviewBridgeClient'
38
60
 
39
61
  export { startPreviewScript, DEFAULT_PREVIEW_SCRIPT } from './startPreviewScript'
40
62
  export type { PreviewScriptStep, StartPreviewScriptParams } from './startPreviewScript'
63
+ export { PREVIEW_FILE_SAMPLES, resolvePreviewFileSample } from './previewFileSamples'
64
+ export { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
65
+ export { previewFileBase64 } from './previewMediaSource'
66
+ export { MediaTypesPreview, MEDIA_TYPES_CONVERSATION_ID } from './MediaTypesPreview'
67
+ export type { MediaTypesPreviewProps } from './MediaTypesPreview'
68
+
69
+ export { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
70
+ export type { CreatePreviewMediaUploaderParams, PreviewMediaUploadRequest } from './createPreviewMediaUploader'
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { mediaTypeOf } from './ConversationPreview'
3
+
4
+ describe('mediaTypeOf', () => {
5
+ it('deriva imagem, vídeo e áudio pelo prefixo do MIME', () => {
6
+ expect(mediaTypeOf('image/jpeg')).toBe('image')
7
+ expect(mediaTypeOf('video/mp4')).toBe('video')
8
+ expect(mediaTypeOf('audio/webm')).toBe('audio')
9
+ })
10
+
11
+ it('trata o resto como documento, inclusive MIME desconhecido', () => {
12
+ expect(mediaTypeOf('application/pdf')).toBe('document')
13
+ expect(mediaTypeOf('')).toBe('document')
14
+ })
15
+ })
@@ -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
- expect(waiting.every((conversation) => conversation.waitingHuman)).toBe(true)
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,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',
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
+ }