@adatechnology/conversations-ui 0.1.0 → 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 +2 -2
- 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
package/src/MessageComposer.tsx
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
type KeyboardEvent,
|
|
8
|
+
type ChangeEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
2
11
|
import { AudioRecorderButton } from './AudioRecorderButton'
|
|
3
12
|
import type { ConversationsFeatures } from './types'
|
|
4
13
|
import { cn } from './lib/cn'
|
|
5
14
|
import { COMPOSER_BAR_CLASS, QUICK_REPLY_PILL_CLASS } from './composer.constant'
|
|
6
15
|
import { EmojiPicker } from './EmojiPicker'
|
|
16
|
+
import { QuickRepliesPicker } from './quickReplies/QuickRepliesPicker'
|
|
17
|
+
import { useQuickRepliesPicker } from './quickReplies/useQuickRepliesPicker'
|
|
18
|
+
import { detectQuickReplyShortcut, replaceQuickReplyShortcut } from './quickReplies/quickReplyShortcut'
|
|
19
|
+
import type { QuickReply as SavedQuickReply } from './quickReplies/quickReply.types'
|
|
20
|
+
import type { QuickRepliesPickerLabels } from './quickReplies/labels'
|
|
7
21
|
|
|
8
22
|
/**
|
|
9
23
|
* Mensagem pronta que o atendente cola no campo com um clique.
|
|
@@ -23,17 +37,11 @@ export interface QuickReply {
|
|
|
23
37
|
* Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
|
|
24
38
|
* `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
|
|
25
39
|
*/
|
|
26
|
-
export function applyQuickReplyVariables(
|
|
27
|
-
template: string,
|
|
28
|
-
variables: Readonly<Record<string, string>> = {},
|
|
29
|
-
): string {
|
|
40
|
+
export function applyQuickReplyVariables(template: string, variables: Readonly<Record<string, string>> = {}): string {
|
|
30
41
|
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
|
|
31
42
|
}
|
|
32
43
|
|
|
33
|
-
export function resolveQuickReply(
|
|
34
|
-
quickReply: QuickReply,
|
|
35
|
-
variables: Readonly<Record<string, string>> = {},
|
|
36
|
-
): string {
|
|
44
|
+
export function resolveQuickReply(quickReply: QuickReply, variables: Readonly<Record<string, string>> = {}): string {
|
|
37
45
|
return typeof quickReply.text === 'function'
|
|
38
46
|
? quickReply.text(variables)
|
|
39
47
|
: applyQuickReplyVariables(quickReply.text, variables)
|
|
@@ -44,6 +52,7 @@ export interface MessageComposerLabels {
|
|
|
44
52
|
attach: string
|
|
45
53
|
send: string
|
|
46
54
|
removeAttachment: string
|
|
55
|
+
quickReplies: string
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
|
|
@@ -51,6 +60,27 @@ export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
|
|
|
51
60
|
attach: 'Anexar',
|
|
52
61
|
send: 'Enviar',
|
|
53
62
|
removeAttachment: 'Remover anexo',
|
|
63
|
+
quickReplies: 'Mensagens prontas',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Mensagens prontas do produto, cadastradas pela tela de gestão. **Opcional por capacidade:** sem
|
|
68
|
+
* esta prop o botão de raio e o atalho `/` simplesmente não existem — não um botão que abre uma
|
|
69
|
+
* lista vazia.
|
|
70
|
+
*/
|
|
71
|
+
export interface MessageComposerSavedQuickReplies {
|
|
72
|
+
readonly listQuickReplies: (params?: { search?: string }) => Promise<SavedQuickReply[]>
|
|
73
|
+
/** Chave do cache do picker — troca de conversa não deve reconsultar o que já carregou. */
|
|
74
|
+
readonly conversationId: string
|
|
75
|
+
readonly variables?: Readonly<Record<string, string>>
|
|
76
|
+
readonly labels?: Partial<QuickRepliesPickerLabels>
|
|
77
|
+
/**
|
|
78
|
+
* Avisada antes de inserir o texto, para o host empurrar os anexos da mensagem escolhida na fila
|
|
79
|
+
* do composer (QR-32). Sem esta prop nada muda — o texto continua sendo inserido do mesmo jeito.
|
|
80
|
+
*/
|
|
81
|
+
readonly onSelect?: (quickReply: SavedQuickReply) => void
|
|
82
|
+
/** Sem `sendStoredAttachments` no host, a linha com anexo avisa em vez de prometer envio (QR-33). */
|
|
83
|
+
readonly hasAttachmentsCapability?: boolean
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
export interface MessageComposerProps {
|
|
@@ -73,6 +103,8 @@ export interface MessageComposerProps {
|
|
|
73
103
|
quickReplies?: readonly QuickReply[]
|
|
74
104
|
/** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
|
|
75
105
|
quickReplyVariables?: Readonly<Record<string, string>>
|
|
106
|
+
/** Botão de raio + atalho `/` para as mensagens prontas cadastradas. Ausente, nenhum dos dois aparece. */
|
|
107
|
+
savedQuickReplies?: MessageComposerSavedQuickReplies
|
|
76
108
|
className?: string
|
|
77
109
|
classNames?: Partial<MessageComposerClassNames>
|
|
78
110
|
}
|
|
@@ -113,6 +145,7 @@ export const MessageComposer = ({
|
|
|
113
145
|
onChange: externalOnChange,
|
|
114
146
|
quickReplies,
|
|
115
147
|
quickReplyVariables,
|
|
148
|
+
savedQuickReplies,
|
|
116
149
|
features,
|
|
117
150
|
placeholder = 'Digite uma mensagem...',
|
|
118
151
|
maxLength,
|
|
@@ -127,25 +160,150 @@ export const MessageComposer = ({
|
|
|
127
160
|
const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
|
|
128
161
|
const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
|
|
129
162
|
const removeAttachmentLabel = labels?.removeAttachment ?? DEFAULT_MESSAGE_COMPOSER_LABELS.removeAttachment
|
|
163
|
+
const quickRepliesLabel = labels?.quickReplies ?? DEFAULT_MESSAGE_COMPOSER_LABELS.quickReplies
|
|
130
164
|
const [internalText, setInternalText] = useState('')
|
|
131
165
|
const [showEmoji, setShowEmoji] = useState(false)
|
|
132
166
|
const [attachments, setAttachments] = useState<FilePreview[]>([])
|
|
133
167
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
134
168
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
169
|
+
const quickRepliesListboxId = useId()
|
|
170
|
+
const [isQuickRepliesOpen, setIsQuickRepliesOpen] = useState(false)
|
|
171
|
+
const [quickRepliesTerm, setQuickRepliesTerm] = useState('')
|
|
172
|
+
const [shortcutStart, setShortcutStart] = useState<number | null>(null)
|
|
173
|
+
/** `shortcut`: a busca vem do `/termo` digitado no campo. `button`: busca própria do picker — o
|
|
174
|
+
* raio nunca insere `/` na mensagem (QR-04). */
|
|
175
|
+
const [quickRepliesMode, setQuickRepliesMode] = useState<'shortcut' | 'button'>('shortcut')
|
|
176
|
+
const [caretPosition, setCaretPosition] = useState(0)
|
|
177
|
+
const quickRepliesPopoverRef = useRef<HTMLDivElement>(null)
|
|
178
|
+
const quickRepliesTriggerRef = useRef<HTMLButtonElement>(null)
|
|
135
179
|
|
|
136
180
|
const isControlled = externalValue !== undefined
|
|
137
181
|
const text = isControlled ? externalValue : internalText
|
|
138
182
|
|
|
139
|
-
const setText = useCallback(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
183
|
+
const setText = useCallback(
|
|
184
|
+
(newText: string) => {
|
|
185
|
+
if (isControlled) {
|
|
186
|
+
externalOnChange?.(newText)
|
|
187
|
+
} else {
|
|
188
|
+
setInternalText(newText)
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
[isControlled, externalOnChange],
|
|
192
|
+
)
|
|
146
193
|
|
|
147
194
|
const showEmojiButton = features?.emoji !== false
|
|
148
195
|
const showAttachButton = features?.documents !== false
|
|
196
|
+
const showQuickRepliesButton = Boolean(savedQuickReplies)
|
|
197
|
+
|
|
198
|
+
// Deriva o picker só do texto até o cursor: digitar "/doc" abre, apagar a "/" fecha, e mover o
|
|
199
|
+
// cursor para antes do atalho fecha também — sem isso o picker continuava aberto olhando para um
|
|
200
|
+
// "/termo" que não é mais o que está na frente do cursor (a raiz do bug de caret).
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!showQuickRepliesButton || quickRepliesMode === 'button') return
|
|
203
|
+
const match = detectQuickReplyShortcut(text.slice(0, caretPosition))
|
|
204
|
+
setIsQuickRepliesOpen(Boolean(match))
|
|
205
|
+
setQuickRepliesTerm(match?.term ?? '')
|
|
206
|
+
setShortcutStart(match?.start ?? null)
|
|
207
|
+
}, [text, caretPosition, showQuickRepliesButton, quickRepliesMode])
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!showQuickRepliesButton) setIsQuickRepliesOpen(false)
|
|
211
|
+
}, [showQuickRepliesButton])
|
|
212
|
+
|
|
213
|
+
const closeQuickReplies = useCallback(() => {
|
|
214
|
+
setIsQuickRepliesOpen(false)
|
|
215
|
+
setShortcutStart(null)
|
|
216
|
+
setQuickRepliesTerm('')
|
|
217
|
+
setQuickRepliesMode('shortcut')
|
|
218
|
+
}, [])
|
|
219
|
+
|
|
220
|
+
const closeQuickRepliesAndRefocus = useCallback(() => {
|
|
221
|
+
closeQuickReplies()
|
|
222
|
+
textareaRef.current?.focus()
|
|
223
|
+
}, [closeQuickReplies])
|
|
224
|
+
|
|
225
|
+
const trackCaret = useCallback((event: { currentTarget: HTMLTextAreaElement }) => {
|
|
226
|
+
setCaretPosition(event.currentTarget.selectionStart)
|
|
227
|
+
}, [])
|
|
228
|
+
|
|
229
|
+
const insertSavedQuickReply = useCallback(
|
|
230
|
+
(quickReply: SavedQuickReply) => {
|
|
231
|
+
savedQuickReplies?.onSelect?.(quickReply)
|
|
232
|
+
const ta = textareaRef.current
|
|
233
|
+
if (!ta) return
|
|
234
|
+
const resolvedBody = applyQuickReplyVariables(quickReply.body, savedQuickReplies?.variables)
|
|
235
|
+
|
|
236
|
+
if (quickRepliesMode === 'button') {
|
|
237
|
+
const start = ta.selectionStart
|
|
238
|
+
const end = ta.selectionEnd
|
|
239
|
+
const newText = text.slice(0, start) + resolvedBody + text.slice(end)
|
|
240
|
+
setText(newText)
|
|
241
|
+
closeQuickReplies()
|
|
242
|
+
requestAnimationFrame(() => {
|
|
243
|
+
ta.focus()
|
|
244
|
+
const caret = start + resolvedBody.length
|
|
245
|
+
ta.setSelectionRange(caret, caret)
|
|
246
|
+
})
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (shortcutStart === null) return
|
|
251
|
+
// Faixa exata do atalho detectado (`/` + termo), nunca a posição atual do cursor — que pode
|
|
252
|
+
// ter se movido para antes ou depois do "/termo" desde que ele foi digitado.
|
|
253
|
+
const shortcutEnd = shortcutStart + 1 + quickRepliesTerm.length
|
|
254
|
+
const { text: newText, caret } = replaceQuickReplyShortcut({
|
|
255
|
+
text,
|
|
256
|
+
start: shortcutStart,
|
|
257
|
+
caret: shortcutEnd,
|
|
258
|
+
replacement: resolvedBody,
|
|
259
|
+
})
|
|
260
|
+
setText(newText)
|
|
261
|
+
closeQuickReplies()
|
|
262
|
+
requestAnimationFrame(() => {
|
|
263
|
+
ta.focus()
|
|
264
|
+
ta.setSelectionRange(caret, caret)
|
|
265
|
+
})
|
|
266
|
+
},
|
|
267
|
+
[text, setText, shortcutStart, quickRepliesTerm, quickRepliesMode, savedQuickReplies, closeQuickReplies],
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
const quickRepliesPicker = useQuickRepliesPicker({
|
|
271
|
+
isOpen: isQuickRepliesOpen,
|
|
272
|
+
search: quickRepliesTerm,
|
|
273
|
+
listQuickReplies: savedQuickReplies?.listQuickReplies,
|
|
274
|
+
quickReplyVariables: savedQuickReplies?.variables,
|
|
275
|
+
onSelect: insertSavedQuickReply,
|
|
276
|
+
onClose: closeQuickRepliesAndRefocus,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// Botão de raio: abre o picker com busca própria — o campo de mensagem não é tocado (QR-04).
|
|
280
|
+
// Clicar de novo com o picker já aberto nesse modo fecha — o raio é um toggle, não só um "abrir".
|
|
281
|
+
const openQuickRepliesViaButton = useCallback(() => {
|
|
282
|
+
if (isQuickRepliesOpen && quickRepliesMode === 'button') {
|
|
283
|
+
closeQuickRepliesAndRefocus()
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
setQuickRepliesMode('button')
|
|
287
|
+
setQuickRepliesTerm('')
|
|
288
|
+
setShortcutStart(null)
|
|
289
|
+
setIsQuickRepliesOpen(true)
|
|
290
|
+
}, [isQuickRepliesOpen, quickRepliesMode, closeQuickRepliesAndRefocus])
|
|
291
|
+
|
|
292
|
+
// Clique fora do popover em modo botão fecha e devolve o foco ao campo — a busca própria não
|
|
293
|
+
// tem `blur` do campo para fechar sozinha, como o modo atalho tem. O próprio botão de raio é
|
|
294
|
+
// ignorado aqui: o `mousedown` nele já é tratado por `openQuickRepliesViaButton` como toggle, e
|
|
295
|
+
// sem essa exclusão o outside-click fechava primeiro e o `onClick` do botão reabria em seguida.
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (!isQuickRepliesOpen || quickRepliesMode !== 'button') return
|
|
298
|
+
const handleOutsideClick = (event: MouseEvent) => {
|
|
299
|
+
const target = event.target as Node
|
|
300
|
+
if (quickRepliesPopoverRef.current?.contains(target)) return
|
|
301
|
+
if (quickRepliesTriggerRef.current?.contains(target)) return
|
|
302
|
+
closeQuickRepliesAndRefocus()
|
|
303
|
+
}
|
|
304
|
+
document.addEventListener('mousedown', handleOutsideClick)
|
|
305
|
+
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
|
306
|
+
}, [isQuickRepliesOpen, quickRepliesMode, closeQuickRepliesAndRefocus])
|
|
149
307
|
|
|
150
308
|
const sendMessage = useCallback(() => {
|
|
151
309
|
const trimmed = text.trim()
|
|
@@ -161,12 +319,20 @@ export const MessageComposer = ({
|
|
|
161
319
|
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
162
320
|
}, [text, attachments, onSend, onAttach, isControlled])
|
|
163
321
|
|
|
164
|
-
const handleKeyDown = useCallback(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
322
|
+
const handleKeyDown = useCallback(
|
|
323
|
+
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
324
|
+
// Em modo botão quem recebe as teclas é a busca própria do picker, não o campo.
|
|
325
|
+
if (isQuickRepliesOpen && quickRepliesMode === 'shortcut') {
|
|
326
|
+
quickRepliesPicker.handleKeyDown(e)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
330
|
+
e.preventDefault()
|
|
331
|
+
if (!disabled) sendMessage()
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
[sendMessage, disabled, isQuickRepliesOpen, quickRepliesMode, quickRepliesPicker],
|
|
335
|
+
)
|
|
170
336
|
|
|
171
337
|
const handleInput = useCallback(() => {
|
|
172
338
|
const ta = textareaRef.current
|
|
@@ -175,19 +341,25 @@ export const MessageComposer = ({
|
|
|
175
341
|
ta.style.height = `${Math.min(ta.scrollHeight, 100)}px`
|
|
176
342
|
}, [])
|
|
177
343
|
|
|
178
|
-
const handleEmojiSelect = useCallback(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
ta.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
344
|
+
const handleEmojiSelect = useCallback(
|
|
345
|
+
(emoji: string) => {
|
|
346
|
+
const ta = textareaRef.current
|
|
347
|
+
if (!ta) {
|
|
348
|
+
setText(text + emoji)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
const start = ta.selectionStart
|
|
352
|
+
const end = ta.selectionEnd
|
|
353
|
+
const newText = text.slice(0, start) + emoji + text.slice(end)
|
|
354
|
+
setText(newText)
|
|
355
|
+
requestAnimationFrame(() => {
|
|
356
|
+
ta.focus()
|
|
357
|
+
ta.setSelectionRange(start + emoji.length, start + emoji.length)
|
|
358
|
+
handleInput()
|
|
359
|
+
})
|
|
360
|
+
},
|
|
361
|
+
[text, setText, handleInput],
|
|
362
|
+
)
|
|
191
363
|
|
|
192
364
|
const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
193
365
|
const files = e.target.files
|
|
@@ -197,12 +369,12 @@ export const MessageComposer = ({
|
|
|
197
369
|
const file = files[i]
|
|
198
370
|
previews.push({ file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' })
|
|
199
371
|
}
|
|
200
|
-
setAttachments(prev => [...prev, ...previews])
|
|
372
|
+
setAttachments((prev) => [...prev, ...previews])
|
|
201
373
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
202
374
|
}, [])
|
|
203
375
|
|
|
204
376
|
const removeAttachment = useCallback((index: number) => {
|
|
205
|
-
setAttachments(prev => {
|
|
377
|
+
setAttachments((prev) => {
|
|
206
378
|
const next = [...prev]
|
|
207
379
|
if (next[index].previewUrl) URL.revokeObjectURL(next[index].previewUrl)
|
|
208
380
|
next.splice(index, 1)
|
|
@@ -210,20 +382,6 @@ export const MessageComposer = ({
|
|
|
210
382
|
})
|
|
211
383
|
}, [])
|
|
212
384
|
|
|
213
|
-
const insertFormatting = useCallback((marker: string) => {
|
|
214
|
-
const ta = textareaRef.current
|
|
215
|
-
if (!ta) return
|
|
216
|
-
const start = ta.selectionStart; const end = ta.selectionEnd
|
|
217
|
-
const sel = text.slice(start, end)
|
|
218
|
-
if (sel) {
|
|
219
|
-
setText(text.slice(0, start) + marker + sel + marker + text.slice(end))
|
|
220
|
-
requestAnimationFrame(() => {
|
|
221
|
-
ta.focus()
|
|
222
|
-
ta.setSelectionRange(start + marker.length + sel.length + marker.length, start + marker.length + sel.length + marker.length)
|
|
223
|
-
})
|
|
224
|
-
}
|
|
225
|
-
}, [text, setText])
|
|
226
|
-
|
|
227
385
|
/**
|
|
228
386
|
* Microfone por padrão, sem o host precisar compor nada.
|
|
229
387
|
*
|
|
@@ -239,8 +397,7 @@ export const MessageComposer = ({
|
|
|
239
397
|
* duração ou revisão diferentes) não muda de comportamento ao atualizar.
|
|
240
398
|
*/
|
|
241
399
|
const effectiveIdleAction =
|
|
242
|
-
idleAction ??
|
|
243
|
-
(onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
|
|
400
|
+
idleAction ?? (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
|
|
244
401
|
|
|
245
402
|
const canSend = text.trim().length > 0 || attachments.length > 0
|
|
246
403
|
const remaining = maxLength ? maxLength - text.length : null
|
|
@@ -261,7 +418,8 @@ export const MessageComposer = ({
|
|
|
261
418
|
>
|
|
262
419
|
{quickReplies.map((quickReply) => (
|
|
263
420
|
<button
|
|
264
|
-
data-cv-tooltip={quickReply.label}
|
|
421
|
+
data-cv-tooltip={quickReply.label}
|
|
422
|
+
aria-label={quickReply.label}
|
|
265
423
|
key={quickReply.key}
|
|
266
424
|
type="button"
|
|
267
425
|
// Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
|
|
@@ -287,10 +445,20 @@ export const MessageComposer = ({
|
|
|
287
445
|
<img src={a.previewUrl} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-200" />
|
|
288
446
|
) : (
|
|
289
447
|
<div className="w-16 h-16 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center">
|
|
290
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5"
|
|
448
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5">
|
|
449
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
450
|
+
<polyline points="14 2 14 8 20 8" />
|
|
451
|
+
</svg>
|
|
291
452
|
</div>
|
|
292
453
|
)}
|
|
293
|
-
<button
|
|
454
|
+
<button
|
|
455
|
+
data-cv-tooltip={removeAttachmentLabel}
|
|
456
|
+
aria-label={removeAttachmentLabel}
|
|
457
|
+
onClick={() => removeAttachment(i)}
|
|
458
|
+
className="absolute -top-2 -right-2 w-5 h-5 bg-gray-600 text-white rounded-full flex items-center justify-center hover:bg-gray-800 text-xs"
|
|
459
|
+
>
|
|
460
|
+
✕
|
|
461
|
+
</button>
|
|
294
462
|
</div>
|
|
295
463
|
))}
|
|
296
464
|
</div>
|
|
@@ -299,8 +467,18 @@ export const MessageComposer = ({
|
|
|
299
467
|
<div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
|
|
300
468
|
{showEmojiButton && (
|
|
301
469
|
<div className="relative flex-shrink-0">
|
|
302
|
-
<button
|
|
303
|
-
|
|
470
|
+
<button
|
|
471
|
+
data-cv-tooltip={emojiLabel}
|
|
472
|
+
onClick={() => setShowEmoji((v) => !v)}
|
|
473
|
+
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors"
|
|
474
|
+
aria-label={emojiLabel}
|
|
475
|
+
>
|
|
476
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
477
|
+
<circle cx="12" cy="12" r="10" />
|
|
478
|
+
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
|
|
479
|
+
<circle cx="9" cy="9" r="0.5" fill="currentColor" />
|
|
480
|
+
<circle cx="15" cy="9" r="0.5" fill="currentColor" />
|
|
481
|
+
</svg>
|
|
304
482
|
</button>
|
|
305
483
|
{showEmoji && (
|
|
306
484
|
<div className="absolute bottom-full left-0 mb-2 z-10">
|
|
@@ -310,23 +488,97 @@ export const MessageComposer = ({
|
|
|
310
488
|
</div>
|
|
311
489
|
)}
|
|
312
490
|
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
491
|
+
<div className="relative flex-1">
|
|
492
|
+
<textarea
|
|
493
|
+
ref={textareaRef}
|
|
494
|
+
value={text}
|
|
495
|
+
onChange={(e) => {
|
|
496
|
+
setText(e.target.value)
|
|
497
|
+
setCaretPosition(e.target.selectionStart)
|
|
498
|
+
handleInput()
|
|
499
|
+
}}
|
|
500
|
+
onKeyDown={handleKeyDown}
|
|
501
|
+
onKeyUp={trackCaret}
|
|
502
|
+
onClick={trackCaret}
|
|
503
|
+
onSelect={trackCaret}
|
|
504
|
+
placeholder={placeholder}
|
|
505
|
+
rows={1}
|
|
506
|
+
disabled={disabled}
|
|
507
|
+
role={showQuickRepliesButton ? 'combobox' : undefined}
|
|
508
|
+
aria-expanded={showQuickRepliesButton ? isQuickRepliesOpen : undefined}
|
|
509
|
+
aria-controls={showQuickRepliesButton ? quickRepliesListboxId : undefined}
|
|
510
|
+
aria-activedescendant={
|
|
511
|
+
isQuickRepliesOpen && quickRepliesMode === 'shortcut'
|
|
512
|
+
? `${quickRepliesListboxId}-option-${quickRepliesPicker.highlightedIndex}`
|
|
513
|
+
: undefined
|
|
514
|
+
}
|
|
515
|
+
className="w-full resize-none bg-transparent text-[15px] text-[#3b4a54] placeholder-[#8696a0] outline-none py-1.5 max-h-[100px] leading-relaxed"
|
|
516
|
+
style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }}
|
|
517
|
+
/>
|
|
518
|
+
{isQuickRepliesOpen && (
|
|
519
|
+
<div ref={quickRepliesPopoverRef} className="absolute bottom-full left-0 mb-2 z-10">
|
|
520
|
+
<QuickRepliesPicker
|
|
521
|
+
key={quickRepliesMode}
|
|
522
|
+
id={quickRepliesListboxId}
|
|
523
|
+
items={quickRepliesPicker.items}
|
|
524
|
+
search={quickRepliesTerm}
|
|
525
|
+
highlightedIndex={quickRepliesPicker.highlightedIndex}
|
|
526
|
+
isLoading={quickRepliesPicker.isLoading}
|
|
527
|
+
hasError={quickRepliesPicker.hasError}
|
|
528
|
+
onHover={quickRepliesPicker.setHighlightedIndex}
|
|
529
|
+
onSelect={insertSavedQuickReply}
|
|
530
|
+
labels={savedQuickReplies?.labels}
|
|
531
|
+
variables={savedQuickReplies?.variables}
|
|
532
|
+
hasAttachmentsCapability={savedQuickReplies?.hasAttachmentsCapability}
|
|
533
|
+
ownSearch={
|
|
534
|
+
quickRepliesMode === 'button'
|
|
535
|
+
? {
|
|
536
|
+
value: quickRepliesTerm,
|
|
537
|
+
onChange: setQuickRepliesTerm,
|
|
538
|
+
onKeyDown: quickRepliesPicker.handleKeyDown,
|
|
539
|
+
label: quickRepliesLabel,
|
|
540
|
+
}
|
|
541
|
+
: undefined
|
|
542
|
+
}
|
|
543
|
+
/>
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
|
|
548
|
+
{showQuickRepliesButton && (
|
|
549
|
+
<button
|
|
550
|
+
ref={quickRepliesTriggerRef}
|
|
551
|
+
type="button"
|
|
552
|
+
data-cv-tooltip={quickRepliesLabel}
|
|
553
|
+
aria-label={quickRepliesLabel}
|
|
554
|
+
onClick={openQuickRepliesViaButton}
|
|
555
|
+
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors"
|
|
556
|
+
>
|
|
557
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
558
|
+
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
|
559
|
+
</svg>
|
|
560
|
+
</button>
|
|
561
|
+
)}
|
|
324
562
|
|
|
325
563
|
{showAttachButton && (
|
|
326
564
|
<>
|
|
327
|
-
<input
|
|
328
|
-
|
|
329
|
-
|
|
565
|
+
<input
|
|
566
|
+
ref={fileInputRef}
|
|
567
|
+
type="file"
|
|
568
|
+
multiple
|
|
569
|
+
accept={acceptedFileTypes}
|
|
570
|
+
onChange={handleFileChange}
|
|
571
|
+
className="hidden"
|
|
572
|
+
/>
|
|
573
|
+
<button
|
|
574
|
+
data-cv-tooltip={attachLabel}
|
|
575
|
+
onClick={() => fileInputRef.current?.click()}
|
|
576
|
+
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors"
|
|
577
|
+
aria-label={attachLabel}
|
|
578
|
+
>
|
|
579
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
580
|
+
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
581
|
+
</svg>
|
|
330
582
|
</button>
|
|
331
583
|
</>
|
|
332
584
|
)}
|
|
@@ -345,7 +597,9 @@ export const MessageComposer = ({
|
|
|
345
597
|
}`}
|
|
346
598
|
aria-label={sendLabel}
|
|
347
599
|
>
|
|
348
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"
|
|
600
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
601
|
+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
|
602
|
+
</svg>
|
|
349
603
|
</button>
|
|
350
604
|
)}
|
|
351
605
|
</div>
|
|
@@ -9,9 +9,7 @@ const QUICK_REPLIES: RichComposerQuickReply[] = [
|
|
|
9
9
|
{ id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
|
|
10
10
|
]
|
|
11
11
|
|
|
12
|
-
const VARIABLES: RichComposerVariable[] = [
|
|
13
|
-
{ id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
|
|
14
|
-
]
|
|
12
|
+
const VARIABLES: RichComposerVariable[] = [{ id: 'name', label: 'Nome do cliente', value: '{{nome}}' }]
|
|
15
13
|
|
|
16
14
|
describe('RichMessageComposer', () => {
|
|
17
15
|
it('mostra as dicas padrão em todas as ações da barra', () => {
|
|
@@ -80,7 +78,12 @@ describe('RichMessageComposer', () => {
|
|
|
80
78
|
<RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
|
|
81
79
|
)
|
|
82
80
|
const comTexto = renderToStaticMarkup(
|
|
83
|
-
<RichMessageComposer
|
|
81
|
+
<RichMessageComposer
|
|
82
|
+
value="oi"
|
|
83
|
+
onChange={noop}
|
|
84
|
+
onSend={noop}
|
|
85
|
+
idleAction={<button aria-label="Gravar áudio" />}
|
|
86
|
+
/>,
|
|
84
87
|
)
|
|
85
88
|
|
|
86
89
|
expect(vazio).toContain('aria-label="Gravar áudio"')
|
|
@@ -110,4 +113,19 @@ describe('RichMessageComposer', () => {
|
|
|
110
113
|
expect(markup).toContain('data-cv-tooltip="Enviar"')
|
|
111
114
|
expect(markup).not.toContain('aria-label="Gravar áudio"')
|
|
112
115
|
})
|
|
116
|
+
|
|
117
|
+
it('sem savedQuickReplies, não desenha o botão de raio', () => {
|
|
118
|
+
const withPort = renderToStaticMarkup(
|
|
119
|
+
<RichMessageComposer
|
|
120
|
+
value=""
|
|
121
|
+
onChange={noop}
|
|
122
|
+
onSend={noop}
|
|
123
|
+
savedQuickReplies={{ listQuickReplies: async () => [], conversationId: 'c1' }}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
const withoutPort = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
127
|
+
|
|
128
|
+
expect(withPort).toContain('data-cv-tooltip="Mensagens prontas"')
|
|
129
|
+
expect(withoutPort).not.toContain('data-cv-tooltip="Mensagens prontas"')
|
|
130
|
+
})
|
|
113
131
|
})
|