@adatechnology/conversations-ui 0.1.0-rc.11 → 0.1.0-rc.13

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,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
+ })
@@ -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
@@ -6,6 +6,21 @@ 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'
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'
9
24
  export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
10
25
  export { SimpleEmojiPicker } from './SimpleEmojiPicker'
11
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
@@ -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,