@adatechnology/conversations-ui 0.1.0-rc.0 → 0.1.0-rc.10
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/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
- package/dist/chunk-FWNAWAJP.js +1743 -0
- package/dist/flows/index.js +3 -3
- package/dist/index.d.ts +619 -136
- package/dist/index.js +1064 -1024
- package/dist/preview/index.d.ts +330 -0
- package/dist/preview/index.js +1281 -0
- package/dist/styles.css +198 -0
- package/dist/types-B5C1DLu1.d.ts +365 -0
- package/package.json +10 -3
- package/src/Avatar.tsx +30 -4
- package/src/ChannelIcon.tsx +87 -0
- package/src/ConversationContextPanel.tsx +106 -0
- package/src/ConversationDocumentsPanel.tsx +425 -0
- package/src/ConversationHeader.tsx +257 -0
- package/src/ConversationListItem.tsx +54 -7
- package/src/ConversationLocalesProvider.tsx +16 -0
- package/src/ConversationRow.tsx +137 -0
- package/src/DateDivider.tsx +16 -3
- package/src/DocumentsLibrary.tsx +322 -0
- package/src/EmojiPicker.tsx +69 -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 +143 -0
- package/src/Lightbox.tsx +18 -3
- package/src/MediaRenderer.tsx +14 -11
- package/src/MessageBubble.tsx +54 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +73 -19
- package/src/Wallpaper.test.tsx +21 -0
- package/src/Wallpaper.tsx +54 -6
- package/src/WhatsAppMessageEditor.tsx +28 -4
- package/src/WindowExpiredNotice.tsx +57 -0
- package/src/conversationChannel.test.ts +53 -0
- package/src/conversationChannel.ts +146 -0
- package/src/conversationTranscript.test.ts +65 -0
- package/src/conversationTranscript.ts +64 -0
- package/src/conversationWindow.test.ts +90 -0
- package/src/conversationWindow.ts +78 -0
- package/src/emojiCatalog.test.ts +35 -0
- package/src/emojiCatalog.ts +189 -0
- package/src/flows/FlowMapCanvas.tsx +2 -2
- package/src/hooks/useConversationActions.ts +56 -0
- package/src/hooks/useConversationDocuments.ts +15 -9
- package/src/hooks/useConversationList.ts +15 -9
- package/src/hooks/useConversationMessages.ts +2 -2
- package/src/index.ts +112 -16
- package/src/lib/cn.test.ts +29 -0
- package/src/lib/cn.ts +15 -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/phone.ts +34 -0
- package/src/preview/AudioRecorderButton.tsx +125 -0
- package/src/preview/ConversationPreview.tsx +315 -0
- package/src/preview/MediaTypesPreview.tsx +87 -0
- package/src/preview/audioRecorderFormat.test.ts +67 -0
- package/src/preview/conversationPreviewFailures.test.ts +64 -0
- package/src/preview/createMockConversationsApi.ts +271 -0
- package/src/preview/createMockSSEProvider.ts +40 -0
- package/src/preview/createPreviewWebhookClient.test.ts +105 -0
- package/src/preview/createPreviewWebhookClient.ts +126 -0
- package/src/preview/index.ts +54 -0
- package/src/preview/mediaTypeOf.test.ts +15 -0
- package/src/preview/mockDocumentsSearch.test.ts +57 -0
- package/src/preview/mockEventSource.ts +53 -0
- package/src/preview/preview.test.ts +177 -0
- package/src/preview/previewFileSamples.test.ts +151 -0
- package/src/preview/previewFileSamples.ts +74 -0
- package/src/preview/previewFixtures.ts +440 -0
- package/src/preview/previewMediaSource.test.ts +62 -0
- package/src/preview/previewMediaSource.ts +91 -0
- package/src/preview/previewStore.ts +193 -0
- package/src/preview/startPreviewScript.ts +60 -0
- package/src/providers/types.ts +163 -11
- package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
- package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
- package/src/styles.css +136 -0
- package/src/types.ts +46 -1
- package/src/useDarkMode.ts +26 -0
- package/src/useIsNarrow.ts +29 -0
- package/src/useWaitingNotifications.ts +74 -29
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { InteractiveMessage } from './InteractiveMessage'
|
|
5
|
+
import type { InteractivePayload } from './types'
|
|
6
|
+
|
|
7
|
+
function render(payload: InteractivePayload): string {
|
|
8
|
+
return renderToStaticMarkup(<InteractiveMessage payload={payload} />)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('InteractiveMessage', () => {
|
|
12
|
+
it('formata negrito no corpo, como o aparelho faz', () => {
|
|
13
|
+
const markup = render({ type: 'button', body: { text: 'Qual o *prazo desejado*?' } })
|
|
14
|
+
|
|
15
|
+
expect(markup).toContain('<strong>prazo desejado</strong>')
|
|
16
|
+
expect(markup).not.toContain('*prazo desejado*')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('formata cabeçalho e rodapé também', () => {
|
|
20
|
+
const markup = render({
|
|
21
|
+
type: 'list',
|
|
22
|
+
header: { text: '*Menu*' },
|
|
23
|
+
body: { text: 'corpo' },
|
|
24
|
+
footer: { text: '_Selecione_' },
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(markup).toContain('<strong>Menu</strong>')
|
|
28
|
+
expect(markup).toContain('<em>Selecione</em>')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('deixa título de opção literal, porque o WhatsApp não formata ali', () => {
|
|
32
|
+
const markup = render({
|
|
33
|
+
type: 'button',
|
|
34
|
+
body: { text: 'corpo' },
|
|
35
|
+
action: { buttons: [{ reply: { id: 'sim', title: '*Sim*' } }] },
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(markup).toContain('*Sim*')
|
|
39
|
+
expect(markup).not.toContain('<strong>Sim</strong>')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu interativo do WhatsApp — botões de resposta rápida e lista de opções.
|
|
3
|
+
*
|
|
4
|
+
* A mensagem é gravada com o payload cru que foi enviado à Meta, então a UI apenas o desenha; não
|
|
5
|
+
* há segunda fonte de verdade sobre quais opções existiam naquele momento. Sem isto, mensagem
|
|
6
|
+
* `interactive` aparecia como texto solto e o menu simplesmente sumia da conversa — inclusive no
|
|
7
|
+
* simulador, onde o operador precisa ver exatamente o que o cliente vê.
|
|
8
|
+
*
|
|
9
|
+
* `onSelect` é opcional de propósito: no histórico da inbox as opções são leitura (o operador não
|
|
10
|
+
* responde no lugar do cliente); no simulador elas são clicáveis.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState } from 'react'
|
|
14
|
+
import type { InteractiveOption, InteractivePayload, InteractiveSelection } from './types'
|
|
15
|
+
import { cn } from './lib/cn'
|
|
16
|
+
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
17
|
+
|
|
18
|
+
/** `parseWhatsAppFormatting` emite `<strong>`/`<em>`/`<del>`, que o reset do Tailwind zera. */
|
|
19
|
+
const FORMATTING_CLASSES = '[&_strong]:font-bold [&_em]:italic [&_del]:line-through'
|
|
20
|
+
|
|
21
|
+
export interface InteractiveMessageLabels {
|
|
22
|
+
/** Fallback do rótulo do botão que abre a lista, quando o payload não traz um. */
|
|
23
|
+
openList: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_INTERACTIVE_MESSAGE_LABELS: InteractiveMessageLabels = {
|
|
27
|
+
openList: 'Ver opções',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface InteractiveMessageProps {
|
|
31
|
+
payload: InteractivePayload
|
|
32
|
+
onSelect?: (selection: InteractiveSelection) => void
|
|
33
|
+
labels?: Partial<InteractiveMessageLabels>
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectRows(payload: InteractivePayload): InteractiveOption[] {
|
|
38
|
+
return (payload.action?.sections ?? []).flatMap((section) => section.rows ?? [])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function collectButtons(payload: InteractivePayload): InteractiveOption[] {
|
|
42
|
+
return (payload.action?.buttons ?? [])
|
|
43
|
+
.map((button) => button.reply)
|
|
44
|
+
.filter((reply): reply is InteractiveOption => reply !== undefined)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function InteractiveMessage({ payload, onSelect, labels, className }: InteractiveMessageProps) {
|
|
48
|
+
const openListLabel = labels?.openList ?? DEFAULT_INTERACTIVE_MESSAGE_LABELS.openList
|
|
49
|
+
const [isListOpen, setIsListOpen] = useState(false)
|
|
50
|
+
|
|
51
|
+
const buttons = collectButtons(payload)
|
|
52
|
+
const sections = payload.action?.sections ?? []
|
|
53
|
+
const hasRows = collectRows(payload).length > 0
|
|
54
|
+
const isInteractable = onSelect !== undefined
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn('flex flex-col gap-1', className)}>
|
|
58
|
+
{/* Cabeçalho, corpo e rodapé passam pela formatação do WhatsApp — o aparelho renderiza
|
|
59
|
+
`*negrito*` neles como em qualquer texto. Título e descrição de opção NÃO: ali o WhatsApp
|
|
60
|
+
mostra os asteriscos literais, e formatá-los aqui deixaria o simulador mais bonito que o
|
|
61
|
+
aparelho, que é o tipo de divergência que só aparece com o cliente na frente. */}
|
|
62
|
+
{payload.header?.text ? (
|
|
63
|
+
<p className={cn('text-sm font-semibold text-gray-900 dark:text-gray-100', FORMATTING_CLASSES)}>
|
|
64
|
+
{parseWhatsAppFormatting(payload.header.text)}
|
|
65
|
+
</p>
|
|
66
|
+
) : null}
|
|
67
|
+
|
|
68
|
+
{payload.body?.text ? (
|
|
69
|
+
<p
|
|
70
|
+
className={cn(
|
|
71
|
+
'whitespace-pre-wrap break-words text-sm text-gray-900 dark:text-gray-100',
|
|
72
|
+
FORMATTING_CLASSES,
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{parseWhatsAppFormatting(payload.body.text)}
|
|
76
|
+
</p>
|
|
77
|
+
) : null}
|
|
78
|
+
|
|
79
|
+
{payload.footer?.text ? (
|
|
80
|
+
<p className={cn('text-xs text-gray-500 dark:text-gray-400', FORMATTING_CLASSES)}>
|
|
81
|
+
{parseWhatsAppFormatting(payload.footer.text)}
|
|
82
|
+
</p>
|
|
83
|
+
) : null}
|
|
84
|
+
|
|
85
|
+
{buttons.length > 0 ? (
|
|
86
|
+
<div className="mt-1 flex flex-col gap-1 border-t border-gray-200 pt-1 dark:border-gray-700">
|
|
87
|
+
{buttons.map((button) => (
|
|
88
|
+
<button
|
|
89
|
+
key={button.id}
|
|
90
|
+
type="button"
|
|
91
|
+
disabled={!isInteractable}
|
|
92
|
+
onClick={() => onSelect?.({ kind: 'button', option: button })}
|
|
93
|
+
className="rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors enabled:hover:bg-teal-50 disabled:cursor-default dark:text-teal-300 dark:enabled:hover:bg-teal-900/30"
|
|
94
|
+
>
|
|
95
|
+
{button.title}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
|
|
101
|
+
{hasRows ? (
|
|
102
|
+
<div className="mt-1 border-t border-gray-200 pt-1 dark:border-gray-700">
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => setIsListOpen((open) => !open)}
|
|
106
|
+
aria-expanded={isListOpen}
|
|
107
|
+
className="w-full rounded-md px-3 py-1.5 text-sm font-medium text-teal-700 transition-colors hover:bg-teal-50 dark:text-teal-300 dark:hover:bg-teal-900/30"
|
|
108
|
+
>
|
|
109
|
+
☰ {payload.action?.button ?? openListLabel}
|
|
110
|
+
</button>
|
|
111
|
+
|
|
112
|
+
{isListOpen ? (
|
|
113
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
114
|
+
{sections.map((section, sectionIndex) => (
|
|
115
|
+
<div key={section.title ?? sectionIndex} className="flex flex-col">
|
|
116
|
+
{section.title ? (
|
|
117
|
+
<p className="px-3 py-1 text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">
|
|
118
|
+
{section.title}
|
|
119
|
+
</p>
|
|
120
|
+
) : null}
|
|
121
|
+
{(section.rows ?? []).map((row) => (
|
|
122
|
+
<button
|
|
123
|
+
key={row.id}
|
|
124
|
+
type="button"
|
|
125
|
+
disabled={!isInteractable}
|
|
126
|
+
onClick={() => onSelect?.({ kind: 'list', option: row })}
|
|
127
|
+
className="rounded-md px-3 py-1.5 text-left text-sm text-gray-900 transition-colors enabled:hover:bg-gray-100 disabled:cursor-default dark:text-gray-100 dark:enabled:hover:bg-gray-700"
|
|
128
|
+
>
|
|
129
|
+
<span className="block">{row.title}</span>
|
|
130
|
+
{row.description ? (
|
|
131
|
+
<span className="block text-xs text-gray-500 dark:text-gray-400">{row.description}</span>
|
|
132
|
+
) : null}
|
|
133
|
+
</button>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
) : null}
|
|
139
|
+
</div>
|
|
140
|
+
) : null}
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
package/src/Lightbox.tsx
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
+
export interface LightboxLabels {
|
|
2
|
+
/** Texto alternativo quando a imagem não tem legenda — sem ele o leitor de tela anuncia a URL. */
|
|
3
|
+
imageAlt: string
|
|
4
|
+
close: string
|
|
5
|
+
}
|
|
6
|
+
|
|
1
7
|
export interface LightboxProps {
|
|
2
8
|
imageUrl: string
|
|
3
9
|
caption?: string
|
|
4
10
|
onClose: () => void
|
|
11
|
+
labels?: Partial<LightboxLabels>
|
|
5
12
|
}
|
|
6
13
|
|
|
7
|
-
export
|
|
14
|
+
export const DEFAULT_LIGHTBOX_LABELS: LightboxLabels = {
|
|
15
|
+
imageAlt: 'Imagem',
|
|
16
|
+
close: 'Fechar',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Lightbox({ imageUrl, caption, onClose, labels }: LightboxProps) {
|
|
20
|
+
const imageAltLabel = labels?.imageAlt ?? DEFAULT_LIGHTBOX_LABELS.imageAlt
|
|
21
|
+
const closeLabel = labels?.close ?? DEFAULT_LIGHTBOX_LABELS.close
|
|
22
|
+
|
|
8
23
|
return (
|
|
9
24
|
<div className="fixed inset-0 z-50 bg-black/85 flex items-center justify-center p-4" onClick={onClose}>
|
|
10
25
|
<div className="max-w-[90vw] max-h-[90vh] flex flex-col items-center" onClick={(e) => e.stopPropagation()}>
|
|
11
|
-
<img src={imageUrl} alt={caption ??
|
|
26
|
+
<img src={imageUrl} alt={caption ?? imageAltLabel} className="max-w-full max-h-[80vh] object-contain rounded-lg" />
|
|
12
27
|
{caption && <p className="text-white text-sm mt-3 text-center">{caption}</p>}
|
|
13
|
-
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">
|
|
28
|
+
<button onClick={onClose} className="mt-4 px-4 py-2 bg-white/20 text-white rounded-lg hover:bg-white/30 transition-colors">{closeLabel}</button>
|
|
14
29
|
</div>
|
|
15
30
|
</div>
|
|
16
31
|
)
|
package/src/MediaRenderer.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { AudioPlayer } from './AudioPlayer'
|
|
|
3
3
|
import { FileIcon } from './FileIcon'
|
|
4
4
|
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
5
|
import { formatFileSize } from './lib/format'
|
|
6
|
+
import { cn } from './lib/cn'
|
|
6
7
|
import type { MessagePayload } from './types'
|
|
7
8
|
|
|
8
9
|
function resolveMediaSource(message: MessagePayload): string | null {
|
|
@@ -30,6 +31,8 @@ export interface MediaRendererProps {
|
|
|
30
31
|
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
31
32
|
// porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
32
33
|
onResolveUrl?: ResolveMediaUrl
|
|
34
|
+
/** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
|
|
35
|
+
className?: string
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
|
|
@@ -55,7 +58,7 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
|
|
|
55
58
|
return { url, loading, error, load }
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
|
|
61
|
+
export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
|
|
59
62
|
const { bubble } = useConversationLocales()
|
|
60
63
|
const eagerSrc = resolveMediaSource(message)
|
|
61
64
|
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
@@ -70,14 +73,14 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
70
73
|
if (!src && canLazyLoad) {
|
|
71
74
|
return (
|
|
72
75
|
<button onClick={lazy.load} className={lazyButtonClass}>
|
|
73
|
-
{lazy.loading ?
|
|
76
|
+
{lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewImage}
|
|
74
77
|
</button>
|
|
75
78
|
)
|
|
76
79
|
}
|
|
77
80
|
return (
|
|
78
81
|
<div className="min-w-[200px]">
|
|
79
82
|
{src ? (
|
|
80
|
-
<img src={src} alt={message.caption ??
|
|
83
|
+
<img src={src} alt={message.caption ?? bubble.imageAlt} className="w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity" onClick={() => onLightbox(src)} loading="lazy" />
|
|
81
84
|
) : (
|
|
82
85
|
<div className="w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400">
|
|
83
86
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" /></svg>
|
|
@@ -90,7 +93,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
90
93
|
if (!src && canLazyLoad) {
|
|
91
94
|
return (
|
|
92
95
|
<button onClick={lazy.load} className={lazyButtonClass}>
|
|
93
|
-
{lazy.loading ?
|
|
96
|
+
{lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.viewVideo}
|
|
94
97
|
</button>
|
|
95
98
|
)
|
|
96
99
|
}
|
|
@@ -110,14 +113,14 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
110
113
|
if (!src && canLazyLoad) {
|
|
111
114
|
return (
|
|
112
115
|
<button onClick={lazy.load} className={lazyButtonClass}>
|
|
113
|
-
{lazy.loading ?
|
|
116
|
+
{lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
|
|
114
117
|
</button>
|
|
115
118
|
)
|
|
116
119
|
}
|
|
117
120
|
return (
|
|
118
121
|
<div className="min-w-[200px]">
|
|
119
122
|
{src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
|
|
120
|
-
<div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">
|
|
123
|
+
<div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
|
|
121
124
|
)}
|
|
122
125
|
</div>
|
|
123
126
|
)
|
|
@@ -126,16 +129,16 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
126
129
|
const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
|
|
127
130
|
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
|
|
128
131
|
return (
|
|
129
|
-
<div className=
|
|
132
|
+
<div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
|
|
130
133
|
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
131
134
|
<FileIcon filename={message.filename} mimeType={message.mimeType} />
|
|
132
135
|
</div>
|
|
133
136
|
<div className="flex-1 min-w-0">
|
|
134
|
-
<p className="text-sm font-medium truncate">{message.filename ??
|
|
137
|
+
<p className="text-sm font-medium truncate">{message.filename ?? bubble.untitledDocument}</p>
|
|
135
138
|
<p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel}</p>
|
|
136
139
|
</div>
|
|
137
140
|
{src ? (
|
|
138
|
-
<a href={src} download={message.filename} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors" aria-label=
|
|
141
|
+
<a href={src} download={message.filename} className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors" aria-label={bubble.downloadFile}>
|
|
139
142
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-gray-600"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
|
|
140
143
|
</a>
|
|
141
144
|
) : canLazyLoad ? (
|
|
@@ -143,12 +146,12 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
|
|
|
143
146
|
onClick={lazy.load}
|
|
144
147
|
disabled={lazy.loading}
|
|
145
148
|
className="w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 flex-shrink-0 transition-colors disabled:opacity-50"
|
|
146
|
-
aria-label=
|
|
149
|
+
aria-label={bubble.downloadFile}
|
|
147
150
|
>
|
|
148
151
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-gray-600"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
|
|
149
152
|
</button>
|
|
150
153
|
) : null}
|
|
151
|
-
{lazy.error && <span className="text-xs text-red-500 flex-shrink-0">
|
|
154
|
+
{lazy.error && <span className="text-xs text-red-500 flex-shrink-0">{bubble.mediaError}</span>}
|
|
152
155
|
</div>
|
|
153
156
|
)
|
|
154
157
|
}
|
package/src/MessageBubble.tsx
CHANGED
|
@@ -1,11 +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 } 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'
|
|
10
|
+
import { cn } from './lib/cn'
|
|
11
|
+
import { createMediaUrlResolver } from './lib/createMediaUrlResolver'
|
|
12
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
9
13
|
import { formatTimestamp, formatDateTime } from './lib/format'
|
|
10
14
|
|
|
11
15
|
export interface MessageBubbleProps {
|
|
@@ -16,7 +20,17 @@ export interface MessageBubbleProps {
|
|
|
16
20
|
isSelecting?: boolean
|
|
17
21
|
isSelected?: boolean
|
|
18
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
|
+
*/
|
|
19
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
|
+
className?: string
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
// Hex arbitrários (não os tokens `whatsapp.*` do Tailwind) — o pacote fica autocontido,
|
|
@@ -37,15 +51,26 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
|
|
|
37
51
|
// tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
|
|
38
52
|
export function MessageBubble({
|
|
39
53
|
message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
|
|
40
|
-
onResolveMediaUrl,
|
|
54
|
+
onResolveMediaUrl, onInteractiveSelect, className,
|
|
41
55
|
}: MessageBubbleProps) {
|
|
42
56
|
const { bubble, selection } = useConversationLocales()
|
|
43
57
|
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
|
58
|
+
const context = useConversations()
|
|
59
|
+
|
|
60
|
+
// Resolvedor pelo contexto quando o host não passa um: o contrato já declara `getDocumentUrl` e
|
|
61
|
+
// `getMediaProxyUrl`, então exigir que cada projeto ligasse esse fio só garantia que a mídia não
|
|
62
|
+
// carregasse em quem esquecesse. `useMemo` porque o resolvedor é a identidade de que o
|
|
63
|
+
// `MediaRenderer` depende para não refazer busca a cada render.
|
|
64
|
+
const resolveMediaUrl = useMemo(
|
|
65
|
+
() => onResolveMediaUrl ?? (context?.api ? createMediaUrlResolver(context.api) : undefined),
|
|
66
|
+
[onResolveMediaUrl, context?.api],
|
|
67
|
+
)
|
|
44
68
|
|
|
45
69
|
const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
|
|
46
70
|
const hasError = message.status === 'failed'
|
|
47
71
|
const isMedia = MEDIA_TYPES.has(message.type)
|
|
48
72
|
const isTemplate = message.type === 'template'
|
|
73
|
+
const isInteractive = message.type === 'interactive'
|
|
49
74
|
const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
|
|
50
75
|
|
|
51
76
|
const tooltipText = message.status === 'read' && message.readAt
|
|
@@ -73,7 +98,14 @@ export function MessageBubble({
|
|
|
73
98
|
)
|
|
74
99
|
|
|
75
100
|
return (
|
|
76
|
-
<div
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
'flex items-end gap-1.5 group',
|
|
104
|
+
isMine ? 'justify-end' : 'justify-start',
|
|
105
|
+
isFirstInGroup ? 'mt-2' : 'mt-0.5',
|
|
106
|
+
className,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
77
109
|
{isMine && checkbox}
|
|
78
110
|
<div
|
|
79
111
|
onClick={isSelecting ? onToggleSelect : undefined}
|
|
@@ -92,8 +124,25 @@ export function MessageBubble({
|
|
|
92
124
|
</div>
|
|
93
125
|
)}
|
|
94
126
|
|
|
127
|
+
{/* Sinaliza, não censura nem esconde: cliente xingando é cliente irritado, e o atendente
|
|
128
|
+
precisa ler o que foi dito para responder. A etiqueta serve para achar o caso na thread
|
|
129
|
+
e para justificar escalar. */}
|
|
130
|
+
{message.moderation?.isOffensive && (
|
|
131
|
+
<div
|
|
132
|
+
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"
|
|
133
|
+
title={message.moderation.terms.length > 0 ? message.moderation.terms.join(', ') : undefined}
|
|
134
|
+
>
|
|
135
|
+
<span aria-hidden>⚠️</span>
|
|
136
|
+
<span>{bubble.moderationFlagged}</span>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
95
140
|
{isMedia ? (
|
|
96
|
-
<MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={
|
|
141
|
+
<MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
|
|
142
|
+
) : isInteractive && message.payload ? (
|
|
143
|
+
// O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
|
|
144
|
+
// só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
|
|
145
|
+
<InteractiveMessage payload={message.payload} onSelect={onInteractiveSelect} />
|
|
97
146
|
) : (
|
|
98
147
|
<>
|
|
99
148
|
{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,8 +1,22 @@
|
|
|
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
2
|
import type { ConversationsFeatures } from './types'
|
|
3
|
+
import { cn } from './lib/cn'
|
|
3
4
|
import { EmojiPicker } from './EmojiPicker'
|
|
4
5
|
|
|
6
|
+
export interface MessageComposerLabels {
|
|
7
|
+
emoji: string
|
|
8
|
+
attach: string
|
|
9
|
+
send: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_MESSAGE_COMPOSER_LABELS: MessageComposerLabels = {
|
|
13
|
+
emoji: 'Emoji',
|
|
14
|
+
attach: 'Anexar',
|
|
15
|
+
send: 'Enviar',
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
export interface MessageComposerProps {
|
|
19
|
+
labels?: Partial<MessageComposerLabels>
|
|
6
20
|
onSend: (text: string) => void
|
|
7
21
|
onAttach?: (file: File) => void
|
|
8
22
|
value?: string
|
|
@@ -12,10 +26,36 @@ export interface MessageComposerProps {
|
|
|
12
26
|
maxLength?: number
|
|
13
27
|
disabled?: boolean
|
|
14
28
|
acceptedFileTypes?: string
|
|
29
|
+
/**
|
|
30
|
+
* Ocupa o lugar do botão de enviar enquanto não há nada para enviar — é onde o WhatsApp põe o
|
|
31
|
+
* microfone. Fora do campo, o botão vira um bloco solto ao lado do pill e quebra a barra.
|
|
32
|
+
*/
|
|
33
|
+
idleAction?: ReactNode
|
|
34
|
+
className?: string
|
|
35
|
+
classNames?: Partial<MessageComposerClassNames>
|
|
15
36
|
}
|
|
16
37
|
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
export interface MessageComposerClassNames {
|
|
39
|
+
root: string
|
|
40
|
+
field: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Exatamente o que a Meta aceita em mensagem de mídia — imagem, sticker, áudio, vídeo e a lista
|
|
45
|
+
* fechada de documentos. Oferecer no seletor um formato que o WhatsApp recusa (`.zip`, `.rtf`)
|
|
46
|
+
* empurra a falha para depois do envio, quando já não dá para explicar ao operador o que houve.
|
|
47
|
+
* Produto com regra própria passa `acceptedFileTypes`.
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_ACCEPTED_FILE_TYPES = [
|
|
50
|
+
'image/jpeg,image/png,image/webp',
|
|
51
|
+
'audio/aac,audio/mp4,audio/mpeg,audio/amr,audio/ogg',
|
|
52
|
+
'video/mp4,video/3gpp',
|
|
53
|
+
'application/pdf,text/plain,text/csv',
|
|
54
|
+
'application/msword,application/vnd.ms-excel,application/vnd.ms-powerpoint',
|
|
55
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
56
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
57
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
58
|
+
].join(',')
|
|
19
59
|
|
|
20
60
|
interface FilePreview {
|
|
21
61
|
file: File
|
|
@@ -32,7 +72,14 @@ export const MessageComposer = ({
|
|
|
32
72
|
maxLength,
|
|
33
73
|
disabled = false,
|
|
34
74
|
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
75
|
+
idleAction,
|
|
76
|
+
className,
|
|
77
|
+
classNames,
|
|
78
|
+
labels,
|
|
35
79
|
}: MessageComposerProps) => {
|
|
80
|
+
const emojiLabel = labels?.emoji ?? DEFAULT_MESSAGE_COMPOSER_LABELS.emoji
|
|
81
|
+
const attachLabel = labels?.attach ?? DEFAULT_MESSAGE_COMPOSER_LABELS.attach
|
|
82
|
+
const sendLabel = labels?.send ?? DEFAULT_MESSAGE_COMPOSER_LABELS.send
|
|
36
83
|
const [internalText, setInternalText] = useState('')
|
|
37
84
|
const [showEmoji, setShowEmoji] = useState(false)
|
|
38
85
|
const [attachments, setAttachments] = useState<FilePreview[]>([])
|
|
@@ -134,7 +181,10 @@ export const MessageComposer = ({
|
|
|
134
181
|
const remaining = maxLength ? maxLength - text.length : null
|
|
135
182
|
|
|
136
183
|
return (
|
|
137
|
-
|
|
184
|
+
/* A barra é a superfície (cinza, largura cheia, sem raio) e o campo dentro é que arredonda —
|
|
185
|
+
ordem do WhatsApp. Invertido, o pill arredondado ia até a borda da tela e os cantos
|
|
186
|
+
descobriam o fundo branco da página, que lia como defeito. */
|
|
187
|
+
<div className={cn('bg-[#f0f2f5] px-2 py-2', className)}>
|
|
138
188
|
{attachments.length > 0 && (
|
|
139
189
|
<div className="flex gap-2 px-1 pb-2 overflow-x-auto">
|
|
140
190
|
{attachments.map((a, i) => (
|
|
@@ -152,10 +202,10 @@ export const MessageComposer = ({
|
|
|
152
202
|
</div>
|
|
153
203
|
)}
|
|
154
204
|
|
|
155
|
-
<div className=
|
|
205
|
+
<div className={cn('flex items-end gap-1.5 rounded-xl bg-white px-3 py-2', classNames?.field)}>
|
|
156
206
|
{showEmojiButton && (
|
|
157
207
|
<div className="relative flex-shrink-0">
|
|
158
|
-
<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=
|
|
208
|
+
<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={emojiLabel}>
|
|
159
209
|
<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>
|
|
160
210
|
</button>
|
|
161
211
|
{showEmoji && (
|
|
@@ -181,24 +231,28 @@ export const MessageComposer = ({
|
|
|
181
231
|
{showAttachButton && (
|
|
182
232
|
<>
|
|
183
233
|
<input ref={fileInputRef} type="file" multiple accept={acceptedFileTypes} onChange={handleFileChange} className="hidden" />
|
|
184
|
-
<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=
|
|
234
|
+
<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={attachLabel}>
|
|
185
235
|
<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>
|
|
186
236
|
</button>
|
|
187
237
|
</>
|
|
188
238
|
)}
|
|
189
239
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
240
|
+
{!canSend && idleAction ? (
|
|
241
|
+
<div className="flex-shrink-0">{idleAction}</div>
|
|
242
|
+
) : (
|
|
243
|
+
<button
|
|
244
|
+
onClick={sendMessage}
|
|
245
|
+
disabled={!canSend || disabled}
|
|
246
|
+
className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
|
|
247
|
+
canSend && !disabled
|
|
248
|
+
? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
|
|
249
|
+
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
250
|
+
}`}
|
|
251
|
+
aria-label={sendLabel}
|
|
252
|
+
>
|
|
253
|
+
<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>
|
|
254
|
+
</button>
|
|
255
|
+
)}
|
|
202
256
|
</div>
|
|
203
257
|
|
|
204
258
|
{remaining !== null && (
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ConversationWallpaper } from './Wallpaper'
|
|
5
|
+
|
|
6
|
+
describe('ConversationWallpaper', () => {
|
|
7
|
+
it('desenha o fundo sozinho, sem depender do styles.css do host', () => {
|
|
8
|
+
const markup = renderToStaticMarkup(<ConversationWallpaper />)
|
|
9
|
+
|
|
10
|
+
expect(markup).toContain('background-color:#efeae2')
|
|
11
|
+
expect(markup).toContain('background-image:url("data:image/svg+xml,')
|
|
12
|
+
expect(markup).toContain('cv-wallpaper')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('deixa o produto sobrescrever o fundo por style', () => {
|
|
16
|
+
const markup = renderToStaticMarkup(<ConversationWallpaper style={{ backgroundColor: '#123456' }} />)
|
|
17
|
+
|
|
18
|
+
expect(markup).toContain('background-color:#123456')
|
|
19
|
+
expect(markup).not.toContain('background-color:#efeae2')
|
|
20
|
+
})
|
|
21
|
+
})
|