@adatechnology/conversations-ui 0.1.0-rc.10 → 0.1.0-rc.12
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-FWNAWAJP.js → chunk-3RKFU46R.js} +35 -0
- package/dist/index.d.ts +103 -2
- package/dist/index.js +694 -435
- package/dist/preview/index.d.ts +12 -1
- package/dist/preview/index.js +21 -6
- package/package.json +1 -1
- package/src/MessageComposer.tsx +73 -0
- package/src/RichMessageComposer.test.tsx +83 -0
- package/src/RichMessageComposer.tsx +379 -0
- package/src/index.ts +11 -0
- package/src/preview/AudioRecorderButton.tsx +33 -3
- package/src/preview/ConversationPreview.tsx +8 -1
- package/src/quickReply.test.ts +58 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barra de composição do atendente: texto rico com a formatação do WhatsApp, respostas rápidas e
|
|
3
|
+
* anexos.
|
|
4
|
+
*
|
|
5
|
+
* Distinto do `MessageComposer`, que é um `textarea` simples: aqui o operador vê o negrito em
|
|
6
|
+
* negrito enquanto escreve, e não os asteriscos. Quem só precisa de uma caixa de texto continua no
|
|
7
|
+
* `MessageComposer`.
|
|
8
|
+
*
|
|
9
|
+
* Nada aqui sabe de produto. O que aparece na barra é decidido por `toolbar`, o texto de cada dica
|
|
10
|
+
* por `tooltips`, e as respostas rápidas chegam prontas por `quickReplies` — rótulo e conteúdo são
|
|
11
|
+
* do host, a mecânica é daqui.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type KeyboardEvent, type ChangeEvent, type FormEvent, type ReactNode } from 'react'
|
|
15
|
+
import { Bold, Italic, Strikethrough, Code, Paperclip, SendHorizonal, Braces } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
import { cn } from './lib/cn'
|
|
18
|
+
import { htmlToWA, waToHTML } from './lib/whatsapp-formatting'
|
|
19
|
+
import { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
20
|
+
import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
21
|
+
|
|
22
|
+
/** Cada ação que a barra sabe oferecer. `toolbar` decide quais delas aparecem. */
|
|
23
|
+
export const RICH_COMPOSER_ACTION = {
|
|
24
|
+
BOLD: 'bold',
|
|
25
|
+
ITALIC: 'italic',
|
|
26
|
+
STRIKETHROUGH: 'strikethrough',
|
|
27
|
+
MONOSPACE: 'monospace',
|
|
28
|
+
EMOJI: 'emoji',
|
|
29
|
+
ATTACH: 'attach',
|
|
30
|
+
VARIABLES: 'variables',
|
|
31
|
+
} as const
|
|
32
|
+
export type RichComposerAction = (typeof RICH_COMPOSER_ACTION)[keyof typeof RICH_COMPOSER_ACTION]
|
|
33
|
+
|
|
34
|
+
/** Texto das dicas. O host troca só o que quiser; o resto fica no padrão. */
|
|
35
|
+
export interface RichComposerTooltips {
|
|
36
|
+
bold: string
|
|
37
|
+
italic: string
|
|
38
|
+
strikethrough: string
|
|
39
|
+
monospace: string
|
|
40
|
+
emoji: string
|
|
41
|
+
attach: string
|
|
42
|
+
send: string
|
|
43
|
+
variables: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
|
|
47
|
+
bold: 'Negrito',
|
|
48
|
+
italic: 'Itálico',
|
|
49
|
+
strikethrough: 'Tachado',
|
|
50
|
+
monospace: 'Monoespaçado',
|
|
51
|
+
emoji: 'Emojis',
|
|
52
|
+
attach: 'Anexar arquivo',
|
|
53
|
+
send: 'Enviar',
|
|
54
|
+
variables: 'Variáveis disponíveis',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Um valor que o operador pode inserir no texto sem digitar. `value` é o que entra no campo — pode
|
|
59
|
+
* ser o dado já resolvido ("Anderson") ou um marcador a resolver depois ("{{nome}}"): quem decide é
|
|
60
|
+
* o host, porque só ele sabe se a substituição acontece aqui ou no envio.
|
|
61
|
+
*/
|
|
62
|
+
export interface RichComposerVariable {
|
|
63
|
+
id: string
|
|
64
|
+
label: string
|
|
65
|
+
value: string
|
|
66
|
+
/** Prévia do que será inserido, para o operador conferir antes de tocar. */
|
|
67
|
+
tooltip?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Uma resposta rápida: o que o operador lê no chip e o que cai no campo ao tocar nele. */
|
|
71
|
+
export interface RichComposerQuickReply {
|
|
72
|
+
id: string
|
|
73
|
+
label: string
|
|
74
|
+
text: string
|
|
75
|
+
/** Dica ao passar o mouse. Sem isto, o chip não tem `title` — rótulo curto já se explica. */
|
|
76
|
+
tooltip?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cada formatação vira o elemento correspondente dentro do campo, não os asteriscos crus: o ponto
|
|
81
|
+
* do editor rico é o operador ver o negrito em negrito. A conversão para a notação do WhatsApp
|
|
82
|
+
* acontece na saída, no `htmlToWA`.
|
|
83
|
+
*/
|
|
84
|
+
const FORMATTING_ELEMENT = {
|
|
85
|
+
bold: 'strong',
|
|
86
|
+
italic: 'em',
|
|
87
|
+
strikethrough: 'del',
|
|
88
|
+
monospace: 'code',
|
|
89
|
+
} as const
|
|
90
|
+
|
|
91
|
+
const MONOSPACE_CLASS = 'bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm'
|
|
92
|
+
|
|
93
|
+
/** Handle imperativo: `contentEditable` controlado pelo React perde o cursor a cada tecla. */
|
|
94
|
+
export interface RichMessageComposerHandle {
|
|
95
|
+
setContent: (text: string) => void
|
|
96
|
+
clear: () => void
|
|
97
|
+
focus: () => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RichMessageComposerProps {
|
|
101
|
+
/** Texto na notação do WhatsApp (`*negrito*`), não HTML. */
|
|
102
|
+
value: string
|
|
103
|
+
onChange: (value: string) => void
|
|
104
|
+
onSend: () => void
|
|
105
|
+
onAttachFiles?: (files: FileList) => void
|
|
106
|
+
quickReplies?: RichComposerQuickReply[]
|
|
107
|
+
/** Variáveis que o operador pode inserir no texto. Vazio ou ausente, o botão não aparece. */
|
|
108
|
+
variables?: RichComposerVariable[]
|
|
109
|
+
/**
|
|
110
|
+
* Quais ações aparecem. Ausente, todas aparecem — menos as que não têm como funcionar (anexo sem
|
|
111
|
+
* `onAttachFiles` some sozinho, porque botão que não faz nada é pior que botão nenhum).
|
|
112
|
+
*/
|
|
113
|
+
toolbar?: Partial<Record<RichComposerAction, boolean>>
|
|
114
|
+
tooltips?: Partial<RichComposerTooltips>
|
|
115
|
+
placeholder?: string
|
|
116
|
+
disabled?: boolean
|
|
117
|
+
isSending?: boolean
|
|
118
|
+
/** Ocupa o lugar do enviar enquanto não há nada para enviar — onde o WhatsApp põe o microfone. */
|
|
119
|
+
idleAction?: ReactNode
|
|
120
|
+
/** Prévia dos anexos já escolhidos, desenhada pelo host acima da barra. */
|
|
121
|
+
attachmentsPreview?: ReactNode
|
|
122
|
+
acceptedFileTypes?: string
|
|
123
|
+
className?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMessageComposerProps>(function RichMessageComposer({
|
|
127
|
+
value,
|
|
128
|
+
onChange,
|
|
129
|
+
onSend,
|
|
130
|
+
onAttachFiles,
|
|
131
|
+
quickReplies,
|
|
132
|
+
variables,
|
|
133
|
+
toolbar,
|
|
134
|
+
tooltips,
|
|
135
|
+
placeholder,
|
|
136
|
+
disabled = false,
|
|
137
|
+
isSending = false,
|
|
138
|
+
idleAction,
|
|
139
|
+
attachmentsPreview,
|
|
140
|
+
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
141
|
+
className,
|
|
142
|
+
}, ref) {
|
|
143
|
+
const editorRef = useRef<HTMLDivElement>(null)
|
|
144
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
145
|
+
const variablesRef = useRef<HTMLDivElement>(null)
|
|
146
|
+
const [isVariablesOpen, setIsVariablesOpen] = useState(false)
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!isVariablesOpen) return
|
|
150
|
+
const closeOnOutsideClick = (event: MouseEvent) => {
|
|
151
|
+
if (!variablesRef.current?.contains(event.target as Node)) setIsVariablesOpen(false)
|
|
152
|
+
}
|
|
153
|
+
document.addEventListener('mousedown', closeOnOutsideClick)
|
|
154
|
+
return () => document.removeEventListener('mousedown', closeOnOutsideClick)
|
|
155
|
+
}, [isVariablesOpen])
|
|
156
|
+
|
|
157
|
+
const tooltipOf = (action: keyof RichComposerTooltips): string =>
|
|
158
|
+
tooltips?.[action] ?? DEFAULT_RICH_COMPOSER_TOOLTIPS[action]
|
|
159
|
+
const shows = (action: RichComposerAction): boolean => toolbar?.[action] !== false
|
|
160
|
+
|
|
161
|
+
/** Escreve no campo sem passar pelo React: `contentEditable` controlado perde o cursor a cada tecla. */
|
|
162
|
+
const replaceContent = useCallback((text: string) => {
|
|
163
|
+
const editor = editorRef.current
|
|
164
|
+
if (!editor) return
|
|
165
|
+
editor.innerHTML = waToHTML(text)
|
|
166
|
+
onChange(htmlToWA(editor.innerHTML))
|
|
167
|
+
editor.focus()
|
|
168
|
+
}, [onChange])
|
|
169
|
+
|
|
170
|
+
useImperativeHandle(ref, () => ({
|
|
171
|
+
setContent: replaceContent,
|
|
172
|
+
clear: () => {
|
|
173
|
+
const editor = editorRef.current
|
|
174
|
+
if (!editor) return
|
|
175
|
+
editor.innerHTML = ''
|
|
176
|
+
onChange('')
|
|
177
|
+
},
|
|
178
|
+
focus: () => editorRef.current?.focus(),
|
|
179
|
+
}), [onChange, replaceContent])
|
|
180
|
+
|
|
181
|
+
/** Range vivo dentro do campo, ou `undefined` se o cursor está em outro lugar da página. */
|
|
182
|
+
const currentRange = useCallback((): Range | undefined => {
|
|
183
|
+
const editor = editorRef.current
|
|
184
|
+
if (!editor) return undefined
|
|
185
|
+
editor.focus()
|
|
186
|
+
const selection = window.getSelection()
|
|
187
|
+
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return undefined
|
|
188
|
+
return selection.getRangeAt(0)
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
const commitRange = useCallback((range: Range, node: Node) => {
|
|
192
|
+
const selection = window.getSelection()
|
|
193
|
+
range.setStartAfter(node)
|
|
194
|
+
range.collapse(true)
|
|
195
|
+
selection?.removeAllRanges()
|
|
196
|
+
selection?.addRange(range)
|
|
197
|
+
onChange(htmlToWA(editorRef.current?.innerHTML ?? ''))
|
|
198
|
+
}, [onChange])
|
|
199
|
+
|
|
200
|
+
const insertAtCursor = useCallback((fragment: string) => {
|
|
201
|
+
const range = currentRange()
|
|
202
|
+
if (!range) return
|
|
203
|
+
range.deleteContents()
|
|
204
|
+
const textNode = document.createTextNode(fragment)
|
|
205
|
+
range.insertNode(textNode)
|
|
206
|
+
commitRange(range, textNode)
|
|
207
|
+
}, [commitRange, currentRange])
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Envolve a seleção no elemento da formatação. Sem seleção não faz nada: abrir a marcação e
|
|
211
|
+
* esperar que o operador digite dentro dela é o caminho que sai com marcador sobrando quando ele
|
|
212
|
+
* clica noutro lugar antes.
|
|
213
|
+
*/
|
|
214
|
+
const wrapSelection = useCallback((action: keyof typeof FORMATTING_ELEMENT) => {
|
|
215
|
+
const range = currentRange()
|
|
216
|
+
const selectedText = range?.toString()
|
|
217
|
+
if (!range || !selectedText) return
|
|
218
|
+
range.deleteContents()
|
|
219
|
+
const wrapper = document.createElement(FORMATTING_ELEMENT[action])
|
|
220
|
+
if (action === 'monospace') wrapper.className = MONOSPACE_CLASS
|
|
221
|
+
wrapper.textContent = selectedText
|
|
222
|
+
range.insertNode(wrapper)
|
|
223
|
+
commitRange(range, wrapper)
|
|
224
|
+
}, [commitRange, currentRange])
|
|
225
|
+
|
|
226
|
+
const handleInput = useCallback((event: FormEvent<HTMLDivElement>) => {
|
|
227
|
+
onChange(htmlToWA(event.currentTarget.innerHTML))
|
|
228
|
+
}, [onChange])
|
|
229
|
+
|
|
230
|
+
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
|
231
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
232
|
+
event.preventDefault()
|
|
233
|
+
if (!disabled) onSend()
|
|
234
|
+
}
|
|
235
|
+
}, [disabled, onSend])
|
|
236
|
+
|
|
237
|
+
const handleFileChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
238
|
+
if (event.target.files?.length) onAttachFiles?.(event.target.files)
|
|
239
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
240
|
+
}, [onAttachFiles])
|
|
241
|
+
|
|
242
|
+
const canSend = value.trim().length > 0
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<div className={cn('flex flex-col', className)}>
|
|
246
|
+
{attachmentsPreview}
|
|
247
|
+
|
|
248
|
+
{quickReplies?.length ? (
|
|
249
|
+
/* Uma linha rolável no celular, quebrando em várias no desktop: empilhar os chips no
|
|
250
|
+
celular come a altura da conversa, que é o que o operador precisa ver. */
|
|
251
|
+
<div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
252
|
+
{quickReplies.map((quickReply) => (
|
|
253
|
+
<button
|
|
254
|
+
key={quickReply.id}
|
|
255
|
+
type="button"
|
|
256
|
+
title={quickReply.tooltip}
|
|
257
|
+
onClick={() => replaceContent(quickReply.text)}
|
|
258
|
+
className="whitespace-nowrap rounded-full border border-gray-200 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-teal-800 dark:hover:bg-teal-950/40 dark:hover:text-teal-400"
|
|
259
|
+
>
|
|
260
|
+
{quickReply.label}
|
|
261
|
+
</button>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
|
|
266
|
+
<div className="flex flex-wrap items-end gap-2">
|
|
267
|
+
{/* Só no desktop: no celular não há como selecionar texto e tocar no botão sem perder a
|
|
268
|
+
seleção, e a formatação sai digitada à mão de qualquer jeito. */}
|
|
269
|
+
<div className="mb-0.5 hidden items-center gap-0.5 sm:flex">
|
|
270
|
+
{([
|
|
271
|
+
[RICH_COMPOSER_ACTION.BOLD, Bold],
|
|
272
|
+
[RICH_COMPOSER_ACTION.ITALIC, Italic],
|
|
273
|
+
[RICH_COMPOSER_ACTION.STRIKETHROUGH, Strikethrough],
|
|
274
|
+
[RICH_COMPOSER_ACTION.MONOSPACE, Code],
|
|
275
|
+
] as const)
|
|
276
|
+
.filter(([action]) => shows(action))
|
|
277
|
+
.map(([action, Icon]) => (
|
|
278
|
+
<button
|
|
279
|
+
key={action}
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={() => wrapSelection(action)}
|
|
282
|
+
title={tooltipOf(action)}
|
|
283
|
+
aria-label={tooltipOf(action)}
|
|
284
|
+
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
|
|
285
|
+
>
|
|
286
|
+
<Icon size={15} />
|
|
287
|
+
</button>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{shows(RICH_COMPOSER_ACTION.EMOJI) ? (
|
|
292
|
+
<SimpleEmojiPicker onSelect={insertAtCursor} label={tooltipOf('emoji')} />
|
|
293
|
+
) : null}
|
|
294
|
+
|
|
295
|
+
{variables?.length && shows(RICH_COMPOSER_ACTION.VARIABLES) ? (
|
|
296
|
+
<div ref={variablesRef} className="relative">
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
onClick={() => setIsVariablesOpen((open) => !open)}
|
|
300
|
+
title={tooltipOf('variables')}
|
|
301
|
+
aria-label={tooltipOf('variables')}
|
|
302
|
+
aria-expanded={isVariablesOpen}
|
|
303
|
+
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
|
|
304
|
+
>
|
|
305
|
+
<Braces size={18} />
|
|
306
|
+
</button>
|
|
307
|
+
{isVariablesOpen ? (
|
|
308
|
+
<div className="absolute bottom-full left-0 z-20 mb-2 max-h-56 w-64 overflow-y-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
|
309
|
+
{variables.map((variable) => (
|
|
310
|
+
<button
|
|
311
|
+
key={variable.id}
|
|
312
|
+
type="button"
|
|
313
|
+
title={variable.tooltip ?? variable.value}
|
|
314
|
+
onClick={() => { insertAtCursor(variable.value); setIsVariablesOpen(false) }}
|
|
315
|
+
className="flex w-full flex-col items-start px-3 py-1.5 text-left hover:bg-teal-50 dark:hover:bg-teal-950/40"
|
|
316
|
+
>
|
|
317
|
+
<span className="text-sm text-gray-800 dark:text-gray-100">{variable.label}</span>
|
|
318
|
+
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">{variable.value}</span>
|
|
319
|
+
</button>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
) : null}
|
|
323
|
+
</div>
|
|
324
|
+
) : null}
|
|
325
|
+
|
|
326
|
+
{onAttachFiles && shows(RICH_COMPOSER_ACTION.ATTACH) ? (
|
|
327
|
+
<>
|
|
328
|
+
<input
|
|
329
|
+
ref={fileInputRef}
|
|
330
|
+
type="file"
|
|
331
|
+
multiple
|
|
332
|
+
accept={acceptedFileTypes}
|
|
333
|
+
onChange={handleFileChange}
|
|
334
|
+
className="hidden"
|
|
335
|
+
/>
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
onClick={() => fileInputRef.current?.click()}
|
|
339
|
+
title={tooltipOf('attach')}
|
|
340
|
+
aria-label={tooltipOf('attach')}
|
|
341
|
+
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-teal-50 hover:text-teal-600 dark:text-gray-500 dark:hover:bg-teal-900/30 dark:hover:text-teal-400"
|
|
342
|
+
>
|
|
343
|
+
<Paperclip size={18} />
|
|
344
|
+
</button>
|
|
345
|
+
</>
|
|
346
|
+
) : null}
|
|
347
|
+
|
|
348
|
+
<div
|
|
349
|
+
ref={editorRef}
|
|
350
|
+
contentEditable={!disabled}
|
|
351
|
+
suppressContentEditableWarning
|
|
352
|
+
role="textbox"
|
|
353
|
+
aria-multiline="true"
|
|
354
|
+
aria-label={placeholder}
|
|
355
|
+
data-placeholder={placeholder}
|
|
356
|
+
onInput={handleInput}
|
|
357
|
+
onKeyDown={handleKeyDown}
|
|
358
|
+
style={{ minHeight: '36px', maxHeight: '120px' }}
|
|
359
|
+
className="flex-1 min-w-[180px] overflow-y-auto rounded-2xl border border-gray-200 bg-gray-50 px-4 py-2 text-sm text-gray-800 transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-teal-300 dark:border-gray-700 dark:bg-gray-700 dark:text-gray-100 empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 dark:empty:before:text-gray-500 before:pointer-events-none [&_strong]:font-semibold [&_em]:italic [&_del]:line-through [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-sm dark:[&_code]:bg-white/10"
|
|
360
|
+
/>
|
|
361
|
+
|
|
362
|
+
{!canSend && idleAction ? (
|
|
363
|
+
<div className="flex-shrink-0">{idleAction}</div>
|
|
364
|
+
) : (
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
onClick={onSend}
|
|
368
|
+
disabled={disabled || isSending || !canSend}
|
|
369
|
+
title={tooltipOf('send')}
|
|
370
|
+
aria-label={tooltipOf('send')}
|
|
371
|
+
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-teal-600 text-white transition-colors hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-40"
|
|
372
|
+
>
|
|
373
|
+
{isSending ? <span className="text-xs">⏳</span> : <SendHorizonal size={16} />}
|
|
374
|
+
</button>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,15 @@ export { AudioPlayer } from './AudioPlayer'
|
|
|
6
6
|
export { EmojiPicker, DEFAULT_EMOJI_PICKER_LABELS } from './EmojiPicker'
|
|
7
7
|
export { EMOJI_CATEGORIES, searchEmojis } from './emojiCatalog'
|
|
8
8
|
export { MessageComposer, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
9
|
+
export { RichMessageComposer, RICH_COMPOSER_ACTION, DEFAULT_RICH_COMPOSER_TOOLTIPS } from './RichMessageComposer'
|
|
10
|
+
export type {
|
|
11
|
+
RichMessageComposerProps,
|
|
12
|
+
RichComposerAction,
|
|
13
|
+
RichComposerTooltips,
|
|
14
|
+
RichComposerQuickReply,
|
|
15
|
+
RichComposerVariable,
|
|
16
|
+
RichMessageComposerHandle,
|
|
17
|
+
} from './RichMessageComposer'
|
|
9
18
|
export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
|
|
10
19
|
export { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
11
20
|
export { DateDivider } from './DateDivider'
|
|
@@ -181,3 +190,5 @@ export type { ConversationRealtimeHandler } from './hooks/useConversationRealtim
|
|
|
181
190
|
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
182
191
|
export { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
183
192
|
export type { ConversationHeaderUtility } from './ConversationHeader'
|
|
193
|
+
export { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
|
|
194
|
+
export type { QuickReply } from './MessageComposer'
|
|
@@ -27,10 +27,28 @@ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
|
|
|
27
27
|
export interface AudioRecorderButtonProps {
|
|
28
28
|
onRecorded: (file: File) => void | Promise<void>
|
|
29
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
|
|
30
41
|
labels?: Partial<AudioRecorderButtonLabels>
|
|
31
42
|
disabled?: boolean
|
|
32
43
|
}
|
|
33
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
|
+
|
|
34
52
|
/**
|
|
35
53
|
* Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
|
|
36
54
|
* saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
|
|
@@ -52,11 +70,19 @@ export function resolveRecordingFormat(): RecordingFormat | undefined {
|
|
|
52
70
|
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
export function AudioRecorderButton({
|
|
73
|
+
export function AudioRecorderButton({
|
|
74
|
+
onRecorded,
|
|
75
|
+
onFailure,
|
|
76
|
+
onRecordingChange,
|
|
77
|
+
maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
78
|
+
labels,
|
|
79
|
+
disabled,
|
|
80
|
+
}: AudioRecorderButtonProps) {
|
|
56
81
|
const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
|
|
57
82
|
const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
|
|
58
83
|
const [isRecording, setIsRecording] = useState(false)
|
|
59
84
|
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
85
|
+
const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
60
86
|
|
|
61
87
|
const stop = useCallback(() => {
|
|
62
88
|
recorderRef.current?.stop()
|
|
@@ -81,7 +107,9 @@ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }:
|
|
|
81
107
|
// Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
|
|
82
108
|
// aceso depois do envio, e o operador acha que o simulador continua ouvindo.
|
|
83
109
|
stream.getTracks().forEach((track) => track.stop())
|
|
110
|
+
clearTimeout(autoStopRef.current)
|
|
84
111
|
setIsRecording(false)
|
|
112
|
+
onRecordingChange?.(false)
|
|
85
113
|
recorderRef.current = null
|
|
86
114
|
// O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
|
|
87
115
|
// gravador, mas quem valida upload compara com `audio/ogg` puro.
|
|
@@ -93,11 +121,13 @@ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }:
|
|
|
93
121
|
|
|
94
122
|
recorderRef.current = recorder
|
|
95
123
|
recorder.start()
|
|
124
|
+
autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
|
|
96
125
|
setIsRecording(true)
|
|
126
|
+
onRecordingChange?.(true)
|
|
97
127
|
} catch {
|
|
98
128
|
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
|
|
99
129
|
}
|
|
100
|
-
}, [labels?.denied, labels?.unsupported, onFailure, onRecorded])
|
|
130
|
+
}, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange])
|
|
101
131
|
|
|
102
132
|
return (
|
|
103
133
|
<button
|
|
@@ -111,7 +141,7 @@ export function AudioRecorderButton({ onRecorded, onFailure, labels, disabled }:
|
|
|
111
141
|
vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
|
|
112
142
|
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${
|
|
113
143
|
isRecording
|
|
114
|
-
? 'bg-red-500 text-white hover:bg-red-600'
|
|
144
|
+
? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
|
|
115
145
|
: 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
116
146
|
}`}
|
|
117
147
|
>
|
|
@@ -128,6 +128,7 @@ export function ConversationPreview({
|
|
|
128
128
|
const [messages, setMessages] = useState<MessagePayload[]>([])
|
|
129
129
|
const [failure, setFailure] = useState<string | undefined>(undefined)
|
|
130
130
|
const [loadFailure, setLoadFailure] = useState<string | undefined>(undefined)
|
|
131
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
131
132
|
// Mensagens que o servidor ainda não devolveu. Sem isto, quem não consegue LER a conversa (sessão
|
|
132
133
|
// ausente, API fora) digita, envia com sucesso e não vê absolutamente nada mudar — o preview fica
|
|
133
134
|
// indistinguível de quebrado. São descartadas assim que uma leitura dá certo: aí quem manda na
|
|
@@ -300,12 +301,18 @@ export function ConversationPreview({
|
|
|
300
301
|
<MessageComposer
|
|
301
302
|
onSend={(text) => void handleSend(text)}
|
|
302
303
|
onAttach={uploadMedia ? (file) => void handleAttach(file) : undefined}
|
|
303
|
-
|
|
304
|
+
/* Gravando, o campo diz o que falta fazer: o botão é um interruptor e o segundo toque é
|
|
305
|
+
que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
|
|
306
|
+
microfone está quebrado. */
|
|
307
|
+
placeholder={
|
|
308
|
+
isRecording ? 'Gravando… toque no quadrado para enviar' : (placeholder ?? 'Escreva como o cliente…')
|
|
309
|
+
}
|
|
304
310
|
idleAction={
|
|
305
311
|
uploadMedia ? (
|
|
306
312
|
<AudioRecorderButton
|
|
307
313
|
onRecorded={(file) => void handleAttach(file)}
|
|
308
314
|
onFailure={(message) => setFailure(message)}
|
|
315
|
+
onRecordingChange={setIsRecording}
|
|
309
316
|
/>
|
|
310
317
|
) : undefined
|
|
311
318
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guarda a interpolação das mensagens rápidas.
|
|
3
|
+
*
|
|
4
|
+
* O caso que decide o desenho: variável ausente. Deixar `{{nome}}` no texto significa o atendente
|
|
5
|
+
* mandar "Olá {{nome}}!" para o cliente — pior que a saudação sem nome.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { applyQuickReplyVariables, resolveQuickReply } from './MessageComposer'
|
|
11
|
+
|
|
12
|
+
describe('applyQuickReplyVariables', () => {
|
|
13
|
+
it('troca a variável pelo valor', () => {
|
|
14
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!', { nome: 'Marina' })).toBe('Olá Marina!')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('aceita espaço dentro das chaves', () => {
|
|
18
|
+
expect(applyQuickReplyVariables('Olá {{ nome }}!', { nome: 'Rita' })).toBe('Olá Rita!')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('troca todas as ocorrências', () => {
|
|
22
|
+
expect(applyQuickReplyVariables('{{nome}}, confirma? Obrigado, {{nome}}.', { nome: 'Ana' })).toBe(
|
|
23
|
+
'Ana, confirma? Obrigado, Ana.',
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Nunca vaza o literal para o cliente.
|
|
28
|
+
it('apaga a variável que não foi passada', () => {
|
|
29
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!', {})).toBe('Olá !')
|
|
30
|
+
expect(applyQuickReplyVariables('Olá {{nome}}!')).toBe('Olá !')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('não mexe em texto sem variável', () => {
|
|
34
|
+
expect(applyQuickReplyVariables('Bom dia!', { nome: 'X' })).toBe('Bom dia!')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('resolveQuickReply', () => {
|
|
39
|
+
it('interpola quando o texto é string', () => {
|
|
40
|
+
const resolvido = resolveQuickReply({ key: 'g', label: '👋', text: 'Olá {{nome}}!' }, { nome: 'Rita' })
|
|
41
|
+
|
|
42
|
+
expect(resolvido).toBe('Olá Rita!')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// A função existe para o que a string não resolve: escolher copy por produto, pluralizar, formatar.
|
|
46
|
+
it('chama a função com as variáveis', () => {
|
|
47
|
+
const resolvido = resolveQuickReply(
|
|
48
|
+
{ key: 's', label: '📋', text: (variables) => `Status de ${variables['produto'] ?? 'seu pedido'}` },
|
|
49
|
+
{ produto: 'financiamento' },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
expect(resolvido).toBe('Status de financiamento')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('função sem variáveis não quebra', () => {
|
|
56
|
+
expect(resolveQuickReply({ key: 'c', label: '📞', text: () => 'Posso ligar?' })).toBe('Posso ligar?')
|
|
57
|
+
})
|
|
58
|
+
})
|