@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.41
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-BJNRLLDO.js +2708 -0
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2389 -678
- package/dist/index.d.ts +1171 -42
- package/dist/index.js +3755 -843
- package/dist/preview/index.d.ts +157 -42
- package/dist/preview/index.js +772 -191
- 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 +14 -3
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +347 -24
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +163 -45
- package/src/ConversationListItem.tsx +19 -2
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +31 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +382 -0
- package/src/EmojiPicker.tsx +70 -55
- package/src/FileIcon.test.ts +83 -0
- package/src/FileIcon.tsx +88 -11
- package/src/InteractiveMessage.test.tsx +41 -0
- package/src/InteractiveMessage.tsx +146 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +92 -16
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +75 -6
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +155 -19
- 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.test.tsx +21 -0
- package/src/Wallpaper.tsx +67 -7
- package/src/WhatsAppMessageEditor.tsx +34 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/audioRecorderFormat.test.ts +67 -0
- 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/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -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 +106 -69
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1219 -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/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/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +11 -7
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- 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 +129 -13
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/createMediaUrlResolver.ts +33 -0
- package/src/lib/paginated.test.ts +33 -0
- package/src/lib/paginated.ts +26 -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 +225 -17
- 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/MediaTypesPreview.tsx +87 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +175 -15
- 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 +127 -4
- package/src/preview/index.ts +51 -3
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/preview.test.ts +5 -3
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +288 -1
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +185 -10
- package/src/quickReply.test.ts +58 -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 +4 -1
- 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/types.ts +64 -1
- package/src/useWaitingNotifications.ts +74 -29
- 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/chunk-4R6Y43DQ.js +0 -726
- package/dist/chunk-NV2RZ5KT.js +0 -56
- package/dist/types-C0PtaO7S.d.ts +0 -207
package/src/MessageBubble.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
2
|
import { Check } from 'lucide-react'
|
|
3
|
-
import type { MessagePayload } from './types'
|
|
3
|
+
import type { InteractiveSelection, MessagePayload, MessageTranscription } from './types'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
import { StatusTicks } from './StatusTicks'
|
|
6
6
|
import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
|
|
7
7
|
import { Lightbox } from './Lightbox'
|
|
8
|
+
import { InteractiveMessage } from './InteractiveMessage'
|
|
8
9
|
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
9
10
|
import { cn } from './lib/cn'
|
|
11
|
+
import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
12
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
10
13
|
import { formatTimestamp, formatDateTime } from './lib/format'
|
|
11
14
|
|
|
12
15
|
export interface MessageBubbleProps {
|
|
@@ -17,7 +20,22 @@ export interface MessageBubbleProps {
|
|
|
17
20
|
isSelecting?: boolean
|
|
18
21
|
isSelected?: boolean
|
|
19
22
|
onToggleSelect?: () => void
|
|
23
|
+
/**
|
|
24
|
+
* Como buscar a mídia da mensagem. Ausente, o balão usa o `ConversationsApi` do
|
|
25
|
+
* `ConversationsProvider` — passe apenas para sobrescrever (cache próprio, CDN, proxy do host).
|
|
26
|
+
*/
|
|
20
27
|
onResolveMediaUrl?: ResolveMediaUrl
|
|
28
|
+
/**
|
|
29
|
+
* Toque numa opção do menu interativo. Ausente, as opções aparecem desabilitadas — que é o certo
|
|
30
|
+
* no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
|
|
31
|
+
*/
|
|
32
|
+
onInteractiveSelect?: (selection: InteractiveSelection) => void
|
|
33
|
+
/**
|
|
34
|
+
* Pede a transcrição do áudio. Ausente, o balão usa `transcribeAudio` do `ConversationsApi` do
|
|
35
|
+
* contexto — passe apenas para sobrescrever. Sem nenhum dos dois, o bloco de transcrição só exibe
|
|
36
|
+
* o que já veio pronto do backend.
|
|
37
|
+
*/
|
|
38
|
+
onTranscribeAudio?: (messageId: string) => Promise<MessageTranscription | void>
|
|
21
39
|
className?: string
|
|
22
40
|
}
|
|
23
41
|
|
|
@@ -39,15 +57,50 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
|
|
|
39
57
|
// tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
|
|
40
58
|
export function MessageBubble({
|
|
41
59
|
message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
|
|
42
|
-
onResolveMediaUrl, className,
|
|
60
|
+
onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
|
|
43
61
|
}: MessageBubbleProps) {
|
|
44
62
|
const { bubble, selection } = useConversationLocales()
|
|
45
63
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
|
64
|
+
const context = useConversations()
|
|
65
|
+
|
|
66
|
+
// Resolvedor pelo contexto quando o host não passa um: o contrato já declara `getDocumentUrl` e
|
|
67
|
+
// `getMediaProxyUrl`, então exigir que cada projeto ligasse esse fio só garantia que a mídia não
|
|
68
|
+
// carregasse em quem esquecesse. `useMemo` porque o resolvedor é a identidade de que o
|
|
69
|
+
// `MediaRenderer` depende para não refazer busca a cada render.
|
|
70
|
+
const resolveMediaUrl = useMemo(
|
|
71
|
+
() => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
|
|
72
|
+
[onResolveMediaUrl, context?.api],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `undefined` quando nem o host nem o `ConversationsApi` sabem transcrever — é essa ausência que o
|
|
77
|
+
* bloco de transcrição consulta para decidir se desenha a afordância, mesmo padrão de
|
|
78
|
+
* `takeover`/`release`.
|
|
79
|
+
*/
|
|
80
|
+
const requestTranscription = useMemo(() => {
|
|
81
|
+
const transcribe = onTranscribeAudio ?? context?.api?.transcribeAudio?.bind(context.api)
|
|
82
|
+
if (!transcribe) return undefined
|
|
83
|
+
// Devolve o resultado em vez de descartar: é o que o bloco exibe na hora, sem esperar refetch.
|
|
84
|
+
return () => transcribe(message.id)
|
|
85
|
+
}, [onTranscribeAudio, context?.api, message.id])
|
|
46
86
|
|
|
47
87
|
const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
|
|
48
88
|
const hasError = message.status === 'failed'
|
|
49
89
|
const isMedia = MEDIA_TYPES.has(message.type)
|
|
50
90
|
const isTemplate = message.type === 'template'
|
|
91
|
+
const isInteractive = message.type === 'interactive'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A legenda chega em `caption` quando o cliente manda, e em `content` quando somos nós — é lá que
|
|
95
|
+
* a API grava o texto do envio. Sem legenda a API repete o nome do arquivo em `content`, que não
|
|
96
|
+
* é conteúdo e não deve aparecer sob a mídia.
|
|
97
|
+
*/
|
|
98
|
+
const mediaCaption = isMedia
|
|
99
|
+
? [message.caption, message.content].find((text) => {
|
|
100
|
+
const trimmed = text?.trim()
|
|
101
|
+
return trimmed && trimmed !== message.filename
|
|
102
|
+
})
|
|
103
|
+
: undefined
|
|
51
104
|
const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
|
|
52
105
|
|
|
53
106
|
const tooltipText = message.status === 'read' && message.readAt
|
|
@@ -63,7 +116,7 @@ export function MessageBubble({
|
|
|
63
116
|
const checkbox = (
|
|
64
117
|
<button
|
|
65
118
|
onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
|
|
66
|
-
|
|
119
|
+
data-cv-tooltip={selection.select} aria-label={selection.select}
|
|
67
120
|
className={`
|
|
68
121
|
flex-shrink-0 self-end mb-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
|
|
69
122
|
${isSelected ? 'bg-teal-600 border-teal-600' : 'bg-white/80 dark:bg-black/40 border-black/20 dark:border-white/30'}
|
|
@@ -107,7 +160,7 @@ export function MessageBubble({
|
|
|
107
160
|
{message.moderation?.isOffensive && (
|
|
108
161
|
<div
|
|
109
162
|
className="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
|
|
110
|
-
|
|
163
|
+
data-cv-tooltip={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
|
|
111
164
|
>
|
|
112
165
|
<span aria-hidden>⚠️</span>
|
|
113
166
|
<span>{bubble.moderationFlagged}</span>
|
|
@@ -115,7 +168,23 @@ export function MessageBubble({
|
|
|
115
168
|
)}
|
|
116
169
|
|
|
117
170
|
{isMedia ? (
|
|
118
|
-
|
|
171
|
+
<>
|
|
172
|
+
<MediaRenderer
|
|
173
|
+
message={message}
|
|
174
|
+
onLightbox={setLightboxSrc}
|
|
175
|
+
onResolveUrl={resolveMediaUrl}
|
|
176
|
+
{...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
|
|
177
|
+
/>
|
|
178
|
+
{mediaCaption ? (
|
|
179
|
+
<div className="mt-1 text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words leading-[19px]">
|
|
180
|
+
{parseWhatsAppFormatting(mediaCaption)}
|
|
181
|
+
</div>
|
|
182
|
+
) : null}
|
|
183
|
+
</>
|
|
184
|
+
) : isInteractive && message.payload ? (
|
|
185
|
+
// O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
|
|
186
|
+
// só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
|
|
187
|
+
<InteractiveMessage payload={message.payload} onSelect={onInteractiveSelect} />
|
|
119
188
|
) : (
|
|
120
189
|
<>
|
|
121
190
|
{isTemplate && (
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { MessageComposer } from './MessageComposer'
|
|
5
|
+
|
|
6
|
+
describe('MessageComposer', () => {
|
|
7
|
+
it('mostra a ação ociosa no lugar do enviar quando não há nada para enviar', () => {
|
|
8
|
+
const markup = renderToStaticMarkup(
|
|
9
|
+
<MessageComposer onSend={() => {}} idleAction={<button aria-label="Gravar áudio" />} />,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
expect(markup).toContain('aria-label="Gravar áudio"')
|
|
13
|
+
expect(markup).not.toContain('aria-label="Enviar"')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('mantém o botão de enviar quando não há ação ociosa', () => {
|
|
17
|
+
const markup = renderToStaticMarkup(<MessageComposer onSend={() => {}} />)
|
|
18
|
+
|
|
19
|
+
expect(markup).toContain('aria-label="Enviar"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('mostra o enviar assim que há texto, mesmo com ação ociosa configurada', () => {
|
|
23
|
+
const markup = renderToStaticMarkup(
|
|
24
|
+
<MessageComposer
|
|
25
|
+
onSend={() => {}}
|
|
26
|
+
value="oi"
|
|
27
|
+
onChange={() => {}}
|
|
28
|
+
idleAction={<button aria-label="Gravar áudio" />}
|
|
29
|
+
/>,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(markup).toContain('aria-label="Enviar"')
|
|
33
|
+
expect(markup).not.toContain('aria-label="Gravar áudio"')
|
|
34
|
+
})
|
|
35
|
+
})
|
package/src/MessageComposer.tsx
CHANGED
|
@@ -1,9 +1,60 @@
|
|
|
1
|
-
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
|
|
1
|
+
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
|
|
2
|
+
import { AudioRecorderButton } from './AudioRecorderButton'
|
|
2
3
|
import type { ConversationsFeatures } from './types'
|
|
3
4
|
import { cn } from './lib/cn'
|
|
5
|
+
import { COMPOSER_BAR_CLASS, QUICK_REPLY_PILL_CLASS } from './composer.constant'
|
|
4
6
|
import { EmojiPicker } from './EmojiPicker'
|
|
5
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Mensagem pronta que o atendente cola no campo com um clique.
|
|
10
|
+
*
|
|
11
|
+
* `text` aceita string com `{{variavel}}` ou função: a string cobre o caso comum (copy fixa com o
|
|
12
|
+
* nome do cliente no meio) sem o host escrever código, e a função cobre o que precisa de lógica —
|
|
13
|
+
* escolher texto por produto, pluralizar, formatar moeda.
|
|
14
|
+
*/
|
|
15
|
+
export interface QuickReply {
|
|
16
|
+
key: string
|
|
17
|
+
/** O que aparece no chip, emoji incluso: `👋 Saudação`. */
|
|
18
|
+
label: string
|
|
19
|
+
text: string | ((variables: Readonly<Record<string, string>>) => string)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Troca `{{nome}}` pelos valores passados. Variável ausente vira string vazia, e não o literal
|
|
24
|
+
* `{{nome}}`: mandar "Olá {{nome}}!" para o cliente é pior que mandar "Olá !".
|
|
25
|
+
*/
|
|
26
|
+
export function applyQuickReplyVariables(
|
|
27
|
+
template: string,
|
|
28
|
+
variables: Readonly<Record<string, string>> = {},
|
|
29
|
+
): string {
|
|
30
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, chave: string) => variables[chave] ?? '')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveQuickReply(
|
|
34
|
+
quickReply: QuickReply,
|
|
35
|
+
variables: Readonly<Record<string, string>> = {},
|
|
36
|
+
): string {
|
|
37
|
+
return typeof quickReply.text === 'function'
|
|
38
|
+
? quickReply.text(variables)
|
|
39
|
+
: applyQuickReplyVariables(quickReply.text, variables)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MessageComposerLabels {
|
|
43
|
+
emoji: string
|
|
44
|
+
attach: string
|
|
45
|
+
send: string
|
|
46
|
+
removeAttachment: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
|
|
50
|
+
emoji: 'Emoji',
|
|
51
|
+
attach: 'Anexar',
|
|
52
|
+
send: 'Enviar',
|
|
53
|
+
removeAttachment: 'Remover anexo',
|
|
54
|
+
}
|
|
55
|
+
|
|
6
56
|
export interface MessageComposerProps {
|
|
57
|
+
labels?: Partial<MessageComposerLabels>
|
|
7
58
|
onSend: (text: string) => void
|
|
8
59
|
onAttach?: (file: File) => void
|
|
9
60
|
value?: string
|
|
@@ -13,17 +64,42 @@ export interface MessageComposerProps {
|
|
|
13
64
|
maxLength?: number
|
|
14
65
|
disabled?: boolean
|
|
15
66
|
acceptedFileTypes?: string
|
|
67
|
+
/**
|
|
68
|
+
* Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
|
|
69
|
+
* microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
|
|
70
|
+
*/
|
|
71
|
+
idleAction?: ReactNode
|
|
72
|
+
/** Mensagens prontas exibidas acima do campo. Vazio ou ausente, a faixa não é renderizada. */
|
|
73
|
+
quickReplies?: readonly QuickReply[]
|
|
74
|
+
/** Valores para `{{variavel}}` — tipicamente nome do cliente, produto, protocolo. */
|
|
75
|
+
quickReplyVariables?: Readonly<Record<string, string>>
|
|
16
76
|
className?: string
|
|
17
77
|
classNames?: Partial<MessageComposerClassNames>
|
|
18
78
|
}
|
|
19
79
|
|
|
20
80
|
export interface MessageComposerClassNames {
|
|
21
81
|
root: string
|
|
82
|
+
quickReplies: string
|
|
83
|
+
quickReply: string
|
|
22
84
|
field: string
|
|
23
85
|
}
|
|
24
86
|
|
|
25
|
-
|
|
26
|
-
|
|
87
|
+
/**
|
|
88
|
+
* Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
|
|
89
|
+
* fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
|
|
90
|
+
* empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
|
|
91
|
+
* Produto com regra própria passa `acceptedFileTypes`.
|
|
92
|
+
*/
|
|
93
|
+
export const DEFAULT_ACCEPTED_FILE_TYPES = [
|
|
94
|
+
'image/jpeg,image/png,image/webp',
|
|
95
|
+
'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
|
|
96
|
+
'video/mp4,video/3gpp',
|
|
97
|
+
'application/pdf,text/plain,text/csv',
|
|
98
|
+
'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
|
|
99
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
100
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
101
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
102
|
+
].join(',')
|
|
27
103
|
|
|
28
104
|
interface FilePreview {
|
|
29
105
|
file: File
|
|
@@ -35,14 +111,22 @@ export const MessageComposer = ({
|
|
|
35
111
|
onAttach,
|
|
36
112
|
value: externalValue,
|
|
37
113
|
onChange: externalOnChange,
|
|
114
|
+
quickReplies,
|
|
115
|
+
quickReplyVariables,
|
|
38
116
|
features,
|
|
39
117
|
placeholder = 'Digite uma mensagem...',
|
|
40
118
|
maxLength,
|
|
41
119
|
disabled = false,
|
|
42
120
|
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
121
|
+
idleAction,
|
|
43
122
|
className,
|
|
44
123
|
classNames,
|
|
124
|
+
labels,
|
|
45
125
|
}: MessageComposerProps) => {
|
|
126
|
+
const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
|
|
127
|
+
const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
|
|
128
|
+
const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
|
|
129
|
+
const removeAttachmentLabel = labels?.removeAttachment ?? DEFAULT_MESSAGE_COMPOSER_LABELS.removeAttachment
|
|
46
130
|
const [internalText, setInternalText] = useState('')
|
|
47
131
|
const [showEmoji, setShowEmoji] = useState(false)
|
|
48
132
|
const [attachments, setAttachments] = useState<FilePreview[]>([])
|
|
@@ -140,6 +224,24 @@ export const MessageComposer = ({
|
|
|
140
224
|
}
|
|
141
225
|
}, [text, setText])
|
|
142
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Microfone por padrão, sem o host precisar compor nada.
|
|
229
|
+
*
|
|
230
|
+
* Antes o gravador era só um componente exportado e um slot vazio: cada inbox tinha que lembrar de
|
|
231
|
+
* juntar os dois. Dois produtos, dois resultados — um ligou, o outro não, e a ausência não dava
|
|
232
|
+
* erro nenhum. Um composer de WhatsApp sem microfone está incompleto, então o default certo é ter.
|
|
233
|
+
*
|
|
234
|
+
* Depende de `onAttach` porque áudio gravado é um arquivo para entregar, e microfone que grava sem
|
|
235
|
+
* ter para onde mandar é pior que microfone nenhum — o operador fala e o áudio evapora. É a mesma
|
|
236
|
+
* regra de capacidade usada no resto do pacote: sem a porta, a afordância não aparece.
|
|
237
|
+
*
|
|
238
|
+
* `idleAction` continua vencendo: quem já compunha o próprio gravador (com rótulos, limite de
|
|
239
|
+
* duração ou revisão diferentes) não muda de comportamento ao atualizar.
|
|
240
|
+
*/
|
|
241
|
+
const effectiveIdleAction =
|
|
242
|
+
idleAction ??
|
|
243
|
+
(onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
|
|
244
|
+
|
|
143
245
|
const canSend = text.trim().length > 0 || attachments.length > 0
|
|
144
246
|
const remaining = maxLength ? maxLength - text.length : null
|
|
145
247
|
|
|
@@ -147,7 +249,36 @@ export const MessageComposer = ({
|
|
|
147
249
|
/* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
|
|
148
250
|
ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
|
|
149
251
|
descobriam o fundo branco da página, que lia como defeito. */
|
|
150
|
-
<div className={cn(
|
|
252
|
+
<div className={cn(COMPOSER_BAR_CLASS, className)}>
|
|
253
|
+
{/* Uma linha com scroll no celular e wrap no desktop: em 375px seis chips em wrap empurrariam
|
|
254
|
+
o campo para fora da tela, e o campo é a razão de a barra existir. */}
|
|
255
|
+
{quickReplies && quickReplies.length > 0 && (
|
|
256
|
+
<div
|
|
257
|
+
className={cn(
|
|
258
|
+
'mb-2 flex flex-nowrap gap-1 overflow-x-auto px-1 sm:flex-wrap sm:overflow-x-visible [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
259
|
+
classNames?.quickReplies,
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
{quickReplies.map((quickReply) => (
|
|
263
|
+
<button
|
|
264
|
+
data-cv-tooltip={quickReply.label} aria-label={quickReply.label}
|
|
265
|
+
key={quickReply.key}
|
|
266
|
+
type="button"
|
|
267
|
+
// Preenche o campo em vez de enviar: mensagem pronta é ponto de partida, e quem
|
|
268
|
+
// atende quase sempre ajusta uma palavra antes de mandar. Enviar direto no clique
|
|
269
|
+
// transformaria um toque errado em mensagem entregue ao cliente.
|
|
270
|
+
onClick={() => {
|
|
271
|
+
setText(resolveQuickReply(quickReply, quickReplyVariables))
|
|
272
|
+
textareaRef.current?.focus()
|
|
273
|
+
}}
|
|
274
|
+
className={cn(QUICK_REPLY_PILL_CLASS, classNames?.quickReply)}
|
|
275
|
+
>
|
|
276
|
+
{quickReply.label}
|
|
277
|
+
</button>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
|
|
151
282
|
{attachments.length > 0 && (
|
|
152
283
|
<div className="flex gap-2 px-1 pb-2 overflow-x-auto">
|
|
153
284
|
{attachments.map((a, i) => (
|
|
@@ -159,7 +290,7 @@ export const MessageComposer = ({
|
|
|
159
290
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9ca3af" strokeWidth="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
160
291
|
</div>
|
|
161
292
|
)}
|
|
162
|
-
<button onClick={() => removeAttachment(i)} 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">✕</button>
|
|
293
|
+
<button data-cv-tooltip={removeAttachmentLabel} aria-label={removeAttachmentLabel} onClick={() => removeAttachment(i)} 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">✕</button>
|
|
163
294
|
</div>
|
|
164
295
|
))}
|
|
165
296
|
</div>
|
|
@@ -168,7 +299,7 @@ export const MessageComposer = ({
|
|
|
168
299
|
<div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
|
|
169
300
|
{showEmojiButton && (
|
|
170
301
|
<div className="relative flex-shrink-0">
|
|
171
|
-
<button onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label=
|
|
302
|
+
<button data-cv-tooltip={emojiLabel} onClick={() => setShowEmoji(v => !v)} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 transition-colors" aria-label={emojiLabel}>
|
|
172
303
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><circle cx="9" cy="9" r="0.5" fill="currentColor"/><circle cx="15" cy="9" r="0.5" fill="currentColor"/></svg>
|
|
173
304
|
</button>
|
|
174
305
|
{showEmoji && (
|
|
@@ -194,24 +325,29 @@ export const MessageComposer = ({
|
|
|
194
325
|
{showAttachButton && (
|
|
195
326
|
<>
|
|
196
327
|
<input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
|
|
197
|
-
<button onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label=
|
|
328
|
+
<button data-cv-tooltip={attachLabel} onClick={() => fileInputRef.current?.click()} className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-gray-200 flex-shrink-0 transition-colors" aria-label={attachLabel}>
|
|
198
329
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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"/></svg>
|
|
199
330
|
</button>
|
|
200
331
|
</>
|
|
201
332
|
)}
|
|
202
333
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
334
|
+
{!canSend && effectiveIdleAction ? (
|
|
335
|
+
<div className="flex-shrink-0">{effectiveIdleAction}</div>
|
|
336
|
+
) : (
|
|
337
|
+
<button
|
|
338
|
+
data-cv-tooltip={sendLabel}
|
|
339
|
+
onClick={sendMessage}
|
|
340
|
+
disabled={!canSend || disabled}
|
|
341
|
+
className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
|
|
342
|
+
canSend && !disabled
|
|
343
|
+
? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
|
|
344
|
+
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
345
|
+
}`}
|
|
346
|
+
aria-label={sendLabel}
|
|
347
|
+
>
|
|
348
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
|
349
|
+
</button>
|
|
350
|
+
)}
|
|
215
351
|
</div>
|
|
216
352
|
|
|
217
353
|
{remaining !== null && (
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { RichMessageComposer, type RichComposerQuickReply, type RichComposerVariable } from './RichMessageComposer'
|
|
5
|
+
|
|
6
|
+
const noop = () => {}
|
|
7
|
+
|
|
8
|
+
const QUICK_REPLIES: RichComposerQuickReply[] = [
|
|
9
|
+
{ id: 'greeting', label: 'Saudação', text: 'Olá!', tooltip: 'Abre o atendimento' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const VARIABLES: RichComposerVariable[] = [
|
|
13
|
+
{ id: 'name', label: 'Nome do cliente', value: '{{nome}}' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
describe('RichMessageComposer', () => {
|
|
17
|
+
it('mostra as dicas padrão em todas as ações da barra', () => {
|
|
18
|
+
const markup = renderToStaticMarkup(
|
|
19
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} onAttachFiles={noop} />,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
for (const tooltip of ['Negrito', 'Itálico', 'Tachado', 'Monoespaçado', 'Anexar arquivo', 'Enviar']) {
|
|
23
|
+
expect(markup).toContain(`data-cv-tooltip="${tooltip}"`)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('deixa o host trocar o texto de uma dica sem mexer nas outras', () => {
|
|
28
|
+
const markup = renderToStaticMarkup(
|
|
29
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} tooltips={{ bold: 'Deixar em negrito' }} />,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
expect(markup).toContain('data-cv-tooltip="Deixar em negrito"')
|
|
33
|
+
expect(markup).not.toContain('data-cv-tooltip="Negrito"')
|
|
34
|
+
expect(markup).toContain('data-cv-tooltip="Itálico"')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('esconde só a ação que o host desligou', () => {
|
|
38
|
+
const markup = renderToStaticMarkup(
|
|
39
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} toolbar={{ monospace: false }} />,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
expect(markup).not.toContain('data-cv-tooltip="Monoespaçado"')
|
|
43
|
+
expect(markup).toContain('data-cv-tooltip="Negrito"')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('não desenha o anexo quando não há como anexar', () => {
|
|
47
|
+
const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
48
|
+
|
|
49
|
+
expect(markup).not.toContain('data-cv-tooltip="Anexar arquivo"')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('desenha os chips de resposta rápida com o rótulo e a dica do host', () => {
|
|
53
|
+
const markup = renderToStaticMarkup(
|
|
54
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} quickReplies={QUICK_REPLIES} />,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(markup).toContain('Saudação')
|
|
58
|
+
expect(markup).toContain('data-cv-tooltip="Abre o atendimento"')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('só oferece variáveis quando o host manda alguma', () => {
|
|
62
|
+
const semVariaveis = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
63
|
+
const comVariaveis = renderToStaticMarkup(
|
|
64
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} variables={VARIABLES} />,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
expect(semVariaveis).not.toContain('data-cv-tooltip="Variáveis disponíveis"')
|
|
68
|
+
expect(comVariaveis).toContain('data-cv-tooltip="Variáveis disponíveis"')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('nasce com toda formatação apagada, e diz isso ao leitor de tela', () => {
|
|
72
|
+
const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
73
|
+
|
|
74
|
+
expect(markup).toContain('aria-pressed="false"')
|
|
75
|
+
expect(markup).not.toContain('aria-pressed="true"')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('cede o lugar do enviar à ação ociosa enquanto não há texto', () => {
|
|
79
|
+
const vazio = renderToStaticMarkup(
|
|
80
|
+
<RichMessageComposer value="" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
|
|
81
|
+
)
|
|
82
|
+
const comTexto = renderToStaticMarkup(
|
|
83
|
+
<RichMessageComposer value="oi" onChange={noop} onSend={noop} idleAction={<button aria-label="Gravar áudio" />} />,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
expect(vazio).toContain('aria-label="Gravar áudio"')
|
|
87
|
+
expect(vazio).not.toContain('data-cv-tooltip="Enviar"')
|
|
88
|
+
expect(comTexto).toContain('data-cv-tooltip="Enviar"')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('desenha as quatro formatações abertas enquanto a barra não foi medida', () => {
|
|
92
|
+
const markup = renderToStaticMarkup(<RichMessageComposer value="" onChange={noop} onSend={noop} />)
|
|
93
|
+
|
|
94
|
+
expect(markup).toContain('data-cv-tooltip="Negrito"')
|
|
95
|
+
// O botão que recolhe as quatro só aparece com a barra medida e estreita.
|
|
96
|
+
expect(markup).not.toContain('data-cv-tooltip="Formatação"')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('mostra o enviar com anexo na fila e nenhum texto, senão a imagem fica presa', () => {
|
|
100
|
+
const markup = renderToStaticMarkup(
|
|
101
|
+
<RichMessageComposer
|
|
102
|
+
value=""
|
|
103
|
+
onChange={noop}
|
|
104
|
+
onSend={noop}
|
|
105
|
+
hasQueuedAttachments
|
|
106
|
+
idleAction={<button aria-label="Gravar áudio" />}
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(markup).toContain('data-cv-tooltip="Enviar"')
|
|
111
|
+
expect(markup).not.toContain('aria-label="Gravar áudio"')
|
|
112
|
+
})
|
|
113
|
+
})
|