@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41
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/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/chunk-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -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 +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- 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/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -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/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +225 -17
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- 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 +96 -0
- package/src/preview/createPreviewWebhookClient.ts +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- 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/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +4 -1
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveFileIconExtension } from './FileIcon'
|
|
4
|
+
|
|
5
|
+
describe('resolveFileIconExtension', () => {
|
|
6
|
+
// Regressão: o painel de documentos passava só o mimeType, e o do Office é longo
|
|
7
|
+
// (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`), então docx, doc,
|
|
8
|
+
// xlsx e xls apareciam todos com o ícone genérico cinza.
|
|
9
|
+
const OFFICE_MIME_TYPES = [
|
|
10
|
+
{
|
|
11
|
+
filename: 'contrato.docx',
|
|
12
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
13
|
+
expected: 'docx',
|
|
14
|
+
},
|
|
15
|
+
{ filename: 'procuracao.doc', mimeType: 'application/msword', expected: 'doc' },
|
|
16
|
+
{
|
|
17
|
+
filename: 'pedido.xlsx',
|
|
18
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
19
|
+
expected: 'xlsx',
|
|
20
|
+
},
|
|
21
|
+
{ filename: 'tabela.xls', mimeType: 'application/vnd.ms-excel', expected: 'xls' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
for (const testCase of OFFICE_MIME_TYPES) {
|
|
25
|
+
it(`resolve ${testCase.expected} pelo nome, já que o mimeType do Office não bate`, () => {
|
|
26
|
+
expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('resolve pelo mimeType quando o nome não tem extensão conhecida', () => {
|
|
31
|
+
expect(resolveFileIconExtension(undefined, 'application/pdf')).toBe('pdf')
|
|
32
|
+
expect(resolveFileIconExtension('recibo', 'application/zip')).toBe('zip')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('devolve algo fora do mapa para tipo desconhecido, caindo no ícone genérico', () => {
|
|
36
|
+
expect(resolveFileIconExtension('backup', 'application/octet-stream')).toBe('octet-stream')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Imagem, vídeo e áudio entram na biblioteca junto dos documentos — o backend linka as cinco
|
|
40
|
+
// espécies de mídia (image/audio/video/document/sticker), não só `document`.
|
|
41
|
+
const MEDIA_TYPES = [
|
|
42
|
+
{ filename: 'foto.jpg', mimeType: 'image/jpeg', expected: 'jpg' },
|
|
43
|
+
{ filename: 'prateleira.png', mimeType: 'image/png', expected: 'png' },
|
|
44
|
+
{ filename: 'video-do-produto.mp4', mimeType: 'video/mp4', expected: 'mp4' },
|
|
45
|
+
{ filename: 'antigo.3gp', mimeType: 'video/3gp', expected: '3gp' },
|
|
46
|
+
{ filename: 'musica.mp3', mimeType: 'audio/mpeg', expected: 'mp3' },
|
|
47
|
+
{ filename: 'recado.m4a', mimeType: 'audio/mp4', expected: 'm4a' },
|
|
48
|
+
{ filename: 'lista-compras.txt', mimeType: 'text/plain', expected: 'txt' },
|
|
49
|
+
{ filename: 'planilha.csv', mimeType: 'text/csv', expected: 'csv' },
|
|
50
|
+
{
|
|
51
|
+
filename: 'apresentacao.pptx',
|
|
52
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
53
|
+
expected: 'pptx',
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
for (const testCase of MEDIA_TYPES) {
|
|
58
|
+
it(`resolve ${testCase.expected}`, () => {
|
|
59
|
+
expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Áudio e sticker chegam da Meta sem nome de arquivo: o backend salva o id da mídia como rótulo,
|
|
64
|
+
// então a única pista de tipo é o mimeType.
|
|
65
|
+
it('resolve pela família quando o nome é o id da mídia, sem extensão', () => {
|
|
66
|
+
expect(resolveFileIconExtension('wamid-abc123', 'image/webp')).toBe('image')
|
|
67
|
+
expect(resolveFileIconExtension('wamid-abc123', 'video/mp4')).toBe('video')
|
|
68
|
+
expect(resolveFileIconExtension('wamid-abc123', 'audio/aac')).toBe('audio')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// `audio/ogg; codecs=opus` é o formato do áudio de WhatsApp. Com o parâmetro colado, o subtipo
|
|
72
|
+
// viria "ogg; codecs=opus" e o áudio cairia no ícone genérico.
|
|
73
|
+
it('descarta parâmetro do mimeType', () => {
|
|
74
|
+
expect(resolveFileIconExtension('wamid-audio', 'audio/ogg; codecs=opus')).toBe('audio')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Pelo subtipo, `audio/mp4` casaria a chave `mp4` — que é vídeo. A família vem primeiro
|
|
78
|
+
// justamente para um m4a não aparecer com ícone de filme.
|
|
79
|
+
it('não confunde audio/mp4 com vídeo', () => {
|
|
80
|
+
expect(resolveFileIconExtension(undefined, 'audio/mp4')).toBe('audio')
|
|
81
|
+
expect(resolveFileIconExtension(undefined, 'video/mp4')).toBe('video')
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/FileIcon.tsx
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
FileArchive,
|
|
3
|
+
FileAudio,
|
|
4
|
+
FileImage,
|
|
5
|
+
FileSpreadsheet,
|
|
6
|
+
FileText,
|
|
7
|
+
FileVideo,
|
|
8
|
+
File as FileGeneric,
|
|
9
|
+
Presentation,
|
|
10
|
+
} from 'lucide-react'
|
|
11
|
+
|
|
12
|
+
import { cn } from './lib/cn'
|
|
2
13
|
|
|
3
14
|
export interface FileIconProps {
|
|
4
15
|
filename?: string
|
|
@@ -7,27 +18,93 @@ export interface FileIconProps {
|
|
|
7
18
|
className?: string
|
|
8
19
|
}
|
|
9
20
|
|
|
10
|
-
|
|
21
|
+
type IconStyle = { Icon: typeof FileText; colorClass: string }
|
|
22
|
+
|
|
23
|
+
// Um estilo por família, reaproveitado por todas as extensões dela: assim o ícone de um `.jpg`
|
|
24
|
+
// resolvido pelo nome é o mesmo de um `image/jpeg` resolvido pelo mimeType. Com cor por extensão, a
|
|
25
|
+
// mesma foto trocaria de cor conforme o dado que chegou junto.
|
|
26
|
+
const IMAGE_STYLE: IconStyle = { Icon: FileImage, colorClass: 'text-violet-500' }
|
|
27
|
+
const VIDEO_STYLE: IconStyle = { Icon: FileVideo, colorClass: 'text-fuchsia-500' }
|
|
28
|
+
const AUDIO_STYLE: IconStyle = { Icon: FileAudio, colorClass: 'text-amber-500' }
|
|
29
|
+
const SHEET_STYLE: IconStyle = { Icon: FileSpreadsheet, colorClass: 'text-green-600' }
|
|
30
|
+
const WORD_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-blue-500' }
|
|
31
|
+
const SLIDES_STYLE: IconStyle = { Icon: Presentation, colorClass: 'text-orange-600' }
|
|
32
|
+
const TEXT_STYLE: IconStyle = { Icon: FileText, colorClass: 'text-gray-500' }
|
|
33
|
+
|
|
34
|
+
const EXTENSION_STYLE: Record<string, IconStyle> = {
|
|
11
35
|
pdf: { Icon: FileText, colorClass: 'text-red-500' },
|
|
12
|
-
doc:
|
|
13
|
-
docx:
|
|
14
|
-
xls:
|
|
15
|
-
xlsx:
|
|
36
|
+
doc: WORD_STYLE,
|
|
37
|
+
docx: WORD_STYLE,
|
|
38
|
+
xls: SHEET_STYLE,
|
|
39
|
+
xlsx: SHEET_STYLE,
|
|
40
|
+
csv: SHEET_STYLE,
|
|
41
|
+
ppt: SLIDES_STYLE,
|
|
42
|
+
pptx: SLIDES_STYLE,
|
|
16
43
|
zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
|
|
44
|
+
txt: TEXT_STYLE,
|
|
45
|
+
plain: TEXT_STYLE,
|
|
46
|
+
|
|
47
|
+
image: IMAGE_STYLE,
|
|
48
|
+
jpg: IMAGE_STYLE,
|
|
49
|
+
jpeg: IMAGE_STYLE,
|
|
50
|
+
png: IMAGE_STYLE,
|
|
51
|
+
webp: IMAGE_STYLE,
|
|
52
|
+
gif: IMAGE_STYLE,
|
|
53
|
+
heic: IMAGE_STYLE,
|
|
54
|
+
|
|
55
|
+
video: VIDEO_STYLE,
|
|
56
|
+
mp4: VIDEO_STYLE,
|
|
57
|
+
'3gp': VIDEO_STYLE,
|
|
58
|
+
'3gpp': VIDEO_STYLE,
|
|
59
|
+
mov: VIDEO_STYLE,
|
|
60
|
+
webm: VIDEO_STYLE,
|
|
61
|
+
|
|
62
|
+
audio: AUDIO_STYLE,
|
|
63
|
+
mp3: AUDIO_STYLE,
|
|
64
|
+
mpeg: AUDIO_STYLE,
|
|
65
|
+
ogg: AUDIO_STYLE,
|
|
66
|
+
oga: AUDIO_STYLE,
|
|
67
|
+
opus: AUDIO_STYLE,
|
|
68
|
+
aac: AUDIO_STYLE,
|
|
69
|
+
amr: AUDIO_STYLE,
|
|
70
|
+
m4a: AUDIO_STYLE,
|
|
71
|
+
wav: AUDIO_STYLE,
|
|
17
72
|
}
|
|
18
73
|
|
|
19
|
-
|
|
74
|
+
const MEDIA_FAMILIES = new Set(['image', 'video', 'audio'])
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A chave de estilo do arquivo, na ordem em que cada dado é confiável.
|
|
78
|
+
*
|
|
79
|
+
* 1. **extensão do nome** — o mimeType do Office é longo (`…wordprocessingml.document`) e nunca
|
|
80
|
+
* casaria, então quem olhasse só o mimeType perderia o ícone de Word e de Excel;
|
|
81
|
+
* 2. **família do mimeType** (`image/`, `video/`, `audio/`) — tem de vir ANTES do subtipo por causa
|
|
82
|
+
* de `audio/mp4`: pelo subtipo, um áudio m4a ganharia o ícone de vídeo;
|
|
83
|
+
* 3. **subtipo** — cobre `application/pdf` e `application/zip`, que chegam sem nome de arquivo.
|
|
84
|
+
*
|
|
85
|
+
* Áudio de WhatsApp chega como `audio/ogg; codecs=opus`; o parâmetro depois do `;` é descartado,
|
|
86
|
+
* senão o subtipo viria `ogg; codecs=opus` e não casaria nada.
|
|
87
|
+
*
|
|
88
|
+
* Exportada para teste: é a regra que já regrediu uma vez no painel de documentos.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
|
|
20
91
|
const fromFilename = filename?.split('.').pop()?.toLowerCase()
|
|
21
92
|
if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
|
|
22
|
-
|
|
93
|
+
|
|
94
|
+
const [family, subtype] = (mimeType ?? '').split(';')[0]!.toLowerCase().split('/')
|
|
95
|
+
if (family && MEDIA_FAMILIES.has(family)) return family
|
|
96
|
+
|
|
97
|
+
return subtype ?? ''
|
|
23
98
|
}
|
|
24
99
|
|
|
25
100
|
// Melhoria sobre a paridade da bolha de documento (T6.7 usava um único ícone genérico
|
|
26
101
|
// para qualquer tipo de arquivo) — ícone e cor variam por extensão/mimeType.
|
|
27
|
-
export function FileIcon({ filename, mimeType, size = 20, className
|
|
28
|
-
const extension =
|
|
102
|
+
export function FileIcon({ filename, mimeType, size = 20, className }: FileIconProps) {
|
|
103
|
+
const extension = resolveFileIconExtension(filename, mimeType)
|
|
29
104
|
const style = EXTENSION_STYLE[extension] ?? { Icon: FileGeneric, colorClass: 'text-gray-500' }
|
|
30
105
|
const { Icon, colorClass } = style
|
|
31
106
|
|
|
32
|
-
|
|
107
|
+
// `cn` em vez de concatenar: a cor por extensão é a base, e produto que passa `text-*` precisa
|
|
108
|
+
// ganhar dela — concatenado, quem vence é a ordem no CSS gerado, não a intenção de quem chamou.
|
|
109
|
+
return <Icon size={size} className={cn(colorClass, className)} />
|
|
33
110
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { InteractiveMessage } from './InteractiveMessage'
|
|
5
|
+
import type { InteractivePayload } from './types'
|
|
6
|
+
|
|
7
|
+
function render(payload: InteractivePayload): string {
|
|
8
|
+
return renderToStaticMarkup(<InteractiveMessage payload={payload} />)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('InteractiveMessage', () => {
|
|
12
|
+
it('formata negrito no corpo, como o aparelho faz', () => {
|
|
13
|
+
const markup = render({ type: 'button', body: { text: 'Qual o *prazo desejado*?' } })
|
|
14
|
+
|
|
15
|
+
expect(markup).toContain('<strong>prazo desejado</strong>')
|
|
16
|
+
expect(markup).not.toContain('*prazo desejado*')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('formata cabeçalho e rodapé também', () => {
|
|
20
|
+
const markup = render({
|
|
21
|
+
type: 'list',
|
|
22
|
+
header: { text: '*Menu*' },
|
|
23
|
+
body: { text: 'corpo' },
|
|
24
|
+
footer: { text: '_Selecione_' },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(markup).toContain('<strong>Menu</strong>')
|
|
28
|
+
expect(markup).toContain('<em>Selecione</em>')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('deixa título de opção literal, porque o WhatsApp não formata ali', () => {
|
|
32
|
+
const markup = render({
|
|
33
|
+
type: 'button',
|
|
34
|
+
body: { text: 'corpo' },
|
|
35
|
+
action: { buttons: [{ reply: { id: 'sim', title: '*Sim*' } }] },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(markup).toContain('*Sim*')
|
|
39
|
+
expect(markup).not.toContain('<strong>Sim</strong>')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu interativo do WhatsApp — botões de resposta rápida e lista de opções.
|
|
3
|
+
*
|
|
4
|
+
* A mensagem é gravada com o payload cru que foi enviado à Meta, então a UI apenas o desenha; não
|
|
5
|
+
* há segunda fonte de verdade sobre quais opções existiam naquele momento. Sem isto, mensagem
|
|
6
|
+
* `interactive` aparecia como texto solto e o menu simplesmente sumia da conversa — inclusive no
|
|
7
|
+
* simulador, onde o operador precisa ver exatamente o que o cliente vê.
|
|
8
|
+
*
|
|
9
|
+
* `onSelect` é opcional de propósito: no histórico da inbox as opções são leitura (o operador não
|
|
10
|
+
* responde no lugar do cliente); no simulador elas são clicáveis.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState } from 'react'
|
|
14
|
+
import type { InteractiveOption, InteractivePayload, InteractiveSelection } from './types'
|
|
15
|
+
import { cn } from './lib/cn'
|
|
16
|
+
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
17
|
+
|
|
18
|
+
/** `parseWhatsAppFormatting` emite `<strong>`/`<em>`/`<del>`, que o reset do Tailwind zera. */
|
|
19
|
+
const FORMATTING_CLASSES = '[&_strong]:font-bold [&_em]:italic [&_del]:line-through'
|
|
20
|
+
|
|
21
|
+
export interface InteractiveMessageLabels {
|
|
22
|
+
/** Fallback do rótulo do botão que abre a lista, quando o payload não traz um. */
|
|
23
|
+
openList: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_INTERACTIVE_MESSAGE_LABELS: InteractiveMessageLabels = {
|
|
27
|
+
openList: 'Ver opções',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface InteractiveMessageProps {
|
|
31
|
+
payload: InteractivePayload
|
|
32
|
+
onSelect?: (selection: InteractiveSelection) => void
|
|
33
|
+
labels?: Partial<InteractiveMessageLabels>
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectRows(payload: InteractivePayload): InteractiveOption[] {
|
|
38
|
+
return (payload.action?.sections ?? []).flatMap((section) => section.rows ?? [])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectButtons(payload: InteractivePayload): InteractiveOption[] {
|
|
42
|
+
return (payload.action?.buttons ?? [])
|
|
43
|
+
.map((button) => button.reply)
|
|
44
|
+
.filter((reply): reply is InteractiveOption => reply !== undefined)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function InteractiveMessage({ payload, onSelect, labels, className }: InteractiveMessageProps) {
|
|
48
|
+
const openListLabel = labels?.openList ?? DEFAULT_INTERACTIVE_MESSAGE_LABELS.openList
|
|
49
|
+
const [isListOpen, setIsListOpen] = useState(false)
|
|
50
|
+
|
|
51
|
+
const buttons = collectButtons(payload)
|
|
52
|
+
const sections = payload.action?.sections ?? []
|
|
53
|
+
const hasRows = collectRows(payload).length > 0
|
|
54
|
+
const isInteractable = onSelect !== undefined
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn('flex flex-col gap-1', className)}>
|
|
58
|
+
{/* Cabeçalho, corpo e rodapé passam pela formatação do WhatsApp — o aparelho renderiza
|
|
59
|
+
`*negrito*` neles como em qualquer texto. Título e descrição de opção NÃO: ali o WhatsApp
|
|
60
|
+
mostra os asteriscos literais, e formatá-los aqui deixaria o simulador mais bonito que o
|
|
61
|
+
aparelho, que é o tipo de divergência que só aparece com o cliente na frente. */}
|
|
62
|
+
{payload.header?.text ? (
|
|
63
|
+
<p className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
|
|
64
|
+
{parseWhatsAppFormatting(payload.header.text)}
|
|
65
|
+
</p>
|
|
66
|
+
) : null}
|
|
67
|
+
|
|
68
|
+
{payload.body?.text ? (
|
|
69
|
+
<p
|
|
70
|
+
className={cn(
|
|
71
|
+
'whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100',
|
|
72
|
+
FORMATTING_CLASSES,
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{parseWhatsAppFormatting(payload.body.text)}
|
|
76
|
+
</p>
|
|
77
|
+
) : null}
|
|
78
|
+
|
|
79
|
+
{payload.footer?.text ? (
|
|
80
|
+
<p className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
|
|
81
|
+
{parseWhatsAppFormatting(payload.footer.text)}
|
|
82
|
+
</p>
|
|
83
|
+
) : null}
|
|
84
|
+
|
|
85
|
+
{buttons.length > 0 ? (
|
|
86
|
+
<div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
|
|
87
|
+
{buttons.map((button) => (
|
|
88
|
+
<button
|
|
89
|
+
data-cv-tooltip={button.title} aria-label={button.title}
|
|
90
|
+
key={button.id}
|
|
91
|
+
type="button"
|
|
92
|
+
disabled={!isInteractable}
|
|
93
|
+
onClick={() => onSelect?.({ kind: 'button', option: button })}
|
|
94
|
+
className="rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors enabled:hover:bg-teal-50 disabled:cursor-default dark:text-teal-300 dark:enabled:hover:bg-teal-900/30"
|
|
95
|
+
>
|
|
96
|
+
{button.title}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
) : null}
|
|
101
|
+
|
|
102
|
+
{hasRows ? (
|
|
103
|
+
<div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
|
|
104
|
+
<button
|
|
105
|
+
data-cv-tooltip={payload.action?.button ?? openListLabel} aria-label={payload.action?.button ?? openListLabel}
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => setIsListOpen((open) => !open)}
|
|
108
|
+
aria-expanded={isListOpen}
|
|
109
|
+
className="w-full rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors hover:bg-teal-50 dark:text-teal-300 dark:hover:bg-teal-900/30"
|
|
110
|
+
>
|
|
111
|
+
☰ {payload.action?.button ?? openListLabel}
|
|
112
|
+
</button>
|
|
113
|
+
|
|
114
|
+
{isListOpen ? (
|
|
115
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
116
|
+
{sections.map((section, sectionIndex) => (
|
|
117
|
+
<div key={section.title ?? sectionIndex} className="flex flex-col">
|
|
118
|
+
{section.title ? (
|
|
119
|
+
<p className="px-3 py-1 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
|
|
120
|
+
{section.title}
|
|
121
|
+
</p>
|
|
122
|
+
) : null}
|
|
123
|
+
{(section.rows ?? []).map((row) => (
|
|
124
|
+
<button
|
|
125
|
+
data-cv-tooltip={row.title} aria-label={row.title}
|
|
126
|
+
key={row.id}
|
|
127
|
+
type="button"
|
|
128
|
+
disabled={!isInteractable}
|
|
129
|
+
onClick={() => onSelect?.({ kind: 'list', option: row })}
|
|
130
|
+
className="rounded-md px-3 py-1.5 text-left text-sm text-gray-900 transition-colors enabled:hover:bg-gray-100 disabled:cursor-default dark:text-gray-100 dark:enabled:hover:bg-gray-700"
|
|
131
|
+
>
|
|
132
|
+
<span className="block">{row.title}</span>
|
|
133
|
+
{row.description ? (
|
|
134
|
+
<span className="block text-xs text-gray-500 dark:text-gray-400">{row.description}</span>
|
|
135
|
+
) : null}
|
|
136
|
+
</button>
|
|
137
|
+
))}
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
) : null}
|
|
142
|
+
</div>
|
|
143
|
+
) : null}
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
package/src/Lightbox.tsx
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
+
export interface LightboxLabels {
|
|
2
|
+
/** Texto alternativo quando a imagem não tem legenda — sem ele o leitor de tela anuncia a URL. */
|
|
3
|
+
imageAlt: string
|
|
4
|
+
close: string
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export interface LightboxProps {
|
|
2
8
|
imageUrl: string
|
|
3
9
|
caption?: string
|
|
4
10
|
onClose: () => void
|
|
11
|
+
labels?: Partial<LightboxLabels>
|
|
5
12
|
}
|
|
6
13
|
|
|
7
|
-
export
|
|
14
|
+
export const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {
|
|
15
|
+
imageAlt: 'Imagem',
|
|
16
|
+
close: 'Fechar',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps) {
|
|
20
|
+
const imageAltLabel = labels?.imageAlt ?? DEFAULT_LIGHTBOX_LABELS.imageAlt
|
|
21
|
+
const closeLabel = labels?.close ?? DEFAULT_LIGHTBOX_LABELS.close
|
|
22
|
+
|
|
8
23
|
return (
|
|
9
24
|
<div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
|
|
10
25
|
<div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
|
11
|
-
<img src={imageUrl} alt={caption ??
|
|
26
|
+
<img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
|
12
27
|
{caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
|
|
13
|
-
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">
|
|
28
|
+
<button data-cv-tooltip={closeLabel} aria-label={closeLabel} onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
|
|
14
29
|
</div>
|
|
15
30
|
</div>
|
|
16
31
|
)
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
2
|
import { AudioPlayer } from './AudioPlayer'
|
|
3
|
+
import { AudioTranscription } from './AudioTranscription'
|
|
3
4
|
import { FileIcon } from './FileIcon'
|
|
4
5
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
6
|
import { formatFileSize } from './lib/format'
|
|
6
|
-
import
|
|
7
|
+
import { cn } from './lib/cn'
|
|
8
|
+
import type { MessagePayload, MessageTranscription } from './types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rótulo curto do tipo, para a linha de baixo da bolha de documento.
|
|
12
|
+
*
|
|
13
|
+
* O subtipo cru do Office é gigantesco — `vnd.openxmlformats-officedocument.wordprocessingml.document`
|
|
14
|
+
* vira uma linha de 58 caracteres em caixa alta que estica a bolha, empurra a coluna da conversa e
|
|
15
|
+
* quebra o layout de três painéis. A extensão do arquivo diz a mesma coisa em quatro letras.
|
|
16
|
+
*/
|
|
17
|
+
export function documentTypeLabel(filename?: string, mimeType?: string): string {
|
|
18
|
+
const extension = filename?.split('.').pop()
|
|
19
|
+
if (extension && extension.length <= 5 && extension !== filename) return extension.toUpperCase()
|
|
20
|
+
|
|
21
|
+
const subtype = mimeType?.split(';')[0]?.split('/')[1]
|
|
22
|
+
if (!subtype) return 'FILE'
|
|
23
|
+
// Sem extensão e com subtipo longo (áudio/vídeo sem nome), fica o sufixo depois do último ponto:
|
|
24
|
+
// `…wordprocessingml.document` -> `DOCUMENT`, que ainda informa e não estoura.
|
|
25
|
+
const compacto = subtype.split('.').pop() ?? subtype
|
|
26
|
+
return compacto.slice(0, 12).toUpperCase()
|
|
27
|
+
}
|
|
7
28
|
|
|
8
29
|
function resolveMediaSource(message: MessagePayload): string | null {
|
|
9
30
|
if (message.mediaUrl) return message.mediaUrl
|
|
@@ -30,6 +51,15 @@ export interface MediaRendererProps {
|
|
|
30
51
|
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
31
52
|
// porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
32
53
|
onResolveUrl?: ResolveMediaUrl
|
|
54
|
+
/**
|
|
55
|
+
* Pede ao backend a transcrição do áudio desta mensagem. Ausente, o bloco de transcrição só exibe
|
|
56
|
+
* o que já veio pronto — sem oferecer um botão que o host não sabe atender.
|
|
57
|
+
*
|
|
58
|
+
* O que devolver é exibido na hora, sem esperar refetch da lista.
|
|
59
|
+
*/
|
|
60
|
+
onTranscribeAudio?: () => Promise<MessageTranscription | void>
|
|
61
|
+
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
62
|
+
className?: string
|
|
33
63
|
}
|
|
34
64
|
|
|
35
65
|
function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
|
|
@@ -55,23 +85,49 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
|
|
|
55
85
|
return { url, loading, error, load }
|
|
56
86
|
}
|
|
57
87
|
|
|
58
|
-
export function MediaRenderer({
|
|
88
|
+
export function MediaRenderer({
|
|
89
|
+
message,
|
|
90
|
+
onLightbox,
|
|
91
|
+
onResolveUrl,
|
|
92
|
+
onTranscribeAudio,
|
|
93
|
+
className,
|
|
94
|
+
}: MediaRendererProps) {
|
|
59
95
|
const { bubble } = useConversationLocales()
|
|
60
96
|
const eagerSrc = resolveMediaSource(message)
|
|
61
97
|
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
62
98
|
const src = eagerSrc ?? lazy.url
|
|
63
99
|
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
|
|
64
100
|
|
|
65
|
-
|
|
101
|
+
/**
|
|
102
|
+
* O carregamento sob demanda continua sendo um botão, mas com a forma da mídia que ele vai virar
|
|
103
|
+
* — um link sublinhado no meio da conversa lê como texto da mensagem, não como controle, e é a
|
|
104
|
+
* única bolha que não se parece com o que contém.
|
|
105
|
+
*/
|
|
106
|
+
function LazyMediaButton({ icon, label }: { icon: ReactNode; label: string }) {
|
|
107
|
+
return (
|
|
108
|
+
<button
|
|
109
|
+
data-cv-tooltip={label} aria-label={label}
|
|
110
|
+
onClick={lazy.load}
|
|
111
|
+
disabled={lazy.loading}
|
|
112
|
+
className="flex min-w-[180px] items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 text-left transition-colors hover:bg-black/10 disabled:opacity-60 dark:bg-white/10 dark:hover:bg-white/15"
|
|
113
|
+
>
|
|
114
|
+
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-white">
|
|
115
|
+
{icon}
|
|
116
|
+
</span>
|
|
117
|
+
<span className="truncate text-xs text-gray-600 dark:text-gray-300">{label}</span>
|
|
118
|
+
</button>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
66
121
|
|
|
67
122
|
switch (message.type) {
|
|
68
123
|
case 'image':
|
|
69
124
|
case 'sticker': {
|
|
70
125
|
if (!src && canLazyLoad) {
|
|
71
126
|
return (
|
|
72
|
-
<
|
|
73
|
-
{
|
|
74
|
-
|
|
127
|
+
<LazyMediaButton
|
|
128
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg>}
|
|
129
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
|
|
130
|
+
/>
|
|
75
131
|
)
|
|
76
132
|
}
|
|
77
133
|
return (
|
|
@@ -89,9 +145,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
89
145
|
case 'video': {
|
|
90
146
|
if (!src && canLazyLoad) {
|
|
91
147
|
return (
|
|
92
|
-
<
|
|
93
|
-
{
|
|
94
|
-
|
|
148
|
+
<LazyMediaButton
|
|
149
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" /></svg>}
|
|
150
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
|
|
151
|
+
/>
|
|
95
152
|
)
|
|
96
153
|
}
|
|
97
154
|
return (
|
|
@@ -107,11 +164,28 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
107
164
|
)
|
|
108
165
|
}
|
|
109
166
|
case 'audio': {
|
|
167
|
+
/**
|
|
168
|
+
* Fica fora do ramo de carregamento de propósito: a transcrição não depende dos bytes do
|
|
169
|
+
* áudio. Ler o que o cliente disse sem baixar e tocar a nota de voz é o caminho rápido do
|
|
170
|
+
* atendimento — e é justamente o que se perderia se o bloco só aparecesse depois do play.
|
|
171
|
+
*/
|
|
172
|
+
const transcriptionBlock = (
|
|
173
|
+
<AudioTranscription
|
|
174
|
+
transcription={message.transcription}
|
|
175
|
+
isMine={message.direction === 'outbound'}
|
|
176
|
+
{...(onTranscribeAudio ? { onTranscribe: onTranscribeAudio } : {})}
|
|
177
|
+
/>
|
|
178
|
+
)
|
|
179
|
+
|
|
110
180
|
if (!src && canLazyLoad) {
|
|
111
181
|
return (
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
182
|
+
<div className="min-w-[200px]">
|
|
183
|
+
<LazyMediaButton
|
|
184
|
+
icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
|
|
185
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
|
|
186
|
+
/>
|
|
187
|
+
{transcriptionBlock}
|
|
188
|
+
</div>
|
|
115
189
|
)
|
|
116
190
|
}
|
|
117
191
|
return (
|
|
@@ -119,20 +193,21 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
119
193
|
{src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
|
|
120
194
|
<div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
|
|
121
195
|
)}
|
|
196
|
+
{transcriptionBlock}
|
|
122
197
|
</div>
|
|
123
198
|
)
|
|
124
199
|
}
|
|
125
200
|
case 'document': {
|
|
126
|
-
const typeLabel = message.mimeType
|
|
201
|
+
const typeLabel = documentTypeLabel(message.filename, message.mimeType)
|
|
127
202
|
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
|
|
128
203
|
return (
|
|
129
|
-
<div className=
|
|
204
|
+
<div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
|
|
130
205
|
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
131
206
|
<FileIcon filename={message.filename} mimeType={message.mimeType} />
|
|
132
207
|
</div>
|
|
133
208
|
<div className="flex-1 min-w-0">
|
|
134
209
|
<p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
|
|
135
|
-
<p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
|
|
210
|
+
<p className="truncate text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
|
|
136
211
|
</div>
|
|
137
212
|
{src ? (
|
|
138
213
|
<a href={src} download={message.filename} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors" aria-label={bubble.downloadFile}>
|
|
@@ -140,6 +215,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
140
215
|
</a>
|
|
141
216
|
) : canLazyLoad ? (
|
|
142
217
|
<button
|
|
218
|
+
data-cv-tooltip={bubble.downloadFile}
|
|
143
219
|
onClick={lazy.load}
|
|
144
220
|
disabled={lazy.loading}
|
|
145
221
|
className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors disabled:opacity-50"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { MessageBubble } from './MessageBubble'
|
|
5
|
+
import type { MessagePayload } from './types'
|
|
6
|
+
|
|
7
|
+
const IMAGE_MESSAGE: MessagePayload = {
|
|
8
|
+
id: 'msg-1',
|
|
9
|
+
type: 'image',
|
|
10
|
+
direction: 'outbound',
|
|
11
|
+
sender: 'agent',
|
|
12
|
+
timestamp: '2026-08-05T12:00:00.000Z',
|
|
13
|
+
filename: 'planta-baixa.png',
|
|
14
|
+
mediaUrl: 'https://cdn.test/planta-baixa.png',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('MessageBubble com mídia', () => {
|
|
18
|
+
it('mostra a legenda que veio no conteúdo do envio', () => {
|
|
19
|
+
const markup = renderToStaticMarkup(
|
|
20
|
+
<MessageBubble message={{ ...IMAGE_MESSAGE, content: 'Planta do 302' }} isMine />,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
expect(markup).toContain('Planta do 302')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('mostra a legenda que o cliente mandou junto da imagem', () => {
|
|
27
|
+
const markup = renderToStaticMarkup(
|
|
28
|
+
<MessageBubble message={{ ...IMAGE_MESSAGE, direction: 'inbound', sender: 'customer', caption: 'É esse?' }} isMine={false} />,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
expect(markup).toContain('É esse?')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('não repete o nome do arquivo como se fosse legenda', () => {
|
|
35
|
+
const markup = renderToStaticMarkup(
|
|
36
|
+
<MessageBubble message={{ ...IMAGE_MESSAGE, content: 'planta-baixa.png' }} isMine />,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
expect(markup).not.toContain('>planta-baixa.png<')
|
|
40
|
+
})
|
|
41
|
+
})
|