@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.22

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.
Files changed (34) hide show
  1. package/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
  2. package/dist/index.d.ts +142 -9
  3. package/dist/index.js +374 -66
  4. package/dist/preview/index.d.ts +122 -3
  5. package/dist/preview/index.js +137 -21
  6. package/dist/styles.css +112 -0
  7. package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
  8. package/package.json +2 -2
  9. package/src/AudioTranscription.test.tsx +115 -0
  10. package/src/AudioTranscription.tsx +249 -0
  11. package/src/ConversationContextPanel.tsx +205 -52
  12. package/src/ConversationLocalesProvider.tsx +28 -0
  13. package/src/MediaRenderer.tsx +37 -6
  14. package/src/MessageBubble.tsx +26 -3
  15. package/src/MessageComposer.tsx +21 -2
  16. package/src/Wallpaper.tsx +27 -13
  17. package/src/conversationTranscript.test.ts +57 -0
  18. package/src/conversationTranscript.ts +29 -4
  19. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  20. package/src/index.ts +11 -0
  21. package/src/preview/ConversationPreview.tsx +13 -4
  22. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  23. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  24. package/src/preview/createPreviewBridgeClient.ts +37 -1
  25. package/src/preview/createPreviewMediaUploader.ts +82 -0
  26. package/src/preview/createPreviewWebhookClient.test.ts +89 -0
  27. package/src/preview/createPreviewWebhookClient.ts +91 -0
  28. package/src/preview/index.ts +9 -0
  29. package/src/preview/previewMediaUploader.test.ts +61 -0
  30. package/src/providers/types.ts +12 -1
  31. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  32. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  33. package/src/styles.css +122 -0
  34. package/src/types.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.20",
