@adatechnology/conversations-ui 0.1.0-rc.20 → 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-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
- package/dist/index.d.ts +142 -9
- package/dist/index.js +374 -66
- package/dist/preview/index.d.ts +100 -3
- package/dist/preview/index.js +89 -7
- package/dist/styles.css +30 -0
- package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
- package/package.json +2 -2
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +249 -0
- package/src/ConversationContextPanel.tsx +205 -52
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/MediaRenderer.tsx +37 -6
- package/src/MessageBubble.tsx +26 -3
- package/src/MessageComposer.tsx +21 -2
- package/src/Wallpaper.tsx +27 -13
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/index.ts +11 -0
- package/src/preview/ConversationPreview.tsx +13 -4
- package/src/preview/createPreviewBridgeClient.ts +37 -1
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +89 -0
- package/src/preview/createPreviewWebhookClient.ts +91 -0
- package/src/preview/index.ts +6 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/types.ts +12 -1
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +189 -0
- package/src/styles.css +37 -0
- package/src/types.ts +26 -0
package/src/MediaRenderer.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
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'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Rótulo curto do tipo, para a linha de baixo da bolha de documento.
|
|
@@ -50,6 +51,13 @@ export interface MediaRendererProps {
|
|
|
50
51
|
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
51
52
|
// porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
52
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>
|
|
53
61
|
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
54
62
|
className?: string
|
|
55
63
|
}
|
|
@@ -77,7 +85,13 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
|
|
|
77
85
|
return { url, loading, error, load }
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
export function MediaRenderer({
|
|
88
|
+
export function MediaRenderer({
|
|
89
|
+
message,
|
|
90
|
+
onLightbox,
|
|
91
|
+
onResolveUrl,
|
|
92
|
+
onTranscribeAudio,
|
|
93
|
+
className,
|
|
94
|
+
}: MediaRendererProps) {
|
|
81
95
|
const { bubble } = useConversationLocales()
|
|
82
96
|
const eagerSrc = resolveMediaSource(message)
|
|
83
97
|
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
@@ -149,12 +163,28 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
149
163
|
)
|
|
150
164
|
}
|
|
151
165
|
case 'audio': {
|
|
166
|
+
/**
|
|
167
|
+
* Fica fora do ramo de carregamento de propósito: a transcrição não depende dos bytes do
|
|
168
|
+
* áudio. Ler o que o cliente disse sem baixar e tocar a nota de voz é o caminho rápido do
|
|
169
|
+
* atendimento — e é justamente o que se perderia se o bloco só aparecesse depois do play.
|
|
170
|
+
*/
|
|
171
|
+
const transcriptionBlock = (
|
|
172
|
+
<AudioTranscription
|
|
173
|
+
transcription={message.transcription}
|
|
174
|
+
isMine={message.direction === 'outbound'}
|
|
175
|
+
{...(onTranscribeAudio ? { onTranscribe: onTranscribeAudio } : {})}
|
|
176
|
+
/>
|
|
177
|
+
)
|
|
178
|
+
|
|
152
179
|
if (!src && canLazyLoad) {
|
|
153
180
|
return (
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
181
|
+
<div className="min-w-[200px]">
|
|
182
|
+
<LazyMediaButton
|
|
183
|
+
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>}
|
|
184
|
+
label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
|
|
185
|
+
/>
|
|
186
|
+
{transcriptionBlock}
|
|
187
|
+
</div>
|
|
158
188
|
)
|
|
159
189
|
}
|
|
160
190
|
return (
|
|
@@ -162,6 +192,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
|
|
|
162
192
|
{src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
|
|
163
193
|
<div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
|
|
164
194
|
)}
|
|
195
|
+
{transcriptionBlock}
|
|
165
196
|
</div>
|
|
166
197
|
)
|
|
167
198
|
}
|
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,6 +72,18 @@ 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)
|
|
@@ -138,7 +156,12 @@ export function MessageBubble({
|
|
|
138
156
|
)}
|
|
139
157
|
|
|
140
158
|
{isMedia ? (
|
|
141
|
-
<MediaRenderer
|
|
159
|
+
<MediaRenderer
|
|
160
|
+
message={message}
|
|
161
|
+
onLightbox={setLightboxSrc}
|
|
162
|
+
onResolveUrl={resolveMediaUrl}
|
|
163
|
+
{...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
|
|
164
|
+
/>
|
|
142
165
|
) : isInteractive && message.payload ? (
|
|
143
166
|
// O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
|
|
144
167
|
// só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
|
package/src/MessageComposer.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
|
|
2
|
+
import { AudioRecorderButton } from './AudioRecorderButton'
|
|
2
3
|
import type { ConversationsFeatures } from './types'
|
|
3
4
|
import { cn } from './lib/cn'
|
|
4
5
|
import { EmojiPicker } from './EmojiPicker'
|
|
@@ -219,6 +220,24 @@ export const MessageComposer = ({
|
|
|
219
220
|
}
|
|
220
221
|
}, [text, setText])
|
|
221
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Microfone por padrão, sem o host precisar compor nada.
|
|
225
|
+
*
|
|
226
|
+
* Antes o gravador era só um componente exportado e um slot vazio: cada inbox tinha que lembrar de
|
|
227
|
+
* juntar os dois. Dois produtos, dois resultados — um ligou, o outro não, e a ausência não dava
|
|
228
|
+
* erro nenhum. Um composer de WhatsApp sem microfone está incompleto, então o default certo é ter.
|
|
229
|
+
*
|
|
230
|
+
* Depende de `onAttach` porque áudio gravado é um arquivo para entregar, e microfone que grava sem
|
|
231
|
+
* ter para onde mandar é pior que microfone nenhum — o operador fala e o áudio evapora. É a mesma
|
|
232
|
+
* regra de capacidade usada no resto do pacote: sem a porta, a afordância não aparece.
|
|
233
|
+
*
|
|
234
|
+
* `idleAction` continua vencendo: quem já compunha o próprio gravador (com rótulos, limite de
|
|
235
|
+
* duração ou revisão diferentes) não muda de comportamento ao atualizar.
|
|
236
|
+
*/
|
|
237
|
+
const effectiveIdleAction =
|
|
238
|
+
idleAction ??
|
|
239
|
+
(onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
|
|
240
|
+
|
|
222
241
|
const canSend = text.trim().length > 0 || attachments.length > 0
|
|
223
242
|
const remaining = maxLength ? maxLength - text.length : null
|
|
224
243
|
|
|
@@ -310,8 +329,8 @@ export const MessageComposer = ({
|
|
|
310
329
|
</>
|
|
311
330
|
)}
|
|
312
331
|
|
|
313
|
-
{!canSend &&
|
|
314
|
-
<div className="flex-shrink-0">{
|
|
332
|
+
{!canSend && effectiveIdleAction ? (
|
|
333
|
+
<div className="flex-shrink-0">{effectiveIdleAction}</div>
|
|
315
334
|
) : (
|
|
316
335
|
<button
|
|
317
336
|
onClick={sendMessage}
|
package/src/Wallpaper.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* A classe `cv-wallpaper` continua no elemento para quem já sobrescreve por CSS.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import { forwardRef, type CSSProperties, type ReactNode, type UIEvent } from 'react'
|
|
12
12
|
|
|
13
13
|
import { cn } from './lib/cn'
|
|
14
14
|
import { useIsDarkTheme } from './useDarkMode'
|
|
@@ -45,17 +45,31 @@ export interface ConversationWallpaperProps {
|
|
|
45
45
|
className?: string
|
|
46
46
|
/** Ajusta ou substitui o fundo padrão — para produto com identidade visual própria. */
|
|
47
47
|
style?: CSSProperties
|
|
48
|
+
/**
|
|
49
|
+
* Rolagem da área de mensagens. Par do `ref`: é este elemento que rola, então é aqui que
|
|
50
|
+
* `useScrollToLatestMessage` observa a posição para saber se o operador está acompanhando o fim.
|
|
51
|
+
*/
|
|
52
|
+
onScroll?: (event: UIEvent<HTMLDivElement>) => void
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
55
|
+
/**
|
|
56
|
+
* `forwardRef` porque quem controla a rolagem é de fora — o hook precisa do elemento para saltar
|
|
57
|
+
* até a última mensagem. `forwardRef` e não `ref` como prop: o pacote suporta React 18, onde
|
|
58
|
+
* ref-como-prop ainda não existe.
|
|
59
|
+
*/
|
|
60
|
+
export const ConversationWallpaper = forwardRef<HTMLDivElement, ConversationWallpaperProps>(
|
|
61
|
+
function ConversationWallpaper({ children, className, style, onScroll }, ref) {
|
|
62
|
+
const isDark = useIsDarkTheme()
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
ref={ref}
|
|
67
|
+
onScroll={onScroll}
|
|
68
|
+
className={cn('cv-wallpaper', className)}
|
|
69
|
+
style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
|
|
70
|
+
>
|
|
71
|
+
{children}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
)
|
|
@@ -62,4 +62,61 @@ describe('buildTranscriptFilename', () => {
|
|
|
62
62
|
`conversa-${NUMBER}-2026-07-27.txt`,
|
|
63
63
|
)
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* O áudio saía como `<audio>` no arquivo baixado. Um histórico onde o pedido do cliente aparece
|
|
68
|
+
* como marcador vazio é inútil justamente para o caso que motiva o download: auditoria e repasse.
|
|
69
|
+
*/
|
|
70
|
+
it('escreve a transcrição do áudio no lugar do marcador de tipo', () => {
|
|
71
|
+
const texto = buildTranscriptText({
|
|
72
|
+
whatsappNumber: '5511999999999',
|
|
73
|
+
messages: [
|
|
74
|
+
{
|
|
75
|
+
id: '1',
|
|
76
|
+
type: 'audio',
|
|
77
|
+
direction: 'inbound',
|
|
78
|
+
sender: 'customer',
|
|
79
|
+
timestamp: '2026-07-31T12:00:00.000Z',
|
|
80
|
+
transcription: { status: 'done', text: 'quero dois quilos de arroz' },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(texto).toContain('[áudio] quero dois quilos de arroz')
|
|
86
|
+
expect(texto).not.toContain('<audio>')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('mantém o marcador de tipo quando o áudio não foi transcrito', () => {
|
|
90
|
+
const texto = buildTranscriptText({
|
|
91
|
+
whatsappNumber: '5511999999999',
|
|
92
|
+
messages: [
|
|
93
|
+
{ id: '1', type: 'audio', direction: 'inbound', sender: 'customer', timestamp: '2026-07-31T12:00:00.000Z' },
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(texto).toContain('<audio>')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A rota de export do módulo devolve `createdAt`, não `sentAt` — quem mapeia esperando `sentAt`
|
|
102
|
+
* entrega `undefined` aqui, e o arquivo saía com "Invalid Date" em todas as linhas.
|
|
103
|
+
*/
|
|
104
|
+
it('escreve "data indisponível" em vez de Invalid Date quando o horário não vem', () => {
|
|
105
|
+
const texto = buildTranscriptText({
|
|
106
|
+
whatsappNumber: '5511999999999',
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
id: '1',
|
|
110
|
+
type: 'text',
|
|
111
|
+
direction: 'inbound',
|
|
112
|
+
sender: 'customer',
|
|
113
|
+
content: 'ola',
|
|
114
|
+
timestamp: undefined as unknown as string,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(texto).toContain('data indisponível')
|
|
120
|
+
expect(texto).not.toContain('Invalid Date')
|
|
121
|
+
})
|
|
65
122
|
})
|
|
@@ -33,16 +33,41 @@ export function buildTranscriptText(params: BuildTranscriptTextParams): string {
|
|
|
33
33
|
]
|
|
34
34
|
|
|
35
35
|
const lines = params.messages.map((message) => {
|
|
36
|
-
const stamp = new Date(message.timestamp).toLocaleString('pt-BR')
|
|
37
36
|
const author = message.agentName ?? SENDER_LABEL[message.sender]
|
|
38
|
-
|
|
39
|
-
const body = message.content ?? message.caption ?? `<${message.type}>`
|
|
40
|
-
return `[${stamp}] ${author}: ${body}`
|
|
37
|
+
return `[${formatStamp(message.timestamp)}] ${author}: ${bodyOf(message)}`
|
|
41
38
|
})
|
|
42
39
|
|
|
43
40
|
return [...header, ...lines].join('\n')
|
|
44
41
|
}
|
|
45
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Corpo da linha no arquivo.
|
|
45
|
+
*
|
|
46
|
+
* Áudio transcrito entra com o TEXTO, não como `<audio>`. Quem baixa a conversa quer lê-la, e um
|
|
47
|
+
* histórico onde o pedido do cliente aparece como marcador vazio é inútil justamente para o caso que
|
|
48
|
+
* motiva o download: auditoria e repasse. O prefixo `[áudio]` fica na frente para a linha não passar
|
|
49
|
+
* por mensagem digitada — quem audita precisa saber que aquilo foi falado e transcrito por máquina.
|
|
50
|
+
*/
|
|
51
|
+
function bodyOf(message: MessagePayload): string {
|
|
52
|
+
const transcript = message.transcription?.text?.trim()
|
|
53
|
+
if (message.type === 'audio' && transcript) return `[áudio] ${transcript}`
|
|
54
|
+
|
|
55
|
+
// Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
|
|
56
|
+
return message.content ?? message.caption ?? `<${message.type}>`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `Invalid Date` no arquivo é pior do que data ausente: parece dado corrompido e põe em dúvida o
|
|
61
|
+
* resto do transcript. E acontece de verdade — a rota de export do módulo devolve `createdAt`, não
|
|
62
|
+
* `sentAt`, então quem mapeia esperando `sentAt` recebe `undefined` aqui.
|
|
63
|
+
*/
|
|
64
|
+
function formatStamp(timestamp: string | undefined): string {
|
|
65
|
+
if (!timestamp) return 'data indisponível'
|
|
66
|
+
|
|
67
|
+
const parsed = new Date(timestamp)
|
|
68
|
+
return Number.isNaN(parsed.getTime()) ? 'data indisponível' : parsed.toLocaleString('pt-BR')
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
export function buildTranscriptFilename(whatsappNumber: string, generatedAt: Date): string {
|
|
47
72
|
const stamp = generatedAt.toISOString().slice(0, 10)
|
|
48
73
|
return `conversa-${whatsappNumber}-${stamp}.txt`
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mantém a conversa no fim, como todo mensageiro.
|
|
5
|
+
*
|
|
6
|
+
* Abrir uma conversa no topo do histórico é errado por um motivo simples: o que interessa ao
|
|
7
|
+
* atendente é a última mensagem, e ele teria que rolar por semanas de conversa para chegar nela.
|
|
8
|
+
*
|
|
9
|
+
* Existe no pacote, e não em cada host, porque a regra tem duas sutilezas que se descobre só
|
|
10
|
+
* errando — o salto instantâneo na troca de conversa e o respeito a quem rolou para trás — e
|
|
11
|
+
* reimplementá-las em cada inbox garante que uma delas fique de fora.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Distância do fim, em pixels, dentro da qual ainda consideramos o operador "acompanhando".
|
|
16
|
+
*
|
|
17
|
+
* Não é zero porque o navegador arredonda `scrollTop` fracionário em telas com zoom ou densidade
|
|
18
|
+
* alta: exigir o fim exato faria o painel achar que o operador rolou para trás sem ele ter tocado
|
|
19
|
+
* em nada, e a próxima mensagem não apareceria.
|
|
20
|
+
*/
|
|
21
|
+
const NEAR_BOTTOM_THRESHOLD_PX = 120
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Toda rolagem aqui é `'auto'` — nunca `'smooth'`.
|
|
25
|
+
*
|
|
26
|
+
* Tentamos suave para mensagem nova e medimos: em ambiente onde a rolagem suave está desligada, o
|
|
27
|
+
* `scrollTo({ behavior: 'smooth' })` **não faz nada e não avisa** — a mensagem nova simplesmente não
|
|
28
|
+
* entra na vista. E não dá para detectar isso pelo `prefers-reduced-motion`: no navegador em que
|
|
29
|
+
* reproduzimos, a media query respondia `false` e o smooth continuava sendo no-op.
|
|
30
|
+
*
|
|
31
|
+
* Trocar uma animação cosmética por garantia de que o operador vê a mensagem é barato: seguir uma
|
|
32
|
+
* mensagem nova salta a altura de uma bolha, que é quase imperceptível de qualquer forma.
|
|
33
|
+
*/
|
|
34
|
+
const SCROLL_BEHAVIOR: ScrollBehavior = 'auto'
|
|
35
|
+
|
|
36
|
+
export type UseScrollToLatestMessageParams = {
|
|
37
|
+
/** Troca de conversa. Muda ⇒ salto instantâneo para o fim. */
|
|
38
|
+
readonly conversationId: string | undefined
|
|
39
|
+
/** Quantidade de mensagens carregadas. Cresce ⇒ acompanha o fim, se o operador estiver lá. */
|
|
40
|
+
readonly messageCount: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type UseScrollToLatestMessageResult = {
|
|
44
|
+
/** Vai no elemento que rola — tipicamente o `ConversationWallpaper`. */
|
|
45
|
+
readonly containerRef: RefObject<HTMLDivElement | null>
|
|
46
|
+
/** Ligue no `onScroll` do mesmo elemento: é o que detecta o operador lendo o histórico. */
|
|
47
|
+
readonly handleScroll: (event: UIEvent<HTMLDivElement>) => void
|
|
48
|
+
/** `true` quando o operador rolou para trás — serve a um botão "ir para a última". */
|
|
49
|
+
readonly isAwayFromBottom: boolean
|
|
50
|
+
readonly scrollToBottom: (behavior?: ScrollBehavior) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useScrollToLatestMessage({
|
|
54
|
+
conversationId,
|
|
55
|
+
messageCount,
|
|
56
|
+
}: UseScrollToLatestMessageParams): UseScrollToLatestMessageResult {
|
|
57
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
58
|
+
const [isAwayFromBottom, setIsAwayFromBottom] = useState(false)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Espelha `isAwayFromBottom` para o efeito de mensagens novas ler o valor atual sem depender dele.
|
|
62
|
+
*
|
|
63
|
+
* Se o efeito dependesse do estado, cada rolagem do operador o re-disparava e o puxava de volta
|
|
64
|
+
* para o fim — exatamente o que a checagem existe para evitar.
|
|
65
|
+
*/
|
|
66
|
+
const isAwayFromBottomRef = useRef(false)
|
|
67
|
+
|
|
68
|
+
const scrollToBottom = useCallback((behavior: ScrollBehavior = SCROLL_BEHAVIOR) => {
|
|
69
|
+
const container = containerRef.current
|
|
70
|
+
if (!container) return
|
|
71
|
+
container.scrollTo({ top: container.scrollHeight, behavior })
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
|
75
|
+
const target = event.currentTarget
|
|
76
|
+
const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
|
77
|
+
const away = distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX
|
|
78
|
+
|
|
79
|
+
isAwayFromBottomRef.current = away
|
|
80
|
+
setIsAwayFromBottom((current) => (current === away ? current : away))
|
|
81
|
+
}, [])
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Qual conversa já recebeu o salto de abertura.
|
|
85
|
+
*
|
|
86
|
+
* É o que separa "abriu a conversa" de "chegou mensagem", e não dá para usar só `conversationId`
|
|
87
|
+
* num efeito próprio: quando a conversa troca, a lista de mensagens ainda está vazia, então um
|
|
88
|
+
* salto ali rola um container sem conteúdo e não faz nada. O salto real precisa esperar a primeira
|
|
89
|
+
* leva de mensagens — e foi exatamente isso que deixou a conversa abrindo no topo.
|
|
90
|
+
*/
|
|
91
|
+
const jumpedForConversationRef = useRef<string | undefined>(undefined)
|
|
92
|
+
|
|
93
|
+
useLayoutEffect(() => {
|
|
94
|
+
const isNewConversation = jumpedForConversationRef.current !== conversationId
|
|
95
|
+
|
|
96
|
+
if (isNewConversation) {
|
|
97
|
+
// Zera antes de qualquer coisa: "rolado para trás" da conversa anterior não pode bloquear o
|
|
98
|
+
// salto de abertura desta.
|
|
99
|
+
isAwayFromBottomRef.current = false
|
|
100
|
+
setIsAwayFromBottom(false)
|
|
101
|
+
|
|
102
|
+
// Sem mensagens ainda — o salto acontece quando a primeira leva chegar.
|
|
103
|
+
if (messageCount === 0) return
|
|
104
|
+
|
|
105
|
+
jumpedForConversationRef.current = conversationId
|
|
106
|
+
/**
|
|
107
|
+
* `'auto'`, sempre. Animar a rolagem por meses de histórico demora, mostra um borrão de
|
|
108
|
+
* mensagens antigas que ninguém pediu, e some por completo onde a rolagem suave está desligada
|
|
109
|
+
* (`prefers-reduced-motion`, alguns navegadores automatizados) — a conversa simplesmente
|
|
110
|
+
* abriria no topo. Abertura é salto, não animação.
|
|
111
|
+
*/
|
|
112
|
+
scrollToBottom(SCROLL_BEHAVIOR)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mensagem nova: acompanha, mas só se o operador já estava no fim.
|
|
118
|
+
*
|
|
119
|
+
* Puxar a rolagem de quem está lendo o histórico é pior do que não mostrar a mensagem — ele perde
|
|
120
|
+
* a posição e não sabe por quê. Quem rolou para trás recebe `isAwayFromBottom` e decide.
|
|
121
|
+
*/
|
|
122
|
+
if (isAwayFromBottomRef.current) return
|
|
123
|
+
scrollToBottom(SCROLL_BEHAVIOR)
|
|
124
|
+
}, [conversationId, messageCount, scrollToBottom])
|
|
125
|
+
|
|
126
|
+
return { containerRef, handleScroll, isAwayFromBottom, scrollToBottom }
|
|
127
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { InteractiveMessage, DEFAULT_INTERACTIVE_MESSAGE_LABELS } from './Intera
|
|
|
3
3
|
export { ConversationWallpaper } from './Wallpaper'
|
|
4
4
|
export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
export { AudioPlayer } from './AudioPlayer'
|
|
6
|
+
export { AudioTranscription } from './AudioTranscription'
|
|
6
7
|
export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
|
|
7
8
|
export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
|
|
8
9
|
export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
@@ -77,6 +78,7 @@ export { ConversationContextPanel, DEFAULT_CONVERSATION_CONTEXT_LABELS } from '.
|
|
|
77
78
|
export type {
|
|
78
79
|
ConversationContextPanelProps,
|
|
79
80
|
ConversationContextEntry,
|
|
81
|
+
ConversationContextStatus,
|
|
80
82
|
ConversationContextPanelLabels,
|
|
81
83
|
ConversationContextPanelClassNames,
|
|
82
84
|
} from './ConversationContextPanel'
|
|
@@ -108,6 +110,7 @@ export { ConversationsProvider, useConversations } from './providers/Conversatio
|
|
|
108
110
|
export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
|
|
109
111
|
export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
|
|
110
112
|
export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
|
|
113
|
+
export { TranscriptionSettingsForm } from './settings/TranscriptionSettingsForm'
|
|
111
114
|
export {
|
|
112
115
|
WhatsAppTemplatesSettings,
|
|
113
116
|
TEMPLATE_SETTINGS_TAB,
|
|
@@ -118,6 +121,7 @@ export { TopicsForm } from './settings/TopicsForm'
|
|
|
118
121
|
// Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
|
|
119
122
|
// produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
|
|
120
123
|
export { useConversationMessages } from './hooks/useConversationMessages'
|
|
124
|
+
export { useScrollToLatestMessage } from './hooks/useScrollToLatestMessage'
|
|
121
125
|
export { useConversationList } from './hooks/useConversationList'
|
|
122
126
|
export { useConversationContext } from './hooks/useConversationContext'
|
|
123
127
|
export { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
@@ -132,6 +136,7 @@ export { formatTimestamp, formatFileSize, formatDateTime, isSameDay } from './li
|
|
|
132
136
|
|
|
133
137
|
export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
|
|
134
138
|
export type { InteractivePayload, InteractiveSection, InteractiveOption, InteractiveSelection } from './types'
|
|
139
|
+
export type { MessageTranscription, TranscriptionStatus, TranscriptionMode } from './types'
|
|
135
140
|
export type {
|
|
136
141
|
ConversationsApi,
|
|
137
142
|
SSEProvider,
|
|
@@ -152,6 +157,7 @@ export type { MessageBubbleProps } from './MessageBubble'
|
|
|
152
157
|
export type { ConversationWallpaperProps } from './Wallpaper'
|
|
153
158
|
export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
|
|
154
159
|
export type { AudioPlayerProps } from './AudioPlayer'
|
|
160
|
+
export type { AudioTranscriptionProps } from './AudioTranscription'
|
|
155
161
|
export type { EmojiPickerProps, EmojiPickerLabels } from './EmojiPicker'
|
|
156
162
|
export type { EmojiEntry, EmojiCategory } from './emojiCatalog'
|
|
157
163
|
export type { InteractiveMessageProps, InteractiveMessageLabels } from './InteractiveMessage'
|
|
@@ -181,6 +187,10 @@ export type {
|
|
|
181
187
|
WhatsAppCreateTemplateFormLabels,
|
|
182
188
|
} from './settings/WhatsAppCreateTemplateForm'
|
|
183
189
|
export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
|
|
190
|
+
export type {
|
|
191
|
+
TranscriptionSettingsFormProps,
|
|
192
|
+
TranscriptionSettingsFormLabels,
|
|
193
|
+
} from './settings/TranscriptionSettingsForm'
|
|
184
194
|
export type {
|
|
185
195
|
WhatsAppTemplatesSettingsProps,
|
|
186
196
|
WhatsAppTemplatesSettingsLabels,
|
|
@@ -191,6 +201,7 @@ export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/To
|
|
|
191
201
|
export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
|
|
192
202
|
export type { UseConversationListParams, UseConversationListResult } from './hooks/useConversationList'
|
|
193
203
|
export type { UseConversationContextResult } from './hooks/useConversationContext'
|
|
204
|
+
export type { UseScrollToLatestMessageParams, UseScrollToLatestMessageResult } from './hooks/useScrollToLatestMessage'
|
|
194
205
|
export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
|
|
195
206
|
export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
|
|
196
207
|
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
@@ -40,6 +40,7 @@ export type ConversationPreviewProps = {
|
|
|
40
40
|
* inventa um endpoint de upload. Ausente, o compositor não oferece anexo nem gravação: melhor um
|
|
41
41
|
* botão que não existe do que um que falha ao ser tocado.
|
|
42
42
|
*/
|
|
43
|
+
/** Destino alternativo do áudio gravado. Sem isto, usa o do próprio `client`. */
|
|
43
44
|
uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -246,11 +247,19 @@ export function ConversationPreview({
|
|
|
246
247
|
}
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
/**
|
|
251
|
+
* O cliente do preview sabe subir mídia sozinho; a prop é só para quem quer outro destino.
|
|
252
|
+
*
|
|
253
|
+
* Antes isto era `uploadMedia` puro, e o microfone só aparecia no produto que lembrasse de montar
|
|
254
|
+
* o upload — de onde veio a divergência entre dois simuladores da mesma casa.
|
|
255
|
+
*/
|
|
256
|
+
const uploadFile = uploadMedia ?? client.uploadMedia
|
|
257
|
+
|
|
249
258
|
async function handleAttach(file: File): Promise<void> {
|
|
250
|
-
if (!
|
|
259
|
+
if (!uploadFile) return
|
|
251
260
|
setFailure(undefined)
|
|
252
261
|
try {
|
|
253
|
-
const uploaded = await
|
|
262
|
+
const uploaded = await uploadFile(file)
|
|
254
263
|
await client.sendMedia({
|
|
255
264
|
mediaType: mediaTypeOf(uploaded.mimeType ?? file.type),
|
|
256
265
|
mediaId: uploaded.mediaId,
|
|
@@ -303,7 +312,7 @@ export function ConversationPreview({
|
|
|
303
312
|
|
|
304
313
|
<MessageComposer
|
|
305
314
|
onSend={(text) => void handleSend(text)}
|
|
306
|
-
onAttach={
|
|
315
|
+
onAttach={uploadFile ? (file) => void handleAttach(file) : undefined}
|
|
307
316
|
/* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
|
|
308
317
|
que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
|
|
309
318
|
microfone está quebrado. */
|
|
@@ -311,7 +320,7 @@ export function ConversationPreview({
|
|
|
311
320
|
isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
|
|
312
321
|
}
|
|
313
322
|
idleAction={
|
|
314
|
-
|
|
323
|
+
uploadFile ? (
|
|
315
324
|
<AudioRecorderButton
|
|
316
325
|
onRecorded={(file) => void handleAttach(file)}
|
|
317
326
|
onFailure={(message) => setFailure(message)}
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { InboundMediaType, InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing'
|
|
19
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
createPreviewMediaPoster,
|
|
21
|
+
defaultMediaUploadUrl,
|
|
22
|
+
type PreviewWebhookClient,
|
|
23
|
+
type SendPreviewMediaParams,
|
|
24
|
+
} from './createPreviewWebhookClient'
|
|
25
|
+
import type { PreviewUploadedMedia } from './createPreviewMediaUploader'
|
|
20
26
|
|
|
21
27
|
/**
|
|
22
28
|
* Comando semântico entregue ao host. É deliberadamente o QUE o cliente fez, não o payload da Meta:
|
|
@@ -50,6 +56,18 @@ export type CreatePreviewBridgeClientParams = {
|
|
|
50
56
|
readonly endpointUrl?: string
|
|
51
57
|
readonly headers?: Readonly<Record<string, string>>
|
|
52
58
|
readonly fetchImplementation?: typeof fetch
|
|
59
|
+
/**
|
|
60
|
+
* Rota que guarda o áudio gravado. Por padrão, `/v1/preview/media` na origem do `endpointUrl`.
|
|
61
|
+
*
|
|
62
|
+
* Aqui não há assinatura a calcular: a ponte existe justamente para não ter segredo no navegador,
|
|
63
|
+
* e a rota é protegida pela sessão do painel — os mesmos `headers` do comando valem para o upload.
|
|
64
|
+
*/
|
|
65
|
+
readonly mediaUploadUrl?: string
|
|
66
|
+
/**
|
|
67
|
+
* Substitui o upload embutido. Necessário para host que só passa `sendCommand`: sem `endpointUrl`
|
|
68
|
+
* não há origem a derivar, e sem destino o gravador não é desenhado.
|
|
69
|
+
*/
|
|
70
|
+
readonly uploadMedia?: (file: File) => Promise<PreviewUploadedMedia>
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewInboundCommand {
|
|
@@ -72,9 +90,26 @@ function buildFetchSender(params: CreatePreviewBridgeClientParams): SendPreviewI
|
|
|
72
90
|
}
|
|
73
91
|
}
|
|
74
92
|
|
|
93
|
+
/** Só existe quando há para onde mandar: rota explícita, ou origem herdada do `endpointUrl`. */
|
|
94
|
+
function resolveBridgeUpload(
|
|
95
|
+
params: CreatePreviewBridgeClientParams,
|
|
96
|
+
): ((file: File) => Promise<PreviewUploadedMedia>) | undefined {
|
|
97
|
+
if (params.uploadMedia) return params.uploadMedia
|
|
98
|
+
|
|
99
|
+
const url = params.mediaUploadUrl ?? (params.endpointUrl ? defaultMediaUploadUrl(params.endpointUrl) : undefined)
|
|
100
|
+
if (!url) return undefined
|
|
101
|
+
|
|
102
|
+
return createPreviewMediaPoster({
|
|
103
|
+
url,
|
|
104
|
+
...(params.headers ? { headers: async () => params.headers ?? {} } : {}),
|
|
105
|
+
...(params.fetchImplementation ? { fetchImplementation: params.fetchImplementation } : {}),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
75
109
|
export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParams): PreviewWebhookClient {
|
|
76
110
|
const send = params.sendCommand ?? buildFetchSender(params)
|
|
77
111
|
const from = params.from
|
|
112
|
+
const uploadMedia = resolveBridgeUpload(params)
|
|
78
113
|
|
|
79
114
|
return {
|
|
80
115
|
sendText: (text) => send({ kind: 'text', from, text }),
|
|
@@ -82,6 +117,7 @@ export function createPreviewBridgeClient(params: CreatePreviewBridgeClientParam
|
|
|
82
117
|
sendListReply: (reply) => send({ kind: 'listReply', from, reply }),
|
|
83
118
|
sendAudio: (mediaId) => send({ kind: 'audio', from, mediaId }),
|
|
84
119
|
sendMedia: (media) => send({ kind: 'media', from, ...media }),
|
|
120
|
+
...(uploadMedia ? { uploadMedia } : {}),
|
|
85
121
|
}
|
|
86
122
|
}
|
|
87
123
|
|