@adatechnology/conversations-ui 0.1.1 → 0.2.0
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/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
- package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
- package/dist/index.d.ts +304 -9
- package/dist/index.js +2166 -509
- package/dist/preview/index.d.ts +2 -2
- package/dist/preview/index.js +147 -1
- package/dist/styles.css +135 -0
- package/package.json +1 -1
- package/src/MessageComposer.test.tsx +14 -0
- package/src/MessageComposer.tsx +327 -73
- package/src/RichMessageComposer.test.tsx +22 -4
- package/src/RichMessageComposer.tsx +674 -370
- package/src/index.ts +28 -1
- package/src/preview/createMockConversationsApi.ts +184 -0
- package/src/providers/types.ts +34 -0
- package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
- package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
- package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
- package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
- package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
- package/src/quickReplies/createUploadQueue.test.ts +106 -0
- package/src/quickReplies/createUploadQueue.ts +69 -0
- package/src/quickReplies/index.ts +5 -0
- package/src/quickReplies/labels.ts +124 -0
- package/src/quickReplies/quickReply.types.ts +74 -0
- package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
- package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
- package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
- package/src/quickReplies/quickReplyAttachments.ts +289 -0
- package/src/quickReplies/quickReplySearch.test.ts +88 -0
- package/src/quickReplies/quickReplySearch.ts +104 -0
- package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
- package/src/quickReplies/quickReplyShortcut.ts +46 -0
- package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
- package/src/quickReplies/resolveConversationVariables.ts +41 -0
- package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
- package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
- package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
- package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
- package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
- package/src/styles.css +87 -0
- package/src/workspace/ConversationPane.tsx +137 -73
- package/src/workspace/ConversationsWorkspace.tsx +16 -1
- package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
- package/src/workspace/QueuedAttachmentsList.tsx +198 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/labels.ts +14 -0
- package/src/workspace/useComposerAttachmentRetry.ts +155 -0
- package/src/workspace/useComposerQueue.ts +220 -0
|
@@ -11,8 +11,20 @@
|
|
|
11
11
|
* do host, a mecânica é daqui.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
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>(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 ` `/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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
aria-
|
|
440
|
-
|
|
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
|
-
<
|
|
721
|
+
<Braces size={18} />
|
|
449
722
|
</button>
|
|
450
|
-
{
|
|
451
|
-
<div className="absolute bottom-full left-0 z-20 mb-2
|
|
452
|
-
{
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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={
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
<
|
|
848
|
+
{isSending ? <span className="text-xs">⏳</span> : <SendHorizonal size={16} />}
|
|
514
849
|
</button>
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
853
|
+
)
|
|
854
|
+
},
|
|
855
|
+
)
|