@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.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 +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- package/src/preview/AudioRecorderButton.tsx +0 -117
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barra de composição do atendente: texto rico com a formatação do WhatsApp, respostas rápidas e
|
|
3
|
+
* anexos.
|
|
4
|
+
*
|
|
5
|
+
* Distinto do `MessageComposer`, que é um `textarea` simples: aqui o operador vê o negrito em
|
|
6
|
+
* negrito enquanto escreve, e não os asteriscos. Quem só precisa de uma caixa de texto continua no
|
|
7
|
+
* `MessageComposer`.
|
|
8
|
+
*
|
|
9
|
+
* Nada aqui sabe de produto. O que aparece na barra é decidido por `toolbar`, o texto de cada dica
|
|
10
|
+
* por `tooltips`, e as respostas rápidas chegam prontas por `quickReplies` — rótulo e conteúdo são
|
|
11
|
+
* do host, a mecânica é daqui.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type KeyboardEvent, type ChangeEvent, type FormEvent, type ReactNode } from 'react'
|
|
15
|
+
import { Bold, Italic, Strikethrough, Code, Paperclip, SendHorizonal, Braces, Type } from 'lucide-react'
|
|
16
|
+
|
|
17
|
+
import { cn } from './lib/cn'
|
|
18
|
+
import {
|
|
19
|
+
COMPOSER_BAR_CLASS,
|
|
20
|
+
COMPOSER_COMPACT_WIDTH,
|
|
21
|
+
COMPOSER_MONOSPACE_CLASS,
|
|
22
|
+
COMPOSER_TOOL_BUTTON_ACTIVE_CLASS,
|
|
23
|
+
COMPOSER_TOOL_BUTTON_CLASS,
|
|
24
|
+
COMPOSER_TOOL_BUTTON_IDLE_CLASS,
|
|
25
|
+
QUICK_REPLY_PILL_CLASS,
|
|
26
|
+
} from './composer.constant'
|
|
27
|
+
import { useContainerWidth } from './hooks/useContainerWidth'
|
|
28
|
+
import {
|
|
29
|
+
FORMATTING_ACTION,
|
|
30
|
+
activeFormattingIn,
|
|
31
|
+
codeAncestorOf,
|
|
32
|
+
exitMonospaceAfter,
|
|
33
|
+
isFormattingActive,
|
|
34
|
+
startMonospaceAt,
|
|
35
|
+
toggleFormattingCommand,
|
|
36
|
+
unwrapMonospace,
|
|
37
|
+
type FormattingAction,
|
|
38
|
+
} from './lib/composer-formatting'
|
|
39
|
+
import { htmlToWA, waToHTML } from './lib/whatsapp-formatting'
|
|
40
|
+
import { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
41
|
+
import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
42
|
+
|
|
43
|
+
/** Cada ação que a barra sabe oferecer. `toolbar` decide quais delas aparecem. */
|
|
44
|
+
export const RICH_COMPOSER_ACTION = {
|
|
45
|
+
BOLD: 'bold',
|
|
46
|
+
ITALIC: 'italic',
|
|
47
|
+
STRIKETHROUGH: 'strikethrough',
|
|
48
|
+
MONOSPACE: 'monospace',
|
|
49
|
+
EMOJI: 'emoji',
|
|
50
|
+
ATTACH: 'attach',
|
|
51
|
+
VARIABLES: 'variables',
|
|
52
|
+
} as const
|
|
53
|
+
export type RichComposerAction = (typeof RICH_COMPOSER_ACTION)[keyof typeof RICH_COMPOSER_ACTION]
|
|
54
|
+
|
|
55
|
+
/** Texto das dicas. O host troca só o que quiser; o resto fica no padrão. */
|
|
56
|
+
export interface RichComposerTooltips {
|
|
57
|
+
bold: string
|
|
58
|
+
italic: string
|
|
59
|
+
strikethrough: string
|
|
60
|
+
monospace: string
|
|
61
|
+
emoji: string
|
|
62
|
+
attach: string
|
|
63
|
+
send: string
|
|
64
|
+
variables: string
|
|
65
|
+
/** Botão que abre a formatação recolhida, quando a barra está estreita demais para os quatro. */
|
|
66
|
+
formatting: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const DEFAULT_RICH_COMPOSER_TOOLTIPS: RichComposerTooltips = {
|
|
70
|
+
bold: 'Negrito',
|
|
71
|
+
italic: 'Itálico',
|
|
72
|
+
strikethrough: 'Tachado',
|
|
73
|
+
monospace: 'Monoespaçado',
|
|
74
|
+
emoji: 'Emojis',
|
|
75
|
+
attach: 'Anexar arquivo',
|
|
76
|
+
send: 'Enviar',
|
|
77
|
+
variables: 'Variáveis disponíveis',
|
|
78
|
+
formatting: 'Formatação',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Um valor que o operador pode inserir no texto sem digitar. `value` é o que entra no campo — pode
|
|
83
|
+
* ser o dado já resolvido ("Anderson") ou um marcador a resolver depois ("{{nome}}"): quem decide é
|
|
84
|
+
* o host, porque só ele sabe se a substituição acontece aqui ou no envio.
|
|
85
|
+
*/
|
|
86
|
+
export interface RichComposerVariable {
|
|
87
|
+
id: string
|
|
88
|
+
label: string
|
|
89
|
+
value: string
|
|
90
|
+
/** Prévia do que será inserido, para o operador conferir antes de tocar. */
|
|
91
|
+
tooltip?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Uma resposta rápida: o que o operador lê no chip e o que cai no campo ao tocar nele. */
|
|
95
|
+
export interface RichComposerQuickReply {
|
|
96
|
+
id: string
|
|
97
|
+
label: string
|
|
98
|
+
text: string
|
|
99
|
+
/** Dica ao passar o mouse. Sem isto, o chip não tem `title` — rótulo curto já se explica. */
|
|
100
|
+
tooltip?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Cada formatação vira o elemento correspondente dentro do campo, não os asteriscos crus: o ponto
|
|
105
|
+
* do editor rico é o operador ver o negrito em negrito. A conversão para a notação do WhatsApp
|
|
106
|
+
* acontece na saída, no `htmlToWA`.
|
|
107
|
+
*/
|
|
108
|
+
const FORMATTING_ELEMENT = {
|
|
109
|
+
bold: 'strong',
|
|
110
|
+
italic: 'em',
|
|
111
|
+
strikethrough: 'del',
|
|
112
|
+
monospace: 'code',
|
|
113
|
+
} as const
|
|
114
|
+
|
|
115
|
+
/** Handle imperativo: `contentEditable` controlado pelo React perde o cursor a cada tecla. */
|
|
116
|
+
export interface RichMessageComposerHandle {
|
|
117
|
+
setContent: (text: string) => void
|
|
118
|
+
clear: () => void
|
|
119
|
+
focus: () => void
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface RichMessageComposerProps {
|
|
123
|
+
/** Texto na notação do WhatsApp (`*negrito*`), não HTML. */
|
|
124
|
+
value: string
|
|
125
|
+
onChange: (value: string) => void
|
|
126
|
+
onSend: () => void
|
|
127
|
+
onAttachFiles?: (files: FileList) => void
|
|
128
|
+
quickReplies?: RichComposerQuickReply[]
|
|
129
|
+
/** Variáveis que o operador pode inserir no texto. Vazio ou ausente, o botão não aparece. */
|
|
130
|
+
variables?: RichComposerVariable[]
|
|
131
|
+
/**
|
|
132
|
+
* Quais ações aparecem. Ausente, todas aparecem — menos as que não têm como funcionar (anexo sem
|
|
133
|
+
* `onAttachFiles` some sozinho, porque botão que não faz nada é pior que botão nenhum).
|
|
134
|
+
*/
|
|
135
|
+
toolbar?: Partial<Record<RichComposerAction, boolean>>
|
|
136
|
+
tooltips?: Partial<RichComposerTooltips>
|
|
137
|
+
placeholder?: string
|
|
138
|
+
disabled?: boolean
|
|
139
|
+
isSending?: boolean
|
|
140
|
+
/** Ocupa o lugar do enviar enquanto não há nada para enviar — onde o WhatsApp põe o microfone. */
|
|
141
|
+
idleAction?: ReactNode
|
|
142
|
+
/** Prévia dos anexos já escolhidos, desenhada pelo host acima da barra. */
|
|
143
|
+
attachmentsPreview?: ReactNode
|
|
144
|
+
/**
|
|
145
|
+
* Há anexo na fila esperando envio. O campo não conhece a fila — é do host — e sem essa dica ele
|
|
146
|
+
* concluía que não havia nada a enviar: com uma imagem anexada e nenhum texto, o botão de enviar
|
|
147
|
+
* dava lugar ao `idleAction` e a imagem ficava presa.
|
|
148
|
+
*/
|
|
149
|
+
hasQueuedAttachments?: boolean
|
|
150
|
+
acceptedFileTypes?: string
|
|
151
|
+
className?: string
|
|
152
|
+
}
|
|
153
|
+
|
|
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('')
|
|
259
|
+
},
|
|
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])
|
|
307
|
+
|
|
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)
|
|
332
|
+
}
|
|
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
|
+
)
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<div className={cn(COMPOSER_BAR_CLASS, 'flex flex-col', className)}>
|
|
397
|
+
{attachmentsPreview}
|
|
398
|
+
|
|
399
|
+
{quickReplies?.length ? (
|
|
400
|
+
/* Uma linha só, rolando na horizontal. Deixar quebrar em várias linhas faz a barra crescer
|
|
401
|
+
conforme o número de respostas rápidas e empurrar a conversa para cima — o composer
|
|
402
|
+
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}
|
|
417
|
+
|
|
418
|
+
{/* Uma escala só para a barra inteira: todo botão é 36px com ícone de 18, o mesmo gap entre
|
|
419
|
+
todos e nenhuma margem própria. Com tamanhos e espaçamentos diferentes por grupo, o
|
|
420
|
+
`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
|
|
422
|
+
espaço quando a coluna estreita — que é o que ligar a prévia faz — em vez de empurrar
|
|
423
|
+
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
|
|
426
|
+
perder a seleção, e a formatação sai digitada à mão de qualquer jeito.
|
|
427
|
+
|
|
428
|
+
Com a barra estreita os quatro recolhem num botão só. Ceder a largura ao campo é a
|
|
429
|
+
escolha certa aqui: escrever é a ação da barra, formatar é o acessório — e é o campo,
|
|
430
|
+
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">
|
|
434
|
+
<button
|
|
435
|
+
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
|
+
)}
|
|
447
|
+
>
|
|
448
|
+
<Type size={18} />
|
|
449
|
+
</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}
|
|
453
|
+
</div>
|
|
454
|
+
) : null}
|
|
455
|
+
</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}
|
|
464
|
+
|
|
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
|
+
/>
|
|
506
|
+
<button
|
|
507
|
+
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)}
|
|
512
|
+
>
|
|
513
|
+
<Paperclip size={18} />
|
|
514
|
+
</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
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
)
|
|
551
|
+
})
|
|
@@ -42,10 +42,11 @@ export function SimpleEmojiPicker({ onSelect, label = 'Emojis', pickerWidth = '2
|
|
|
42
42
|
<button
|
|
43
43
|
type="button"
|
|
44
44
|
onClick={() => setOpen((v) => !v)}
|
|
45
|
-
className="w-
|
|
46
|
-
|
|
45
|
+
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-400 dark:text-gray-500 hover:text-teal-600 dark:hover:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 transition-colors"
|
|
46
|
+
data-cv-tooltip={label}
|
|
47
|
+
aria-label={label}
|
|
47
48
|
>
|
|
48
|
-
<Smile size={
|
|
49
|
+
<Smile size={18} />
|
|
49
50
|
</button>
|
|
50
51
|
|
|
51
52
|
{open && (
|
|
@@ -56,6 +57,7 @@ export function SimpleEmojiPicker({ onSelect, label = 'Emojis', pickerWidth = '2
|
|
|
56
57
|
>
|
|
57
58
|
{EMOJIS.map((emoji) => (
|
|
58
59
|
<button
|
|
60
|
+
data-cv-tooltip={emoji} aria-label={emoji}
|
|
59
61
|
key={emoji}
|
|
60
62
|
type="button"
|
|
61
63
|
onClick={() => { onSelect(emoji); setOpen(false) }}
|
package/src/StatusTicks.tsx
CHANGED
|
@@ -32,7 +32,7 @@ export function StatusTicks({ status, title }: StatusTicksProps) {
|
|
|
32
32
|
const colorClass = STATUS_COLOR_CLASS[status] ?? STATUS_COLOR_CLASS.sent
|
|
33
33
|
|
|
34
34
|
return (
|
|
35
|
-
<span className={`cursor-help leading-none flex items-center ${colorClass}`}
|
|
35
|
+
<span className={`cursor-help leading-none flex items-center ${colorClass}`} data-cv-tooltip={title}>
|
|
36
36
|
{status === 'failed' ? <AlertTriangle size={11} /> : <Ticks double={status !== 'sent'} />}
|
|
37
37
|
</span>
|
|
38
38
|
)
|
package/src/Toast.tsx
CHANGED
|
@@ -26,6 +26,8 @@ export const toast = {
|
|
|
26
26
|
},
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const TOAST_CLOSE_LABEL = 'Fechar aviso'
|
|
30
|
+
|
|
29
31
|
const typeStyles: Record<ToastType, string> = {
|
|
30
32
|
success: 'bg-green-600',
|
|
31
33
|
error: 'bg-red-600',
|
|
@@ -77,6 +79,8 @@ function ToastItemComponent({ item, onRemove }: { item: ToastItem; onRemove: (id
|
|
|
77
79
|
setExiting(true)
|
|
78
80
|
setTimeout(() => onRemove(item.id), 300)
|
|
79
81
|
}}
|
|
82
|
+
data-cv-tooltip={TOAST_CLOSE_LABEL}
|
|
83
|
+
aria-label={TOAST_CLOSE_LABEL}
|
|
80
84
|
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity"
|
|
81
85
|
>
|
|
82
86
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { tooltipPositionOf } from './Tooltip'
|
|
4
|
+
|
|
5
|
+
const VIEWPORT_WIDTH = 1024
|
|
6
|
+
|
|
7
|
+
describe('tooltipPositionOf', () => {
|
|
8
|
+
test('desenha acima do alvo e centralizado nele', () => {
|
|
9
|
+
const position = tooltipPositionOf({
|
|
10
|
+
targetRect: { top: 300, bottom: 336, left: 400, width: 36 },
|
|
11
|
+
viewportWidth: VIEWPORT_WIDTH,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(position.placement).toBe('top')
|
|
15
|
+
expect(position.left).toBe(418)
|
|
16
|
+
expect(position.top).toBe(292)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('desce quando o alvo está colado no topo da janela', () => {
|
|
20
|
+
const position = tooltipPositionOf({
|
|
21
|
+
targetRect: { top: 12, bottom: 48, left: 400, width: 36 },
|
|
22
|
+
viewportWidth: VIEWPORT_WIDTH,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
expect(position.placement).toBe('bottom')
|
|
26
|
+
expect(position.top).toBe(56)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('afasta da borda o balão de um alvo no canto', () => {
|
|
30
|
+
const esquerda = tooltipPositionOf({
|
|
31
|
+
targetRect: { top: 300, bottom: 336, left: 0, width: 36 },
|
|
32
|
+
viewportWidth: VIEWPORT_WIDTH,
|
|
33
|
+
})
|
|
34
|
+
const direita = tooltipPositionOf({
|
|
35
|
+
targetRect: { top: 300, bottom: 336, left: VIEWPORT_WIDTH - 36, width: 36 },
|
|
36
|
+
viewportWidth: VIEWPORT_WIDTH,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(esquerda.left).toBe(72)
|
|
40
|
+
expect(direita.left).toBe(VIEWPORT_WIDTH - 72)
|
|
41
|
+
})
|
|
42
|
+
})
|