@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.0
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-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- 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 +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- 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.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- 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/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 +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -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/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- 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/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 +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -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 +84 -45
- 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/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 +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -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 +1 -0
- 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/theme.ts +13 -0
- package/src/types.ts +26 -0
- 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/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Moon, Sun } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { cn } from './lib/cn'
|
|
4
|
+
import { ICON_SIZE_ACTION } from './icon.constant'
|
|
5
|
+
|
|
6
|
+
export interface DarkModeToggleLabels {
|
|
7
|
+
/** Ação de ir para o tema escuro — é o rótulo lido enquanto o tema claro está ativo. */
|
|
8
|
+
toDark: string
|
|
9
|
+
toLight: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DarkModeToggleClassNames {
|
|
13
|
+
/** O tamanho do ícone vem daqui quando o host usa a própria escala (`size-4`, `size-5`). */
|
|
14
|
+
icon?: string
|
|
15
|
+
label?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DarkModeToggleProps {
|
|
19
|
+
isDark: boolean
|
|
20
|
+
onToggle: () => void
|
|
21
|
+
/** Rótulo ao lado do ícone. Só-ícone exige a área de toque mínima, que a classe `cv-touch` dá. */
|
|
22
|
+
showLabel?: boolean
|
|
23
|
+
labels?: Partial<DarkModeToggleLabels>
|
|
24
|
+
/**
|
|
25
|
+
* Substitui a aparência do botão. As classes passam por `tailwind-merge`, então utilitário do
|
|
26
|
+
* host vence o do pacote em vez de somar: `px-4` descarta o `px-3` daqui.
|
|
27
|
+
*
|
|
28
|
+
* A exceção é utilitário de token do host (`rounded-panel`), que o merge não reconhece como
|
|
29
|
+
* família e deixa passar junto com o `rounded-lg` do pacote — aí quem decide é a ordem no CSS
|
|
30
|
+
* gerado, não a do atributo. Nesse caso use a forma arbitrária (`rounded-[var(--radius-panel)]`),
|
|
31
|
+
* que o merge entende e substitui.
|
|
32
|
+
*/
|
|
33
|
+
className?: string
|
|
34
|
+
classNames?: DarkModeToggleClassNames
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_DARK_MODE_TOGGLE_LABELS: DarkModeToggleLabels = {
|
|
38
|
+
toDark: 'Tema escuro',
|
|
39
|
+
toLight: 'Tema claro',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Interruptor de tema, apresentacional: o estado vem do host, junto com `useDarkMode`.
|
|
44
|
+
*
|
|
45
|
+
* Ele não monta o hook de propósito. `useDarkMode` é o controlador — escreve a classe `dark` no
|
|
46
|
+
* `<html>` e persiste a escolha —, e uma barra lateral que também existe como gaveta fica montada
|
|
47
|
+
* duas vezes na mesma tela. Dois controladores guardam estados iniciais separados, e o segundo
|
|
48
|
+
* botão passa a mostrar o ícone contrário ao tema que está no ar. Com o estado no host, o hook é
|
|
49
|
+
* chamado uma vez acima dos dois e as duas cópias concordam.
|
|
50
|
+
*
|
|
51
|
+
* O rótulo nomeia o destino, não o estado atual: no claro se lê "Tema escuro", que é o que o clique
|
|
52
|
+
* faz. `aria-pressed` diria o oposto na mesma frase, então fica de fora.
|
|
53
|
+
*/
|
|
54
|
+
export function DarkModeToggle({
|
|
55
|
+
isDark,
|
|
56
|
+
onToggle,
|
|
57
|
+
showLabel = false,
|
|
58
|
+
labels,
|
|
59
|
+
className,
|
|
60
|
+
classNames,
|
|
61
|
+
}: DarkModeToggleProps) {
|
|
62
|
+
const label = isDark
|
|
63
|
+
? (labels?.toLight ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toLight)
|
|
64
|
+
: (labels?.toDark ?? DEFAULT_DARK_MODE_TOGGLE_LABELS.toDark)
|
|
65
|
+
|
|
66
|
+
const Icon = isDark ? Sun : Moon
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={onToggle}
|
|
72
|
+
data-cv-tooltip={showLabel ? undefined : label}
|
|
73
|
+
aria-label={label}
|
|
74
|
+
className={cn(
|
|
75
|
+
'inline-flex items-center justify-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors',
|
|
76
|
+
'hover:bg-gray-100 hover:text-gray-900',
|
|
77
|
+
'dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100',
|
|
78
|
+
!showLabel && 'cv-touch',
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{/* Sem `size` quando o host manda classe: a prop vira `width`/`height` no SVG e atributo
|
|
83
|
+
ganha da folha, deixando o ícone fora da escala de tipografia do host. */}
|
|
84
|
+
<Icon
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
className={cn('shrink-0', classNames?.icon)}
|
|
87
|
+
size={classNames?.icon ? undefined : ICON_SIZE_ACTION}
|
|
88
|
+
/>
|
|
89
|
+
{showLabel ? <span className={classNames?.label}>{label}</span> : null}
|
|
90
|
+
</button>
|
|
91
|
+
)
|
|
92
|
+
}
|
package/src/DocumentsLibrary.tsx
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
* clicável. Sem essa referência, uma lista global de anexos não responde nenhuma pergunta.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { useEffect, useState } from 'react'
|
|
11
|
-
import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Users } from 'lucide-react'
|
|
10
|
+
import { useEffect, useRef, useState } from 'react'
|
|
11
|
+
import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Upload, Users } from 'lucide-react'
|
|
12
12
|
import { useConversations } from './providers/ConversationsProvider'
|
|
13
13
|
import { DOCUMENT_SOURCE_FILTER, type DocumentSourceFilter } from './ConversationDocumentsPanel'
|
|
14
14
|
import { FileIcon } from './FileIcon'
|
|
15
15
|
import { cn } from './lib/cn'
|
|
16
|
+
import { PAGINATION_LABELS } from './pagination.constant'
|
|
16
17
|
import { formatDateTime, formatFileSize } from './lib/format'
|
|
17
18
|
import { formatPhone } from './lib/phone'
|
|
18
19
|
import type { CompanyDocument } from './providers/types'
|
|
@@ -33,6 +34,8 @@ export interface DocumentsLibraryLabels {
|
|
|
33
34
|
sortMostRecent: string
|
|
34
35
|
sortOldest: string
|
|
35
36
|
clearFilters: string
|
|
37
|
+
upload: string
|
|
38
|
+
uploadError: string
|
|
36
39
|
total: (count: number) => string
|
|
37
40
|
page: (current: number, last: number) => string
|
|
38
41
|
}
|
|
@@ -53,6 +56,8 @@ export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
|
|
|
53
56
|
sortMostRecent: 'Mais recentes',
|
|
54
57
|
sortOldest: 'Mais antigos',
|
|
55
58
|
clearFilters: 'Limpar filtros',
|
|
59
|
+
upload: 'Enviar documento',
|
|
60
|
+
uploadError: 'Não foi possível enviar o arquivo.',
|
|
56
61
|
total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
|
|
57
62
|
page: (current: number, last: number) => `${current} / ${last}`,
|
|
58
63
|
}
|
|
@@ -105,9 +110,15 @@ export function DocumentsLibrary({
|
|
|
105
110
|
const [loading, setLoading] = useState(false)
|
|
106
111
|
const [failed, setFailed] = useState(false)
|
|
107
112
|
|
|
113
|
+
const [uploading, setUploading] = useState(false)
|
|
114
|
+
const [uploadFailed, setUploadFailed] = useState(false)
|
|
115
|
+
const [reloadToken, setReloadToken] = useState(0)
|
|
116
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
117
|
+
|
|
108
118
|
const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
|
|
109
119
|
const lastPage = Math.max(1, Math.ceil(total / perPage))
|
|
110
120
|
const fetchAll = context?.api.getAllDocuments
|
|
121
|
+
const uploadDocument = context?.api.uploadDocument
|
|
111
122
|
|
|
112
123
|
useEffect(() => {
|
|
113
124
|
if (!fetchAll) return
|
|
@@ -139,7 +150,7 @@ export function DocumentsLibrary({
|
|
|
139
150
|
return () => {
|
|
140
151
|
active = false
|
|
141
152
|
}
|
|
142
|
-
}, [fetchAll, search, sourceFilter, sortDirection, page, perPage])
|
|
153
|
+
}, [fetchAll, search, sourceFilter, sortDirection, page, perPage, reloadToken])
|
|
143
154
|
|
|
144
155
|
function applyFilter(change: () => void): void {
|
|
145
156
|
change()
|
|
@@ -151,6 +162,20 @@ export function DocumentsLibrary({
|
|
|
151
162
|
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
152
163
|
}
|
|
153
164
|
|
|
165
|
+
async function handleUpload(file: File): Promise<void> {
|
|
166
|
+
if (!uploadDocument) return
|
|
167
|
+
setUploading(true)
|
|
168
|
+
setUploadFailed(false)
|
|
169
|
+
try {
|
|
170
|
+
await uploadDocument(file)
|
|
171
|
+
setReloadToken((token) => token + 1)
|
|
172
|
+
} catch {
|
|
173
|
+
setUploadFailed(true)
|
|
174
|
+
} finally {
|
|
175
|
+
setUploading(false)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
154
179
|
// Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
|
|
155
180
|
// sempre e fazer parecer que a empresa não tem arquivo nenhum.
|
|
156
181
|
if (!fetchAll) return null
|
|
@@ -181,6 +206,7 @@ export function DocumentsLibrary({
|
|
|
181
206
|
</select>
|
|
182
207
|
|
|
183
208
|
<button
|
|
209
|
+
data-cv-tooltip={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest} aria-label={sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
|
|
184
210
|
type="button"
|
|
185
211
|
onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
|
|
186
212
|
className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
|
|
@@ -191,6 +217,7 @@ export function DocumentsLibrary({
|
|
|
191
217
|
|
|
192
218
|
{hasFilters ? (
|
|
193
219
|
<button
|
|
220
|
+
data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters}
|
|
194
221
|
type="button"
|
|
195
222
|
onClick={() =>
|
|
196
223
|
applyFilter(() => {
|
|
@@ -204,6 +231,32 @@ export function DocumentsLibrary({
|
|
|
204
231
|
{labels.clearFilters}
|
|
205
232
|
</button>
|
|
206
233
|
) : null}
|
|
234
|
+
|
|
235
|
+
{uploadDocument ? (
|
|
236
|
+
<>
|
|
237
|
+
<input
|
|
238
|
+
ref={fileInputRef}
|
|
239
|
+
type="file"
|
|
240
|
+
hidden
|
|
241
|
+
onChange={(event) => {
|
|
242
|
+
const file = event.target.files?.[0]
|
|
243
|
+
event.target.value = ''
|
|
244
|
+
if (file) void handleUpload(file)
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
<button
|
|
248
|
+
data-cv-tooltip={labels.upload}
|
|
249
|
+
aria-label={labels.upload}
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={() => fileInputRef.current?.click()}
|
|
252
|
+
disabled={uploading}
|
|
253
|
+
className="cv-header-action ml-auto inline-flex items-center gap-1 disabled:opacity-40"
|
|
254
|
+
>
|
|
255
|
+
<Upload size={14} aria-hidden="true" />
|
|
256
|
+
{labels.upload}
|
|
257
|
+
</button>
|
|
258
|
+
</>
|
|
259
|
+
) : null}
|
|
207
260
|
</div>
|
|
208
261
|
|
|
209
262
|
{loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
|
|
@@ -212,6 +265,11 @@ export function DocumentsLibrary({
|
|
|
212
265
|
{labels.failure}
|
|
213
266
|
</p>
|
|
214
267
|
) : null}
|
|
268
|
+
{uploadFailed ? (
|
|
269
|
+
<p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
|
|
270
|
+
{labels.uploadError}
|
|
271
|
+
</p>
|
|
272
|
+
) : null}
|
|
215
273
|
{!loading && !failed && documents.length === 0 ? (
|
|
216
274
|
<p className={cn('text-sm text-gray-500', classNames?.status)}>
|
|
217
275
|
{hasFilters ? labels.noResults : labels.empty}
|
|
@@ -234,7 +292,7 @@ export function DocumentsLibrary({
|
|
|
234
292
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
235
293
|
<FileIcon filename={document.filename} mimeType={document.mimeType} />
|
|
236
294
|
<div className="min-w-0 flex-1">
|
|
237
|
-
<div className={cn('truncate text-sm font-medium', classNames?.filename)}
|
|
295
|
+
<div className={cn('truncate text-sm font-medium', classNames?.filename)} data-cv-tooltip={document.filename}>
|
|
238
296
|
{document.filename}
|
|
239
297
|
</div>
|
|
240
298
|
<div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
|
|
@@ -256,7 +314,7 @@ export function DocumentsLibrary({
|
|
|
256
314
|
<button
|
|
257
315
|
type="button"
|
|
258
316
|
onClick={() => onOpenConversation(document.conversationId)}
|
|
259
|
-
|
|
317
|
+
data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
|
|
260
318
|
className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
|
|
261
319
|
>
|
|
262
320
|
<MessageSquare size={12} />
|
|
@@ -272,7 +330,7 @@ export function DocumentsLibrary({
|
|
|
272
330
|
<button
|
|
273
331
|
type="button"
|
|
274
332
|
onClick={() => void handleOpen(document.id, 'inline')}
|
|
275
|
-
|
|
333
|
+
data-cv-tooltip={labels.view}
|
|
276
334
|
aria-label={`${labels.view}: ${document.filename}`}
|
|
277
335
|
className="cv-header-icon"
|
|
278
336
|
>
|
|
@@ -281,7 +339,7 @@ export function DocumentsLibrary({
|
|
|
281
339
|
<button
|
|
282
340
|
type="button"
|
|
283
341
|
onClick={() => void handleOpen(document.id, 'attachment')}
|
|
284
|
-
|
|
342
|
+
data-cv-tooltip={labels.download}
|
|
285
343
|
aria-label={`${labels.download}: ${document.filename}`}
|
|
286
344
|
className="cv-header-icon"
|
|
287
345
|
>
|
|
@@ -298,6 +356,7 @@ export function DocumentsLibrary({
|
|
|
298
356
|
<span className="text-gray-400">{labels.total(total)}</span>
|
|
299
357
|
<div className="flex items-center gap-2">
|
|
300
358
|
<button
|
|
359
|
+
data-cv-tooltip={PAGINATION_LABELS.previous} aria-label={PAGINATION_LABELS.previous}
|
|
301
360
|
type="button"
|
|
302
361
|
onClick={() => setPage(page - 1)}
|
|
303
362
|
disabled={page <= 1}
|
|
@@ -307,6 +366,7 @@ export function DocumentsLibrary({
|
|
|
307
366
|
</button>
|
|
308
367
|
<span className="text-gray-500">{labels.page(page, lastPage)}</span>
|
|
309
368
|
<button
|
|
369
|
+
data-cv-tooltip={PAGINATION_LABELS.next} aria-label={PAGINATION_LABELS.next}
|
|
310
370
|
type="button"
|
|
311
371
|
onClick={() => setPage(page + 1)}
|
|
312
372
|
disabled={page >= lastPage}
|
package/src/EmojiPicker.tsx
CHANGED
|
@@ -55,6 +55,7 @@ export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerPro
|
|
|
55
55
|
<div className="flex border-b border-gray-200 overflow-x-auto">
|
|
56
56
|
{EMOJI_CATEGORIES.map((category, index) => (
|
|
57
57
|
<button
|
|
58
|
+
data-cv-tooltip={category.name} aria-label={category.name}
|
|
58
59
|
key={category.name}
|
|
59
60
|
onClick={() => setActiveCategory(index)}
|
|
60
61
|
className={`px-3 py-2 text-xs font-medium whitespace-nowrap border-b-2 transition-colors ${
|
|
@@ -79,7 +80,7 @@ export const EmojiPicker = ({ onSelect, labels, className = '' }: EmojiPickerPro
|
|
|
79
80
|
onClick={() => handleSelect(entry.emoji)}
|
|
80
81
|
className="w-8 h-8 flex items-center justify-center text-lg hover:bg-gray-100 rounded transition-colors cursor-pointer"
|
|
81
82
|
aria-label={entry.emoji}
|
|
82
|
-
|
|
83
|
+
data-cv-tooltip={entry.keywords[0]}
|
|
83
84
|
>
|
|
84
85
|
{entry.emoji}
|
|
85
86
|
</button>
|
|
@@ -86,6 +86,7 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
|
|
|
86
86
|
<div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
|
|
87
87
|
{buttons.map((button) => (
|
|
88
88
|
<button
|
|
89
|
+
data-cv-tooltip={button.title} aria-label={button.title}
|
|
89
90
|
key={button.id}
|
|
90
91
|
type="button"
|
|
91
92
|
disabled={!isInteractable}
|
|
@@ -101,6 +102,7 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
|
|
|
101
102
|
{hasRows ? (
|
|
102
103
|
<div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
|
|
103
104
|
<button
|
|
105
|
+
data-cv-tooltip={payload.action?.button ?? openListLabel} aria-label={payload.action?.button ?? openListLabel}
|
|
104
106
|
type="button"
|
|
105
107
|
onClick={() => setIsListOpen((open) => !open)}
|
|
106
108
|
aria-expanded={isListOpen}
|
|
@@ -120,6 +122,7 @@ export function InteractiveMessage({ payload, onSelect, labels, className }: Int
|
|
|
120
122
|
) : null}
|
|
121
123
|
{(section.rows ?? []).map((row) => (
|
|
122
124
|
<button
|
|
125
|
+
data-cv-tooltip={row.title} aria-label={row.title}
|
|
123
126
|
key={row.id}
|
|
124
127
|
type="button"
|
|
125
128
|
disabled={!isInteractable}
|
package/src/Lightbox.tsx
CHANGED
|
@@ -25,7 +25,7 @@ export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps)
|
|
|
25
25
|
<div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
|
26
26
|
<img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
|
27
27
|
{caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
|
|
28
|
-
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
|
|
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>
|
|
29
29
|
</div>
|
|
30
30
|
</div>
|
|
31
31
|
)
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -1,10 +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
7
|
import { cn } from './lib/cn'
|
|
7
|
-
import type { MessagePayload } from './types'
|
|
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
|
+
}
|
|
8
28
|
|
|
9
29
|
function resolveMediaSource(message: MessagePayload): string | null {
|
|
10
30
|
if (message.mediaUrl) return message.mediaUrl
|
|
@@ -31,6 +51,13 @@ export interface MediaRendererProps {
|
|
|
31
51
|
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
32
52
|
// porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
33
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>
|
|
34
61
|
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
35
62
|
className?: string
|
|
36
63
|
}
|
|
@@ -58,23 +85,49 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
|
|
|
58
85
|
return { url, loading, error, load }
|
|
59
86
|
}
|
|
60
87
|
|
|
61
|
-
export function MediaRenderer({
|
|
88
|
+
export function MediaRenderer({
|
|
89
|
+
message,
|
|
90
|
+
onLightbox,
|
|
91
|
+
onResolveUrl,
|
|
92
|
+
onTranscribeAudio,
|
|
93
|
+
className,
|
|
94
|
+
}: MediaRendererProps) {
|
|
62
95
|
const { bubble } = useConversationLocales()
|
|
63
96
|
const eagerSrc = resolveMediaSource(message)
|
|
64
97
|
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
65
98
|
const src = eagerSrc ?? lazy.url
|
|
66
99
|
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
|
|
67
100
|
|
|
68
|
-
|
|
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
|
+
}
|
|
69
121
|
|
|
70
122
|
switch (message.type) {
|
|
71
123
|
case 'image':
|
|
72
124
|
case 'sticker': {
|
|
73
125
|
if (!src && canLazyLoad) {
|
|
74
126
|
return (
|
|
75
|
-
<
|
|
76
|
-
{
|
|
77
|
-
|
|
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
|
+
/>
|
|
78
131
|
)
|
|
79
132
|
}
|
|
80
133
|
return (
|
|
@@ -92,9 +145,10 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
92
145
|
case 'video': {
|
|
93
146
|
if (!src && canLazyLoad) {
|
|
94
147
|
return (
|
|
95
|
-
<
|
|
96
|
-
{
|
|
97
|
-
|
|
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
|
+
/>
|
|
98
152
|
)
|
|
99
153
|
}
|
|
100
154
|
return (
|
|
@@ -110,11 +164,28 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
110
164
|
)
|
|
111
165
|
}
|
|
112
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
|
+
|
|
113
180
|
if (!src && canLazyLoad) {
|
|
114
181
|
return (
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
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>
|
|
118
189
|
)
|
|
119
190
|
}
|
|
120
191
|
return (
|
|
@@ -122,11 +193,12 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
122
193
|
{src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
|
|
123
194
|
<div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
|
|
124
195
|
)}
|
|
196
|
+
{transcriptionBlock}
|
|
125
197
|
</div>
|
|
126
198
|
)
|
|
127
199
|
}
|
|
128
200
|
case 'document': {
|
|
129
|
-
const typeLabel = message.mimeType
|
|
201
|
+
const typeLabel = documentTypeLabel(message.filename, message.mimeType)
|
|
130
202
|
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
|
|
131
203
|
return (
|
|
132
204
|
<div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
|
|
@@ -135,7 +207,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
135
207
|
</div>
|
|
136
208
|
<div className="flex-1 min-w-0">
|
|
137
209
|
<p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
|
|
138
|
-
<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>
|
|
139
211
|
</div>
|
|
140
212
|
{src ? (
|
|
141
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}>
|
|
@@ -143,6 +215,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
143
215
|
</a>
|
|
144
216
|
) : canLazyLoad ? (
|
|
145
217
|
<button
|
|
218
|
+
data-cv-tooltip={bubble.downloadFile}
|
|
146
219
|
onClick={lazy.load}
|
|
147
220
|
disabled={lazy.loading}
|
|
148
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
|
+
})
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
2
|
import { Check } from 'lucide-react'
|
|
3
|
-
import type { InteractiveSelection, MessagePayload } from './types'
|
|
3
|
+
import type { InteractiveSelection, MessagePayload, MessageTranscription } from './types'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
import { StatusTicks } from './StatusTicks'
|
|
6
6
|
import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
|
|
@@ -30,6 +30,12 @@ export interface MessageBubbleProps {
|
|
|
30
30
|
* no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
|
|
31
31
|
*/
|
|
32
32
|
onInteractiveSelect?: (selection: InteractiveSelection) => void
|
|
33
|
+
/**
|
|
34
|
+
* Pede a transcrição do áudio. Ausente, o balão usa `transcribeAudio` do `ConversationsApi` do
|
|
35
|
+
* contexto — passe apenas para sobrescrever. Sem nenhum dos dois, o bloco de transcrição só exibe
|
|
36
|
+
* o que já veio pronto do backend.
|
|
37
|
+
*/
|
|
38
|
+
onTranscribeAudio?: (messageId: string) => Promise<MessageTranscription | void>
|
|
33
39
|
className?: string
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -51,7 +57,7 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
|
|
|
51
57
|
// tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
|
|
52
58
|
export function MessageBubble({
|
|
53
59
|
message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
|
|
54
|
-
onResolveMediaUrl, onInteractiveSelect, className,
|
|
60
|
+
onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
|
|
55
61
|
}: MessageBubbleProps) {
|
|
56
62
|
const { bubble, selection } = useConversationLocales()
|
|
57
63
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
|
@@ -66,11 +72,35 @@ export function MessageBubble({
|
|
|
66
72
|
[onResolveMediaUrl, context?.api],
|
|
67
73
|
)
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* `undefined` quando nem o host nem o `ConversationsApi` sabem transcrever — é essa ausência que o
|
|
77
|
+
* bloco de transcrição consulta para decidir se desenha a afordância, mesmo padrão de
|
|
78
|
+
* `takeover`/`release`.
|
|
79
|
+
*/
|
|
80
|
+
const requestTranscription = useMemo(() => {
|
|
81
|
+
const transcribe = onTranscribeAudio ?? context?.api?.transcribeAudio?.bind(context.api)
|
|
82
|
+
if (!transcribe) return undefined
|
|
83
|
+
// Devolve o resultado em vez de descartar: é o que o bloco exibe na hora, sem esperar refetch.
|
|
84
|
+
return () => transcribe(message.id)
|
|
85
|
+
}, [onTranscribeAudio, context?.api, message.id])
|
|
86
|
+
|
|
69
87
|
const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
|
|
70
88
|
const hasError = message.status === 'failed'
|
|
71
89
|
const isMedia = MEDIA_TYPES.has(message.type)
|
|
72
90
|
const isTemplate = message.type === 'template'
|
|
73
91
|
const isInteractive = message.type === 'interactive'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A legenda chega em `caption` quando o cliente manda, e em `content` quando somos nós — é lá que
|
|
95
|
+
* a API grava o texto do envio. Sem legenda a API repete o nome do arquivo em `content`, que não
|
|
96
|
+
* é conteúdo e não deve aparecer sob a mídia.
|
|
97
|
+
*/
|
|
98
|
+
const mediaCaption = isMedia
|
|
99
|
+
? [message.caption, message.content].find((text) => {
|
|
100
|
+
const trimmed = text?.trim()
|
|
101
|
+
return trimmed && trimmed !== message.filename
|
|
102
|
+
})
|
|
103
|
+
: undefined
|
|
74
104
|
const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
|
|
75
105
|
|
|
76
106
|
const tooltipText = message.status === 'read' && message.readAt
|
|
@@ -86,7 +116,7 @@ export function MessageBubble({
|
|
|
86
116
|
const checkbox = (
|
|
87
117
|
<button
|
|
88
118
|
onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
|
|
89
|
-
|
|
119
|
+
data-cv-tooltip={selection.select} aria-label={selection.select}
|
|
90
120
|
className={`
|
|
91
121
|
flex-shrink-0 self-end mb-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
|
|
92
122
|
${isSelected ? 'bg-teal-600 border-teal-600' : 'bg-white/80 dark:bg-black/40 border-black/20 dark:border-white/30'}
|
|
@@ -130,7 +160,7 @@ export function MessageBubble({
|
|
|
130
160
|
{message.moderation?.isOffensive && (
|
|
131
161
|
<div
|
|
132
162
|
className="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
|
|
133
|
-
|
|
163
|
+
data-cv-tooltip={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
|
|
134
164
|
>
|
|
135
165
|
<span aria-hidden>⚠️</span>
|
|
136
166
|
<span>{bubble.moderationFlagged}</span>
|
|
@@ -138,7 +168,19 @@ export function MessageBubble({
|
|
|
138
168
|
)}
|
|
139
169
|
|
|
140
170
|
{isMedia ? (
|
|
141
|
-
|
|
171
|
+
<>
|
|
172
|
+
<MediaRenderer
|
|
173
|
+
message={message}
|
|
174
|
+
onLightbox={setLightboxSrc}
|
|
175
|
+
onResolveUrl={resolveMediaUrl}
|
|
176
|
+
{...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
|
|
177
|
+
/>
|
|
178
|
+
{mediaCaption ? (
|
|
179
|
+
<div className="mt-1 text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words leading-[19px]">
|
|
180
|
+
{parseWhatsAppFormatting(mediaCaption)}
|
|
181
|
+
</div>
|
|
182
|
+
) : null}
|
|
183
|
+
</>
|
|
142
184
|
) : isInteractive && message.payload ? (
|
|
143
185
|
// O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
|
|
144
186
|
// só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
|