@adatechnology/conversations-ui 0.0.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/channel/index.d.ts +2 -0
- package/dist/channel/index.js +0 -0
- package/dist/chunk-ZDURDZTM.js +199 -0
- package/dist/flows/index.d.ts +246 -0
- package/dist/flows/index.js +1110 -0
- package/dist/index.d.ts +542 -0
- package/dist/index.js +2077 -0
- package/dist/styles.css +11 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +52 -0
- package/src/AudioPlayer.tsx +103 -0
- package/src/Avatar.tsx +63 -0
- package/src/ConversationListItem.tsx +147 -0
- package/src/ConversationLocalesProvider.tsx +76 -0
- package/src/DateDivider.tsx +32 -0
- package/src/EmojiPicker.tsx +77 -0
- package/src/FileIcon.tsx +33 -0
- package/src/Lightbox.tsx +17 -0
- package/src/MediaRenderer.tsx +158 -0
- package/src/MessageBubble.tsx +129 -0
- package/src/MessageComposer.tsx +211 -0
- package/src/MessageTail.tsx +18 -0
- package/src/MessageText.tsx +42 -0
- package/src/MessageTimestamp.tsx +22 -0
- package/src/SimpleEmojiPicker.tsx +72 -0
- package/src/StatusTicks.tsx +39 -0
- package/src/Toast.tsx +140 -0
- package/src/Wallpaper.tsx +13 -0
- package/src/WhatsAppMessageEditor.tsx +142 -0
- package/src/channel/index.ts +1 -0
- package/src/conversations/index.ts +1 -0
- package/src/flows/FlowGroupFrame.tsx +21 -0
- package/src/flows/FlowGroupHeader.tsx +31 -0
- package/src/flows/FlowMapCanvas.tsx +76 -0
- package/src/flows/FlowMapNode.tsx +39 -0
- package/src/flows/FlowNodeCard.tsx +150 -0
- package/src/flows/FlowNodePanel.tsx +356 -0
- package/src/flows/FlowPalette.tsx +137 -0
- package/src/flows/FlowPortalNode.tsx +30 -0
- package/src/flows/FlowWhatsAppPreview.tsx +67 -0
- package/src/flows/flowGraph.ts +391 -0
- package/src/flows/index.ts +55 -0
- package/src/flows/labels.ts +187 -0
- package/src/hooks/useAsyncResource.ts +38 -0
- package/src/hooks/useConversationContext.ts +23 -0
- package/src/hooks/useConversationDocuments.ts +33 -0
- package/src/hooks/useConversationList.ts +32 -0
- package/src/hooks/useConversationMessages.ts +64 -0
- package/src/hooks/useConversationRealtime.ts +50 -0
- package/src/index.ts +87 -0
- package/src/lib/format.ts +32 -0
- package/src/lib/phone.ts +26 -0
- package/src/lib/whatsapp-formatting.tsx +215 -0
- package/src/providers/ConversationsProvider.tsx +29 -0
- package/src/providers/types.ts +54 -0
- package/src/settings/TopicsForm.tsx +109 -0
- package/src/settings/WelcomeFarewellForm.tsx +118 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +309 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +264 -0
- package/src/styles.css +21 -0
- package/src/theme.ts +30 -0
- package/src/types.ts +44 -0
- package/src/useDarkMode.ts +48 -0
- package/src/useWaitingNotifications.ts +64 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { AudioPlayer } from './AudioPlayer'
|
|
3
|
+
import { FileIcon } from './FileIcon'
|
|
4
|
+
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
|
+
import { formatFileSize } from './lib/format'
|
|
6
|
+
import type { MessagePayload } from './types'
|
|
7
|
+
|
|
8
|
+
function resolveMediaSource(message: MessagePayload): string | null {
|
|
9
|
+
if (message.mediaUrl) return message.mediaUrl
|
|
10
|
+
if (message.base64) {
|
|
11
|
+
const prefix = message.mimeType
|
|
12
|
+
? `data:${message.mimeType};base64,`
|
|
13
|
+
: 'data:application/octet-stream;base64,'
|
|
14
|
+
return prefix + message.base64
|
|
15
|
+
}
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasLazyRef(message: MessagePayload): boolean {
|
|
20
|
+
return Boolean(message.uploadId || message.mediaId)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ResolveMediaUrl = (message: MessagePayload) => Promise<string | null>
|
|
24
|
+
|
|
25
|
+
export interface MediaRendererProps {
|
|
26
|
+
message: MessagePayload
|
|
27
|
+
onLightbox: (src: string) => void
|
|
28
|
+
// Porta injetada pelo host para resolver `uploadId`/`mediaId` numa URL assinada sob
|
|
29
|
+
// demanda (lazy) β o pacote nunca chama um endpoint fixo. Paridade com o padrΓ£o
|
|
30
|
+
// loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
|
|
31
|
+
// porΓ©m delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
|
|
32
|
+
onResolveUrl?: ResolveMediaUrl
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
|
|
36
|
+
const [url, setUrl] = useState<string | null>(null)
|
|
37
|
+
const [loading, setLoading] = useState(false)
|
|
38
|
+
const [error, setError] = useState(false)
|
|
39
|
+
|
|
40
|
+
const load = async () => {
|
|
41
|
+
if (url || loading || !onResolveUrl) return
|
|
42
|
+
setLoading(true)
|
|
43
|
+
setError(false)
|
|
44
|
+
try {
|
|
45
|
+
const resolved = await onResolveUrl(message)
|
|
46
|
+
if (resolved) setUrl(resolved)
|
|
47
|
+
else setError(true)
|
|
48
|
+
} catch {
|
|
49
|
+
setError(true)
|
|
50
|
+
} finally {
|
|
51
|
+
setLoading(false)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { url, loading, error, load }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
|
|
59
|
+
const { bubble } = useConversationLocales()
|
|
60
|
+
const eagerSrc = resolveMediaSource(message)
|
|
61
|
+
const lazy = useLazyMediaUrl(message, onResolveUrl)
|
|
62
|
+
const src = eagerSrc ?? lazy.url
|
|
63
|
+
const canLazyLoad = !eagerSrc && hasLazyRef(message) && Boolean(onResolveUrl)
|
|
64
|
+
|
|
65
|
+
const lazyButtonClass = 'text-xs text-blue-600 underline flex items-center gap-1'
|
|
66
|
+
|
|
67
|
+
switch (message.type) {
|
|
68
|
+
case 'image':
|
|
69
|
+
case 'sticker': {
|
|
70
|
+
if (!src && canLazyLoad) {
|
|
71
|
+
return (
|
|
72
|
+
<button onClick={lazy.load} className={lazyButtonClass}>
|
|
73
|
+
{lazy.loading ? 'Carregando...' : lazy.error ? 'Erro β tentar novamente' : bubble.viewImage}
|
|
74
|
+
</button>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
<div className="min-w-[200px]">
|
|
79
|
+
{src ? (
|
|
80
|
+
<img src={src} alt={message.caption ?? 'Image'} className="w-full max-h-80 object-cover cursor-pointer hover:opacity-90 transition-opacity" onClick={() => onLightbox(src)} loading="lazy" />
|
|
81
|
+
) : (
|
|
82
|
+
<div className="w-full h-40 bg-gray-200 flex items-center justify-center text-gray-400">
|
|
83
|
+
<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>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
case 'video': {
|
|
90
|
+
if (!src && canLazyLoad) {
|
|
91
|
+
return (
|
|
92
|
+
<button onClick={lazy.load} className={lazyButtonClass}>
|
|
93
|
+
{lazy.loading ? 'Carregando...' : lazy.error ? 'Erro β tentar novamente' : bubble.viewVideo}
|
|
94
|
+
</button>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<div className="min-w-[200px]">
|
|
99
|
+
{src ? (
|
|
100
|
+
<video src={src} className="w-full max-h-80 rounded-lg" controls preload="metadata"><track kind="captions" /></video>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="w-full h-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400">
|
|
103
|
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><polygon points="23 7 16 12 23 17 23 7" /><rect x="1" y="5" width="15" height="14" rx="2" ry="2" /></svg>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
case 'audio': {
|
|
110
|
+
if (!src && canLazyLoad) {
|
|
111
|
+
return (
|
|
112
|
+
<button onClick={lazy.load} className={lazyButtonClass}>
|
|
113
|
+
{lazy.loading ? 'Carregando...' : lazy.error ? 'Erro β tentar novamente' : bubble.listenAudio}
|
|
114
|
+
</button>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return (
|
|
118
|
+
<div className="min-w-[200px]">
|
|
119
|
+
{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">Audio unavailable</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
case 'document': {
|
|
126
|
+
const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
|
|
127
|
+
const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex items-center gap-3 min-w-[200px]">
|
|
130
|
+
<div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
|
|
131
|
+
<FileIcon filename={message.filename} mimeType={message.mimeType} />
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex-1 min-w-0">
|
|
134
|
+
<p className="text-sm font-medium truncate">{message.filename ?? 'Document'}</p>
|
|
135
|
+
<p className="text-xs text-gray-500">{sizeLabel ? `${typeLabel} Β· ${sizeLabel}` : typeLabel}</p>
|
|
136
|
+
</div>
|
|
137
|
+
{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="Download">
|
|
139
|
+
<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
|
+
</a>
|
|
141
|
+
) : canLazyLoad ? (
|
|
142
|
+
<button
|
|
143
|
+
onClick={lazy.load}
|
|
144
|
+
disabled={lazy.loading}
|
|
145
|
+
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="Download"
|
|
147
|
+
>
|
|
148
|
+
<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
|
+
</button>
|
|
150
|
+
) : null}
|
|
151
|
+
{lazy.error && <span className="text-xs text-red-500 flex-shrink-0">Erro</span>}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Check } from 'lucide-react'
|
|
3
|
+
import type { MessagePayload } from './types'
|
|
4
|
+
import { useConversationLocales } from './ConversationLocalesProvider'
|
|
5
|
+
import { StatusTicks } from './StatusTicks'
|
|
6
|
+
import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
|
|
7
|
+
import { Lightbox } from './Lightbox'
|
|
8
|
+
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
9
|
+
import { formatTimestamp, formatDateTime } from './lib/format'
|
|
10
|
+
|
|
11
|
+
export interface MessageBubbleProps {
|
|
12
|
+
message: MessagePayload
|
|
13
|
+
isMine: boolean
|
|
14
|
+
senderName?: string | null
|
|
15
|
+
isFirstInGroup?: boolean
|
|
16
|
+
isSelecting?: boolean
|
|
17
|
+
isSelected?: boolean
|
|
18
|
+
onToggleSelect?: () => void
|
|
19
|
+
onResolveMediaUrl?: ResolveMediaUrl
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Hex arbitrΓ‘rios (nΓ£o os tokens `whatsapp.*` do Tailwind) β o pacote fica autocontido,
|
|
23
|
+
// sem exigir que o host replique a configuraΓ§Γ£o de tema feita em tailwind.config.ts do bot.
|
|
24
|
+
const BUBBLE_COLOR: Record<string, string> = {
|
|
25
|
+
agent: 'bg-[#d9fdd3] dark:bg-[#005c4b]',
|
|
26
|
+
bot: 'bg-[#d7f0ec] dark:bg-[#0a3d3a]',
|
|
27
|
+
customer: 'bg-white dark:bg-[#202c33]',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
|
|
31
|
+
|
|
32
|
+
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx β
|
|
33
|
+
// cor por sender, tail sΓ³ no isFirstInGroup, agrupamento mt-2/mt-0.5, ring vermelho em
|
|
34
|
+
// falha, tick de status colorido, tooltip de readAt/janela expirada.
|
|
35
|
+
//
|
|
36
|
+
// Requer '@adatechnology/conversations-ui/styles.css' (ConversationWallpaper) ou um
|
|
37
|
+
// tailwind.config do host expondo as cores `whatsapp.*` β ver Wallpaper.tsx e T6.2.
|
|
38
|
+
export function MessageBubble({
|
|
39
|
+
message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
|
|
40
|
+
onResolveMediaUrl,
|
|
41
|
+
}: MessageBubbleProps) {
|
|
42
|
+
const { bubble, selection } = useConversationLocales()
|
|
43
|
+
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
|
|
44
|
+
|
|
45
|
+
const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
|
|
46
|
+
const hasError = message.status === 'failed'
|
|
47
|
+
const isMedia = MEDIA_TYPES.has(message.type)
|
|
48
|
+
const isTemplate = message.type === 'template'
|
|
49
|
+
const displayName = message.sender === 'agent' && senderName ? senderName : bubble[message.sender] ?? message.sender
|
|
50
|
+
|
|
51
|
+
const tooltipText = message.status === 'read' && message.readAt
|
|
52
|
+
? `${bubble.readAt}${formatDateTime(message.readAt)}`
|
|
53
|
+
: message.status === 'failed'
|
|
54
|
+
? bubble.windowExpired
|
|
55
|
+
: undefined
|
|
56
|
+
|
|
57
|
+
// Cantinho reto no topo (o "rabinho" do balΓ£o do WhatsApp) sΓ³ na primeira mensagem de um grupo
|
|
58
|
+
// consecutivo do mesmo remetente β o resto do grupo fica com os dois cantos superiores arredondados.
|
|
59
|
+
const tailCornerClass = isFirstInGroup ? (isMine ? 'rounded-tr-md' : 'rounded-tl-md') : ''
|
|
60
|
+
|
|
61
|
+
const checkbox = (
|
|
62
|
+
<button
|
|
63
|
+
onClick={(e) => { e.stopPropagation(); onToggleSelect?.() }}
|
|
64
|
+
title={selection.select}
|
|
65
|
+
className={`
|
|
66
|
+
flex-shrink-0 self-end mb-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
|
|
67
|
+
${isSelected ? 'bg-teal-600 border-teal-600' : 'bg-white/80 dark:bg-black/40 border-black/20 dark:border-white/30'}
|
|
68
|
+
${isSelecting ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
|
|
69
|
+
`}
|
|
70
|
+
>
|
|
71
|
+
{isSelected && <Check size={12} className="text-white" strokeWidth={3} />}
|
|
72
|
+
</button>
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div className={`flex items-end gap-1.5 ${isMine ? 'justify-end' : 'justify-start'} group ${isFirstInGroup ? 'mt-2' : 'mt-0.5'}`}>
|
|
77
|
+
{isMine && checkbox}
|
|
78
|
+
<div
|
|
79
|
+
onClick={isSelecting ? onToggleSelect : undefined}
|
|
80
|
+
className={`
|
|
81
|
+
max-w-[75%] sm:max-w-[65%] rounded-2xl ${tailCornerClass} px-2.5 py-1.5 shadow-sm
|
|
82
|
+
${bubbleColor}
|
|
83
|
+
${hasError ? 'ring-1 ring-inset ring-red-400' : ''}
|
|
84
|
+
${isSelecting ? 'cursor-pointer' : ''}
|
|
85
|
+
${isSelected ? 'ring-2 ring-teal-500' : ''}
|
|
86
|
+
relative
|
|
87
|
+
`}
|
|
88
|
+
>
|
|
89
|
+
{isMine && isFirstInGroup && (message.sender === 'bot' || (message.sender === 'agent' && senderName)) && (
|
|
90
|
+
<div className="text-xs font-semibold mb-0.5 text-teal-700 dark:text-teal-400">
|
|
91
|
+
{displayName}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{isMedia ? (
|
|
96
|
+
<MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={onResolveMediaUrl} />
|
|
97
|
+
) : (
|
|
98
|
+
<>
|
|
99
|
+
{isTemplate && (
|
|
100
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mb-0.5">
|
|
101
|
+
<span>π¨</span>
|
|
102
|
+
<span className="font-medium">
|
|
103
|
+
{bubble.templateLabel}{message.templateName ? ` β ${message.templateName}` : ''}
|
|
104
|
+
</span>
|
|
105
|
+
</p>
|
|
106
|
+
)}
|
|
107
|
+
<div className="text-sm text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words leading-[19px]">
|
|
108
|
+
{parseWhatsAppFormatting(message.content ?? '')}
|
|
109
|
+
</div>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<div className="flex items-center justify-end gap-1 mt-0.5 select-none">
|
|
114
|
+
<span className="text-xs text-black/40 dark:text-white/40 font-medium">
|
|
115
|
+
{formatTimestamp(message.timestamp)}
|
|
116
|
+
</span>
|
|
117
|
+
{isMine && message.status && (
|
|
118
|
+
<StatusTicks status={message.status} title={tooltipText} />
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
{!isMine && checkbox}
|
|
123
|
+
|
|
124
|
+
{lightboxSrc && (
|
|
125
|
+
<Lightbox imageUrl={lightboxSrc} onClose={() => setLightboxSrc(null)} />
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent } from 'react'
|
|
2
|
+
import type { ConversationsFeatures } from './types'
|
|
3
|
+
import { EmojiPicker } from './EmojiPicker'
|
|
4
|
+
|
|
5
|
+
export interface MessageComposerProps {
|
|
6
|
+
onSend: (text: string) => void
|
|
7
|
+
onAttach?: (file: File) => void
|
|
8
|
+
value?: string
|
|
9
|
+
onChange?: (value: string) => void
|
|
10
|
+
features?: ConversationsFeatures
|
|
11
|
+
placeholder?: string
|
|
12
|
+
maxLength?: number
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
acceptedFileTypes?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Paridade com financiamento-imobiliario-bot/apps/web/src/pages/ConversationsPage.tsx:1490
|
|
18
|
+
const DEFAULT_ACCEPTED_FILE_TYPES = 'image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip'
|
|
19
|
+
|
|
20
|
+
interface FilePreview {
|
|
21
|
+
file: File
|
|
22
|
+
previewUrl: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MessageComposer = ({
|
|
26
|
+
onSend,
|
|
27
|
+
onAttach,
|
|
28
|
+
value: externalValue,
|
|
29
|
+
onChange: externalOnChange,
|
|
30
|
+
features,
|
|
31
|
+
placeholder = 'Digite uma mensagem...',
|
|
32
|
+
maxLength,
|
|
33
|
+
disabled = false,
|
|
34
|
+
acceptedFileTypes = DEFAULT_ACCEPTED_FILE_TYPES,
|
|
35
|
+
}: MessageComposerProps) => {
|
|
36
|
+
const [internalText, setInternalText] = useState('')
|
|
37
|
+
const [showEmoji, setShowEmoji] = useState(false)
|
|
38
|
+
const [attachments, setAttachments] = useState<FilePreview[]>([])
|
|
39
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
40
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
41
|
+
|
|
42
|
+
const isControlled = externalValue !== undefined
|
|
43
|
+
const text = isControlled ? externalValue : internalText
|
|
44
|
+
|
|
45
|
+
const setText = useCallback((newText: string) => {
|
|
46
|
+
if (isControlled) {
|
|
47
|
+
externalOnChange?.(newText)
|
|
48
|
+
} else {
|
|
49
|
+
setInternalText(newText)
|
|
50
|
+
}
|
|
51
|
+
}, [isControlled, externalOnChange])
|
|
52
|
+
|
|
53
|
+
const showEmojiButton = features?.emoji !== false
|
|
54
|
+
const showAttachButton = features?.documents !== false
|
|
55
|
+
|
|
56
|
+
const sendMessage = useCallback(() => {
|
|
57
|
+
const trimmed = text.trim()
|
|
58
|
+
if (!trimmed && attachments.length === 0) return
|
|
59
|
+
if (trimmed) onSend(trimmed)
|
|
60
|
+
for (const a of attachments) {
|
|
61
|
+
onAttach?.(a.file)
|
|
62
|
+
URL.revokeObjectURL(a.previewUrl)
|
|
63
|
+
}
|
|
64
|
+
if (!isControlled) setInternalText('')
|
|
65
|
+
setAttachments([])
|
|
66
|
+
setShowEmoji(false)
|
|
67
|
+
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
|
68
|
+
}, [text, attachments, onSend, onAttach, isControlled])
|
|
69
|
+
|
|
70
|
+
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
71
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
if (!disabled) sendMessage()
|
|
74
|
+
}
|
|
75
|
+
}, [sendMessage, disabled])
|
|
76
|
+
|
|
77
|
+
const handleInput = useCallback(() => {
|
|
78
|
+
const ta = textareaRef.current
|
|
79
|
+
if (!ta) return
|
|
80
|
+
ta.style.height = 'auto'
|
|
81
|
+
ta.style.height = `${Math.min(ta.scrollHeight, 100)}px`
|
|
82
|
+
}, [])
|
|
83
|
+
|
|
84
|
+
const handleEmojiSelect = useCallback((emoji: string) => {
|
|
85
|
+
const ta = textareaRef.current
|
|
86
|
+
if (!ta) { setText(text + emoji); return }
|
|
87
|
+
const start = ta.selectionStart
|
|
88
|
+
const end = ta.selectionEnd
|
|
89
|
+
const newText = text.slice(0, start) + emoji + text.slice(end)
|
|
90
|
+
setText(newText)
|
|
91
|
+
requestAnimationFrame(() => {
|
|
92
|
+
ta.focus()
|
|
93
|
+
ta.setSelectionRange(start + emoji.length, start + emoji.length)
|
|
94
|
+
handleInput()
|
|
95
|
+
})
|
|
96
|
+
}, [text, setText, handleInput])
|
|
97
|
+
|
|
98
|
+
const handleFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
|
99
|
+
const files = e.target.files
|
|
100
|
+
if (!files) return
|
|
101
|
+
const previews: FilePreview[] = []
|
|
102
|
+
for (let i = 0; i < files.length; i++) {
|
|
103
|
+
const file = files[i]
|
|
104
|
+
previews.push({ file, previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : '' })
|
|
105
|
+
}
|
|
106
|
+
setAttachments(prev => [...prev, ...previews])
|
|
107
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
108
|
+
}, [])
|
|
109
|
+
|
|
110
|
+
const removeAttachment = useCallback((index: number) => {
|
|
111
|
+
setAttachments(prev => {
|
|
112
|
+
const next = [...prev]
|
|
113
|
+
if (next[index].previewUrl) URL.revokeObjectURL(next[index].previewUrl)
|
|
114
|
+
next.splice(index, 1)
|
|
115
|
+
return next
|
|
116
|
+
})
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const insertFormatting = useCallback((marker: string) => {
|
|
120
|
+
const ta = textareaRef.current
|
|
121
|
+
if (!ta) return
|
|
122
|
+
const start = ta.selectionStart; const end = ta.selectionEnd
|
|
123
|
+
const sel = text.slice(start, end)
|
|
124
|
+
if (sel) {
|
|
125
|
+
setText(text.slice(0, start) + marker + sel + marker + text.slice(end))
|
|
126
|
+
requestAnimationFrame(() => {
|
|
127
|
+
ta.focus()
|
|
128
|
+
ta.setSelectionRange(start + marker.length + sel.length + marker.length, start + marker.length + sel.length + marker.length)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}, [text, setText])
|
|
132
|
+
|
|
133
|
+
const canSend = text.trim().length > 0 || attachments.length > 0
|
|
134
|
+
const remaining = maxLength ? maxLength - text.length : null
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
{attachments.length > 0 && (
|
|
139
|
+
<div className="flex gap-2 px-1 pb-2 overflow-x-auto">
|
|
140
|
+
{attachments.map((a, i) => (
|
|
141
|
+
<div key={i} className="relative flex-shrink-0">
|
|
142
|
+
{a.previewUrl ? (
|
|
143
|
+
<img src={a.previewUrl} alt="" className="w-16 h-16 object-cover rounded-lg border border-gray-200" />
|
|
144
|
+
) : (
|
|
145
|
+
<div className="w-16 h-16 bg-gray-100 rounded-lg border border-gray-200 flex items-center justify-center">
|
|
146
|
+
<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>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
<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>
|
|
150
|
+
</div>
|
|
151
|
+
))}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<div className="flex items-end gap-1.5 bg-[#f0f2f5] rounded-xl px-3 py-2">
|
|
156
|
+
{showEmojiButton && (
|
|
157
|
+
<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="Emoji">
|
|
159
|
+
<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
|
+
</button>
|
|
161
|
+
{showEmoji && (
|
|
162
|
+
<div className="absolute bottom-full left-0 mb-2 z-10">
|
|
163
|
+
<EmojiPicker onSelect={handleEmojiSelect} />
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
<textarea
|
|
170
|
+
ref={textareaRef}
|
|
171
|
+
value={text}
|
|
172
|
+
onChange={e => { setText(e.target.value); handleInput() }}
|
|
173
|
+
onKeyDown={handleKeyDown}
|
|
174
|
+
placeholder={placeholder}
|
|
175
|
+
rows={1}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
className="flex-1 resize-none bg-transparent text-[15px] text-[#3b4a54] placeholder-[#8696a0] outline-none py-1.5 max-h-[100px] leading-relaxed"
|
|
178
|
+
style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" }}
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
{showAttachButton && (
|
|
182
|
+
<>
|
|
183
|
+
<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="Anexar">
|
|
185
|
+
<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
|
+
</button>
|
|
187
|
+
</>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
<button
|
|
191
|
+
onClick={sendMessage}
|
|
192
|
+
disabled={!canSend || disabled}
|
|
193
|
+
className={`w-10 h-10 flex items-center justify-center rounded-full flex-shrink-0 transition-all ${
|
|
194
|
+
canSend && !disabled
|
|
195
|
+
? 'bg-[#00a884] text-white hover:bg-[#06cf9c] shadow-sm'
|
|
196
|
+
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
197
|
+
}`}
|
|
198
|
+
aria-label="Enviar"
|
|
199
|
+
>
|
|
200
|
+
<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>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{remaining !== null && (
|
|
205
|
+
<div className="flex justify-end mt-1 pr-1">
|
|
206
|
+
<span className={`text-xs ${remaining < 20 ? 'text-red-500' : 'text-gray-400'}`}>{remaining}</span>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface MessageTailProps {
|
|
2
|
+
isOutbound: boolean
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function MessageTail({ isOutbound }: MessageTailProps) {
|
|
6
|
+
if (isOutbound)
|
|
7
|
+
return (
|
|
8
|
+
<svg className="absolute top-0 -right-[5px] w-[5px] h-[11px]" viewBox="0 0 5 11" fill="#d9fdd3">
|
|
9
|
+
<path d="M5 0H0v11c1.5-2 4.5-2 5-4V0z" />
|
|
10
|
+
</svg>
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<svg className="absolute top-0 -left-[5px] w-[5px] h-[11px]" viewBox="0 0 5 11" fill="white">
|
|
15
|
+
<path d="M0 0h5v11C3.5 9 .5 9 0 7V0z" />
|
|
16
|
+
</svg>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import type { MessagePayload } from './types'
|
|
3
|
+
import { useConversations } from './providers/ConversationsProvider'
|
|
4
|
+
import { parseWhatsAppFormatting } from './lib/whatsapp-formatting'
|
|
5
|
+
|
|
6
|
+
export interface MessageTextProps {
|
|
7
|
+
message: MessagePayload
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function MessageText({ message }: MessageTextProps) {
|
|
11
|
+
const [copied, setCopied] = useState(false)
|
|
12
|
+
|
|
13
|
+
const handleCopy = useCallback(async () => {
|
|
14
|
+
if (!message.content) return
|
|
15
|
+
try {
|
|
16
|
+
await navigator.clipboard.writeText(message.content)
|
|
17
|
+
setCopied(true)
|
|
18
|
+
setTimeout(() => setCopied(false), 2000)
|
|
19
|
+
} catch {
|
|
20
|
+
// Clipboard not available
|
|
21
|
+
}
|
|
22
|
+
}, [message.content])
|
|
23
|
+
|
|
24
|
+
if (message.type === 'template')
|
|
25
|
+
return <p className="text-sm italic text-[#667781]">{message.content ?? 'Template message'}</p>
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
onClick={handleCopy}
|
|
30
|
+
className="text-[14.2px] leading-[19px] whitespace-pre-wrap break-words select-all [&_strong]:font-bold [&_em]:italic [&_del]:line-through"
|
|
31
|
+
>
|
|
32
|
+
<span>
|
|
33
|
+
{parseWhatsAppFormatting(message.content ?? '')}
|
|
34
|
+
</span>
|
|
35
|
+
{copied && (
|
|
36
|
+
<span className="absolute top-0 right-0 -translate-y-full bg-[#3b4a54] text-white text-[11px] px-1.5 py-0.5 rounded shadow-lg">
|
|
37
|
+
Copiado!
|
|
38
|
+
</span>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StatusTicks } from './StatusTicks'
|
|
2
|
+
|
|
3
|
+
export interface MessageTimestampProps {
|
|
4
|
+
timestamp: string
|
|
5
|
+
status?: string
|
|
6
|
+
isOutbound: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function MessageTimestamp({ timestamp, status, isOutbound }: MessageTimestampProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex items-center justify-end gap-[3px]">
|
|
12
|
+
<span className="text-[11px] text-[#667781] leading-[15px] select-none">
|
|
13
|
+
{timestamp}
|
|
14
|
+
</span>
|
|
15
|
+
{isOutbound && status && (
|
|
16
|
+
<span className="flex items-center -mr-[2px]">
|
|
17
|
+
<StatusTicks status={status} />
|
|
18
|
+
</span>
|
|
19
|
+
)}
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Smile } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
const EMOJIS = [
|
|
5
|
+
'π', 'π', 'π€£', 'π', 'π', 'π₯°', 'π', 'π', 'π€ͺ', 'π',
|
|
6
|
+
'π€©', 'π', 'π', 'π', 'π', 'π', 'π’', 'π', 'π€', 'π‘',
|
|
7
|
+
'π₯Ί', 'π°', 'π±', 'π₯³', 'π€', 'π€', 'π', 'π', 'π', 'π',
|
|
8
|
+
'π€', 'πͺ', 'π', 'β€οΈ', 'π§‘', 'π', 'π', 'π', 'π', 'π€',
|
|
9
|
+
'π€', 'π', 'π₯', 'β', 'π', 'β¨', 'π―', 'β
', 'β', 'β οΈ',
|
|
10
|
+
'π', 'π‘', 'π', 'π', 'π', 'π', 'π', 'π°', 'π³', 'π ',
|
|
11
|
+
'π¦', 'π', 'βοΈ', 'β°', 'π
', 'π', 'π', 'π¬', 'π¨οΈ', 'π',
|
|
12
|
+
'π€', 'π―', 'π', 'π', 'π', 'π', 'π', 'π', 'πΌ', 'π‘οΈ',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export interface SimpleEmojiPickerProps {
|
|
16
|
+
onSelect: (emoji: string) => void
|
|
17
|
+
label?: string
|
|
18
|
+
pickerWidth?: string
|
|
19
|
+
pickerMaxHeight?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Paridade com financiamento-imobiliario-bot/apps/web/src/components/SimpleEmojiPicker.tsx β
|
|
23
|
+
// grade ΓΊnica (sem categorias), botΓ£o de alternΓ’ncia com fecho ao clicar fora. Distinto do
|
|
24
|
+
// EmojiPicker categorizado jΓ‘ existente no pacote, que Γ© sempre-aberto/multi-categoria.
|
|
25
|
+
export function SimpleEmojiPicker({ onSelect, label = 'Emojis', pickerWidth = '280px', pickerMaxHeight = '220px' }: SimpleEmojiPickerProps) {
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!open) return
|
|
31
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
32
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
33
|
+
setOpen(false)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
37
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
38
|
+
}, [open])
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div ref={containerRef} className="relative flex-shrink-0">
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={() => setOpen((v) => !v)}
|
|
45
|
+
className="w-7 h-7 flex items-center justify-center rounded-lg 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
|
+
title={label}
|
|
47
|
+
>
|
|
48
|
+
<Smile size={15} />
|
|
49
|
+
</button>
|
|
50
|
+
|
|
51
|
+
{open && (
|
|
52
|
+
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl p-2 z-50">
|
|
53
|
+
<div
|
|
54
|
+
className="grid grid-cols-10 gap-0.5 overflow-y-auto"
|
|
55
|
+
style={{ width: pickerWidth, maxHeight: pickerMaxHeight }}
|
|
56
|
+
>
|
|
57
|
+
{EMOJIS.map((emoji) => (
|
|
58
|
+
<button
|
|
59
|
+
key={emoji}
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={() => { onSelect(emoji); setOpen(false) }}
|
|
62
|
+
className="w-7 h-7 flex items-center justify-center rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-lg leading-none transition-colors"
|
|
63
|
+
>
|
|
64
|
+
{emoji}
|
|
65
|
+
</button>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|