@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.
@@ -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({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
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
- <LazyMediaButton
155
- 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>}
156
- label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
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
  }
@@ -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 message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
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.
@@ -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 && idleAction ? (
314
- <div className="flex-shrink-0">{idleAction}</div>
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 { CSSProperties, ReactNode } from 'react'
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
- export function ConversationWallpaper({ children, className, style }: ConversationWallpaperProps) {
51
- const isDark = useIsDarkTheme()
52
-
53
- return (
54
- <div
55
- className={cn('cv-wallpaper', className)}
56
- style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
57
- >
58
- {children}
59
- </div>
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
- // Mídia sem legenda não tem texto nenhum; marcar o tipo evita uma linha vazia sem explicação.
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 (!uploadMedia) return
259
+ if (!uploadFile) return
251
260
  setFailure(undefined)
252
261
  try {
253
- const uploaded = await uploadMedia(file)
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={uploadMedia ? (file) => void handleAttach(file) : undefined}
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
- uploadMedia ? (
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 type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
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