@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,33 @@
|
|
|
1
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
2
|
+
import { useAsyncResource } from './useAsyncResource'
|
|
3
|
+
import type { ConversationDocument } from '../providers/types'
|
|
4
|
+
|
|
5
|
+
export interface UseConversationDocumentsParams {
|
|
6
|
+
search?: string
|
|
7
|
+
page?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseConversationDocumentsResult {
|
|
11
|
+
documents: ConversationDocument[]
|
|
12
|
+
loading: boolean
|
|
13
|
+
error: Error | undefined
|
|
14
|
+
refetch: () => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useConversationDocuments(
|
|
18
|
+
conversationId: string,
|
|
19
|
+
params?: UseConversationDocumentsParams,
|
|
20
|
+
): UseConversationDocumentsResult {
|
|
21
|
+
const context = useConversations()
|
|
22
|
+
if (!context) {
|
|
23
|
+
throw new Error('useConversationDocuments requires an ancestor <ConversationsProvider>')
|
|
24
|
+
}
|
|
25
|
+
const { api } = context
|
|
26
|
+
|
|
27
|
+
const { data, loading, error, refetch } = useAsyncResource(
|
|
28
|
+
() => api.getDocuments(conversationId, params),
|
|
29
|
+
[conversationId, params?.search, params?.page],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return { documents: data ?? [], loading, error, refetch }
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
2
|
+
import { useAsyncResource } from './useAsyncResource'
|
|
3
|
+
import type { ConversationSummary } from '../providers/types'
|
|
4
|
+
|
|
5
|
+
export interface UseConversationListParams {
|
|
6
|
+
page?: number
|
|
7
|
+
limit?: number
|
|
8
|
+
waitingHuman?: boolean
|
|
9
|
+
search?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseConversationListResult {
|
|
13
|
+
conversations: ConversationSummary[]
|
|
14
|
+
loading: boolean
|
|
15
|
+
error: Error | undefined
|
|
16
|
+
refetch: () => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useConversationList(params?: UseConversationListParams): UseConversationListResult {
|
|
20
|
+
const context = useConversations()
|
|
21
|
+
if (!context) {
|
|
22
|
+
throw new Error('useConversationList requires an ancestor <ConversationsProvider>')
|
|
23
|
+
}
|
|
24
|
+
const { api } = context
|
|
25
|
+
|
|
26
|
+
const { data, loading, error, refetch } = useAsyncResource(
|
|
27
|
+
() => api.fetchConversations(params),
|
|
28
|
+
[params?.page, params?.limit, params?.waitingHuman, params?.search],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return { conversations: data ?? [], loading, error, refetch }
|
|
32
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
3
|
+
import { useAsyncResource } from './useAsyncResource'
|
|
4
|
+
import type { MessagePayload } from '../types'
|
|
5
|
+
|
|
6
|
+
export interface UseConversationMessagesResult {
|
|
7
|
+
messages: MessagePayload[]
|
|
8
|
+
loading: boolean
|
|
9
|
+
error: Error | undefined
|
|
10
|
+
refetch: () => Promise<void>
|
|
11
|
+
sendMessage: (text: string) => Promise<MessagePayload>
|
|
12
|
+
sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
|
|
13
|
+
sendTemplate: (data: { templateName: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
|
|
14
|
+
markRead: () => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Camada headless: dados + ações de uma conversa, sem nenhuma tela acoplada — o produto
|
|
18
|
+
// consome este hook e monta a UI que quiser (ou usa <MessageBubble>/<MessageComposer>
|
|
19
|
+
// por cima, como o pacote já oferece). Requer <ConversationsProvider> como ancestral.
|
|
20
|
+
export function useConversationMessages(
|
|
21
|
+
conversationId: string,
|
|
22
|
+
params?: { limit?: number; before?: string },
|
|
23
|
+
): UseConversationMessagesResult {
|
|
24
|
+
const context = useConversations()
|
|
25
|
+
if (!context) {
|
|
26
|
+
throw new Error('useConversationMessages requires an ancestor <ConversationsProvider>')
|
|
27
|
+
}
|
|
28
|
+
const { api } = context
|
|
29
|
+
|
|
30
|
+
const { data, loading, error, refetch } = useAsyncResource(
|
|
31
|
+
() => api.fetchMessages(conversationId, params),
|
|
32
|
+
[conversationId, params?.limit, params?.before],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const sendMessage = useCallback(
|
|
36
|
+
async (text: string) => {
|
|
37
|
+
const message = await api.sendMessage(conversationId, text)
|
|
38
|
+
await refetch()
|
|
39
|
+
return message
|
|
40
|
+
},
|
|
41
|
+
[api, conversationId, refetch],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
const sendMedia = useCallback(
|
|
45
|
+
async (mediaData: { base64: string; mimeType: string; filename: string; caption?: string }) => {
|
|
46
|
+
const message = await api.sendMedia(conversationId, mediaData)
|
|
47
|
+
await refetch()
|
|
48
|
+
return message
|
|
49
|
+
},
|
|
50
|
+
[api, conversationId, refetch],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const sendTemplate = useCallback(
|
|
54
|
+
async (templateData: { templateName: string; languageCode?: string; bodyParams?: string[] }) => {
|
|
55
|
+
await api.sendTemplate(conversationId, templateData)
|
|
56
|
+
await refetch()
|
|
57
|
+
},
|
|
58
|
+
[api, conversationId, refetch],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const markRead = useCallback(() => api.markRead(conversationId), [api, conversationId])
|
|
62
|
+
|
|
63
|
+
return { messages: data ?? [], loading, error, refetch, sendMessage, sendMedia, sendTemplate, markRead }
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { useConversations } from '../providers/ConversationsProvider'
|
|
3
|
+
|
|
4
|
+
export type ConversationRealtimeHandler = (event: MessageEvent) => void
|
|
5
|
+
|
|
6
|
+
// Assina o SSE de uma conversa via a porta SSEProvider injetada no ConversationsProvider —
|
|
7
|
+
// o pacote nunca abre a conexão diretamente contra um endpoint fixo. Reconecta ao trocar
|
|
8
|
+
// de conversationId e sempre fecha a EventSource anterior no cleanup.
|
|
9
|
+
export function useConversationRealtime(
|
|
10
|
+
conversationId: string | undefined,
|
|
11
|
+
onEvent: ConversationRealtimeHandler,
|
|
12
|
+
): void {
|
|
13
|
+
const context = useConversations()
|
|
14
|
+
const onEventRef = useRef(onEvent)
|
|
15
|
+
onEventRef.current = onEvent
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!context || !conversationId) return
|
|
19
|
+
|
|
20
|
+
const source = context.sse.connectConversationStream(conversationId)
|
|
21
|
+
const handler = (event: MessageEvent) => onEventRef.current(event)
|
|
22
|
+
source.addEventListener('message', handler)
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
source.removeEventListener('message', handler)
|
|
26
|
+
source.close()
|
|
27
|
+
}
|
|
28
|
+
}, [context, conversationId])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Assina o stream global (ex: novas conversas entrando na fila, notificações cross-conversa)
|
|
32
|
+
// — mesma porta SSEProvider, sem conversationId.
|
|
33
|
+
export function useGlobalRealtime(onEvent: ConversationRealtimeHandler): void {
|
|
34
|
+
const context = useConversations()
|
|
35
|
+
const onEventRef = useRef(onEvent)
|
|
36
|
+
onEventRef.current = onEvent
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!context) return
|
|
40
|
+
|
|
41
|
+
const source = context.sse.connectGlobalStream()
|
|
42
|
+
const handler = (event: MessageEvent) => onEventRef.current(event)
|
|
43
|
+
source.addEventListener('message', handler)
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
source.removeEventListener('message', handler)
|
|
47
|
+
source.close()
|
|
48
|
+
}
|
|
49
|
+
}, [context])
|
|
50
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export { MessageBubble } from './MessageBubble'
|
|
2
|
+
export { ConversationWallpaper } from './Wallpaper'
|
|
3
|
+
export { ConversationLocalesProvider, useConversationLocales } from './ConversationLocalesProvider'
|
|
4
|
+
export { AudioPlayer } from './AudioPlayer'
|
|
5
|
+
export { EmojiPicker } from './EmojiPicker'
|
|
6
|
+
export { MessageComposer } from './MessageComposer'
|
|
7
|
+
export { WhatsAppMessageEditor } from './WhatsAppMessageEditor'
|
|
8
|
+
export { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
9
|
+
export { DateDivider } from './DateDivider'
|
|
10
|
+
export { Avatar } from './Avatar'
|
|
11
|
+
export { ConversationListItem } from './ConversationListItem'
|
|
12
|
+
export { ToastProvider, useToast, toast } from './Toast'
|
|
13
|
+
|
|
14
|
+
export { StatusTicks } from './StatusTicks'
|
|
15
|
+
export { Lightbox } from './Lightbox'
|
|
16
|
+
export { MediaRenderer } from './MediaRenderer'
|
|
17
|
+
export { FileIcon } from './FileIcon'
|
|
18
|
+
export { MessageText } from './MessageText'
|
|
19
|
+
export { MessageTimestamp } from './MessageTimestamp'
|
|
20
|
+
export { MessageTail } from './MessageTail'
|
|
21
|
+
|
|
22
|
+
export { useDarkMode } from './useDarkMode'
|
|
23
|
+
export { useWaitingNotifications } from './useWaitingNotifications'
|
|
24
|
+
|
|
25
|
+
export { ConversationsProvider, useConversations } from './providers/ConversationsProvider'
|
|
26
|
+
|
|
27
|
+
// Telas de Settings (T7.1) — apresentacionais, sem chamada de rede própria; o host busca
|
|
28
|
+
// dados (templates da Meta, mensagens salvas) e injeta via props.
|
|
29
|
+
export { WhatsAppTemplateSettingsForm } from './settings/WhatsAppTemplateSettingsForm'
|
|
30
|
+
export { WhatsAppCreateTemplateForm } from './settings/WhatsAppCreateTemplateForm'
|
|
31
|
+
export { WelcomeFarewellForm } from './settings/WelcomeFarewellForm'
|
|
32
|
+
export { TopicsForm } from './settings/TopicsForm'
|
|
33
|
+
|
|
34
|
+
// Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
|
|
35
|
+
// produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
|
|
36
|
+
export { useConversationMessages } from './hooks/useConversationMessages'
|
|
37
|
+
export { useConversationList } from './hooks/useConversationList'
|
|
38
|
+
export { useConversationContext } from './hooks/useConversationContext'
|
|
39
|
+
export { useConversationDocuments } from './hooks/useConversationDocuments'
|
|
40
|
+
export { useConversationRealtime, useGlobalRealtime } from './hooks/useConversationRealtime'
|
|
41
|
+
|
|
42
|
+
export { parseWhatsAppFormatting, waToHTML, htmlToWA, waToHTMLInline } from './lib/whatsapp-formatting'
|
|
43
|
+
export { formatPhone, phoneInitials } from './lib/phone'
|
|
44
|
+
export { formatTimestamp, formatFileSize } from './lib/format'
|
|
45
|
+
|
|
46
|
+
export type { MessagePayload, ConversationsUIConfig, ConversationsTheme, ConversationsFeatures } from './types'
|
|
47
|
+
export type { ConversationsApi, SSEProvider, ConversationSummary, ConversationDocument } from './providers/types'
|
|
48
|
+
|
|
49
|
+
export type { MessageBubbleProps } from './MessageBubble'
|
|
50
|
+
export type { ConversationWallpaperProps } from './Wallpaper'
|
|
51
|
+
export type { ConversationLocales, ConversationLocalesProviderProps } from './ConversationLocalesProvider'
|
|
52
|
+
export type { AudioPlayerProps } from './AudioPlayer'
|
|
53
|
+
export type { EmojiPickerProps } from './EmojiPicker'
|
|
54
|
+
export type { MessageComposerProps } from './MessageComposer'
|
|
55
|
+
export type { WhatsAppMessageEditorProps } from './WhatsAppMessageEditor'
|
|
56
|
+
export type { SimpleEmojiPickerProps } from './SimpleEmojiPicker'
|
|
57
|
+
export type { DateDividerProps } from './DateDivider'
|
|
58
|
+
export type { AvatarProps } from './Avatar'
|
|
59
|
+
export type { ConversationListItemProps } from './ConversationListItem'
|
|
60
|
+
export type { StatusTicksProps } from './StatusTicks'
|
|
61
|
+
export type { LightboxProps } from './Lightbox'
|
|
62
|
+
export type { MediaRendererProps, ResolveMediaUrl } from './MediaRenderer'
|
|
63
|
+
export type { FileIconProps } from './FileIcon'
|
|
64
|
+
export type { MessageTextProps } from './MessageText'
|
|
65
|
+
|
|
66
|
+
export type {
|
|
67
|
+
WhatsAppTemplateSettingsFormProps,
|
|
68
|
+
WhatsAppTemplateSummary,
|
|
69
|
+
WhatsAppTemplateVariableSuggestion,
|
|
70
|
+
WhatsAppTemplateSettingsFormLabels,
|
|
71
|
+
} from './settings/WhatsAppTemplateSettingsForm'
|
|
72
|
+
export type {
|
|
73
|
+
WhatsAppCreateTemplateFormProps,
|
|
74
|
+
WhatsAppCreateTemplateState,
|
|
75
|
+
WhatsAppCreateTemplateResult,
|
|
76
|
+
WhatsAppTemplateHeaderType,
|
|
77
|
+
WhatsAppCreateTemplateFormLabels,
|
|
78
|
+
} from './settings/WhatsAppCreateTemplateForm'
|
|
79
|
+
export type { WelcomeFarewellFormProps, WelcomeFarewellFormLabels } from './settings/WelcomeFarewellForm'
|
|
80
|
+
export type { TopicsFormProps, TopicItem, TopicsFormLabels } from './settings/TopicsForm'
|
|
81
|
+
|
|
82
|
+
export type { UseConversationMessagesResult } from './hooks/useConversationMessages'
|
|
83
|
+
export type { UseConversationListParams, UseConversationListResult } from './hooks/useConversationList'
|
|
84
|
+
export type { UseConversationContextResult } from './hooks/useConversationContext'
|
|
85
|
+
export type { UseConversationDocumentsParams, UseConversationDocumentsResult } from './hooks/useConversationDocuments'
|
|
86
|
+
export type { ConversationRealtimeHandler } from './hooks/useConversationRealtime'
|
|
87
|
+
export type { AsyncResourceState } from './hooks/useAsyncResource'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function formatTimestamp(timestamp: string): string {
|
|
2
|
+
try {
|
|
3
|
+
const date = new Date(timestamp)
|
|
4
|
+
const hours = date.getHours().toString().padStart(2, '0')
|
|
5
|
+
const minutes = date.getMinutes().toString().padStart(2, '0')
|
|
6
|
+
return `${hours}:${minutes}`
|
|
7
|
+
} catch {
|
|
8
|
+
return timestamp
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function formatDateTime(iso: string): string {
|
|
13
|
+
const d = new Date(iso)
|
|
14
|
+
return d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isSameDay(a: Date, b: Date): boolean {
|
|
18
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const FILE_SIZE_UNITS = ['B', 'KB', 'MB', 'GB'] as const
|
|
22
|
+
|
|
23
|
+
export function formatFileSize(bytes: number): string {
|
|
24
|
+
if (!isFinite(bytes) || bytes < 0) return ''
|
|
25
|
+
if (bytes < 1) return '0 B'
|
|
26
|
+
|
|
27
|
+
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), FILE_SIZE_UNITS.length - 1)
|
|
28
|
+
const value = bytes / 1024 ** exponent
|
|
29
|
+
const formatted = exponent === 0 ? value.toString() : value.toFixed(value < 10 ? 1 : 0)
|
|
30
|
+
|
|
31
|
+
return `${formatted} ${FILE_SIZE_UNITS[exponent]}`
|
|
32
|
+
}
|
package/src/lib/phone.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function formatPhone(number: string): string {
|
|
2
|
+
const digits = number.replace(/\D/g, '')
|
|
3
|
+
|
|
4
|
+
if (digits.length === 13) {
|
|
5
|
+
return `+${digits.slice(0, 2)} (${digits.slice(2, 4)}) ${digits.slice(4, 9)}-${digits.slice(9, 13)}`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (digits.length === 12) {
|
|
9
|
+
return `+${digits.slice(0, 2)} (${digits.slice(2, 4)}) ${digits.slice(4, 8)}-${digits.slice(8, 12)}`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (digits.length === 11) {
|
|
13
|
+
return `(${digits.slice(0, 2)}) ${digits.slice(2, 7)}-${digits.slice(7, 11)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (digits.length === 10) {
|
|
17
|
+
return `(${digits.slice(0, 2)}) ${digits.slice(2, 6)}-${digits.slice(6, 10)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function phoneInitials(number: string): string {
|
|
24
|
+
const digits = number.replace(/\D/g, '')
|
|
25
|
+
return digits.slice(-2)
|
|
26
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
interface FormatToken {
|
|
4
|
+
type: 'text' | 'bold' | 'italic' | 'strikethrough' | 'monospace' | 'codeblock'
|
|
5
|
+
content: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const MONOSPACE_REGEX = /```([\s\S]*?)```/g
|
|
9
|
+
const BOLD_REGEX = /\*([^*]+)\*/g
|
|
10
|
+
const ITALIC_REGEX = /_([^_]+)_/g
|
|
11
|
+
const STRIKETHROUGH_REGEX = /~([^~]+)~/g
|
|
12
|
+
const INLINE_CODE_REGEX = /`([^`]+)`/g
|
|
13
|
+
|
|
14
|
+
function tokenize(text: string): FormatToken[] {
|
|
15
|
+
const tokens: FormatToken[] = []
|
|
16
|
+
|
|
17
|
+
let remaining = text
|
|
18
|
+
|
|
19
|
+
const codeBlocks: { index: number; content: string }[] = []
|
|
20
|
+
remaining = remaining.replace(MONOSPACE_REGEX, (_match, content, offset) => {
|
|
21
|
+
codeBlocks.push({ index: offset, content })
|
|
22
|
+
return '\u0000'.repeat(_match.length)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
let codeBlockIndex = 0
|
|
26
|
+
let i = 0
|
|
27
|
+
let buffer = ''
|
|
28
|
+
|
|
29
|
+
while (i < remaining.length) {
|
|
30
|
+
if (remaining[i] === '\u0000') {
|
|
31
|
+
if (buffer) {
|
|
32
|
+
tokens.push(...parseInlineTokens(buffer))
|
|
33
|
+
buffer = ''
|
|
34
|
+
}
|
|
35
|
+
const cb = codeBlocks[codeBlockIndex++]
|
|
36
|
+
tokens.push({ type: 'codeblock', content: cb.content })
|
|
37
|
+
const skip = '```' + cb.content + '```'
|
|
38
|
+
i += skip.length
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
// Check for inline code
|
|
42
|
+
const inlineMatch = remaining.slice(i).match(/^`([^`]+)`/)
|
|
43
|
+
if (inlineMatch && inlineMatch.index === 0) {
|
|
44
|
+
if (buffer) {
|
|
45
|
+
tokens.push(...parseInlineTokens(buffer))
|
|
46
|
+
buffer = ''
|
|
47
|
+
}
|
|
48
|
+
tokens.push({ type: 'monospace', content: inlineMatch[1] })
|
|
49
|
+
i += inlineMatch[0].length
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
buffer += remaining[i]
|
|
54
|
+
i++
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (buffer) {
|
|
58
|
+
tokens.push(...parseInlineTokens(buffer))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return tokens
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseInlineTokens(text: string): FormatToken[] {
|
|
65
|
+
const result: FormatToken[] = []
|
|
66
|
+
let remaining = text
|
|
67
|
+
|
|
68
|
+
while (remaining.length > 0) {
|
|
69
|
+
const boldMatch = remaining.match(/^\*([^*]+)\*/)
|
|
70
|
+
if (boldMatch) {
|
|
71
|
+
result.push({ type: 'bold', content: boldMatch[1] })
|
|
72
|
+
remaining = remaining.slice(boldMatch[0].length)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const italicMatch = remaining.match(/^_([^_]+)_/)
|
|
76
|
+
if (italicMatch) {
|
|
77
|
+
result.push({ type: 'italic', content: italicMatch[1] })
|
|
78
|
+
remaining = remaining.slice(italicMatch[0].length)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
const strikeMatch = remaining.match(/^~([^~]+)~/)
|
|
82
|
+
if (strikeMatch) {
|
|
83
|
+
result.push({ type: 'strikethrough', content: strikeMatch[1] })
|
|
84
|
+
remaining = remaining.slice(strikeMatch[0].length)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const nextSpecial = remaining.search(/[*_~`]/)
|
|
89
|
+
if (nextSpecial === -1) {
|
|
90
|
+
if (remaining) result.push({ type: 'text', content: remaining })
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
if (nextSpecial > 0) {
|
|
94
|
+
result.push({ type: 'text', content: remaining.slice(0, nextSpecial) })
|
|
95
|
+
}
|
|
96
|
+
remaining = remaining.slice(nextSpecial)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function parseWhatsAppFormatting(text: string): ReactNode[] {
|
|
103
|
+
const tokens = tokenize(text)
|
|
104
|
+
|
|
105
|
+
return tokens.map((token, index) => {
|
|
106
|
+
switch (token.type) {
|
|
107
|
+
case 'bold':
|
|
108
|
+
return <strong key={index}>{token.content}</strong>
|
|
109
|
+
case 'italic':
|
|
110
|
+
return <em key={index}>{token.content}</em>
|
|
111
|
+
case 'strikethrough':
|
|
112
|
+
return <del key={index}>{token.content}</del>
|
|
113
|
+
case 'monospace':
|
|
114
|
+
return <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm">{token.content}</code>
|
|
115
|
+
case 'codeblock':
|
|
116
|
+
return (
|
|
117
|
+
<pre key={index} className="bg-gray-100 p-2 rounded text-sm overflow-x-auto my-1">
|
|
118
|
+
<code>{token.content}</code>
|
|
119
|
+
</pre>
|
|
120
|
+
)
|
|
121
|
+
default:
|
|
122
|
+
return <span key={index}>{token.content}</span>
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function escapeHtml(text: string): string {
|
|
128
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function unescapeHtml(text: string): string {
|
|
132
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, ' ')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Marcador da Private Use Area do Unicode — praticamente impossível de colidir com
|
|
136
|
+
// texto real de mensagem, ao contrário de um sentinela em ASCII (ex: dígitos soltos).
|
|
137
|
+
const CODE_TOKEN_MARK = String.fromCharCode(0xe000)
|
|
138
|
+
const CODE_TOKEN_REGEX = new RegExp(`${CODE_TOKEN_MARK}(\\d+)${CODE_TOKEN_MARK}`, 'g')
|
|
139
|
+
|
|
140
|
+
// waToHTML/htmlToWA formam um par round-trip: todo texto que sai de waToHTML deve
|
|
141
|
+
// reconstruir exatamente o original ao passar por htmlToWA (inclusive blocos de
|
|
142
|
+
// código multi-linha, que exigem distinguir ``` de ` via atributo data-wa).
|
|
143
|
+
export function waToHTML(text: string): string {
|
|
144
|
+
if (!text) return ''
|
|
145
|
+
|
|
146
|
+
const codeTokens: { type: 'block' | 'inline'; content: string }[] = []
|
|
147
|
+
let working = text.replace(/```([\s\S]*?)```/g, (_match, content: string) => {
|
|
148
|
+
codeTokens.push({ type: 'block', content })
|
|
149
|
+
return `${CODE_TOKEN_MARK}${codeTokens.length - 1}${CODE_TOKEN_MARK}`
|
|
150
|
+
})
|
|
151
|
+
working = working.replace(/`([^`\n]+)`/g, (_match, content: string) => {
|
|
152
|
+
codeTokens.push({ type: 'inline', content })
|
|
153
|
+
return `${CODE_TOKEN_MARK}${codeTokens.length - 1}${CODE_TOKEN_MARK}`
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
let html = escapeHtml(working)
|
|
157
|
+
html = html.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
|
|
158
|
+
html = html.replace(/_([^_\n]+)_/g, '<em>$1</em>')
|
|
159
|
+
html = html.replace(/~([^~\n]+)~/g, '<del>$1</del>')
|
|
160
|
+
html = html.replace(/\n/g, '<br>')
|
|
161
|
+
|
|
162
|
+
html = html.replace(CODE_TOKEN_REGEX, (_match, indexStr: string) => {
|
|
163
|
+
const token = codeTokens[Number(indexStr)]
|
|
164
|
+
const escaped = escapeHtml(token.content)
|
|
165
|
+
if (token.type === 'block') {
|
|
166
|
+
return `<code data-wa="block" class="block bg-black/5 dark:bg-white/10 rounded px-1.5 py-0.5 font-mono text-sm whitespace-pre-wrap">${escaped.replace(/\n/g, '<br>')}</code>`
|
|
167
|
+
}
|
|
168
|
+
return `<code data-wa="inline" class="bg-black/5 dark:bg-white/10 rounded px-0.5 font-mono text-sm">${escaped}</code>`
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return html
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function htmlToWA(html: string): string {
|
|
175
|
+
if (!html) return ''
|
|
176
|
+
|
|
177
|
+
let text = html
|
|
178
|
+
text = text.replace(/<code data-wa="block"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
|
|
179
|
+
`\`\`\`${unescapeHtml(inner.replace(/<br\s*\/?>/gi, '\n'))}\`\`\``
|
|
180
|
+
))
|
|
181
|
+
text = text.replace(/<code data-wa="inline"[^>]*>([\s\S]*?)<\/code>/gi, (_match, inner: string) => (
|
|
182
|
+
`\`${unescapeHtml(inner)}\``
|
|
183
|
+
))
|
|
184
|
+
|
|
185
|
+
text = text
|
|
186
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
187
|
+
.replace(/<div>/gi, '\n')
|
|
188
|
+
.replace(/<\/div>/gi, '')
|
|
189
|
+
.replace(/<\/p>/gi, '\n')
|
|
190
|
+
.replace(/<p[^>]*>/gi, '')
|
|
191
|
+
|
|
192
|
+
text = text.replace(/<strong>(.*?)<\/strong>/gi, '*$1*')
|
|
193
|
+
text = text.replace(/<b>(.*?)<\/b>/gi, '*$1*')
|
|
194
|
+
text = text.replace(/<em>(.*?)<\/em>/gi, '_$1_')
|
|
195
|
+
text = text.replace(/<i>(.*?)<\/i>/gi, '_$1_')
|
|
196
|
+
text = text.replace(/<del>(.*?)<\/del>/gi, '~$1~')
|
|
197
|
+
text = text.replace(/<s>(.*?)<\/s>/gi, '~$1~')
|
|
198
|
+
// Compat: HTML sem os marcadores data-wa (ex: vindo de outro editor) ainda vira código inline.
|
|
199
|
+
text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
|
|
200
|
+
|
|
201
|
+
text = text.replace(/<[^>]+>/g, '')
|
|
202
|
+
text = unescapeHtml(text)
|
|
203
|
+
return text.trim()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Variante inline para contextos de preview (ex: última mensagem numa lista) — sem
|
|
207
|
+
// suporte a bloco de código nem quebras de linha, propositalmente mais simples.
|
|
208
|
+
export function waToHTMLInline(text: string): string {
|
|
209
|
+
if (!text) return ''
|
|
210
|
+
return escapeHtml(text)
|
|
211
|
+
.replace(/\*([^*\n]+)\*/g, '<strong>$1</strong>')
|
|
212
|
+
.replace(/_([^_\n]+)_/g, '<em>$1</em>')
|
|
213
|
+
.replace(/~([^~\n]+)~/g, '<del>$1</del>')
|
|
214
|
+
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
|
|
215
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
import type { ConversationsApi, SSEProvider } from './types'
|
|
3
|
+
|
|
4
|
+
interface ConversationsContextValue {
|
|
5
|
+
api: ConversationsApi
|
|
6
|
+
sse: SSEProvider
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const ConversationsContext = createContext<ConversationsContextValue | null>(null)
|
|
10
|
+
|
|
11
|
+
export function ConversationsProvider({
|
|
12
|
+
api,
|
|
13
|
+
sse,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
api: ConversationsApi
|
|
17
|
+
sse: SSEProvider
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<ConversationsContext.Provider value={{ api, sse }}>
|
|
22
|
+
{children}
|
|
23
|
+
</ConversationsContext.Provider>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useConversations(): ConversationsContextValue | null {
|
|
28
|
+
return useContext(ConversationsContext)
|
|
29
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { MessagePayload } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface ConversationsApi {
|
|
4
|
+
fetchMessages(conversationId: string, params?: { limit?: number; before?: string }): Promise<MessagePayload[]>
|
|
5
|
+
fetchConversations(params?: {
|
|
6
|
+
page?: number
|
|
7
|
+
limit?: number
|
|
8
|
+
waitingHuman?: boolean
|
|
9
|
+
search?: string
|
|
10
|
+
}): Promise<ConversationSummary[]>
|
|
11
|
+
sendMessage(conversationId: string, text: string): Promise<MessagePayload>
|
|
12
|
+
sendMedia(
|
|
13
|
+
conversationId: string,
|
|
14
|
+
data: { base64: string; mimeType: string; filename: string; caption?: string },
|
|
15
|
+
): Promise<MessagePayload>
|
|
16
|
+
sendTemplate(
|
|
17
|
+
conversationId: string,
|
|
18
|
+
data: { templateName: string; languageCode?: string; bodyParams?: string[] },
|
|
19
|
+
): Promise<void>
|
|
20
|
+
markRead(conversationId: string): Promise<void>
|
|
21
|
+
getContext(conversationId: string): Promise<Record<string, unknown>>
|
|
22
|
+
getDocuments(conversationId: string, params?: { search?: string; page?: number }): Promise<ConversationDocument[]>
|
|
23
|
+
getDocumentUrl(uploadId: string): Promise<string>
|
|
24
|
+
getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SSEProvider {
|
|
28
|
+
connectConversationStream(conversationId: string): EventSource
|
|
29
|
+
connectGlobalStream(): EventSource
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConversationSummary {
|
|
33
|
+
id: string
|
|
34
|
+
whatsappNumber: string
|
|
35
|
+
clientName?: string
|
|
36
|
+
lastContent?: string
|
|
37
|
+
lastDirection?: 'inbound' | 'outbound'
|
|
38
|
+
lastAt: string
|
|
39
|
+
lastInboundAt: string | null
|
|
40
|
+
mode: 'bot' | 'human'
|
|
41
|
+
assignedUserId: string | null
|
|
42
|
+
waitingHuman: boolean
|
|
43
|
+
unread: number
|
|
44
|
+
currentState: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ConversationDocument {
|
|
48
|
+
id: string
|
|
49
|
+
filename: string
|
|
50
|
+
mimeType: string
|
|
51
|
+
sizeBytes: number
|
|
52
|
+
source: string
|
|
53
|
+
linkedAt: string
|
|
54
|
+
}
|