3
+ "version": "0.1.0-rc.22",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.8"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.9"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18 || ^19",
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+
4
+ import { AudioTranscription } from './AudioTranscription'
5
+ import type { MessageTranscription } from './types'
6
+
7
+ function render(props: {
8
+ transcription?: MessageTranscription | null
9
+ onTranscribe?: () => Promise<void>
10
+ }): string {
11
+ return renderToStaticMarkup(<AudioTranscription {...props} />)
12
+ }
13
+
14
+ const noop = async () => undefined
15
+
16
+ describe('AudioTranscription', () => {
17
+ it('mostra o texto e oferece copiar', () => {
18
+ const markup = render({ transcription: { status: 'done', text: 'quero dois pães na chapa' } })
19
+
20
+ expect(markup).toContain('quero dois pães na chapa')
21
+ expect(markup).toContain('Copiar')
22
+ })
23
+
24
+ it('deixa o texto selecionável — é o caminho de quem não tem clipboard disponível', () => {
25
+ const markup = render({ transcription: { status: 'done', text: 'oi' } })
26
+
27
+ expect(markup).toContain('select-all')
28
+ })
29
+
30
+ it('não oferece copiar quando não há texto para copiar', () => {
31
+ const markup = render({ transcription: { status: 'done', text: '' } })
32
+
33
+ expect(markup).not.toContain('Copiar')
34
+ })
35
+
36
+ // Silêncio processado é diferente de não transcrito: sem essa distinção o operador clica em
37
+ // transcrever de novo atrás de um texto que não existe.
38
+ it('diz "sem fala detectada" em áudio processado e vazio, sem oferecer transcrever', () => {
39
+ const markup = render({ transcription: { status: 'done', text: ' ' } })
40
+
41
+ expect(markup).toContain('Sem fala detectada')
42
+ expect(markup).not.toContain('Transcrever áudio')
43
+ })
44
+
45
+ it('oferece transcrever quando nada foi avaliado ainda', () => {
46
+ const markup = render({ transcription: null, onTranscribe: noop })
47
+
48
+ expect(markup).toContain('Transcrever áudio')
49
+ })
50
+
51
+ // Mesmo padrão de takeover/release: sem a porta, a afordância não existe — melhor que um botão
52
+ // que estoura no clique.
53
+ it('não desenha nada quando não há transcrição nem forma de pedir uma', () => {
54
+ expect(render({ transcription: null })).toBe('')
55
+ expect(render({})).toBe('')
56
+ })
57
+
58
+ it('mostra falha com convite a tentar de novo', () => {
59
+ const markup = render({ transcription: { status: 'failed' }, onTranscribe: noop })
60
+
61
+ expect(markup).toContain('Falha ao transcrever')
62
+ })
63
+
64
+ it('trata pendente como em andamento — já foi tentado e vai sair', () => {
65
+ const markup = render({ transcription: { status: 'pending' }, onTranscribe: noop })
66
+
67
+ expect(markup).toContain('Transcrevendo...')
68
+ })
69
+
70
+ it('avisa formato não suportado sem oferecer retry — retentar não conserta codec', () => {
71
+ const markup = render({ transcription: { status: 'unsupported' }, onTranscribe: noop })
72
+
73
+ expect(markup).toContain('não suportado')
74
+ expect(markup).not.toContain('Transcrever novamente')
75
+ })
76
+
77
+ /**
78
+ * Medido: 1147 caracteres produziram uma bolha de 854px, mais alta que a área visível da conversa.
79
+ * Sem o recolhimento, uma nota de voz longa esconde as mensagens seguintes.
80
+ */
81
+ it('recolhe transcrição longa e oferece ver o texto completo', () => {
82
+ const markup = render({ transcription: { status: 'done', text: 'palavra '.repeat(60).trim() } })
83
+
84
+ expect(markup).toContain('ver transcrição completa')
85
+ expect(markup).toContain('-webkit-line-clamp')
86
+ })
87
+
88
+ it('não recolhe transcrição curta', () => {
89
+ const markup = render({ transcription: { status: 'done', text: 'quero dois pães' } })
90
+
91
+ expect(markup).not.toContain('ver transcrição completa')
92
+ expect(markup).not.toContain('-webkit-line-clamp')
93
+ })
94
+
95
+ // Recolher é sobre altura na tela; o operador cola o pedido inteiro no sistema interno.
96
+ it('mantém o texto inteiro no DOM mesmo recolhido, para o copiar levar tudo', () => {
97
+ const markup = render({ transcription: { status: 'done', text: 'primeira ' + 'meio '.repeat(70) + 'ultima' } })
98
+
99
+ expect(markup).toContain('primeira')
100
+ expect(markup).toContain('ultima')
101
+ })
102
+
103
+ it('oferece retranscrever quando já há texto e o host sabe transcrever', () => {
104
+ const markup = render({ transcription: { status: 'done', text: 'ruim' }, onTranscribe: noop })
105
+
106
+ expect(markup).toContain('Transcrever novamente')
107
+ })
108
+
109
+ it('não oferece retranscrever quando o host não sabe transcrever', () => {
110
+ const markup = render({ transcription: { status: 'done', text: 'ok' } })
111
+
112
+ expect(markup).toContain('ok')
113
+ expect(markup).not.toContain('Transcrever novamente')
114
+ })
115
+ })
@@ -0,0 +1,249 @@
1
+ import { useCallback, useState } from 'react'
2
+ import { Check, Copy, FileText, Loader2, RefreshCw } from 'lucide-react'
3
+ import { useConversationLocales } from './ConversationLocalesProvider'
4
+ import { cn } from './lib/cn'
5
+ import type { MessageTranscription } from './types'
6
+
7
+ export interface AudioTranscriptionProps {
8
+ transcription?: MessageTranscription | null
9
+ /**
10
+ * Pede a transcrição ao backend. Ausente, o bloco nunca oferece o botão — um host em modo
11
+ * automático não tem rota para isso, e desenhar um botão que estoura no clique é pior do que não
12
+ * desenhar nada.
13
+ *
14
+ * O que for devolvido é exibido na hora. Sem isso o texto só apareceria no próximo refetch da
15
+ * lista, e transcrição não emite evento de tempo real: o operador clicaria, veria o spinner
16
+ * terminar e continuaria sem o texto na tela.
17
+ */
18
+ onTranscribe?: () => Promise<MessageTranscription | void>
19
+ isMine?: boolean
20
+ }
21
+
22
+ /** Feedback de "copiado" some sozinho — confirmação que exige clique para fechar é ruído. */
23
+ const COPIED_FEEDBACK_MS = 2000
24
+
25
+ /**
26
+ * Acima disto a transcrição nasce recolhida.
27
+ *
28
+ * Medido: 1147 caracteres (cerca de um minuto de fala) produziram uma bolha de 854px — mais alta que
29
+ * a área visível da conversa, empurrando todo o resto para fora. Nota de voz de três minutos
30
+ * triplicaria. O limite é em caracteres, e não em linhas de CSS, porque a decisão precisa acontecer
31
+ * antes de renderizar: `line-clamp` sozinho esconde o texto mas ainda paga o layout inteiro.
32
+ */
33
+ const COLLAPSE_ABOVE_CHARS = 320
34
+
35
+ /** Linhas visíveis quando recolhido — o bastante para saber do que o cliente está falando. */
36
+ const COLLAPSED_LINE_CLAMP = 4
37
+
38
+ /**
39
+ * Transcrição sob a nota de voz, com botão de copiar.
40
+ *
41
+ * Copiar é o motivo de o bloco existir: o operador cola o pedido do cliente no sistema interno, no
42
+ * orçamento, na busca. Seleção manual de texto dentro de um balão de chat é exatamente onde o
43
+ * arrasto do mouse falha — pega o balão vizinho, o horário, o nome do remetente.
44
+ *
45
+ * `navigator.clipboard.writeText` com try/catch silencioso, mesmo padrão de `MessageText.tsx`: a API
46
+ * exige contexto seguro e permissão, e um host servindo em HTTP simples não a tem. Falhar sem alarme
47
+ * é o certo — o texto continua na tela para seleção manual.
48
+ */
49
+ export function AudioTranscription({ transcription, onTranscribe, isMine = false }: AudioTranscriptionProps) {
50
+ const { transcription: locales } = useConversationLocales()
51
+ const [hasCopied, setHasCopied] = useState(false)
52
+ const [isTranscribing, setIsTranscribing] = useState(false)
53
+ const [hasRequestFailed, setHasRequestFailed] = useState(false)
54
+ /**
55
+ * Resultado que acabou de voltar do backend, exibido antes de a lista ser refeita.
56
+ *
57
+ * Tem precedência sobre a prop porque é o dado mais novo: o refetch, quando vier, traz a mesma
58
+ * coisa. Sem isto o clique terminaria em nada visível.
59
+ */
60
+ const [justTranscribed, setJustTranscribed] = useState<MessageTranscription | undefined>()
61
+ const [isExpanded, setIsExpanded] = useState(false)
62
+
63
+ const effective = justTranscribed ?? transcription
64
+ const text = effective?.text?.trim() ?? ''
65
+ const status = effective?.status
66
+
67
+ // Nasce recolhido e o operador decide: abrir tudo por padrão faria a nota de voz longa esconder as
68
+ // mensagens seguintes, que é justamente o contexto de que ele precisa para responder.
69
+ const isLong = text.length > COLLAPSE_ABOVE_CHARS
70
+ const isTruncated = isLong && !isExpanded
71
+ const isDone = status === 'done'
72
+ const hasText = isDone && text.length > 0
73
+ // Silêncio já processado: dizer "sem fala detectada" evita o operador clicar em transcrever de novo
74
+ // atrás de um texto que não existe.
75
+ const isSilent = isDone && text.length === 0
76
+
77
+ const handleCopy = useCallback(async () => {
78
+ if (!text) return
79
+ try {
80
+ await navigator.clipboard.writeText(text)
81
+ setHasCopied(true)
82
+ setTimeout(() => setHasCopied(false), COPIED_FEEDBACK_MS)
83
+ } catch {
84
+ // Clipboard indisponível (contexto não seguro, permissão negada) — o texto segue selecionável.
85
+ }
86
+ }, [text])
87
+
88
+ const handleTranscribe = useCallback(async () => {
89
+ if (!onTranscribe || isTranscribing) return
90
+ setIsTranscribing(true)
91
+ setHasRequestFailed(false)
92
+ try {
93
+ const result = await onTranscribe()
94
+ if (result) setJustTranscribed(result)
95
+ } catch {
96
+ setHasRequestFailed(true)
97
+ } finally {
98
+ setIsTranscribing(false)
99
+ }
100
+ }, [onTranscribe, isTranscribing])
101
+
102
+ const dividerClass = isMine ? 'border-black/10 dark:border-white/10' : 'border-black/10 dark:border-white/10'
103
+
104
+ // Nada avaliado e sem rota para pedir: o balão fica como era antes da transcrição existir.
105
+ if (!status && !onTranscribe) return null
106
+
107
+ if (!status || status === 'pending' || status === 'failed') {
108
+ return (
109
+ <div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
110
+ <TranscribeButton
111
+ label={resolveActionLabel({ status, hasRequestFailed, isTranscribing, locales })}
112
+ isBusy={isTranscribing}
113
+ onClick={handleTranscribe}
114
+ isDisabled={!onTranscribe}
115
+ />
116
+ </div>
117
+ )
118
+ }
119
+
120
+ if (status === 'unsupported') {
121
+ return (
122
+ <div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
123
+ <p className="text-xs italic text-gray-500 dark:text-gray-400">{locales.unsupported}</p>
124
+ </div>
125
+ )
126
+ }
127
+
128
+ return (
129
+ <div className={`mt-1.5 border-t pt-1.5 ${dividerClass}`}>
130
+ <div className="mb-0.5 flex items-center gap-1.5">
131
+ <FileText size={11} className="flex-shrink-0 text-gray-500 dark:text-gray-400" aria-hidden />
132
+ <span className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
133
+ {locales.label}
134
+ </span>
135
+
136
+ {hasText && (
137
+ <button
138
+ onClick={handleCopy}
139
+ title={locales.copy}
140
+ aria-label={hasCopied ? locales.copied : locales.copy}
141
+ className="ml-auto flex flex-shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
142
+ >
143
+ {hasCopied ? (
144
+ <>
145
+ <Check size={11} aria-hidden />
146
+ <span>{locales.copied}</span>
147
+ </>
148
+ ) : (
149
+ <>
150
+ <Copy size={11} aria-hidden />
151
+ <span>{locales.copy}</span>
152
+ </>
153
+ )}
154
+ </button>
155
+ )}
156
+ </div>
157
+
158
+ {isSilent ? (
159
+ <p className="text-xs italic text-gray-500 dark:text-gray-400">{locales.empty}</p>
160
+ ) : (
161
+ <>
162
+ {/* `select-all` para o clique único selecionar tudo: é o fallback quando o clipboard não
163
+ está disponível, e o caminho de quem prefere copiar com o teclado. */}
164
+ <p
165
+ className={cn(
166
+ 'select-all whitespace-pre-wrap break-words text-[13px] leading-[18px] text-gray-700 dark:text-gray-200',
167
+ isTruncated && 'overflow-hidden',
168
+ )}
169
+ style={
170
+ isTruncated
171
+ ? {
172
+ display: '-webkit-box',
173
+ WebkitBoxOrient: 'vertical',
174
+ WebkitLineClamp: COLLAPSED_LINE_CLAMP,
175
+ }
176
+ : undefined
177
+ }
178
+ >
179
+ {text}
180
+ </p>
181
+ {/* O botão de copiar leva o texto INTEIRO, recolhido ou não — recolher é sobre altura na
182
+ tela, não sobre o que o operador cola no sistema interno. */}
183
+ {isLong && (
184
+ <button
185
+ onClick={() => setIsExpanded((current) => !current)}
186
+ className="mt-1 text-[11px] font-medium text-gray-500 underline decoration-dotted hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
187
+ >
188
+ {isExpanded ? locales.showLess : locales.showMore}
189
+ </button>
190
+ )}
191
+ </>
192
+ )}
193
+
194
+ {/* Retranscrever fica escondido atrás do hover do balão: é ação rara e paga cota de engine. */}
195
+ {onTranscribe && (
196
+ <button
197
+ onClick={handleTranscribe}
198
+ disabled={isTranscribing}
199
+ className="mt-1 flex items-center gap-1 text-[11px] text-gray-400 opacity-0 transition-opacity hover:text-gray-600 focus:opacity-100 group-hover:opacity-100 disabled:opacity-50 dark:text-gray-500 dark:hover:text-gray-300"
200
+ >
201
+ {isTranscribing ? <Loader2 size={10} className="animate-spin" aria-hidden /> : <RefreshCw size={10} aria-hidden />}
202
+ <span>{isTranscribing ? locales.transcribing : locales.retry}</span>
203
+ </button>
204
+ )}
205
+ </div>
206
+ )
207
+ }
208
+
209
+ function resolveActionLabel(params: {
210
+ status?: MessageTranscription['status']
211
+ hasRequestFailed: boolean
212
+ isTranscribing: boolean
213
+ locales: { transcribe: string; transcribing: string; retry: string; failed: string }
214
+ }): string {
215
+ if (params.isTranscribing) return params.locales.transcribing
216
+ if (params.hasRequestFailed) return params.locales.retry
217
+ // `pending` é cota estourada ou falha transitória do lado do servidor: já foi tentado e vai sair,
218
+ // mas o operador que não quer esperar a retomada pode forçar agora.
219
+ if (params.status === 'pending') return params.locales.transcribing
220
+ if (params.status === 'failed') return params.locales.failed
221
+ return params.locales.transcribe
222
+ }
223
+
224
+ function TranscribeButton({
225
+ label,
226
+ isBusy,
227
+ isDisabled,
228
+ onClick,
229
+ }: {
230
+ label: string
231
+ isBusy: boolean
232
+ isDisabled: boolean
233
+ onClick: () => void
234
+ }) {
235
+ return (
236
+ <button
237
+ onClick={onClick}
238
+ disabled={isBusy || isDisabled}
239
+ className="flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11px] text-gray-500 transition-colors hover:bg-black/5 hover:text-gray-700 disabled:opacity-60 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200"
240
+ >
241
+ {isBusy ? (
242
+ <Loader2 size={11} className="animate-spin" aria-hidden />
243
+ ) : (
244
+ <FileText size={11} aria-hidden />
245
+ )}
246
+ <span>{label}</span>
247
+ </button>
248
+ )
249
+ }