@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21
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-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-TV4OQRGH.js +2187 -0
- package/dist/flows/index.d.ts +14 -2
- package/dist/flows/index.js +117 -39
- package/dist/index.d.ts +862 -136
- package/dist/index.js +1746 -1124
- package/dist/preview/index.d.ts +470 -0
- package/dist/preview/index.js +1329 -0
- package/dist/styles.css +228 -0
- package/dist/types-C6A_9edv.d.ts +456 -0
- package/package.json +10 -3
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +280 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +44 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +96 -22
- package/src/MessageBubble.tsx +77 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +165 -19
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +380 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +69 -7
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +122 -0
- package/src/conversationTranscript.ts +89 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowMapCanvas.tsx +17 -14
- package/src/flows/FlowMapNode.tsx +3 -1
- package/src/flows/FlowNodeCard.tsx +22 -4
- package/src/flows/FlowNodePanel.tsx +132 -35
- package/src/flows/FlowPalette.tsx +6 -2
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/flowGraph.ts +5 -5
- package/src/flows/labels.ts +5 -0
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +140 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -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/lib/phone.ts +34 -0
- package/src/preview/ConversationPreview.tsx +334 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +194 -0
- package/src/preview/createPreviewWebhookClient.ts +222 -0
- package/src/preview/index.ts +67 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +175 -12
- package/src/quickReply.test.ts +58 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +173 -0
- package/src/types.ts +72 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversas de partida do preview. Cobrem o espaço de estados que a inbox precisa saber desenhar —
|
|
3
|
+
* bot em atendimento, cliente esperando humano, conversa já assumida por atendente, conversa com
|
|
4
|
+
* áudio — porque estado que não aparece em fixture é estado que ninguém testa.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { MessagePayload } from '../types'
|
|
8
|
+
import type { ConversationDocument, ConversationSummary } from '../providers/types'
|
|
9
|
+
|
|
10
|
+
// Datas fixas: fixture com data relativa ao relógio faz o mesmo cenário renderizar diferente a
|
|
11
|
+
// cada execução, e separadores de dia deixam de ser verificáveis.
|
|
12
|
+
const BASE_DAY = '2026-07-26'
|
|
13
|
+
|
|
14
|
+
function at(time: string): string {
|
|
15
|
+
return `${BASE_DAY}T${time}.000Z`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PREVIEW_CONVERSATIONS: readonly ConversationSummary[] = [
|
|
19
|
+
{
|
|
20
|
+
id: '5511988887777',
|
|
21
|
+
whatsappNumber: '5511988887777',
|
|
22
|
+
clientName: 'Marina Alves',
|
|
23
|
+
lastContent: 'quero 2kg de arroz e um óleo',
|
|
24
|
+
lastDirection: 'inbound',
|
|
25
|
+
lastAt: at('14:32:00'),
|
|
26
|
+
lastInboundAt: at('14:32:00'),
|
|
27
|
+
mode: 'bot',
|
|
28
|
+
assignedUserId: null,
|
|
29
|
+
waitingHuman: false,
|
|
30
|
+
unread: 2,
|
|
31
|
+
currentState: 'list_review',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: '5511977776666',
|
|
35
|
+
whatsappNumber: '5511977776666',
|
|
36
|
+
clientName: 'Diego Prado',
|
|
37
|
+
lastContent: 'preciso falar com alguém',
|
|
38
|
+
lastDirection: 'inbound',
|
|
39
|
+
lastAt: at('14:20:00'),
|
|
40
|
+
lastInboundAt: at('14:20:00'),
|
|
41
|
+
mode: 'bot',
|
|
42
|
+
assignedUserId: null,
|
|
43
|
+
waitingHuman: true,
|
|
44
|
+
unread: 1,
|
|
45
|
+
currentState: 'awaiting_human',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: '5511966665555',
|
|
49
|
+
whatsappNumber: '5511966665555',
|
|
50
|
+
clientName: 'Sofia Nakamura',
|
|
51
|
+
lastContent: 'já separei seu pedido, confere?',
|
|
52
|
+
lastDirection: 'outbound',
|
|
53
|
+
lastAt: at('13:58:00'),
|
|
54
|
+
lastInboundAt: at('13:50:00'),
|
|
55
|
+
mode: 'human',
|
|
56
|
+
assignedUserId: 'agent-1',
|
|
57
|
+
waitingHuman: false,
|
|
58
|
+
unread: 0,
|
|
59
|
+
currentState: 'human_handling',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: '5511955554444',
|
|
63
|
+
whatsappNumber: '5511955554444',
|
|
64
|
+
lastContent: 'Áudio',
|
|
65
|
+
lastDirection: 'inbound',
|
|
66
|
+
lastAt: at('13:31:00'),
|
|
67
|
+
lastInboundAt: at('13:31:00'),
|
|
68
|
+
mode: 'bot',
|
|
69
|
+
assignedUserId: null,
|
|
70
|
+
waitingHuman: false,
|
|
71
|
+
unread: 1,
|
|
72
|
+
currentState: 'list_import',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
// Cobre TODO tipo que o composer aceita (DEFAULT_ACCEPTED_FILE_TYPES: image/*, video/*,
|
|
76
|
+
// audio/*, .pdf, .doc, .docx, .xls, .xlsx, .zip) mais sticker. Existe para que cada ramo do
|
|
77
|
+
// MediaRenderer e cada ícone/cor do FileIcon apareçam em algum lugar — ramo sem fixture é ramo
|
|
78
|
+
// que ninguém olha até quebrar em produção.
|
|
79
|
+
id: '5511944443333',
|
|
80
|
+
whatsappNumber: '5511944443333',
|
|
81
|
+
clientName: 'Rita Documentos',
|
|
82
|
+
lastContent: 'segue a planilha do pedido',
|
|
83
|
+
lastDirection: 'inbound',
|
|
84
|
+
lastAt: at('15:10:00'),
|
|
85
|
+
lastInboundAt: at('15:10:00'),
|
|
86
|
+
mode: 'human',
|
|
87
|
+
assignedUserId: 'agent-1',
|
|
88
|
+
waitingHuman: false,
|
|
89
|
+
unread: 3,
|
|
90
|
+
currentState: 'human_handling',
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
export const PREVIEW_MESSAGES: Readonly<Record<string, readonly MessagePayload[]>> = {
|
|
95
|
+
'5511988887777': [
|
|
96
|
+
{
|
|
97
|
+
id: 'fixture-1',
|
|
98
|
+
type: 'text',
|
|
99
|
+
content: 'oi, boa tarde',
|
|
100
|
+
direction: 'inbound',
|
|
101
|
+
sender: 'customer',
|
|
102
|
+
timestamp: at('14:30:00'),
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'fixture-2',
|
|
106
|
+
type: 'text',
|
|
107
|
+
content: 'Boa tarde! Me manda sua lista de compras que eu monto o carrinho.',
|
|
108
|
+
direction: 'outbound',
|
|
109
|
+
sender: 'bot',
|
|
110
|
+
timestamp: at('14:30:30'),
|
|
111
|
+
status: 'read',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: 'fixture-3',
|
|
115
|
+
type: 'text',
|
|
116
|
+
content: 'quero 2kg de arroz e um óleo',
|
|
117
|
+
direction: 'inbound',
|
|
118
|
+
sender: 'customer',
|
|
119
|
+
timestamp: at('14:32:00'),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
'5511977776666': [
|
|
123
|
+
{
|
|
124
|
+
id: 'fixture-4',
|
|
125
|
+
type: 'text',
|
|
126
|
+
content: 'esse valor do frete está certo?',
|
|
127
|
+
direction: 'inbound',
|
|
128
|
+
sender: 'customer',
|
|
129
|
+
timestamp: at('14:19:00'),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
id: 'fixture-5',
|
|
133
|
+
type: 'text',
|
|
134
|
+
content: 'preciso falar com alguém',
|
|
135
|
+
direction: 'inbound',
|
|
136
|
+
sender: 'customer',
|
|
137
|
+
timestamp: at('14:20:00'),
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
'5511966665555': [
|
|
141
|
+
{
|
|
142
|
+
id: 'fixture-6',
|
|
143
|
+
type: 'text',
|
|
144
|
+
content: 'consegue trocar o leite integral por desnatado?',
|
|
145
|
+
direction: 'inbound',
|
|
146
|
+
sender: 'customer',
|
|
147
|
+
timestamp: at('13:50:00'),
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'fixture-7',
|
|
151
|
+
type: 'text',
|
|
152
|
+
content: 'já separei seu pedido, confere?',
|
|
153
|
+
direction: 'outbound',
|
|
154
|
+
sender: 'agent',
|
|
155
|
+
timestamp: at('13:58:00'),
|
|
156
|
+
status: 'delivered',
|
|
157
|
+
agentName: 'Ana',
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
'5511955554444': [
|
|
161
|
+
{
|
|
162
|
+
id: 'fixture-8',
|
|
163
|
+
type: 'audio',
|
|
164
|
+
mediaId: 'preview-audio-1',
|
|
165
|
+
mimeType: 'audio/ogg',
|
|
166
|
+
direction: 'inbound',
|
|
167
|
+
sender: 'customer',
|
|
168
|
+
timestamp: at('13:31:00'),
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
// Um tipo por mensagem, na ordem em que o MediaRenderer os trata.
|
|
172
|
+
'5511944443333': [
|
|
173
|
+
{
|
|
174
|
+
id: 'fixture-doc-image',
|
|
175
|
+
type: 'image',
|
|
176
|
+
mediaId: 'preview-image-1',
|
|
177
|
+
mimeType: 'image/png',
|
|
178
|
+
caption: 'foto da prateleira',
|
|
179
|
+
direction: 'inbound',
|
|
180
|
+
sender: 'customer',
|
|
181
|
+
timestamp: at('15:00:00'),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'fixture-doc-video',
|
|
185
|
+
type: 'video',
|
|
186
|
+
mediaId: 'preview-video-1',
|
|
187
|
+
mimeType: 'video/mp4',
|
|
188
|
+
direction: 'inbound',
|
|
189
|
+
sender: 'customer',
|
|
190
|
+
timestamp: at('15:01:00'),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: 'fixture-doc-audio',
|
|
194
|
+
type: 'audio',
|
|
195
|
+
mediaId: 'preview-audio-2',
|
|
196
|
+
mimeType: 'audio/ogg',
|
|
197
|
+
direction: 'inbound',
|
|
198
|
+
sender: 'customer',
|
|
199
|
+
timestamp: at('15:02:00'),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'fixture-doc-sticker',
|
|
203
|
+
type: 'sticker',
|
|
204
|
+
mediaId: 'preview-sticker-1',
|
|
205
|
+
mimeType: 'image/webp',
|
|
206
|
+
direction: 'inbound',
|
|
207
|
+
sender: 'customer',
|
|
208
|
+
timestamp: at('15:03:00'),
|
|
209
|
+
},
|
|
210
|
+
// Os cinco ramos do FileIcon: pdf, doc, xls, zip e o genérico do fallback.
|
|
211
|
+
{
|
|
212
|
+
id: 'fixture-doc-pdf',
|
|
213
|
+
type: 'document',
|
|
214
|
+
uploadId: 'preview/documentos/nota-fiscal.pdf',
|
|
215
|
+
filename: 'nota-fiscal.pdf',
|
|
216
|
+
mimeType: 'application/pdf',
|
|
217
|
+
sizeBytes: 184_320,
|
|
218
|
+
direction: 'inbound',
|
|
219
|
+
sender: 'customer',
|
|
220
|
+
timestamp: at('15:04:00'),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'fixture-doc-docx',
|
|
224
|
+
type: 'document',
|
|
225
|
+
uploadId: 'preview/documentos/contrato.docx',
|
|
226
|
+
filename: 'contrato.docx',
|
|
227
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
228
|
+
sizeBytes: 42_112,
|
|
229
|
+
direction: 'inbound',
|
|
230
|
+
sender: 'customer',
|
|
231
|
+
timestamp: at('15:05:00'),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
id: 'fixture-doc-doc',
|
|
235
|
+
type: 'document',
|
|
236
|
+
uploadId: 'preview/documentos/procuracao.doc',
|
|
237
|
+
filename: 'procuracao.doc',
|
|
238
|
+
mimeType: 'application/msword',
|
|
239
|
+
sizeBytes: 31_744,
|
|
240
|
+
direction: 'inbound',
|
|
241
|
+
sender: 'customer',
|
|
242
|
+
timestamp: at('15:06:00'),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'fixture-doc-xlsx',
|
|
246
|
+
type: 'document',
|
|
247
|
+
uploadId: 'preview/documentos/pedido.xlsx',
|
|
248
|
+
filename: 'pedido.xlsx',
|
|
249
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
250
|
+
sizeBytes: 15_872,
|
|
251
|
+
direction: 'inbound',
|
|
252
|
+
sender: 'customer',
|
|
253
|
+
timestamp: at('15:07:00'),
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: 'fixture-doc-xls',
|
|
257
|
+
type: 'document',
|
|
258
|
+
uploadId: 'preview/documentos/tabela-antiga.xls',
|
|
259
|
+
filename: 'tabela-antiga.xls',
|
|
260
|
+
mimeType: 'application/vnd.ms-excel',
|
|
261
|
+
sizeBytes: 9_216,
|
|
262
|
+
direction: 'inbound',
|
|
263
|
+
sender: 'customer',
|
|
264
|
+
timestamp: at('15:08:00'),
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: 'fixture-doc-zip',
|
|
268
|
+
type: 'document',
|
|
269
|
+
uploadId: 'preview/documentos/comprovantes.zip',
|
|
270
|
+
filename: 'comprovantes.zip',
|
|
271
|
+
mimeType: 'application/zip',
|
|
272
|
+
sizeBytes: 2_355_200,
|
|
273
|
+
direction: 'inbound',
|
|
274
|
+
sender: 'customer',
|
|
275
|
+
timestamp: at('15:09:00'),
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: 'fixture-doc-txt',
|
|
279
|
+
type: 'document',
|
|
280
|
+
uploadId: 'preview/documentos/lista-compras.txt',
|
|
281
|
+
filename: 'lista-compras.txt',
|
|
282
|
+
mimeType: 'text/plain',
|
|
283
|
+
sizeBytes: 1_024,
|
|
284
|
+
direction: 'inbound',
|
|
285
|
+
sender: 'customer',
|
|
286
|
+
timestamp: at('15:10:00'),
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
id: 'fixture-doc-csv',
|
|
290
|
+
type: 'document',
|
|
291
|
+
uploadId: 'preview/documentos/planilha-do-pedido.csv',
|
|
292
|
+
filename: 'planilha-do-pedido.csv',
|
|
293
|
+
mimeType: 'text/csv',
|
|
294
|
+
sizeBytes: 2_048,
|
|
295
|
+
direction: 'inbound',
|
|
296
|
+
sender: 'customer',
|
|
297
|
+
timestamp: at('15:11:00'),
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
id: 'fixture-doc-pptx',
|
|
301
|
+
type: 'document',
|
|
302
|
+
uploadId: 'preview/documentos/apresentacao.pptx',
|
|
303
|
+
filename: 'apresentacao.pptx',
|
|
304
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
305
|
+
sizeBytes: 512_000,
|
|
306
|
+
direction: 'inbound',
|
|
307
|
+
sender: 'customer',
|
|
308
|
+
timestamp: at('15:12:00'),
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
id: 'fixture-doc-ppt',
|
|
312
|
+
type: 'document',
|
|
313
|
+
uploadId: 'preview/documentos/antiga.ppt',
|
|
314
|
+
filename: 'antiga.ppt',
|
|
315
|
+
mimeType: 'application/vnd.ms-powerpoint',
|
|
316
|
+
sizeBytes: 348_160,
|
|
317
|
+
direction: 'inbound',
|
|
318
|
+
sender: 'customer',
|
|
319
|
+
timestamp: at('15:13:00'),
|
|
320
|
+
},
|
|
321
|
+
// Mídia enviada COMO arquivo: aí a Meta manda o nome, então é onde a extensão de música e de
|
|
322
|
+
// vídeo aparece na lista. Mandada como `image`/`audio`/`video` (acima e abaixo) ela chega sem
|
|
323
|
+
// nome nenhum, e o rótulo vira o id da mídia — como em produção.
|
|
324
|
+
{
|
|
325
|
+
id: 'fixture-doc-mp3',
|
|
326
|
+
type: 'document',
|
|
327
|
+
uploadId: 'preview/documentos/musica-do-anuncio.mp3',
|
|
328
|
+
filename: 'musica-do-anuncio.mp3',
|
|
329
|
+
mimeType: 'audio/mpeg',
|
|
330
|
+
sizeBytes: 3_145_728,
|
|
331
|
+
direction: 'inbound',
|
|
332
|
+
sender: 'customer',
|
|
333
|
+
timestamp: at('15:14:00'),
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: 'fixture-doc-m4a',
|
|
337
|
+
type: 'document',
|
|
338
|
+
uploadId: 'preview/documentos/recado.m4a',
|
|
339
|
+
filename: 'recado.m4a',
|
|
340
|
+
mimeType: 'audio/mp4',
|
|
341
|
+
sizeBytes: 486_400,
|
|
342
|
+
direction: 'inbound',
|
|
343
|
+
sender: 'customer',
|
|
344
|
+
timestamp: at('15:15:00'),
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: 'fixture-doc-mp4-file',
|
|
348
|
+
type: 'document',
|
|
349
|
+
uploadId: 'preview/documentos/video-do-produto.mp4',
|
|
350
|
+
filename: 'video-do-produto.mp4',
|
|
351
|
+
mimeType: 'video/mp4',
|
|
352
|
+
sizeBytes: 8_388_608,
|
|
353
|
+
direction: 'inbound',
|
|
354
|
+
sender: 'customer',
|
|
355
|
+
timestamp: at('15:16:00'),
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
id: 'fixture-doc-jpg',
|
|
359
|
+
type: 'document',
|
|
360
|
+
uploadId: 'preview/documentos/foto-do-recibo.jpg',
|
|
361
|
+
filename: 'foto-do-recibo.jpg',
|
|
362
|
+
mimeType: 'image/jpeg',
|
|
363
|
+
sizeBytes: 262_144,
|
|
364
|
+
direction: 'inbound',
|
|
365
|
+
sender: 'customer',
|
|
366
|
+
timestamp: at('15:17:00'),
|
|
367
|
+
},
|
|
368
|
+
// As espécies restantes que a Meta aceita, sem nome de arquivo — fecham a lista de tipos
|
|
369
|
+
// aceitos e exercitam o fallback de ícone pela família do mimeType.
|
|
370
|
+
{
|
|
371
|
+
id: 'fixture-media-jpeg',
|
|
372
|
+
type: 'image',
|
|
373
|
+
mediaId: 'preview-image-jpeg',
|
|
374
|
+
mimeType: 'image/jpeg',
|
|
375
|
+
sizeBytes: 138_240,
|
|
376
|
+
direction: 'inbound',
|
|
377
|
+
sender: 'customer',
|
|
378
|
+
timestamp: at('15:18:00'),
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 'fixture-media-3gp',
|
|
382
|
+
type: 'video',
|
|
383
|
+
mediaId: 'preview-video-3gp',
|
|
384
|
+
mimeType: 'video/3gp',
|
|
385
|
+
sizeBytes: 921_600,
|
|
386
|
+
direction: 'inbound',
|
|
387
|
+
sender: 'customer',
|
|
388
|
+
timestamp: at('15:19:00'),
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
id: 'fixture-media-aac',
|
|
392
|
+
type: 'audio',
|
|
393
|
+
mediaId: 'preview-audio-aac',
|
|
394
|
+
mimeType: 'audio/aac',
|
|
395
|
+
sizeBytes: 76_800,
|
|
396
|
+
direction: 'inbound',
|
|
397
|
+
sender: 'customer',
|
|
398
|
+
timestamp: at('15:20:00'),
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
id: 'fixture-media-amr',
|
|
402
|
+
type: 'audio',
|
|
403
|
+
mediaId: 'preview-audio-amr',
|
|
404
|
+
mimeType: 'audio/amr',
|
|
405
|
+
sizeBytes: 24_576,
|
|
406
|
+
direction: 'inbound',
|
|
407
|
+
sender: 'customer',
|
|
408
|
+
timestamp: at('15:21:00'),
|
|
409
|
+
},
|
|
410
|
+
],
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A biblioteca de arquivos da conversa, como o backend a devolveria. Deriva das mensagens acima em
|
|
415
|
+
* vez de repetir os dados: fixture duplicada divergiria na primeira edição, e o painel passaria a
|
|
416
|
+
* mostrar arquivo que a thread não tem.
|
|
417
|
+
*
|
|
418
|
+
* Entram as CINCO espécies de mídia, na mesma lista de `extractMediaDescriptor` no backend — não só
|
|
419
|
+
* `document`. Filtrar por documento era infidelidade grave do preview: `IngestInboundMedia` linka
|
|
420
|
+
* foto, vídeo, áudio e sticker na mesma tabela, então na tela real eles aparecem na biblioteca e no
|
|
421
|
+
* preview não apareciam.
|
|
422
|
+
*/
|
|
423
|
+
const LIBRARY_MEDIA_TYPES = new Set(['document', 'image', 'video', 'audio', 'sticker'])
|
|
424
|
+
|
|
425
|
+
export const PREVIEW_DOCUMENTS: Readonly<Record<string, readonly ConversationDocument[]>> = {
|
|
426
|
+
'5511944443333': (PREVIEW_MESSAGES['5511944443333'] ?? [])
|
|
427
|
+
.filter((message) => LIBRARY_MEDIA_TYPES.has(message.type))
|
|
428
|
+
.map((message) => ({
|
|
429
|
+
// Mídia não tem `uploadId` na mensagem: o backend deriva a key do id da mídia na Meta, e o
|
|
430
|
+
// caminho abaixo imita esse formato para o download assinado receber algo do mesmo feitio.
|
|
431
|
+
id: message.uploadId ?? (message.mediaId ? `preview/inbound/${message.mediaId}` : message.id),
|
|
432
|
+
// Só documento chega nomeado. Para os outros o backend salva o id da mídia como rótulo —
|
|
433
|
+
// manter isso aqui é o que faz o ícone por família do mimeType ser exercitado de verdade.
|
|
434
|
+
filename: message.filename ?? message.mediaId ?? message.id,
|
|
435
|
+
mimeType: message.mimeType ?? 'application/octet-stream',
|
|
436
|
+
sizeBytes: message.sizeBytes ?? 0,
|
|
437
|
+
source: message.sender,
|
|
438
|
+
linkedAt: message.timestamp,
|
|
439
|
+
})),
|
|
440
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda o resolvedor de mídia do preview.
|
|
3
|
+
*
|
|
4
|
+
* As asserções comparam com `previewFileUrl(tipo)` em vez de com a data URL da amostra: o bun também
|
|
5
|
+
* tem `URL.createObjectURL`, então o retorno é `blob:` aqui como no navegador, e blob URL não expõe
|
|
6
|
+
* o conteúdo para comparar. O que este teste trava é o MAPEAMENTO — mensagem → amostra do tipo certo
|
|
7
|
+
* —, enquanto os bytes de cada tipo são conferidos em `previewFileSamples.test.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'bun:test'
|
|
11
|
+
|
|
12
|
+
import { createPreviewMediaResolver, previewFileUrl } from './previewMediaSource'
|
|
13
|
+
import type { MessagePayload } from '../types'
|
|
14
|
+
|
|
15
|
+
const resolve = createPreviewMediaResolver()
|
|
16
|
+
|
|
17
|
+
function message(partial: Partial<MessagePayload>): MessagePayload {
|
|
18
|
+
return {
|
|
19
|
+
id: 'msg-1',
|
|
20
|
+
type: 'image',
|
|
21
|
+
direction: 'inbound',
|
|
22
|
+
sender: 'customer',
|
|
23
|
+
timestamp: '2026-07-26T15:00:00.000Z',
|
|
24
|
+
...partial,
|
|
25
|
+
} as MessagePayload
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('createPreviewMediaResolver', () => {
|
|
29
|
+
it('resolve mídia pelo mediaId, achando o tipo na biblioteca do fixture', async () => {
|
|
30
|
+
// preview-image-1 é a foto png da thread da Rita.
|
|
31
|
+
expect(await resolve(message({ mediaId: 'preview-image-1' }))).toBe(previewFileUrl('image/png'))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('resolve documento pelo uploadId', async () => {
|
|
35
|
+
const resolved = await resolve(message({ type: 'document', uploadId: 'preview/documentos/nota-fiscal.pdf' }))
|
|
36
|
+
|
|
37
|
+
expect(resolved).toBe(previewFileUrl('application/pdf'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// O sticker é webp e o áudio de voz é opus: dois tipos que o painel antes nem listava.
|
|
41
|
+
it('resolve sticker e áudio', async () => {
|
|
42
|
+
expect(await resolve(message({ mediaId: 'preview-sticker-1' }))).toBe(previewFileUrl('image/webp'))
|
|
43
|
+
expect(await resolve(message({ mediaId: 'preview-audio-2' }))).toBe(previewFileUrl('audio/ogg'))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Mídia que não está na biblioteca ainda tem de abrir: cai no mimeType da própria mensagem.
|
|
47
|
+
it('usa o mimeType da mensagem quando o id é desconhecido', async () => {
|
|
48
|
+
const resolved = await resolve(message({ mediaId: 'nunca-visto', mimeType: 'image/jpeg' }))
|
|
49
|
+
|
|
50
|
+
expect(resolved).toBe(previewFileUrl('image/jpeg'))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Sem referência nenhuma o MediaRenderer não deve receber URL — devolver algo aqui faria a bolha
|
|
54
|
+
// mostrar arquivo que a mensagem não tem.
|
|
55
|
+
it('devolve null para mensagem sem uploadId nem mediaId', async () => {
|
|
56
|
+
expect(await resolve(message({ type: 'text' }))).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reaproveita a mesma URL para o mesmo tipo', () => {
|
|
60
|
+
expect(previewFileUrl('application/pdf')).toBe(previewFileUrl('application/pdf'))
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Como o preview entrega mídia para a UI abrir.
|
|
3
|
+
*
|
|
4
|
+
* Separado de `previewFileSamples` (que é gerado) porque aqui está a parte que depende do navegador:
|
|
5
|
+
* **blob URL, não data URL**. O Chrome bloqueia navegação de topo para `data:` desde a v60, então
|
|
6
|
+
* `window.open(dataUrl)` — que é o que os botões "visualizar" e "baixar" fazem — abre aba em branco
|
|
7
|
+
* mesmo com bytes perfeitamente válidos. Em `src` de `<img>`/`<video>` a data URL funcionaria; no
|
|
8
|
+
* `window.open` não, e o mesmo `getDocumentUrl` alimenta os dois.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MessagePayload } from '../types'
|
|
12
|
+
import type { ResolveMediaUrl } from '../MediaRenderer'
|
|
13
|
+
import { resolvePreviewFileSample } from './previewFileSamples'
|
|
14
|
+
import { PREVIEW_DOCUMENTS } from './previewFixtures'
|
|
15
|
+
|
|
16
|
+
// Uma blob URL por tipo, reaproveitada: cada `createObjectURL` retém o blob até um `revokeObjectURL`
|
|
17
|
+
// que ninguém chamaria, e a lista redesenha a cada filtro digitado.
|
|
18
|
+
const blobUrlCache = new Map<string, string>()
|
|
19
|
+
|
|
20
|
+
function toBlob(dataUrl: string): Blob {
|
|
21
|
+
const [head, payload] = dataUrl.split(',')
|
|
22
|
+
const mimeType = head!
|
|
23
|
+
.replace(/^data:/, '')
|
|
24
|
+
.replace(/;base64$/, '')
|
|
25
|
+
.replace(/;charset=.*$/, '')
|
|
26
|
+
|
|
27
|
+
if (!head!.endsWith(';base64')) {
|
|
28
|
+
return new Blob([decodeURIComponent(payload!)], { type: mimeType || 'text/plain' })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const binary = atob(payload!)
|
|
32
|
+
const bytes = new Uint8Array(binary.length)
|
|
33
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
34
|
+
return new Blob([bytes], { type: mimeType })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** URL que o preview pode abrir em aba nova. Cai na data URL fora do navegador (teste, SSR). */
|
|
38
|
+
export function previewFileUrl(mimeType: string | undefined, filename?: string): string {
|
|
39
|
+
const dataUrl = resolvePreviewFileSample(mimeType, filename)
|
|
40
|
+
if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return dataUrl
|
|
41
|
+
|
|
42
|
+
const cached = blobUrlCache.get(dataUrl)
|
|
43
|
+
if (cached) return cached
|
|
44
|
+
|
|
45
|
+
const blobUrl = URL.createObjectURL(toBlob(dataUrl))
|
|
46
|
+
blobUrlCache.set(dataUrl, blobUrl)
|
|
47
|
+
return blobUrl
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A mesma amostra em base64 cru, como o proxy de mídia do backend a devolveria.
|
|
52
|
+
*
|
|
53
|
+
* Existe porque `getMediaProxyUrl` é o caminho da mídia AINDA NÃO ingerida (só existe o id na Meta),
|
|
54
|
+
* e o contrato pede `{ mimeType, data }` — não URL. Sem isto o mock devolvia o PNG 1x1 para
|
|
55
|
+
* qualquer id, e vídeo e áudio da thread apareciam quebrados apesar de haver amostra válida.
|
|
56
|
+
*/
|
|
57
|
+
export function previewFileBase64(mimeType: string | undefined, filename?: string): { mimeType: string; data: string } {
|
|
58
|
+
const dataUrl = resolvePreviewFileSample(mimeType, filename)
|
|
59
|
+
const [head, payload] = dataUrl.split(',')
|
|
60
|
+
const declared = head!
|
|
61
|
+
.replace(/^data:/, '')
|
|
62
|
+
.replace(/;base64$/, '')
|
|
63
|
+
.replace(/;charset=.*$/, '')
|
|
64
|
+
|
|
65
|
+
if (head!.endsWith(';base64')) return { mimeType: declared, data: payload! }
|
|
66
|
+
return { mimeType: declared, data: btoa(decodeURIComponent(payload!)) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* O `onResolveMediaUrl` que o `MessageBubble` espera.
|
|
71
|
+
*
|
|
72
|
+
* Sem ele, foto, vídeo e áudio da thread ficam parados no placeholder para sempre — o
|
|
73
|
+
* `MediaRenderer` só resolve `uploadId`/`mediaId` por esta porta, de propósito, para o pacote nunca
|
|
74
|
+
* chamar endpoint fixo. O preview não tinha resolvedor nenhum, então nenhuma mídia carregava.
|
|
75
|
+
*/
|
|
76
|
+
export function createPreviewMediaResolver(): ResolveMediaUrl {
|
|
77
|
+
const byId = new Map<string, { mimeType: string; filename: string }>()
|
|
78
|
+
for (const documents of Object.values(PREVIEW_DOCUMENTS)) {
|
|
79
|
+
for (const document of documents) {
|
|
80
|
+
byId.set(document.id, { mimeType: document.mimeType, filename: document.filename })
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return async (message: MessagePayload): Promise<string | null> => {
|
|
85
|
+
const reference = message.uploadId ?? (message.mediaId ? `preview/inbound/${message.mediaId}` : undefined)
|
|
86
|
+
if (!reference) return null
|
|
87
|
+
|
|
88
|
+
const known = byId.get(reference)
|
|
89
|
+
return previewFileUrl(known?.mimeType ?? message.mimeType, known?.filename ?? message.filename)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* O teste que importa aqui é o do prefixo: ele é duplicado entre este pacote (UI) e o
|
|
3
|
+
* meta-whatsapp-module (backend), e divergir significa o simulador gerar um id que o servidor não
|
|
4
|
+
* reconhece — o áudio chega, o backend busca na Meta, volta 404 e ninguém entende por quê.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from 'bun:test'
|
|
8
|
+
import { PREVIEW_MEDIA_ID_PREFIX as CONTRACT_PREFIX } from '@adatechnology/meta-whatsapp-contracts'
|
|
9
|
+
|
|
10
|
+
import { createPreviewMediaUploader, PREVIEW_MEDIA_ID_PREFIX } from './createPreviewMediaUploader'
|
|
11
|
+
|
|
12
|
+
describe('createPreviewMediaUploader', () => {
|
|
13
|
+
it('reexporta o prefixo do contrato, sem cópia própria', () => {
|
|
14
|
+
expect(PREVIEW_MEDIA_ID_PREFIX).toBe(CONTRACT_PREFIX)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('devolve o id prefixado com o uploadId da rota', async () => {
|
|
18
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'chave/do/objeto' }) })
|
|
19
|
+
|
|
20
|
+
const result = await upload(new File([new Uint8Array([1, 2, 3])], 'nota.ogg', { type: 'audio/ogg' }))
|
|
21
|
+
|
|
22
|
+
expect(result.mediaId).toBe(`${CONTRACT_PREFIX}chave/do/objeto`)
|
|
23
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
24
|
+
expect(result.filename).toBe('nota.ogg')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('manda o binário em base64 para a rota', async () => {
|
|
28
|
+
let recebido: string | undefined
|
|
29
|
+
const upload = createPreviewMediaUploader({
|
|
30
|
+
upload: async (request) => {
|
|
31
|
+
recebido = request.base64
|
|
32
|
+
return { uploadId: 'k' }
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await upload(new File([new TextEncoder().encode('audio')], 'a.ogg', { type: 'audio/ogg' }))
|
|
37
|
+
|
|
38
|
+
expect(atob(recebido!)).toBe('audio')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Gravador entrega áudio sem nome, e navegador antigo entrega sem mime.
|
|
42
|
+
it('preenche nome e mime quando o arquivo vem sem eles', async () => {
|
|
43
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
44
|
+
|
|
45
|
+
const result = await upload(new File([new Uint8Array([1])], '', { type: '' }))
|
|
46
|
+
|
|
47
|
+
expect(result.mimeType).toBe('audio/ogg')
|
|
48
|
+
expect(result.filename).toBe('audio.ogg')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Espalhar o array de bytes como argumentos estoura a pilha em arquivo grande — o erro que só
|
|
53
|
+
* aparece na primeira gravação de verdade, nunca no clipe curto do teste.
|
|
54
|
+
*/
|
|
55
|
+
it('converte arquivo grande sem estourar a pilha', async () => {
|
|
56
|
+
const upload = createPreviewMediaUploader({ upload: async () => ({ uploadId: 'k' }) })
|
|
57
|
+
const grande = new File([new Uint8Array(300_000)], 'longo.ogg', { type: 'audio/ogg' })
|
|
58
|
+
|
|
59
|
+
expect((await upload(grande)).mediaId).toContain(CONTRACT_PREFIX)
|
|
60
|
+
})
|
|
61
|
+
})
|