@adatechnology/conversations-ui 0.1.1 → 0.2.1

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 (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2181 -507
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +467 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +328 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +169 -0
  49. package/src/workspace/useComposerQueue.ts +229 -0
@@ -11,8 +11,20 @@
11
11
  * do host, a mecânica é daqui.
12
12
  */
13
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, Type } from 'lucide-react'
14
+ import {
15
+ forwardRef,
16
+ useCallback,
17
+ useEffect,
18
+ useId,
19
+ useImperativeHandle,
20
+ useRef,
21
+ useState,
22
+ type KeyboardEvent,
23
+ type ChangeEvent,
24
+ type FormEvent,
25
+ type ReactNode,
26
+ } from 'react'
27
+ import { Bold, Italic, Strikethrough, Code, Paperclip, SendHorizonal, Braces, Type, Zap } from 'lucide-react'
16
28
 
17
29
  import { cn } from './lib/cn'
18
30
  import {
@@ -38,7 +50,12 @@ import {
38
50
  } from './lib/composer-formatting'
39
51
  import { htmlToWA, waToHTML } from './lib/whatsapp-formatting'
40
52
  import { SimpleEmojiPicker } from './SimpleEmojiPicker'
41
- import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
53
+ import { DEFAULT_ACCEPTED_FILE_TYPES, applyQuickReplyVariables } from './MessageComposer'
54
+ import { QuickRepliesPicker } from './quickReplies/QuickRepliesPicker'
55
+ import { useQuickRepliesPicker } from './quickReplies/useQuickRepliesPicker'
56
+ import { detectQuickReplyShortcut } from './quickReplies/quickReplyShortcut'
57
+ import type { QuickReply as SavedQuickReply } from './quickReplies/quickReply.types'
58
+ import type { QuickRepliesPickerLabels } from './quickReplies/labels'
42
59
 
43
60
  /** Cada ação que a barra sabe oferecer. `toolbar` decide quais delas aparecem. */
44
61
  export const RICH_COMPOSER_ACTION = {
@@ -49,6 +66,7 @@ export const RICH_COMPOSER_ACTION = {
49
66
  EMOJI: 'emoji',
50
67
  ATTACH: 'attach',
51
68
  VARIABLES: 'variables',
69
+ QUICK_REPLIES: 'quickReplies',
52
70
  } as const
53
71
  export type RichComposerAction = (typeof RICH_COMPOSER_ACTION)[keyof typeof RICH_COMPOSER_ACTION]
54
72
 
@@ -64,6 +82,7 @@ export interface RichComposerTooltips {
64
82
  variables: string
65
83
  /** Botão que abre a formatação recolhida, quando a barra está estreita demais para os quatro. */
66
84
  formatting: string
85
+ quickReplies: string
67
86
  }
68
87
 
69
88
  export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
@@ -76,6 +95,26 @@ export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
76
95
  send: 'Enviar',
77
96
  variables: 'Variáveis disponíveis',
78
97
  formatting: 'Formatação',
98
+ quickReplies: 'Mensagens prontas',
99
+ }
100
+
101
+ /**
102
+ * Mensagens prontas do produto. **Opcional por capacidade:** sem esta prop o botão de raio e o
103
+ * atalho `/` não existem — não um botão que abre uma lista vazia.
104
+ */
105
+ export interface RichComposerSavedQuickReplies {
106
+ readonly listQuickReplies: (params?: { search?: string }) => Promise<SavedQuickReply[]>
107
+ /** Chave do cache do picker — troca de conversa não deve reconsultar o que já carregou. */
108
+ readonly conversationId: string
109
+ readonly variables?: Readonly<Record<string, string>>
110
+ readonly labels?: Partial<QuickRepliesPickerLabels>
111
+ /**
112
+ * Avisada antes de inserir o texto, para o host empurrar os anexos da mensagem escolhida na fila
113
+ * do composer (QR-32). Sem esta prop nada muda — o texto continua sendo inserido do mesmo jeito.
114
+ */
115
+ readonly onSelect?: (quickReply: SavedQuickReply) => void
116
+ /** Sem `sendStoredAttachments` no host, a linha com anexo avisa em vez de prometer envio (QR-33). */
117
+ readonly hasAttachmentsCapability?: boolean
79
118
  }
80
119
 
81
120
  /**
@@ -128,6 +167,8 @@ export interface RichMessageComposerProps {
128
167
  quickReplies?: RichComposerQuickReply[]
129
168
  /** Variáveis que o operador pode inserir no texto. Vazio ou ausente, o botão não aparece. */
130
169
  variables?: RichComposerVariable[]
170
+ /** Botão de raio + atalho `/` para as mensagens prontas cadastradas. Ausente, nenhum dos dois aparece. */
171
+ savedQuickReplies?: RichComposerSavedQuickReplies
131
172
  /**
132
173
  * Quais ações aparecem. Ausente, todas aparecem — menos as que não têm como funcionar (anexo sem
133
174
  * `onAttachFiles` some sozinho, porque botão que não faz nada é pior que botão nenhum).
@@ -151,401 +192,664 @@ export interface RichMessageComposerProps {
151
192
  className?: string
152
193
  }
153
194
 
154
- export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMessageComposerProps>(function RichMessageComposer({
155
- value,
156
- onChange,
157
- onSend,
158
- onAttachFiles,
159
- quickReplies,
160
- variables,
161
- toolbar,
162
- tooltips,
163
- placeholder,
164
- disabled = false,
165
- isSending = false,
166
- idleAction,
167
- attachmentsPreview,
168
- hasQueuedAttachments = false,
169
- acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
170
- className,
171
- }, ref) {
172
- const editorRef = useRef<HTMLDivElement>(null)
173
- const fileInputRef = useRef<HTMLInputElement>(null)
174
- const variablesRef = useRef<HTMLDivElement>(null)
175
- const formattingRef = useRef<HTMLDivElement>(null)
176
- const barRef = useRef<HTMLDivElement>(null)
177
- const [isVariablesOpen, setIsVariablesOpen] = useState(false)
178
- const [isFormattingOpen, setIsFormattingOpen] = useState(false)
179
- const [activeFormatting, setActiveFormatting] = useState('')
180
-
181
- const barWidth = useContainerWidth(barRef)
182
- const isCompact = barWidth !== undefined && barWidth < COMPOSER_COMPACT_WIDTH
183
-
184
- /**
185
- * O último texto que este campo emitiu. É o que distingue o eco do próprio `onChange` — que deve
186
- * ser ignorado, senão o cursor volta ao início a cada tecla — de um `value` vindo de fora, que
187
- * precisa ser escrito no campo. Sem essa distinção o texto continuava na tela depois de enviado.
188
- */
189
- const lastEmittedRef = useRef('')
190
-
191
- const emitChange = useCallback((html: string) => {
192
- const text = htmlToWA(html)
193
- lastEmittedRef.current = text
194
- onChange(text)
195
- }, [onChange])
196
-
197
- useEffect(() => {
198
- const editor = editorRef.current
199
- if (!editor || value === lastEmittedRef.current) return
200
- lastEmittedRef.current = value
201
- editor.innerHTML = waToHTML(value)
202
- }, [value])
203
-
204
- const refreshActiveFormatting = useCallback(() => {
205
- const editor = editorRef.current
206
- if (editor) setActiveFormatting(activeFormattingIn(editor))
207
- }, [])
208
-
209
- // `selectionchange` é do documento: não existe evento de "o cursor andou" no próprio campo.
210
- useEffect(() => {
211
- document.addEventListener('selectionchange', refreshActiveFormatting)
212
- return () => document.removeEventListener('selectionchange', refreshActiveFormatting)
213
- }, [refreshActiveFormatting])
214
-
215
- useEffect(() => {
216
- if (!isVariablesOpen) return
217
- const closeOnOutsideClick = (event: MouseEvent) => {
218
- if (!variablesRef.current?.contains(event.target as Node)) setIsVariablesOpen(false)
219
- }
220
- document.addEventListener('mousedown', closeOnOutsideClick)
221
- return () => document.removeEventListener('mousedown', closeOnOutsideClick)
222
- }, [isVariablesOpen])
223
-
224
- useEffect(() => {
225
- if (!isFormattingOpen) return
226
- const closeOnOutsideClick = (event: MouseEvent) => {
227
- if (!formattingRef.current?.contains(event.target as Node)) setIsFormattingOpen(false)
228
- }
229
- document.addEventListener('mousedown', closeOnOutsideClick)
230
- return () => document.removeEventListener('mousedown', closeOnOutsideClick)
231
- }, [isFormattingOpen])
232
-
233
- // A barra voltou a ser larga (a prévia fechou): os botões reaparecem na linha e o popover ficaria
234
- // aberto ancorado num botão que não existe mais.
235
- useEffect(() => {
236
- if (!isCompact) setIsFormattingOpen(false)
237
- }, [isCompact])
238
-
239
- const tooltipOf = (action: keyof RichComposerTooltips): string =>
240
- tooltips?.[action] ?? DEFAULT_RICH_COMPOSER_TOOLTIPS[action]
241
- const shows = (action: RichComposerAction): boolean => toolbar?.[action] !== false
242
-
243
- /** Escreve no campo sem passar pelo React: `contentEditable` controlado perde o cursor a cada tecla. */
244
- const replaceContent = useCallback((text: string) => {
245
- const editor = editorRef.current
246
- if (!editor) return
247
- editor.innerHTML = waToHTML(text)
248
- emitChange(editor.innerHTML)
249
- editor.focus()
250
- }, [emitChange])
251
-
252
- useImperativeHandle(ref, () => ({
253
- setContent: replaceContent,
254
- clear: () => {
255
- const editor = editorRef.current
256
- if (!editor) return
257
- editor.innerHTML = ''
258
- emitChange('')
195
+ export const RichMessageComposer = forwardRef<RichMessageComposerHandle, RichMessageComposerProps>(
196
+ function RichMessageComposer(
197
+ {
198
+ value,
199
+ onChange,
200
+ onSend,
201
+ onAttachFiles,
202
+ quickReplies,
203
+ variables,
204
+ savedQuickReplies,
205
+ toolbar,
206
+ tooltips,
207
+ placeholder,
208
+ disabled = false,
209
+ isSending = false,
210
+ idleAction,
211
+ attachmentsPreview,
212
+ hasQueuedAttachments = false,
213
+ acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
214
+ className,
259
215
  },
260
- focus: () => editorRef.current?.focus(),
261
- }), [emitChange, replaceContent])
262
-
263
- /** Range vivo dentro do campo, ou `undefined` se o cursor está em outro lugar da página. */
264
- const currentRange = useCallback((): Range | undefined => {
265
- const editor = editorRef.current
266
- if (!editor) return undefined
267
- editor.focus()
268
- const selection = window.getSelection()
269
- if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return undefined
270
- return selection.getRangeAt(0)
271
- }, [])
272
-
273
- const commitRange = useCallback((range: Range, node: Node) => {
274
- const selection = window.getSelection()
275
- range.setStartAfter(node)
276
- range.collapse(true)
277
- selection?.removeAllRanges()
278
- selection?.addRange(range)
279
- emitChange(editorRef.current?.innerHTML ?? '')
280
- }, [emitChange])
281
-
282
- const insertAtCursor = useCallback((fragment: string) => {
283
- const range = currentRange()
284
- if (!range) return
285
- range.deleteContents()
286
- const textNode = document.createTextNode(fragment)
287
- range.insertNode(textNode)
288
- commitRange(range, textNode)
289
- }, [commitRange, currentRange])
290
-
291
- /**
292
- * Envolve a seleção no elemento da formatação. Sem seleção não faz nada: abrir a marcação e
293
- * esperar que o operador digite dentro dela é o caminho que sai com marcador sobrando quando ele
294
- * clica noutro lugar antes.
295
- */
296
- const wrapSelection = useCallback((action: keyof typeof FORMATTING_ELEMENT) => {
297
- const range = currentRange()
298
- const selectedText = range?.toString()
299
- if (!range || !selectedText) return
300
- range.deleteContents()
301
- const wrapper = document.createElement(FORMATTING_ELEMENT[action])
302
- if (action === 'monospace') wrapper.className = COMPOSER_MONOSPACE_CLASS
303
- wrapper.textContent = selectedText
304
- range.insertNode(wrapper)
305
- commitRange(range, wrapper)
306
- }, [commitRange, currentRange])
216
+ ref,
217
+ ) {
218
+ const editorRef = useRef<HTMLDivElement>(null)
219
+ const fileInputRef = useRef<HTMLInputElement>(null)
220
+ const variablesRef = useRef<HTMLDivElement>(null)
221
+ const formattingRef = useRef<HTMLDivElement>(null)
222
+ const barRef = useRef<HTMLDivElement>(null)
223
+ const [isVariablesOpen, setIsVariablesOpen] = useState(false)
224
+ const [isFormattingOpen, setIsFormattingOpen] = useState(false)
225
+ const [activeFormatting, setActiveFormatting] = useState('')
226
+ const quickRepliesListboxId = useId()
227
+ const showQuickRepliesButton = Boolean(savedQuickReplies)
228
+ const [isQuickRepliesOpen, setIsQuickRepliesOpen] = useState(false)
229
+ const [quickRepliesTerm, setQuickRepliesTerm] = useState('')
230
+ /** `shortcut`: a busca vem do `/termo` digitado no campo. `button`: busca própria do picker —
231
+ * o raio nunca insere `/` no campo (QR-04). */
232
+ const [quickRepliesMode, setQuickRepliesMode] = useState<'shortcut' | 'button'>('shortcut')
233
+ const quickRepliesPopoverRef = useRef<HTMLDivElement>(null)
234
+ const quickRepliesTriggerRef = useRef<HTMLButtonElement>(null)
235
+ /** Cursor guardado ao abrir pelo botão — o foco vai para a busca própria, e é a ele que a
236
+ * inserção volta antes de escrever o corpo resolvido. */
237
+ const savedRangeRef = useRef<Range | undefined>(undefined)
238
+
239
+ const barWidth = useContainerWidth(barRef)
240
+ const isCompact = barWidth !== undefined && barWidth < COMPOSER_COMPACT_WIDTH
241
+
242
+ /**
243
+ * O último texto que este campo emitiu. É o que distingue o eco do próprio `onChange` — que deve
244
+ * ser ignorado, senão o cursor volta ao início a cada tecla — de um `value` vindo de fora, que
245
+ * precisa ser escrito no campo. Sem essa distinção o texto continuava na tela depois de enviado.
246
+ */
247
+ const lastEmittedRef = useRef('')
248
+
249
+ const emitChange = useCallback(
250
+ (html: string) => {
251
+ const text = htmlToWA(html)
252
+ lastEmittedRef.current = text
253
+ onChange(text)
254
+ },
255
+ [onChange],
256
+ )
257
+
258
+ useEffect(() => {
259
+ const editor = editorRef.current
260
+ if (!editor || value === lastEmittedRef.current) return
261
+ lastEmittedRef.current = value
262
+ editor.innerHTML = waToHTML(value)
263
+ }, [value])
307
264
 
308
- /**
309
- * Uma regra só para os quatro botões, a mesma dos editores de texto:
310
- *
311
- * - cursor vago — liga ou desliga daqui para a frente, sem tocar no que já está escrito;
312
- * - com seleção — aplica na seleção, ou tira dela se ela já estiver toda formatada.
313
- *
314
- * `execCommand` faz isso sozinho para negrito, itálico e tachado. Monoespaçado não tem comando
315
- * nativo e é feito à mão, com a mesma regra. Onde nem o comando existe (ambiente sem editor de
316
- * verdade), sobra o wrap da seleção — que era tudo o que a barra fazia antes.
317
- */
318
- const applyFormatting = useCallback((action: FormattingAction) => {
319
- const editor = editorRef.current
320
- const range = currentRange()
321
- if (!editor || !range) return
322
-
323
- if (action === FORMATTING_ACTION.MONOSPACE) {
324
- const code = codeAncestorOf({ node: range.startContainer, editor })
325
- if (range.collapsed) {
326
- if (code) exitMonospaceAfter(code)
327
- else startMonospaceAt(range)
328
- } else if (code) {
329
- unwrapMonospace(code)
330
- } else {
331
- wrapSelection(action)
265
+ const refreshActiveFormatting = useCallback(() => {
266
+ const editor = editorRef.current
267
+ if (editor) setActiveFormatting(activeFormattingIn(editor))
268
+ }, [])
269
+
270
+ // `selectionchange` é do documento: não existe evento de "o cursor andou" no próprio campo.
271
+ useEffect(() => {
272
+ document.addEventListener('selectionchange', refreshActiveFormatting)
273
+ return () => document.removeEventListener('selectionchange', refreshActiveFormatting)
274
+ }, [refreshActiveFormatting])
275
+
276
+ useEffect(() => {
277
+ if (!isVariablesOpen) return
278
+ const closeOnOutsideClick = (event: MouseEvent) => {
279
+ if (!variablesRef.current?.contains(event.target as Node)) setIsVariablesOpen(false)
332
280
  }
333
- emitChange(editor.innerHTML)
334
- refreshActiveFormatting()
335
- return
336
- }
337
-
338
- if (toggleFormattingCommand(action)) emitChange(editor.innerHTML)
339
- else wrapSelection(action)
340
- refreshActiveFormatting()
341
- }, [currentRange, emitChange, refreshActiveFormatting, wrapSelection])
342
-
343
- const handleInput = useCallback((event: FormEvent<HTMLDivElement>) => {
344
- emitChange(event.currentTarget.innerHTML)
345
- refreshActiveFormatting()
346
- }, [emitChange, refreshActiveFormatting])
347
-
348
- const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
349
- if (event.key === 'Enter' && !event.shiftKey) {
350
- event.preventDefault()
351
- // Sem esta guarda, cada Enter durante o upload dispara outro envio — foi o que duplicou a imagem.
352
- if (!disabled && !isSending) onSend()
353
- }
354
- }, [disabled, isSending, onSend])
355
-
356
- const handleFileChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
357
- if (event.target.files?.length) onAttachFiles?.(event.target.files)
358
- if (fileInputRef.current) fileInputRef.current.value = ''
359
- }, [onAttachFiles])
360
-
361
- const canSend = value.trim().length > 0 || hasQueuedAttachments
362
-
363
- // Os mesmos botões servem à linha e ao popover: duas listas separadas divergiriam na primeira
364
- // formatação nova.
365
- const formattingButtons = ([
366
- [RICH_COMPOSER_ACTION.BOLD, Bold],
367
- [RICH_COMPOSER_ACTION.ITALIC, Italic],
368
- [RICH_COMPOSER_ACTION.STRIKETHROUGH, Strikethrough],
369
- [RICH_COMPOSER_ACTION.MONOSPACE, Code],
370
- ] as const)
371
- .filter(([action]) => shows(action))
372
- .map(([action, Icon]) => {
373
- const isActive = isFormattingActive({ active: activeFormatting, action })
374
- return (
375
- <button
376
- key={action}
377
- type="button"
378
- // O clique tira o foco do campo antes do `onClick`, e com ele a seleção que a
379
- // formatação precisa — segurar o mousedown é o que mantém o cursor onde está.
380
- onMouseDown={(event) => event.preventDefault()}
381
- onClick={() => applyFormatting(action)}
382
- data-cv-tooltip={tooltipOf(action)}
383
- aria-label={tooltipOf(action)}
384
- aria-pressed={isActive}
385
- className={cn(
386
- COMPOSER_TOOL_BUTTON_CLASS,
387
- isActive ? COMPOSER_TOOL_BUTTON_ACTIVE_CLASS : COMPOSER_TOOL_BUTTON_IDLE_CLASS,
388
- )}
389
- >
390
- <Icon size={18} />
391
- </button>
392
- )
281
+ document.addEventListener('mousedown', closeOnOutsideClick)
282
+ return () => document.removeEventListener('mousedown', closeOnOutsideClick)
283
+ }, [isVariablesOpen])
284
+
285
+ useEffect(() => {
286
+ if (!isFormattingOpen) return
287
+ const closeOnOutsideClick = (event: MouseEvent) => {
288
+ if (!formattingRef.current?.contains(event.target as Node)) setIsFormattingOpen(false)
289
+ }
290
+ document.addEventListener('mousedown', closeOnOutsideClick)
291
+ return () => document.removeEventListener('mousedown', closeOnOutsideClick)
292
+ }, [isFormattingOpen])
293
+
294
+ // A barra voltou a ser larga (a prévia fechou): os botões reaparecem na linha e o popover ficaria
295
+ // aberto ancorado num botão que não existe mais.
296
+ useEffect(() => {
297
+ if (!isCompact) setIsFormattingOpen(false)
298
+ }, [isCompact])
299
+
300
+ const tooltipOf = (action: keyof RichComposerTooltips): string =>
301
+ tooltips?.[action] ?? DEFAULT_RICH_COMPOSER_TOOLTIPS[action]
302
+ const shows = (action: RichComposerAction): boolean => toolbar?.[action] !== false
303
+
304
+ /** Escreve no campo sem passar pelo React: `contentEditable` controlado perde o cursor a cada tecla. */
305
+ const replaceContent = useCallback(
306
+ (text: string) => {
307
+ const editor = editorRef.current
308
+ if (!editor) return
309
+ editor.innerHTML = waToHTML(text)
310
+ emitChange(editor.innerHTML)
311
+ editor.focus()
312
+ },
313
+ [emitChange],
314
+ )
315
+
316
+ useImperativeHandle(
317
+ ref,
318
+ () => ({
319
+ setContent: replaceContent,
320
+ clear: () => {
321
+ const editor = editorRef.current
322
+ if (!editor) return
323
+ editor.innerHTML = ''
324
+ emitChange('')
325
+ },
326
+ focus: () => editorRef.current?.focus(),
327
+ }),
328
+ [emitChange, replaceContent],
329
+ )
330
+
331
+ /** Range vivo dentro do campo, ou `undefined` se o cursor está em outro lugar da página. */
332
+ const currentRange = useCallback((): Range | undefined => {
333
+ const editor = editorRef.current
334
+ if (!editor) return undefined
335
+ editor.focus()
336
+ const selection = window.getSelection()
337
+ if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return undefined
338
+ return selection.getRangeAt(0)
339
+ }, [])
340
+
341
+ /**
342
+ * Como `currentRange`, mas sem forçar foco no campo — usada para detectar o atalho a cada
343
+ * mudança de `value`, inclusive quando o campo não é o elemento focado (ex.: `value` trocado
344
+ * de fora). Forçar foco ali roubaria o foco de quem estava digitando em outro lugar.
345
+ */
346
+ const rangeIfFocused = useCallback((): Range | undefined => {
347
+ const editor = editorRef.current
348
+ if (!editor || document.activeElement !== editor) return undefined
349
+ const selection = window.getSelection()
350
+ if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return undefined
351
+ return selection.getRangeAt(0)
352
+ }, [])
353
+
354
+ /**
355
+ * Texto plano do início do campo até o cursor, direto da árvore do DOM — nunca do `value`
356
+ * emitido (que já passou pelo `htmlToWA` e carrega `&nbsp;`/marcação, não o texto que o
357
+ * operador vê antes do cursor).
358
+ */
359
+ const textBeforeCaret = useCallback((range: Range): string => {
360
+ const editor = editorRef.current
361
+ if (!editor) return ''
362
+ const preRange = document.createRange()
363
+ preRange.selectNodeContents(editor)
364
+ preRange.setEnd(range.startContainer, range.startOffset)
365
+ return preRange.toString()
366
+ }, [])
367
+
368
+ // Deriva o picker do texto plano até o cursor, lido do DOM (não do `value` com HTML emitido):
369
+ // digitar "/doc" abre, apagar a "/" ou mover o cursor para antes dela fecha.
370
+ useEffect(() => {
371
+ if (!showQuickRepliesButton) {
372
+ setIsQuickRepliesOpen(false)
373
+ return
374
+ }
375
+ if (quickRepliesMode === 'button') return
376
+ const range = rangeIfFocused()
377
+ const textToCaret = range ? textBeforeCaret(range) : ''
378
+ const match = detectQuickReplyShortcut(textToCaret)
379
+ setIsQuickRepliesOpen(Boolean(match))
380
+ setQuickRepliesTerm(match?.term ?? '')
381
+ }, [value, showQuickRepliesButton, quickRepliesMode, rangeIfFocused, textBeforeCaret])
382
+
383
+ const commitRange = useCallback(
384
+ (range: Range, node: Node) => {
385
+ const selection = window.getSelection()
386
+ range.setStartAfter(node)
387
+ range.collapse(true)
388
+ selection?.removeAllRanges()
389
+ selection?.addRange(range)
390
+ emitChange(editorRef.current?.innerHTML ?? '')
391
+ },
392
+ [emitChange],
393
+ )
394
+
395
+ const insertAtCursor = useCallback(
396
+ (fragment: string) => {
397
+ const range = currentRange()
398
+ if (!range) return
399
+ range.deleteContents()
400
+ const textNode = document.createTextNode(fragment)
401
+ range.insertNode(textNode)
402
+ commitRange(range, textNode)
403
+ },
404
+ [commitRange, currentRange],
405
+ )
406
+
407
+ const closeQuickReplies = useCallback(() => {
408
+ setIsQuickRepliesOpen(false)
409
+ setQuickRepliesTerm('')
410
+ setQuickRepliesMode('shortcut')
411
+ }, [])
412
+
413
+ const closeQuickRepliesAndRefocus = useCallback(() => {
414
+ closeQuickReplies()
415
+ editorRef.current?.focus()
416
+ }, [closeQuickReplies])
417
+
418
+ /**
419
+ * Troca o `/termo` pelo corpo resolvido (modo atalho) ou insere o corpo na posição guardada ao
420
+ * abrir pelo botão (modo botão — nunca houve `/` para trocar). No modo atalho, assume que o
421
+ * atalho inteiro está no mesmo nó de texto do cursor — verdadeiro no caso comum, o operador
422
+ * acabou de digitá-lo; fora desse caso, não insere nada em vez de arriscar deixar o "/termo"
423
+ * literal no meio do texto.
424
+ */
425
+ const insertSavedQuickReply = useCallback(
426
+ (quickReply: SavedQuickReply) => {
427
+ savedQuickReplies?.onSelect?.(quickReply)
428
+ const resolvedBody = applyQuickReplyVariables(quickReply.body, savedQuickReplies?.variables)
429
+
430
+ if (quickRepliesMode === 'button') {
431
+ const editor = editorRef.current
432
+ const savedRange = savedRangeRef.current
433
+ if (editor && savedRange) {
434
+ editor.focus()
435
+ const selection = window.getSelection()
436
+ selection?.removeAllRanges()
437
+ selection?.addRange(savedRange)
438
+ }
439
+ insertAtCursor(resolvedBody)
440
+ closeQuickReplies()
441
+ // Rede de segurança: fechar o picker desmonta a busca própria, que tinha o foco do
442
+ // navegador — sem isto o foco cai para `document.body` em vez de voltar ao campo com o
443
+ // cursor logo depois do texto inserido.
444
+ requestAnimationFrame(() => editor?.focus())
445
+ return
446
+ }
447
+
448
+ const range = currentRange()
449
+ if (!range) return
450
+ const match = detectQuickReplyShortcut(textBeforeCaret(range))
451
+ if (!match) {
452
+ closeQuickReplies()
453
+ return
454
+ }
455
+ const shortcutLength = match.term.length + 1
456
+ if (range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset >= shortcutLength) {
457
+ range.setStart(range.startContainer, range.startOffset - shortcutLength)
458
+ range.deleteContents()
459
+ const textNode = document.createTextNode(resolvedBody)
460
+ range.insertNode(textNode)
461
+ commitRange(range, textNode)
462
+ }
463
+ closeQuickReplies()
464
+ },
465
+ [
466
+ quickRepliesMode,
467
+ currentRange,
468
+ textBeforeCaret,
469
+ commitRange,
470
+ insertAtCursor,
471
+ savedQuickReplies,
472
+ closeQuickReplies,
473
+ ],
474
+ )
475
+
476
+ const quickRepliesPicker = useQuickRepliesPicker({
477
+ isOpen: isQuickRepliesOpen,
478
+ search: quickRepliesTerm,
479
+ listQuickReplies: savedQuickReplies?.listQuickReplies,
480
+ quickReplyVariables: savedQuickReplies?.variables,
481
+ onSelect: insertSavedQuickReply,
482
+ onClose: closeQuickRepliesAndRefocus,
393
483
  })
394
484
 
395
- return (
396
- <div className={cn(COMPOSER_BAR_CLASS, 'flex flex-col', className)}>
397
- {attachmentsPreview}
485
+ // Botão de raio: guarda o cursor e abre o picker com busca própria — o campo não é tocado
486
+ // (QR-04). O cursor guardado é para onde a seleção volta ao inserir o corpo escolhido.
487
+ // Clicar de novo com o picker já aberto nesse modo fecha — o raio é um toggle.
488
+ const openQuickRepliesViaButton = useCallback(() => {
489
+ if (isQuickRepliesOpen && quickRepliesMode === 'button') {
490
+ closeQuickRepliesAndRefocus()
491
+ return
492
+ }
493
+ savedRangeRef.current = currentRange()
494
+ setQuickRepliesMode('button')
495
+ setQuickRepliesTerm('')
496
+ setIsQuickRepliesOpen(true)
497
+ }, [isQuickRepliesOpen, quickRepliesMode, currentRange, closeQuickRepliesAndRefocus])
498
+
499
+ // Clique fora do popover em modo botão fecha e devolve o foco ao campo. O próprio botão de
500
+ // raio é ignorado: seu `mousedown` já é tratado como toggle por `openQuickRepliesViaButton`,
501
+ // e sem essa exclusão o outside-click fechava primeiro e o `onClick` reabria em seguida.
502
+ useEffect(() => {
503
+ if (!isQuickRepliesOpen || quickRepliesMode !== 'button') return
504
+ const handleOutsideClick = (event: MouseEvent) => {
505
+ const target = event.target as Node
506
+ if (quickRepliesPopoverRef.current?.contains(target)) return
507
+ if (quickRepliesTriggerRef.current?.contains(target)) return
508
+ closeQuickRepliesAndRefocus()
509
+ }
510
+ document.addEventListener('mousedown', handleOutsideClick)
511
+ return () => document.removeEventListener('mousedown', handleOutsideClick)
512
+ }, [isQuickRepliesOpen, quickRepliesMode, closeQuickRepliesAndRefocus])
513
+
514
+ /**
515
+ * Envolve a seleção no elemento da formatação. Sem seleção não faz nada: abrir a marcação e
516
+ * esperar que o operador digite dentro dela é o caminho que sai com marcador sobrando quando ele
517
+ * clica noutro lugar antes.
518
+ */
519
+ const wrapSelection = useCallback(
520
+ (action: keyof typeof FORMATTING_ELEMENT) => {
521
+ const range = currentRange()
522
+ const selectedText = range?.toString()
523
+ if (!range || !selectedText) return
524
+ range.deleteContents()
525
+ const wrapper = document.createElement(FORMATTING_ELEMENT[action])
526
+ if (action === 'monospace') wrapper.className = COMPOSER_MONOSPACE_CLASS
527
+ wrapper.textContent = selectedText
528
+ range.insertNode(wrapper)
529
+ commitRange(range, wrapper)
530
+ },
531
+ [commitRange, currentRange],
532
+ )
533
+
534
+ /**
535
+ * Uma regra só para os quatro botões, a mesma dos editores de texto:
536
+ *
537
+ * - cursor vago — liga ou desliga daqui para a frente, sem tocar no que já está escrito;
538
+ * - com seleção — aplica na seleção, ou tira dela se ela já estiver toda formatada.
539
+ *
540
+ * `execCommand` faz isso sozinho para negrito, itálico e tachado. Monoespaçado não tem comando
541
+ * nativo e é feito à mão, com a mesma regra. Onde nem o comando existe (ambiente sem editor de
542
+ * verdade), sobra o wrap da seleção — que era tudo o que a barra fazia antes.
543
+ */
544
+ const applyFormatting = useCallback(
545
+ (action: FormattingAction) => {
546
+ const editor = editorRef.current
547
+ const range = currentRange()
548
+ if (!editor || !range) return
549
+
550
+ if (action === FORMATTING_ACTION.MONOSPACE) {
551
+ const code = codeAncestorOf({ node: range.startContainer, editor })
552
+ if (range.collapsed) {
553
+ if (code) exitMonospaceAfter(code)
554
+ else startMonospaceAt(range)
555
+ } else if (code) {
556
+ unwrapMonospace(code)
557
+ } else {
558
+ wrapSelection(action)
559
+ }
560
+ emitChange(editor.innerHTML)
561
+ refreshActiveFormatting()
562
+ return
563
+ }
564
+
565
+ if (toggleFormattingCommand(action)) emitChange(editor.innerHTML)
566
+ else wrapSelection(action)
567
+ refreshActiveFormatting()
568
+ },
569
+ [currentRange, emitChange, refreshActiveFormatting, wrapSelection],
570
+ )
571
+
572
+ const handleInput = useCallback(
573
+ (event: FormEvent<HTMLDivElement>) => {
574
+ emitChange(event.currentTarget.innerHTML)
575
+ refreshActiveFormatting()
576
+ },
577
+ [emitChange, refreshActiveFormatting],
578
+ )
579
+
580
+ const handleKeyDown = useCallback(
581
+ (event: KeyboardEvent<HTMLDivElement>) => {
582
+ // Em modo botão quem recebe as teclas é a busca própria do picker, não o campo.
583
+ if (isQuickRepliesOpen && quickRepliesMode === 'shortcut') {
584
+ quickRepliesPicker.handleKeyDown(event)
585
+ return
586
+ }
587
+ if (event.key === 'Enter' && !event.shiftKey) {
588
+ event.preventDefault()
589
+ // Sem esta guarda, cada Enter durante o upload dispara outro envio — foi o que duplicou a imagem.
590
+ if (!disabled && !isSending) onSend()
591
+ }
592
+ },
593
+ [disabled, isSending, onSend, isQuickRepliesOpen, quickRepliesMode, quickRepliesPicker],
594
+ )
595
+
596
+ const handleFileChange = useCallback(
597
+ (event: ChangeEvent<HTMLInputElement>) => {
598
+ if (event.target.files?.length) onAttachFiles?.(event.target.files)
599
+ if (fileInputRef.current) fileInputRef.current.value = ''
600
+ },
601
+ [onAttachFiles],
602
+ )
603
+
604
+ const canSend = value.trim().length > 0 || hasQueuedAttachments
605
+
606
+ // Os mesmos botões servem à linha e ao popover: duas listas separadas divergiriam na primeira
607
+ // formatação nova.
608
+ const formattingButtons = (
609
+ [
610
+ [RICH_COMPOSER_ACTION.BOLD, Bold],
611
+ [RICH_COMPOSER_ACTION.ITALIC, Italic],
612
+ [RICH_COMPOSER_ACTION.STRIKETHROUGH, Strikethrough],
613
+ [RICH_COMPOSER_ACTION.MONOSPACE, Code],
614
+ ] as const
615
+ )
616
+ .filter(([action]) => shows(action))
617
+ .map(([action, Icon]) => {
618
+ const isActive = isFormattingActive({ active: activeFormatting, action })
619
+ return (
620
+ <button
621
+ key={action}
622
+ type="button"
623
+ // O clique tira o foco do campo antes do `onClick`, e com ele a seleção que a
624
+ // formatação precisa — segurar o mousedown é o que mantém o cursor onde está.
625
+ onMouseDown={(event) => event.preventDefault()}
626
+ onClick={() => applyFormatting(action)}
627
+ data-cv-tooltip={tooltipOf(action)}
628
+ aria-label={tooltipOf(action)}
629
+ aria-pressed={isActive}
630
+ className={cn(
631
+ COMPOSER_TOOL_BUTTON_CLASS,
632
+ isActive ? COMPOSER_TOOL_BUTTON_ACTIVE_CLASS : COMPOSER_TOOL_BUTTON_IDLE_CLASS,
633
+ )}
634
+ >
635
+ <Icon size={18} />
636
+ </button>
637
+ )
638
+ })
398
639
 
399
- {quickReplies?.length ? (
400
- /* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
640
+ return (
641
+ <div className={cn(COMPOSER_BAR_CLASS, 'flex flex-col', className)}>
642
+ {attachmentsPreview}
643
+
644
+ {quickReplies?.length ? (
645
+ /* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
401
646
  conforme o número de respostas rápidas e empurrar a conversa para cima — o composer
402
647
  precisa ter altura previsível, independente de quantos atalhos o host configurou. */
403
- <div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
404
- {quickReplies.map((quickReply) => (
405
- <button
406
- key={quickReply.id}
407
- type="button"
408
- data-cv-tooltip={quickReply.tooltip} aria-label={quickReply.tooltip}
409
- onClick={() => replaceContent(quickReply.text)}
410
- className={cn(QUICK_REPLY_PILL_CLASS, 'flex-shrink-0')}
411
- >
412
- {quickReply.label}
413
- </button>
414
- ))}
415
- </div>
416
- ) : null}
648
+ <div className="mb-2 flex flex-nowrap gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
649
+ {quickReplies.map((quickReply) => (
650
+ <button
651
+ key={quickReply.id}
652
+ type="button"
653
+ data-cv-tooltip={quickReply.tooltip}
654
+ aria-label={quickReply.tooltip}
655
+ onClick={() => replaceContent(quickReply.text)}
656
+ className={cn(QUICK_REPLY_PILL_CLASS, 'flex-shrink-0')}
657
+ >
658
+ {quickReply.label}
659
+ </button>
660
+ ))}
661
+ </div>
662
+ ) : null}
417
663
 
418
- {/* Uma escala só para a barra inteira: todo botão é 36px com ícone de 18, o mesmo gap entre
664
+ {/* Uma escala só para a barra inteira: todo botão é 36px com ícone de 18, o mesmo gap entre
419
665
  todos e nenhuma margem própria. Com tamanhos e espaçamentos diferentes por grupo, o
420
666
  `items-end` alinhava as bases mas os ícones ficavam em alturas e ritmos distintos. */}
421
- {/* Uma linha só, e ela não quebra: `flex-nowrap` com o campo em `min-w-0` faz o texto ceder
667
+ {/* Uma linha só, e ela não quebra: `flex-nowrap` com o campo em `min-w-0` faz o texto ceder
422
668
  espaço quando a coluna estreita — que é o que ligar a prévia faz — em vez de empurrar
423
669
  botão para uma segunda faixa. */}
424
- <div ref={barRef} className="flex flex-nowrap items-end gap-1">
425
- {/* Formatação só no desktop: no celular não há como selecionar texto e tocar no botão sem
670
+ <div ref={barRef} className="flex flex-nowrap items-end gap-1">
671
+ {/* Formatação só no desktop: no celular não há como selecionar texto e tocar no botão sem
426
672
  perder a seleção, e a formatação sai digitada à mão de qualquer jeito.
427
673
 
428
674
  Com a barra estreita os quatro recolhem num botão só. Ceder a largura ao campo é a
429
675
  escolha certa aqui: escrever é a ação da barra, formatar é o acessório — e é o campo,
430
676
  não o botão, que fica inutilizável quando encolhe. */}
431
- {formattingButtons.length > 0 ? (
432
- isCompact ? (
433
- <div ref={formattingRef} className="relative hidden flex-shrink-0 sm:block">
677
+ {formattingButtons.length > 0 ? (
678
+ isCompact ? (
679
+ <div ref={formattingRef} className="relative hidden flex-shrink-0 sm:block">
680
+ <button
681
+ type="button"
682
+ onMouseDown={(event) => event.preventDefault()}
683
+ onClick={() => setIsFormattingOpen((open) => !open)}
684
+ data-cv-tooltip={tooltipOf('formatting')}
685
+ aria-label={tooltipOf('formatting')}
686
+ aria-expanded={isFormattingOpen}
687
+ className={cn(
688
+ COMPOSER_TOOL_BUTTON_CLASS,
689
+ activeFormatting || isFormattingOpen
690
+ ? COMPOSER_TOOL_BUTTON_ACTIVE_CLASS
691
+ : COMPOSER_TOOL_BUTTON_IDLE_CLASS,
692
+ )}
693
+ >
694
+ <Type size={18} />
695
+ </button>
696
+ {isFormattingOpen ? (
697
+ <div className="absolute bottom-full left-0 z-20 mb-2 flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
698
+ {formattingButtons}
699
+ </div>
700
+ ) : null}
701
+ </div>
702
+ ) : (
703
+ <div className="hidden flex-shrink-0 items-center gap-1 sm:flex">{formattingButtons}</div>
704
+ )
705
+ ) : null}
706
+
707
+ {shows(RICH_COMPOSER_ACTION.EMOJI) ? (
708
+ <SimpleEmojiPicker onSelect={insertAtCursor} label={tooltipOf('emoji')} />
709
+ ) : null}
710
+
711
+ {variables?.length && shows(RICH_COMPOSER_ACTION.VARIABLES) ? (
712
+ <div ref={variablesRef} className="relative">
434
713
  <button
435
714
  type="button"
436
- onMouseDown={(event) => event.preventDefault()}
437
- onClick={() => setIsFormattingOpen((open) => !open)}
438
- data-cv-tooltip={tooltipOf('formatting')}
439
- aria-label={tooltipOf('formatting')}
440
- aria-expanded={isFormattingOpen}
441
- className={cn(
442
- COMPOSER_TOOL_BUTTON_CLASS,
443
- activeFormatting || isFormattingOpen
444
- ? COMPOSER_TOOL_BUTTON_ACTIVE_CLASS
445
- : COMPOSER_TOOL_BUTTON_IDLE_CLASS,
446
- )}
715
+ onClick={() => setIsVariablesOpen((open) => !open)}
716
+ data-cv-tooltip={tooltipOf('variables')}
717
+ aria-label={tooltipOf('variables')}
718
+ aria-expanded={isVariablesOpen}
719
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
447
720
  >
448
- <Type size={18} />
721
+ <Braces size={18} />
449
722
  </button>
450
- {isFormattingOpen ? (
451
- <div className="absolute bottom-full left-0 z-20 mb-2 flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800">
452
- {formattingButtons}
723
+ {isVariablesOpen ? (
724
+ <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">
725
+ {variables.map((variable) => (
726
+ <button
727
+ key={variable.id}
728
+ type="button"
729
+ data-cv-tooltip={variable.tooltip ?? variable.value}
730
+ aria-label={variable.tooltip ?? variable.value}
731
+ onClick={() => {
732
+ insertAtCursor(variable.value)
733
+ setIsVariablesOpen(false)
734
+ }}
735
+ 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"
736
+ >
737
+ <span className="text-sm text-gray-800 dark:text-gray-100">{variable.label}</span>
738
+ <span className="font-mono text-xs text-gray-400 dark:text-gray-500">{variable.value}</span>
739
+ </button>
740
+ ))}
453
741
  </div>
454
742
  ) : null}
455
743
  </div>
456
- ) : (
457
- <div className="hidden flex-shrink-0 items-center gap-1 sm:flex">{formattingButtons}</div>
458
- )
459
- ) : null}
460
-
461
- {shows(RICH_COMPOSER_ACTION.EMOJI) ? (
462
- <SimpleEmojiPicker onSelect={insertAtCursor} label={tooltipOf('emoji')} />
463
- ) : null}
744
+ ) : null}
464
745
 
465
- {variables?.length && shows(RICH_COMPOSER_ACTION.VARIABLES) ? (
466
- <div ref={variablesRef} className="relative">
467
- <button
468
- type="button"
469
- onClick={() => setIsVariablesOpen((open) => !open)}
470
- data-cv-tooltip={tooltipOf('variables')}
471
- aria-label={tooltipOf('variables')}
472
- aria-expanded={isVariablesOpen}
473
- className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
474
- >
475
- <Braces size={18} />
476
- </button>
477
- {isVariablesOpen ? (
478
- <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">
479
- {variables.map((variable) => (
480
- <button
481
- key={variable.id}
482
- type="button"
483
- data-cv-tooltip={variable.tooltip ?? variable.value} aria-label={variable.tooltip ?? variable.value}
484
- onClick={() => { insertAtCursor(variable.value); setIsVariablesOpen(false) }}
485
- 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"
486
- >
487
- <span className="text-sm text-gray-800 dark:text-gray-100">{variable.label}</span>
488
- <span className="font-mono text-xs text-gray-400 dark:text-gray-500">{variable.value}</span>
489
- </button>
490
- ))}
491
- </div>
492
- ) : null}
493
- </div>
494
- ) : null}
495
-
496
- {onAttachFiles && shows(RICH_COMPOSER_ACTION.ATTACH) ? (
497
- <>
498
- <input
499
- ref={fileInputRef}
500
- type="file"
501
- multiple
502
- accept={acceptedFileTypes}
503
- onChange={handleFileChange}
504
- className="hidden"
505
- />
746
+ {showQuickRepliesButton && shows(RICH_COMPOSER_ACTION.QUICK_REPLIES) ? (
747
+ <div className="relative">
748
+ <button
749
+ ref={quickRepliesTriggerRef}
750
+ type="button"
751
+ onClick={openQuickRepliesViaButton}
752
+ data-cv-tooltip={tooltipOf('quickReplies')}
753
+ aria-label={tooltipOf('quickReplies')}
754
+ aria-haspopup="listbox"
755
+ aria-expanded={isQuickRepliesOpen}
756
+ aria-controls={quickRepliesListboxId}
757
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
758
+ >
759
+ <Zap size={18} />
760
+ </button>
761
+ {isQuickRepliesOpen ? (
762
+ <div ref={quickRepliesPopoverRef} className="absolute bottom-full left-0 z-20 mb-2">
763
+ <QuickRepliesPicker
764
+ key={quickRepliesMode}
765
+ id={quickRepliesListboxId}
766
+ items={quickRepliesPicker.items}
767
+ search={quickRepliesTerm}
768
+ highlightedIndex={quickRepliesPicker.highlightedIndex}
769
+ isLoading={quickRepliesPicker.isLoading}
770
+ hasError={quickRepliesPicker.hasError}
771
+ onHover={quickRepliesPicker.setHighlightedIndex}
772
+ onSelect={insertSavedQuickReply}
773
+ labels={savedQuickReplies?.labels}
774
+ variables={savedQuickReplies?.variables}
775
+ hasAttachmentsCapability={savedQuickReplies?.hasAttachmentsCapability}
776
+ ownSearch={
777
+ quickRepliesMode === 'button'
778
+ ? {
779
+ value: quickRepliesTerm,
780
+ onChange: setQuickRepliesTerm,
781
+ onKeyDown: quickRepliesPicker.handleKeyDown,
782
+ label: tooltipOf('quickReplies'),
783
+ }
784
+ : undefined
785
+ }
786
+ />
787
+ </div>
788
+ ) : null}
789
+ </div>
790
+ ) : null}
791
+
792
+ {onAttachFiles && shows(RICH_COMPOSER_ACTION.ATTACH) ? (
793
+ <>
794
+ <input
795
+ ref={fileInputRef}
796
+ type="file"
797
+ multiple
798
+ accept={acceptedFileTypes}
799
+ onChange={handleFileChange}
800
+ className="hidden"
801
+ />
802
+ <button
803
+ type="button"
804
+ onClick={() => fileInputRef.current?.click()}
805
+ data-cv-tooltip={tooltipOf('attach')}
806
+ aria-label={tooltipOf('attach')}
807
+ className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
808
+ >
809
+ <Paperclip size={18} />
810
+ </button>
811
+ </>
812
+ ) : null}
813
+
814
+ <div
815
+ ref={editorRef}
816
+ contentEditable={!disabled}
817
+ suppressContentEditableWarning
818
+ role="textbox"
819
+ aria-multiline="true"
820
+ aria-label={placeholder}
821
+ aria-expanded={showQuickRepliesButton ? isQuickRepliesOpen : undefined}
822
+ aria-controls={showQuickRepliesButton ? quickRepliesListboxId : undefined}
823
+ aria-activedescendant={
824
+ isQuickRepliesOpen && quickRepliesMode === 'shortcut'
825
+ ? `${quickRepliesListboxId}-option-${quickRepliesPicker.highlightedIndex}`
826
+ : undefined
827
+ }
828
+ data-placeholder={placeholder}
829
+ onInput={handleInput}
830
+ onKeyDown={handleKeyDown}
831
+ style={{ minHeight: '36px', maxHeight: '120px' }}
832
+ // `min-w-0` em vez de uma largura mínima em px: com o mínimo fixo o campo se recusava a
833
+ // encolher junto com a coluna e era ele quem estourava a linha ao ligar a prévia.
834
+ className="ml-1 min-w-0 flex-1 overflow-y-auto rounded-2xl border border-transparent bg-white 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 [&_b]:font-semibold [&_em]:italic [&_i]:italic [&_del]:line-through [&_s]:line-through [&_strike]:line-through [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-sm dark:[&_code]:bg-white/10"
835
+ />
836
+
837
+ {!canSend && idleAction ? (
838
+ <div className="flex-shrink-0">{idleAction}</div>
839
+ ) : (
506
840
  <button
507
841
  type="button"
508
- onClick={() => fileInputRef.current?.click()}
509
- data-cv-tooltip={tooltipOf('attach')}
510
- aria-label={tooltipOf('attach')}
511
- className={cn(COMPOSER_TOOL_BUTTON_CLASS, COMPOSER_TOOL_BUTTON_IDLE_CLASS)}
842
+ onClick={onSend}
843
+ disabled={disabled || isSending || !canSend}
844
+ data-cv-tooltip={tooltipOf('send')}
845
+ aria-label={tooltipOf('send')}
846
+ 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"
512
847
  >
513
- <Paperclip size={18} />
848
+ {isSending ? <span className="text-xs">⏳</span> : <SendHorizonal size={16} />}
514
849
  </button>
515
- </>
516
- ) : null}
517
-
518
- <div
519
- ref={editorRef}
520
- contentEditable={!disabled}
521
- suppressContentEditableWarning
522
- role="textbox"
523
- aria-multiline="true"
524
- aria-label={placeholder}
525
- data-placeholder={placeholder}
526
- onInput={handleInput}
527
- onKeyDown={handleKeyDown}
528
- style={{ minHeight: '36px', maxHeight: '120px' }}
529
- // `min-w-0` em vez de uma largura mínima em px: com o mínimo fixo o campo se recusava a
530
- // encolher junto com a coluna e era ele quem estourava a linha ao ligar a prévia.
531
- className="ml-1 min-w-0 flex-1 overflow-y-auto rounded-2xl border border-transparent bg-white 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 [&_b]:font-semibold [&_em]:italic [&_i]:italic [&_del]:line-through [&_s]:line-through [&_strike]:line-through [&_code]:rounded [&_code]:bg-black/5 [&_code]:px-0.5 [&_code]:font-mono [&_code]:text-sm dark:[&_code]:bg-white/10"
532
- />
533
-
534
- {!canSend && idleAction ? (
535
- <div className="flex-shrink-0">{idleAction}</div>
536
- ) : (
537
- <button
538
- type="button"
539
- onClick={onSend}
540
- disabled={disabled || isSending || !canSend}
541
- data-cv-tooltip={tooltipOf('send')}
542
- aria-label={tooltipOf('send')}
543
- 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"
544
- >
545
- {isSending ? <span className="text-xs">⏳</span> : <SendHorizonal size={16} />}
546
- </button>
547
- )}
850
+ )}
851
+ </div>
548
852
  </div>
549
- </div>
550
- )
551
- })
853
+ )
854
+ },
855
+ )