@adatechnology/conversations-ui 0.1.0-rc.12 → 0.1.0-rc.14

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.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Gravação de áudio no simulador, pelo microfone do próprio navegador.
3
+ *
4
+ * Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
5
+ * poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
6
+ *
7
+ * O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
8
+ * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react'
12
+
13
+ export interface AudioRecorderButtonLabels {
14
+ start: string
15
+ stop: string
16
+ unsupported: string
17
+ denied: string
18
+ review: string
19
+ send: string
20
+ discard: string
21
+ empty: string
22
+ }
23
+
24
+ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
25
+ start: 'Gravar áudio',
26
+ stop: 'Parar gravação',
27
+ unsupported: 'Este navegador não grava áudio.',
28
+ denied: 'Sem permissão para usar o microfone.',
29
+ review: 'Ouça antes de enviar',
30
+ send: 'Enviar áudio',
31
+ discard: 'Descartar áudio',
32
+ empty: 'Nada foi captado pelo microfone.',
33
+ }
34
+
35
+ export interface AudioRecorderButtonProps {
36
+ onRecorded: (file: File) => void | Promise<void>
37
+ onFailure?: (message: string) => void
38
+ /**
39
+ * Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
40
+ * envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
41
+ * que o microfone está quebrado.
42
+ */
43
+ onRecordingChange?: (isRecording: boolean) => void
44
+ /**
45
+ * Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
46
+ * confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
47
+ * sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
48
+ */
49
+ reviewBeforeSend?: boolean
50
+ /**
51
+ * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
52
+ * tem. Produto com limite próprio sobrescreve.
53
+ */
54
+ maxDurationMilliseconds?: number
55
+ labels?: Partial<AudioRecorderButtonLabels>
56
+ disabled?: boolean
57
+ }
58
+
59
+ /**
60
+ * Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
61
+ * 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
62
+ * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
63
+ */
64
+ export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
65
+
66
+ /**
67
+ * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
68
+ * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
69
+ * do envio que o formato é inválido é pior do que gravar já no formato certo.
70
+ */
71
+ const RECORDING_FORMATS = [
72
+ { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
73
+ { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
74
+ { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
75
+ ] as const
76
+
77
+ export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
78
+
79
+ /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
80
+ export function resolveRecordingFormat(): RecordingFormat | undefined {
81
+ if (typeof MediaRecorder === 'undefined') return undefined
82
+ // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
83
+ if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
84
+ return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
85
+ }
86
+
87
+ type PendingRecording = { file: File; objectURL: string }
88
+
89
+ export function AudioRecorderButton({
90
+ onRecorded,
91
+ onFailure,
92
+ onRecordingChange,
93
+ reviewBeforeSend = true,
94
+ maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
95
+ labels,
96
+ disabled,
97
+ }: AudioRecorderButtonProps) {
98
+ const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
99
+ labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
100
+ const [isRecording, setIsRecording] = useState(false)
101
+ const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
102
+ const recorderRef = useRef<MediaRecorder | null>(null)
103
+ const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
104
+
105
+ // O `objectURL` é um recurso do documento, não do React: sem revogar, cada gravação descartada
106
+ // deixa o blob inteiro preso na memória da aba até um reload.
107
+ const discard = useCallback(() => {
108
+ setPending((current) => {
109
+ if (current) URL.revokeObjectURL(current.objectURL)
110
+ return undefined
111
+ })
112
+ }, [])
113
+
114
+ useEffect(() => discard, [discard])
115
+
116
+ const confirm = useCallback(() => {
117
+ if (!pending) return
118
+ void onRecorded(pending.file)
119
+ discard()
120
+ }, [discard, onRecorded, pending])
121
+
122
+ const stop = useCallback(() => {
123
+ recorderRef.current?.stop()
124
+ }, [])
125
+
126
+ const start = useCallback(async () => {
127
+ const format = resolveRecordingFormat()
128
+ if (!format || !navigator.mediaDevices?.getUserMedia) {
129
+ onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
130
+ return
131
+ }
132
+
133
+ try {
134
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
135
+ const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
136
+ const chunks: Blob[] = []
137
+
138
+ recorder.addEventListener('dataavailable', (event) => {
139
+ if (event.data.size > 0) chunks.push(event.data)
140
+ })
141
+ recorder.addEventListener('stop', () => {
142
+ // Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
143
+ // aceso depois do envio, e o operador acha que o simulador continua ouvindo.
144
+ stream.getTracks().forEach((track) => track.stop())
145
+ clearTimeout(autoStopRef.current)
146
+ setIsRecording(false)
147
+ onRecordingChange?.(false)
148
+ recorderRef.current = null
149
+ // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
150
+ // gravador, mas quem valida upload compara com `audio/ogg` puro.
151
+ const blob = new Blob(chunks, { type: format.uploadMimeType })
152
+ const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
153
+ type: format.uploadMimeType,
154
+ })
155
+
156
+ // Gravação vazia não vira anexo: microfone mudo ou permissão revogada no meio produzem um
157
+ // blob de zero byte, e mandá-lo adiante só falha lá na frente, sem dizer por quê.
158
+ if (blob.size === 0) {
159
+ onFailure?.(labelOf('empty'))
160
+ return
161
+ }
162
+
163
+ if (!reviewBeforeSend) {
164
+ void onRecorded(file)
165
+ return
166
+ }
167
+
168
+ setPending({ file, objectURL: URL.createObjectURL(blob) })
169
+ })
170
+
171
+ recorderRef.current = recorder
172
+ recorder.start()
173
+ autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
174
+ setIsRecording(true)
175
+ onRecordingChange?.(true)
176
+ } catch {
177
+ onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
178
+ }
179
+ // `labelOf` lê `labels` a cada render e não entra aqui; o que importa para recriar o gravador
180
+ // são o teto de duração, o destino da gravação e se há etapa de revisão.
181
+ // eslint-disable-next-line react-hooks/exhaustive-deps
182
+ }, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend])
183
+
184
+ const toggleLabel = isRecording ? labelOf('stop') : labelOf('start')
185
+
186
+ return (
187
+ /* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
188
+ na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
189
+ conversa saltar. */
190
+ <div className="relative flex-shrink-0">
191
+ {pending && (
192
+ <div
193
+ role="group"
194
+ aria-label={labelOf('review')}
195
+ className="absolute bottom-full right-0 z-20 mb-2 flex w-64 items-center gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
196
+ >
197
+ <audio src={pending.objectURL} controls className="h-8 min-w-0 flex-1" />
198
+ <button
199
+ type="button"
200
+ onClick={discard}
201
+ title={labelOf('discard')}
202
+ aria-label={labelOf('discard')}
203
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
204
+ >
205
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
206
+ </button>
207
+ <button
208
+ type="button"
209
+ onClick={confirm}
210
+ title={labelOf('send')}
211
+ aria-label={labelOf('send')}
212
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
213
+ >
214
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
215
+ </button>
216
+ </div>
217
+ )}
218
+ <button
219
+ type="button"
220
+ disabled={disabled || pending !== undefined}
221
+ onClick={() => (isRecording ? stop() : void start())}
222
+ title={toggleLabel}
223
+ aria-label={toggleLabel}
224
+ aria-pressed={isRecording}
225
+ /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
226
+ vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
227
+ className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${
228
+ isRecording
229
+ ? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
230
+ : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
231
+ }`}
232
+ >
233
+ {isRecording ? (
234
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
235
+ ) : (
236
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
237
+ )}
238
+ </button>
239
+ </div>
240
+ )
241
+ }
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { resolveRecordingFormat } from './AudioRecorderButton'
4
- import { DEFAULT_ACCEPTED_FILE_TYPES } from '../MessageComposer'
4
+ import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
5
5
 
6
6
  const originalMediaRecorder = (globalThis as Record<string, unknown>).MediaRecorder
7
7
 
package/src/index.ts CHANGED
@@ -15,6 +15,12 @@ export type {
15
15
  RichComposerVariable,
16
16
  RichMessageComposerHandle,
17
17
  } from './RichMessageComposer'
18
+ export {
19
+ AudioRecorderButton,
20
+ DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
21
+ DEFAULT_MAX_RECORDING_MILLISECONDS,
22
+ } from './AudioRecorderButton'
23
+ export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from './AudioRecorderButton'
18
24
  export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
19
25
  export { SimpleEmojiPicker } from './SimpleEmojiPicker'
20
26
  export { DateDivider } from './DateDivider'
@@ -20,7 +20,7 @@ import { MessageComposer } from '../MessageComposer'
20
20
  import { DateDivider } from '../DateDivider'
21
21
  import { ConversationWallpaper } from '../Wallpaper'
22
22
  import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
23
- import { AudioRecorderButton } from './AudioRecorderButton'
23
+ import { AudioRecorderButton } from '../AudioRecorderButton'
24
24
 
25
25
  export type ConversationPreviewProps = {
26
26
  client: PreviewWebhookClient
@@ -305,7 +305,7 @@ export function ConversationPreview({
305
305
  que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
306
306
  microfone está quebrado. */
307
307
  placeholder={
308
- isRecording ? 'Gravando… toque no quadrado para enviar' : (placeholder ?? 'Escreva como o cliente…')
308
+ isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
309
309
  }
310
310
  idleAction={
311
311
  uploadMedia ? (
@@ -29,8 +29,8 @@ export { ConversationPreview } from './ConversationPreview'
29
29
  export { mediaTypeOf } from './ConversationPreview'
30
30
  export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
31
31
 
32
- export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from './AudioRecorderButton'
33
- export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from './AudioRecorderButton'
32
+ export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
33
+ export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
34
34
 
35
35
  export {
36
36
  createPreviewWebhookClient,
@@ -1,155 +0,0 @@
1
- /**
2
- * Gravação de áudio no simulador, pelo microfone do próprio navegador.
3
- *
4
- * Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
5
- * poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
6
- *
7
- * O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
8
- * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
- */
10
-
11
- import { useCallback, useRef, useState } from 'react'
12
-
13
- export interface AudioRecorderButtonLabels {
14
- start: string
15
- stop: string
16
- unsupported: string
17
- denied: string
18
- }
19
-
20
- export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
21
- start: 'Gravar áudio',
22
- stop: 'Parar gravação',
23
- unsupported: 'Este navegador não grava áudio.',
24
- denied: 'Sem permissão para usar o microfone.',
25
- }
26
-
27
- export interface AudioRecorderButtonProps {
28
- onRecorded: (file: File) => void | Promise<void>
29
- onFailure?: (message: string) => void
30
- /**
31
- * Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
32
- * envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
33
- * que o microfone está quebrado.
34
- */
35
- onRecordingChange?: (isRecording: boolean) => void
36
- /**
37
- * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
38
- * tem. Produto com limite próprio sobrescreve.
39
- */
40
- maxDurationMilliseconds?: number
41
- labels?: Partial<AudioRecorderButtonLabels>
42
- disabled?: boolean
43
- }
44
-
45
- /**
46
- * Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
47
- * 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
48
- * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
49
- */
50
- export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
51
-
52
- /**
53
- * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
54
- * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
55
- * do envio que o formato é inválido é pior do que gravar já no formato certo.
56
- */
57
- const RECORDING_FORMATS = [
58
- { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
59
- { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
60
- { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
61
- ] as const
62
-
63
- export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
64
-
65
- /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
66
- export function resolveRecordingFormat(): RecordingFormat | undefined {
67
- if (typeof MediaRecorder === 'undefined') return undefined
68
- // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
69
- if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
70
- return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
71
- }
72
-
73
- export function AudioRecorderButton({
74
- onRecorded,
75
- onFailure,
76
- onRecordingChange,
77
- maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
78
- labels,
79
- disabled,
80
- }: AudioRecorderButtonProps) {
81
- const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
82
- const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
83
- const [isRecording, setIsRecording] = useState(false)
84
- const recorderRef = useRef<MediaRecorder | null>(null)
85
- const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
86
-
87
- const stop = useCallback(() => {
88
- recorderRef.current?.stop()
89
- }, [])
90
-
91
- const start = useCallback(async () => {
92
- const format = resolveRecordingFormat()
93
- if (!format || !navigator.mediaDevices?.getUserMedia) {
94
- onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
95
- return
96
- }
97
-
98
- try {
99
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
100
- const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
101
- const chunks: Blob[] = []
102
-
103
- recorder.addEventListener('dataavailable', (event) => {
104
- if (event.data.size > 0) chunks.push(event.data)
105
- })
106
- recorder.addEventListener('stop', () => {
107
- // Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
108
- // aceso depois do envio, e o operador acha que o simulador continua ouvindo.
109
- stream.getTracks().forEach((track) => track.stop())
110
- clearTimeout(autoStopRef.current)
111
- setIsRecording(false)
112
- onRecordingChange?.(false)
113
- recorderRef.current = null
114
- // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
115
- // gravador, mas quem valida upload compara com `audio/ogg` puro.
116
- const blob = new Blob(chunks, { type: format.uploadMimeType })
117
- void onRecorded(
118
- new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType }),
119
- )
120
- })
121
-
122
- recorderRef.current = recorder
123
- recorder.start()
124
- autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
125
- setIsRecording(true)
126
- onRecordingChange?.(true)
127
- } catch {
128
- onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
129
- }
130
- }, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange])
131
-
132
- return (
133
- <button
134
- type="button"
135
- disabled={disabled}
136
- onClick={() => (isRecording ? stop() : void start())}
137
- title={isRecording ? stopLabel : startLabel}
138
- aria-label={isRecording ? stopLabel : startLabel}
139
- aria-pressed={isRecording}
140
- /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
141
- vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
142
- className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${
143
- isRecording
144
- ? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
145
- : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
146
- }`}
147
- >
148
- {isRecording ? (
149
- <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
150
- ) : (
151
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
152
- )}
153
- </button>
154
- )
155
- }