From 179348c3dc359ca499108e770da83d5a1dba248c Mon Sep 17 00:00:00 2001 From: akbarxleqi Date: Thu, 28 May 2026 15:24:47 +0700 Subject: [PATCH 1/2] feat(dashboard): add real-time chat room with replies, reactions, and deletions - Real-time chat room UI page showing active chats and messages - Quoted reply support (quoted message bubble + input preview banner) - Message reactions (emoji badge overlay + interactive hover menu) - Message deletions (delete for everyone integration) - Synced NestJS backend event adapter, SQLite storage, and WebSocket gateway broadcasts --- dashboard/src/App.tsx | 3 + dashboard/src/components/Layout.tsx | 2 + dashboard/src/hooks/queries.ts | 11 + dashboard/src/hooks/useWebSocket.ts | 114 +- dashboard/src/i18n/locales/en.json | 1 + dashboard/src/i18n/locales/he.json | 1 + dashboard/src/pages/Chats.css | 969 ++++++++++++++++ dashboard/src/pages/Chats.tsx | 1003 +++++++++++++++++ dashboard/src/pages/Sessions.tsx | 10 +- dashboard/src/services/api.ts | 45 + dashboard/vite.config.ts | 5 + package-lock.json | 51 +- .../adapters/whatsapp-web-js.adapter.ts | 63 ++ .../interfaces/whatsapp-engine.interface.ts | 5 + src/modules/events/dto/ws-messages.dto.ts | 1 + src/modules/events/events.gateway.ts | 14 + src/modules/message/message.service.ts | 64 ++ src/modules/session/session.controller.ts | 31 + src/modules/session/session.module.ts | 3 +- src/modules/session/session.service.ts | 148 +++ 20 files changed, 2487 insertions(+), 57 deletions(-) create mode 100644 dashboard/src/pages/Chats.css create mode 100644 dashboard/src/pages/Chats.tsx diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 21c67492..7bc55b2f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -11,6 +11,7 @@ import './App.css'; const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login }))); const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard }))); const Sessions = lazy(() => import('./pages/Sessions').then(m => ({ default: m.Sessions }))); +const Chats = lazy(() => import('./pages/Chats').then(m => ({ default: m.Chats }))); const Webhooks = lazy(() => import('./pages/Webhooks').then(m => ({ default: m.Webhooks }))); const Logs = lazy(() => import('./pages/Logs').then(m => ({ default: m.Logs }))); const ApiKeys = lazy(() => import('./pages/ApiKeys').then(m => ({ default: m.ApiKeys }))); @@ -18,6 +19,7 @@ const MessageTester = lazy(() => import('./pages/MessageTester').then(m => ({ de const Infrastructure = lazy(() => import('./pages/Infrastructure').then(m => ({ default: m.Infrastructure }))); const Plugins = lazy(() => import('./pages/Plugins')); + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -101,6 +103,7 @@ function AppContent() { }> } /> } /> + } /> } /> {role === 'admin' && } />} } /> diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx index f2815bb8..5a78cc39 100644 --- a/dashboard/src/components/Layout.tsx +++ b/dashboard/src/components/Layout.tsx @@ -19,6 +19,7 @@ import { ChevronLeft, ChevronRight, Languages, + MessageSquare, } from 'lucide-react'; import { useTheme } from '../hooks/useTheme'; import { type UserRole } from '../hooks/useRole'; @@ -33,6 +34,7 @@ interface LayoutProps { const allNavItems = [ { to: '/', icon: LayoutDashboard, key: 'dashboard' as const, adminOnly: false }, { to: '/sessions', icon: Smartphone, key: 'sessions' as const, adminOnly: false }, + { to: '/chats', icon: MessageSquare, key: 'chats' as const, adminOnly: false }, { to: '/webhooks', icon: Webhook, key: 'webhooks' as const, adminOnly: false }, { to: '/api-keys', icon: Key, key: 'apiKeys' as const, adminOnly: true }, { to: '/message-tester', icon: Send, key: 'messageTester' as const, adminOnly: false }, diff --git a/dashboard/src/hooks/queries.ts b/dashboard/src/hooks/queries.ts index 21e641b6..3ba9b57f 100644 --- a/dashboard/src/hooks/queries.ts +++ b/dashboard/src/hooks/queries.ts @@ -15,6 +15,7 @@ export const queryKeys = { sessions: ['sessions'] as const, sessionStats: ['sessions', 'stats'] as const, sessionGroups: (sessionId: string) => ['sessions', sessionId, 'groups'] as const, + sessionChats: (sessionId: string) => ['sessions', sessionId, 'chats'] as const, webhooks: ['webhooks'] as const, apiKeys: ['apiKeys'] as const, logs: (params: { severity?: string; page: number; limit: number }) => @@ -52,6 +53,16 @@ export function useSessionGroupsQuery(sessionId: string, enabled: boolean) { }); } +export function useSessionChatsQuery(sessionId: string, enabled: boolean) { + return useQuery({ + queryKey: queryKeys.sessionChats(sessionId), + queryFn: () => sessionApi.getChats(sessionId), + enabled: enabled && !!sessionId, + staleTime: 10_000, + }); +} + + export function useCreateSessionMutation() { const queryClient = useQueryClient(); return useMutation({ diff --git a/dashboard/src/hooks/useWebSocket.ts b/dashboard/src/hooks/useWebSocket.ts index 592f7c8a..98e5bce0 100644 --- a/dashboard/src/hooks/useWebSocket.ts +++ b/dashboard/src/hooks/useWebSocket.ts @@ -19,10 +19,43 @@ interface MessageEvent { timestamp: string; } +interface MessageAckEvent { + sessionId: string; + messageId: string; + ack: number; + ackName: string; + chatId?: string; + timestamp: string; +} + +interface MessageReactionEvent { + sessionId: string; + messageId: string; + chatId: string; + reaction: string; + senderId: string; + reactions: Record; + timestamp: string; +} + +interface MessageRevokedEvent { + sessionId: string; + id: string; + chatId: string; + from: string; + to: string; + body: string; + type: string; + timestamp: number; +} + interface WebSocketEvents { onSessionStatus?: (event: SessionStatusEvent) => void; onQRCode?: (event: QRCodeEvent) => void; onMessage?: (event: MessageEvent) => void; + onMessageAck?: (event: MessageAckEvent) => void; + onMessageReaction?: (event: MessageReactionEvent) => void; + onMessageRevoked?: (event: MessageRevokedEvent) => void; } // Use current origin for WebSocket (goes through nginx proxy in Docker) @@ -75,6 +108,25 @@ export function useWebSocket(events: WebSocketEvents = {}) { }); }, []); + const subscribe = useCallback((sessionId: string, eventsList: string[]) => { + if (socketRef.current?.connected) { + socketRef.current.emit('message', { + type: 'subscribe', + sessionId, + events: eventsList, + }); + } + }, []); + + const unsubscribe = useCallback((sessionId: string) => { + if (socketRef.current?.connected) { + socketRef.current.emit('message', { + type: 'unsubscribe', + sessionId, + }); + } + }, []); + useEffect(() => { connect(); @@ -92,24 +144,56 @@ export function useWebSocket(events: WebSocketEvents = {}) { const socket = socketRef.current; - if (events.onSessionStatus) { - socket.on('session:status', events.onSessionStatus); - } - - if (events.onQRCode) { - socket.on('session:qr', events.onQRCode); - } + const handleIncomingMessage = (msg: any) => { + if (msg && msg.type === 'event' && msg.payload) { + const { event, sessionId, data } = msg.payload; + if (event === 'session.status' && events.onSessionStatus) { + events.onSessionStatus({ sessionId, status: data.status, timestamp: msg.timestamp }); + } else if (event === 'session.qr' && events.onQRCode) { + events.onQRCode({ sessionId, qrCode: data.qrCode, timestamp: msg.timestamp }); + } else if ((event === 'message.received' || event === 'message.sent') && events.onMessage) { + events.onMessage({ sessionId, message: data, timestamp: msg.timestamp }); + } else if (event === 'message.ack' && events.onMessageAck) { + events.onMessageAck({ + sessionId, + messageId: data.messageId, + ack: data.ack, + ackName: data.ackName, + chatId: data.chatId, + timestamp: msg.timestamp, + }); + } else if (event === 'message.reaction' && events.onMessageReaction) { + events.onMessageReaction({ + sessionId, + messageId: data.messageId, + chatId: data.chatId, + reaction: data.reaction, + senderId: data.senderId, + reactions: data.reactions, + timestamp: msg.timestamp, + }); + } else if (event === 'message.revoked' && events.onMessageRevoked) { + events.onMessageRevoked({ + sessionId, + id: data.id, + chatId: data.chatId, + from: data.from, + to: data.to, + body: data.body, + type: data.type, + timestamp: data.timestamp, + }); + } + } + }; - if (events.onMessage) { - socket.on('session:message', events.onMessage); - } + socket.on('message', handleIncomingMessage); return () => { - socket.off('session:status'); - socket.off('session:qr'); - socket.off('session:message'); + socket.off('message', handleIncomingMessage); }; - }, [events.onSessionStatus, events.onQRCode, events.onMessage]); + }, [events]); - return { isConnected }; + return { isConnected, subscribe, unsubscribe }; } + diff --git a/dashboard/src/i18n/locales/en.json b/dashboard/src/i18n/locales/en.json index 53049244..4ae48a83 100644 --- a/dashboard/src/i18n/locales/en.json +++ b/dashboard/src/i18n/locales/en.json @@ -56,6 +56,7 @@ "nav": { "dashboard": "Dashboard", "sessions": "Sessions", + "chats": "Chats", "webhooks": "Webhooks", "apiKeys": "API Keys", "messageTester": "Message Tester", diff --git a/dashboard/src/i18n/locales/he.json b/dashboard/src/i18n/locales/he.json index 6088a1d0..b0602798 100644 --- a/dashboard/src/i18n/locales/he.json +++ b/dashboard/src/i18n/locales/he.json @@ -56,6 +56,7 @@ "nav": { "dashboard": "דאשבורד", "sessions": "Sessions", + "chats": "שיחות", "webhooks": "Webhooks", "apiKeys": "מפתחות API", "messageTester": "בדיקת הודעות", diff --git a/dashboard/src/pages/Chats.css b/dashboard/src/pages/Chats.css new file mode 100644 index 00000000..637d336a --- /dev/null +++ b/dashboard/src/pages/Chats.css @@ -0,0 +1,969 @@ +.chats-page { + padding: 2rem; + width: 100%; + height: calc(100vh - 4rem); + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.chats-layout { + display: flex; + flex: 1; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + box-shadow: var(--shadow-md, 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)); + height: calc(100vh - 12rem); + margin-top: 1rem; +} + +/* ============================================================================= + SIDEBAR: Left Column + ============================================================================= */ +.chats-sidebar { + width: 320px; + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + background: var(--bg-white); + flex-shrink: 0; +} + +.sidebar-header-box { + padding: 1.25rem; + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.session-select-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.session-selector { + width: 100%; + padding: 0.625rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius, 8px); + background: var(--bg-light); + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + outline: none; + cursor: pointer; + transition: all 0.2s; +} + +.session-selector:focus { + border-color: var(--primary); + background: var(--bg-white); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1); +} + +.chat-search-input { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: var(--radius, 8px); +} + +.chat-search-input svg { + color: var(--text-muted); + flex-shrink: 0; +} + +.chat-search-input input { + flex: 1; + border: none; + background: none; + font-size: 0.875rem; + color: var(--text-primary); + outline: none; +} + +.chats-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Custom Scrollbar for chats list */ +.chats-list::-webkit-scrollbar, +.room-messages::-webkit-scrollbar { + width: 6px; +} + +.chats-list::-webkit-scrollbar-thumb, +.room-messages::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 3px; +} + +.chats-list::-webkit-scrollbar-track, +.room-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chats-list-loading, +.chats-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + color: var(--text-muted); + font-size: 0.875rem; + gap: 0.5rem; +} + +.chat-item-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.15s ease; + user-select: none; +} + +.chat-item-card:hover { + background: var(--bg-light); +} + +.chat-item-card.active { + background: rgba(34, 197, 94, 0.08); +} + +.chat-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--bg-light); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + flex-shrink: 0; +} + +.chat-item-card.active .chat-avatar { + background: var(--bg-white); + color: var(--primary); + border-color: rgba(34, 197, 94, 0.3); +} + +.chat-item-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; /* Ensures text truncation works */ +} + +.chat-item-top { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.chat-item-name { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-item-time { + font-size: 0.75rem; + color: var(--text-muted); + white-shrink: 0; +} + +.chat-item-bottom { + display: flex; + justify-content: space-between; + align-items: center; +} + +.chat-item-snippet { + font-size: 0.8125rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} + +.chat-item-snippet .no-message { + font-style: italic; + opacity: 0.6; +} + +.chat-unread-badge { + background: var(--primary); + color: white; + font-size: 0.75rem; + font-weight: 700; + padding: 0.125rem 0.375rem; + border-radius: 10px; + min-width: 14px; + text-align: center; + box-shadow: 0 2px 4px rgba(34, 197, 94, 0.3); +} + +/* ============================================================================= + CHAT ROOM: Right Column + ============================================================================= */ +.chats-room { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bg-light); +} + +.chats-room-placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + text-align: center; + color: var(--text-muted); +} + +.placeholder-icon { + margin-bottom: 1.5rem; + opacity: 0.3; + color: var(--text-secondary); +} + +.chats-room-placeholder h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-secondary); + margin: 0 0 0.5rem; +} + +.chats-room-placeholder p { + font-size: 0.9375rem; + max-width: 380px; + margin: 0; +} + +.room-container { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; +} + +.room-header { + height: 70px; + background: var(--bg-white); + border-bottom: 1px solid var(--border); + padding: 0 1.5rem; + display: flex; + align-items: center; + gap: 0.75rem; + box-sizing: border-box; +} + +.room-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-light); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.room-contact-info h3 { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.room-contact-info span { + font-size: 0.75rem; + color: var(--text-muted); + font-family: monospace; +} + +.room-messages { + flex: 1; + padding: 1.5rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.75rem; + background-image: radial-gradient(var(--border) 1px, transparent 0); + background-size: 24px 24px; +} + +.messages-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-muted); + font-size: 0.9375rem; + gap: 0.75rem; +} + +.messages-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-muted); + font-size: 0.9375rem; + gap: 0.75rem; + text-align: center; + padding: 2rem; +} + +.message-bubble-wrapper { + display: flex; + width: 100%; +} + +.message-bubble-wrapper.incoming { + justify-content: flex-start; +} + +.message-bubble-wrapper.outgoing { + justify-content: flex-end; +} + +.message-bubble { + max-width: 100%; + padding: 0.625rem 0.875rem 0.375rem; + border-radius: 12px; + font-size: 0.9375rem; + line-height: 1.4; + position: relative; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.message-bubble.incoming { + background: var(--bg-white); + color: var(--text-primary); + border-bottom-left-radius: 2px; + border: 1px solid var(--border); +} + +.message-bubble.outgoing { + background: #d9fdd3; /* Soft WhatsApp Green */ + color: #111b21; + border-bottom-right-radius: 2px; +} + +/* Dark theme outgoing chat bubble override */ +[data-theme='dark'] .message-bubble.outgoing { + background: #005c4b; /* Dark WhatsApp Green */ + color: #e9edef; +} + +.message-text { + word-break: break-word; + white-space: pre-wrap; +} + +.message-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.25rem; + margin-top: 0.25rem; +} + +.message-time { + font-size: 0.6875rem; + color: var(--text-muted); +} + +.message-status-icon { + font-size: 0.75rem; +} + +.message-status-icon.read { + color: #53bdeb; /* WhatsApp blue checks */ +} + +/* ============================================================================= + INPUT BAR: Bottom Footer + ============================================================================= */ +.room-input-footer { + background: var(--bg-white); + border-top: 1px solid var(--border); + padding: 1rem 1.5rem; + box-sizing: border-box; +} + +.input-form { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.message-text-input { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 24px; + font-size: 0.9375rem; + background: var(--bg-light); + color: var(--text-primary); + outline: none; + transition: all 0.2s; + box-sizing: border-box; +} + +.message-text-input:focus { + border-color: var(--primary); + background: var(--bg-white); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.1); +} + +.btn-send-message { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: var(--primary); + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + transition: background 0.2s, transform 0.1s; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3); +} + +.btn-send-message:hover:not(:disabled) { + background: var(--primary-hover); + transform: scale(1.05); +} + +.btn-send-message:disabled { + background: var(--border); + color: var(--text-muted); + cursor: not-allowed; + box-shadow: none; +} + +/* ============================================================================= + GENERAL CHATS STYLES & ERROR STATES + ============================================================================= */ +.chats-loading-container, +.chats-error-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: var(--bg-white); + border: 1px solid var(--border); + border-radius: 16px; + padding: 4rem 2rem; + margin-top: 1rem; + text-align: center; + color: var(--text-secondary); + box-shadow: var(--shadow-sm); + flex: 1; +} + +.chats-error-state h3 { + font-size: 1.25rem; + margin: 1rem 0 0.5rem; + color: var(--text-primary); +} + +.chats-error-state p { + font-size: 0.9375rem; + max-width: 450px; + margin: 0; + color: var(--text-muted); +} + +.text-warn { + color: #ea580c; +} + +/* ============================================================================= + ATTACHMENT PREVIEW & EMOJI PICKER + ============================================================================= */ +.btn-input-accessory { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.5rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.btn-input-accessory:hover { + background: var(--bg-light); + color: var(--text-primary); +} + +.btn-input-accessory.active { + background: rgba(34, 197, 94, 0.1); + color: var(--primary); +} + +.attachment-preview-banner { + background: var(--bg-white); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + gap: 1rem; + box-sizing: border-box; +} + +.preview-thumbnail { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--border); +} + +.preview-file-icon { + width: 48px; + height: 48px; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} + +.preview-file-info { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.preview-filename { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.preview-filesize { + font-size: 0.75rem; + color: var(--text-muted); +} + +.btn-remove-attachment { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.375rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.btn-remove-attachment:hover { + background: #fee2e2; + color: #dc2626; +} + +.chats-emoji-picker { + background: var(--bg-white); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + box-sizing: border-box; + max-height: 120px; + overflow-y: auto; +} + +.emoji-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.emoji-btn { + background: transparent; + border: none; + font-size: 1.25rem; + padding: 0.375rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.emoji-btn:hover { + background: var(--bg-light); + transform: scale(1.15); +} + +.message-bubble.media-type { + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + +/* ============================================================================= + RESPONSIVENESS + ============================================================================= */ +@media (max-width: 768px) { + .chats-page { + padding: 1rem; + height: auto; + } + + .chats-layout { + flex-direction: column; + height: 600px; + } + + .chats-sidebar { + width: 100%; + height: 250px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .chats-room { + height: 350px; + } + + .room-header { + height: 60px; + } +} + +/* ============================================================================= + MEDIA RENDERING STYLES + ============================================================================= */ +.chat-image-media { + max-width: 100%; + max-height: 250px; + border-radius: 8px; + display: block; + margin-bottom: 0.5rem; + cursor: pointer; + object-fit: cover; +} + +.chat-video-media { + max-width: 100%; + max-height: 250px; + border-radius: 8px; + display: block; + margin-bottom: 0.5rem; +} + +.chat-audio-media { + max-width: 100%; + display: block; + margin-bottom: 0.5rem; +} + +.chat-document-media { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--bg-light); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + text-decoration: none; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.5rem; + transition: all 0.2s; + word-break: break-all; +} + +.chat-document-media:hover { + background: var(--border); +} + +[data-theme='dark'] .chat-document-media { + background: #202c33; + border-color: #2f3b43; +} + +.message-bubble.media-type { + padding: 0.5rem; +} + +/* ============================================================================= + HOVER MENU, REPLIES, REACTIONS, AND DELETIONS + ============================================================================= */ +.message-bubble-container { + position: relative; + display: flex; + align-items: center; + gap: 0.5rem; + max-width: 70%; +} + +.message-bubble-wrapper.outgoing .message-bubble-container { + flex-direction: row-reverse; +} + +.message-bubble-wrapper.incoming .message-bubble-container { + flex-direction: row; +} + +/* Hover Menu */ +.message-actions-menu { + display: none; + align-items: center; + gap: 0.25rem; + background: var(--bg-white); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + border-radius: 20px; + padding: 2px 6px; + z-index: 5; +} + +.message-bubble-container:hover .message-actions-menu { + display: flex; +} + +.action-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 4px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; +} + +.action-btn:hover { + background: var(--bg-light); + color: var(--text-primary); +} + +.action-btn.delete-btn:hover { + color: #dc2626; + background: #fee2e2; +} + +/* Reaction Quick Popover */ +.reaction-trigger-wrapper { + position: relative; +} + +.reaction-quick-popover { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-6px); + background: var(--bg-white); + border: 1px solid var(--border); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + border-radius: 24px; + padding: 0.375rem 0.625rem; + gap: 0.375rem; + z-index: 50; + white-space: nowrap; +} + +.reaction-quick-popover::after { + content: ''; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 12px; + background: transparent; +} + +.reaction-trigger-wrapper:hover .reaction-quick-popover { + display: flex; +} + +.reaction-quick-popover button { + background: transparent; + border: none; + font-size: 1.25rem; + padding: 2px; + cursor: pointer; + transition: transform 0.1s ease; +} + +.reaction-quick-popover button:hover { + transform: scale(1.3); +} + +/* Reactions Badge */ +.message-reactions-badge { + position: absolute; + bottom: -12px; + background: var(--bg-white); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + border-radius: 12px; + padding: 2px 6px; + display: flex; + align-items: center; + gap: 2px; + font-size: 0.75rem; + z-index: 2; + cursor: pointer; + user-select: none; +} + +.message-bubble-wrapper.outgoing .message-reactions-badge { + right: 12px; +} + +.message-bubble-wrapper.incoming .message-reactions-badge { + left: 12px; +} + +.reaction-emoji-span { + font-size: 0.8125rem; +} + +.reactions-count-span { + font-weight: 600; + color: var(--text-secondary); + margin-left: 2px; +} + +/* Quoted Box inside Bubble */ +.message-quote-box { + background: rgba(0, 0, 0, 0.04); + border-left: 4px solid var(--primary); + border-radius: 4px; + padding: 0.375rem 0.625rem; + margin-bottom: 0.5rem; + font-size: 0.8125rem; + color: var(--text-secondary); + cursor: pointer; +} + +.message-bubble.outgoing .message-quote-box { + background: rgba(0, 0, 0, 0.05); + border-left-color: #008069; +} + +[data-theme='dark'] .message-quote-box { + background: rgba(255, 255, 255, 0.08); +} + +.quote-body { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 250px; +} + +/* Replying Preview Banner above Input Footer */ +.replying-preview-banner { + background: var(--bg-white); + border-top: 1px solid var(--border); + padding: 0.75rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + box-sizing: border-box; +} + +.replying-preview-content { + flex: 1; + border-left: 4px solid var(--primary); + padding-left: 0.75rem; + display: flex; + flex-direction: column; + min-width: 0; +} + +.replying-to-title { + font-size: 0.8125rem; + font-weight: 700; + color: var(--primary); +} + +.replying-to-body { + font-size: 0.875rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.btn-close-reply { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 0.375rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.btn-close-reply:hover { + background: var(--bg-light); + color: var(--text-primary); +} + +/* Revoked / Deleted message type */ +.message-bubble.revoked-type { + font-style: italic; + opacity: 0.6; +} + +[data-theme='dark'] .message-actions-menu, +[data-theme='dark'] .reaction-quick-popover, +[data-theme='dark'] .message-reactions-badge { + background: #202c33; + border-color: #2f3b43; +} diff --git a/dashboard/src/pages/Chats.tsx b/dashboard/src/pages/Chats.tsx new file mode 100644 index 00000000..052c091f --- /dev/null +++ b/dashboard/src/pages/Chats.tsx @@ -0,0 +1,1003 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Search, Send, Loader2, User, Users, AlertCircle, MessageSquare, Paperclip, Smile, X, CornerUpLeft, Trash2 } from 'lucide-react'; +import { sessionApi, messageApi, type Session, type Chat } from '../services/api'; +import { useWebSocket } from '../hooks/useWebSocket'; +import { useDocumentTitle } from '../hooks/useDocumentTitle'; +import { useRole } from '../hooks/useRole'; +import { PageHeader } from '../components/PageHeader'; +import './Chats.css'; + +interface Message { + id: string; + waMessageId?: string; + chatId: string; + from: string; + to: string; + body: string; + type: string; + direction: 'incoming' | 'outgoing'; + status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; + timestamp?: number; + createdAt: string; + metadata?: { + media?: { + mimetype: string; + filename?: string; + data?: string; + }; + quotedMessage?: { + id: string; + body: string; + }; + reactions?: Record; + }; +} + +const getMediaSrc = (media: { mimetype: string; data?: string }) => { + if (!media || !media.data) return ''; + if (media.data.startsWith('data:') || media.data.startsWith('http://') || media.data.startsWith('https://')) { + return media.data; + } + return `data:${media.mimetype};base64,${media.data}`; +}; + +export function Chats() { + const { t } = useTranslation(); + useDocumentTitle(t('nav.chats')); + const { canWrite } = useRole(); + + // Sessions list & active session + const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(''); + const [loadingSessions, setLoadingSessions] = useState(true); + + // Chats list + const [chats, setChats] = useState([]); + const [loadingChats, setLoadingChats] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + // Selected Chat & Message History + const [activeChat, setActiveChat] = useState(null); + const [messages, setMessages] = useState([]); + const [loadingMessages, setLoadingMessages] = useState(false); + const [messageInput, setMessageInput] = useState(''); + const [sending, setSending] = useState(false); + + // File Attachments + const [attachment, setAttachment] = useState<{ file: File; base64: string; mimetype: string; filename: string } | null>(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + // References + const chatBottomRef = useRef(null); + const fileInputRef = useRef(null); + const [replyingTo, setReplyingTo] = useState(null); + + // Popular Emojis + const popularEmojis = ['😀', '😂', '👍', '❤️', '🔥', '👏', '🙏', '🎉', '💡', '🤔', '😅', '😍', '😊', '😭', '😎', '😜', '🚀', '✨']; + + // 1. Fetch available connected sessions on mount + useEffect(() => { + const loadSessions = async () => { + try { + setLoadingSessions(true); + const list = await sessionApi.list(); + const readySessions = list.filter(s => s.status === 'ready'); + setSessions(readySessions); + if (readySessions.length > 0) { + setSelectedSessionId(readySessions[0].id); + } + } catch (err) { + console.error('Failed to load sessions:', err); + } finally { + setLoadingSessions(false); + } + }; + void loadSessions(); + }, []); + + // 2. Fetch chats when active session changes + const loadChats = useCallback(async (sessionId: string) => { + if (!sessionId) return; + try { + setLoadingChats(true); + const data = await sessionApi.getChats(sessionId); + const sorted = [...data].sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return (b.timestamp || 0) - (a.timestamp || 0); + }); + setChats(sorted); + } catch (err) { + console.error('Failed to load chats:', err); + setChats([]); + } finally { + setLoadingChats(false); + } + }, []); + + useEffect(() => { + if (selectedSessionId) { + void loadChats(selectedSessionId); + setActiveChat(null); + setMessages([]); + setAttachment(null); + setPreviewUrl(null); + } + }, [selectedSessionId, loadChats]); + + // 3. WebSocket Integration for real-time messages + const handleIncomingMessage = useCallback( + (event: { sessionId: string; message: any }) => { + if (event.sessionId !== selectedSessionId) return; + + const newMsg = event.message; + + // Update message list if the message belongs to the currently active chat + if (activeChat && newMsg.chatId === activeChat.id) { + // Mark as read/seen in backend + void fetch(`/api/sessions/${selectedSessionId}/chats/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': sessionStorage.getItem('openwa_api_key') || '', + }, + body: JSON.stringify({ chatId: activeChat.id }), + }).catch(err => console.error('Failed to mark incoming chat as read:', err)); + + const mappedMessage: Message = { + id: newMsg.id, + waMessageId: newMsg.id, + chatId: newMsg.chatId, + from: newMsg.from, + to: newMsg.to, + body: newMsg.body, + type: newMsg.type, + direction: newMsg.fromMe ? 'outgoing' : 'incoming', + status: 'sent', + timestamp: newMsg.timestamp, + createdAt: new Date(newMsg.timestamp * 1000).toISOString(), + metadata: newMsg.metadata || { + media: newMsg.media, + quotedMessage: newMsg.quotedMessage, + }, + }; + + setMessages(prev => { + if (prev.some(m => m.id === mappedMessage.id || m.waMessageId === mappedMessage.id)) { + return prev; + } + return [...prev, mappedMessage]; + }); + } + + // Update sidebar chat list + setChats(prevChats => { + const chatIndex = prevChats.findIndex(c => c.id === newMsg.chatId); + const updatedLastMessage = { + id: newMsg.id, + body: newMsg.body, + type: newMsg.type, + timestamp: newMsg.timestamp, + fromMe: newMsg.fromMe, + }; + + if (chatIndex > -1) { + const updatedChats = [...prevChats]; + const targetChat = { ...updatedChats[chatIndex] }; + + targetChat.lastMessage = updatedLastMessage; + targetChat.timestamp = newMsg.timestamp; + + if (!newMsg.fromMe && (!activeChat || activeChat.id !== targetChat.id)) { + targetChat.unreadCount = (targetChat.unreadCount || 0) + 1; + } + + updatedChats.splice(chatIndex, 1); + + let insertIndex = 0; + if (!targetChat.pinned) { + insertIndex = updatedChats.findIndex(c => !c.pinned); + if (insertIndex === -1) insertIndex = updatedChats.length; + } + + updatedChats.splice(insertIndex, 0, targetChat); + return updatedChats; + } else { + void loadChats(selectedSessionId); + return prevChats; + } + }); + }, + [selectedSessionId, activeChat, loadChats], + ); + + const handleIncomingMessageAck = useCallback( + (event: { sessionId: string; messageId: string; ack: number; ackName: string; chatId?: string }) => { + if (event.sessionId !== selectedSessionId) return; + + // Update message status in the UI + setMessages(prev => + prev.map(msg => { + if (msg.id === event.messageId || msg.waMessageId === event.messageId) { + const statusMap: Record = { + [-1]: 'failed', + [0]: 'pending', + [1]: 'sent', + [2]: 'delivered', + [3]: 'read', + [4]: 'read', + }; + return { ...msg, status: statusMap[event.ack] || msg.status }; + } + return msg; + }) + ); + }, + [selectedSessionId] + ); + + const handleIncomingMessageReaction = useCallback( + (event: { sessionId: string; messageId: string; chatId: string; reaction: string; senderId: string; reactions: Record }) => { + if (event.sessionId !== selectedSessionId) return; + + setMessages(prev => + prev.map(msg => { + if (msg.id === event.messageId || msg.waMessageId === event.messageId) { + const metadata = msg.metadata || {}; + return { + ...msg, + metadata: { + ...metadata, + reactions: event.reactions, + }, + }; + } + return msg; + }) + ); + }, + [selectedSessionId] + ); + + const handleIncomingMessageRevoked = useCallback( + (event: { sessionId: string; id: string; chatId: string; from: string; to: string; body: string; type: string }) => { + if (event.sessionId !== selectedSessionId) return; + + setMessages(prev => + prev.map(msg => { + if (msg.id === event.id || msg.waMessageId === event.id) { + return { + ...msg, + body: event.body, + type: event.type, + }; + } + return msg; + }) + ); + }, + [selectedSessionId] + ); + + const { subscribe, unsubscribe } = useWebSocket({ + onMessage: handleIncomingMessage, + onMessageAck: handleIncomingMessageAck, + onMessageReaction: handleIncomingMessageReaction, + onMessageRevoked: handleIncomingMessageRevoked, + }); + + useEffect(() => { + if (selectedSessionId) { + subscribe(selectedSessionId, ['message.received', 'message.sent', 'message.ack', 'message.reaction', 'message.revoked']); + return () => { + unsubscribe(selectedSessionId); + }; + } + }, [selectedSessionId, subscribe, unsubscribe]); + + // 4. Fetch Message History for selected Chat + const loadMessages = useCallback( + async (chatId: string) => { + if (!selectedSessionId || !chatId) return; + try { + setLoadingMessages(true); + // Mark chat as read/seen in backend + void fetch(`/api/sessions/${selectedSessionId}/chats/read`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': sessionStorage.getItem('openwa_api_key') || '', + }, + body: JSON.stringify({ chatId }), + }).catch(err => console.error('Failed to mark chat as read:', err)); + + const response = await fetch( + `/api/sessions/${selectedSessionId}/messages?chatId=${encodeURIComponent(chatId)}&limit=100`, + { + headers: { + 'X-API-Key': sessionStorage.getItem('openwa_api_key') || '', + }, + }, + ); + if (response.ok) { + const data = await response.json(); + const reversed = [...data.messages].reverse(); + setMessages(reversed); + } else { + setMessages([]); + } + } catch (err) { + console.error('Failed to load messages:', err); + setMessages([]); + } finally { + setLoadingMessages(false); + } + }, + [selectedSessionId], + ); + + const handleReactMessage = async (msg: Message, emoji: string) => { + if (!selectedSessionId || !activeChat) return; + + const msgId = msg.waMessageId || msg.id; + const currentReactions = msg.metadata?.reactions || {}; + const sessionPhone = sessions.find(s => s.id === selectedSessionId)?.phone || 'me'; + + let alreadyReacted = false; + for (const [sender, emo] of Object.entries(currentReactions)) { + if ((sender === 'me' || sender.includes(sessionPhone)) && emo === emoji) { + alreadyReacted = true; + break; + } + } + + const emojiToSend = alreadyReacted ? '' : emoji; + + try { + await messageApi.react(selectedSessionId, { + chatId: activeChat.id, + messageId: msgId, + emoji: emojiToSend, + }); + + setMessages(prev => + prev.map(m => { + if (m.id === msg.id || m.waMessageId === msg.id) { + const metadata = m.metadata || {}; + const reactions = { ...(metadata.reactions as Record || {}) }; + if (emojiToSend === '') { + delete reactions['me']; + } else { + reactions['me'] = emojiToSend; + } + return { ...m, metadata: { ...metadata, reactions } }; + } + return m; + }) + ); + } catch (err) { + console.error('Failed to react to message:', err); + } + }; + + const handleDeleteMessage = async (msg: Message) => { + if (!selectedSessionId || !activeChat) return; + const msgId = msg.waMessageId || msg.id; + + if (!window.confirm('Tarik pesan ini untuk semua orang?')) return; + + try { + await messageApi.delete(selectedSessionId, { + chatId: activeChat.id, + messageId: msgId, + forEveryone: true, + }); + + setMessages(prev => + prev.map(m => { + if (m.id === msg.id || m.waMessageId === msg.id) { + return { ...m, body: '🚫 Pesan ini telah dihapus', type: 'revoked' }; + } + return m; + }) + ); + } catch (err) { + console.error('Failed to delete message:', err); + } + }; + + useEffect(() => { + if (activeChat) { + void loadMessages(activeChat.id); + setChats(prev => + prev.map(c => (c.id === activeChat.id ? { ...c, unreadCount: 0 } : c)), + ); + } else { + setMessages([]); + } + }, [activeChat, loadMessages]); + + // 5. Scroll chat to bottom + useEffect(() => { + chatBottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // 6. Handle File selection & Base64 conversion + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // Set preview URL for images + if (file.type.startsWith('image/')) { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + } else { + setPreviewUrl(null); + } + + const reader = new FileReader(); + reader.onload = (event) => { + const dataUrl = event.target?.result as string; + const base64Data = dataUrl.split(',')[1]; + setAttachment({ + file, + base64: base64Data, + mimetype: file.type, + filename: file.name, + }); + }; + reader.readAsDataURL(file); + }; + + const handleRemoveAttachment = () => { + setAttachment(null); + setPreviewUrl(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const triggerFileSelect = () => { + fileInputRef.current?.click(); + }; + + const handleEmojiClick = (emoji: string) => { + setMessageInput(prev => prev + emoji); + setShowEmojiPicker(false); + }; + + // 7. Handle sending message / media + const handleSend = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + if (!selectedSessionId || !activeChat || sending) return; + + const textToSend = messageInput.trim(); + + // Check if we are sending text or media + if (!textToSend && !attachment) return; + + setMessageInput(''); + setSending(true); + + const tempId = `temp_${Date.now()}`; + + // Create temporary bubble message + const tempMessage: Message = { + id: tempId, + chatId: activeChat.id, + from: 'me', + to: activeChat.id, + body: attachment ? (attachment.mimetype.startsWith('image/') || attachment.mimetype.startsWith('video/') || attachment.mimetype.startsWith('audio/') ? textToSend : attachment.filename) : textToSend, + type: attachment ? attachment.mimetype.split('/')[0] : 'text', + direction: 'outgoing', + status: 'pending', + createdAt: new Date().toISOString(), + metadata: attachment ? { + media: { + mimetype: attachment.mimetype, + filename: attachment.filename, + data: attachment.base64, + } + } : (replyingTo ? { + quotedMessage: { + id: replyingTo.waMessageId || replyingTo.id, + body: replyingTo.type !== 'text' ? `[${replyingTo.type}]` : replyingTo.body, + } + } : undefined), + }; + + setMessages(prev => [...prev, tempMessage]); + + // Store local attachment & reply states + const currentAttachment = attachment; + const currentReplyingTo = replyingTo; + handleRemoveAttachment(); + setReplyingTo(null); + + try { + let result; + + if (currentAttachment) { + // Determine backend category + let mediaType: 'image' | 'video' | 'audio' | 'document' = 'document'; + const mime = currentAttachment.mimetype; + if (mime.startsWith('image/')) mediaType = 'image'; + else if (mime.startsWith('video/')) mediaType = 'video'; + else if (mime.startsWith('audio/')) mediaType = 'audio'; + + result = await messageApi.sendMedia(selectedSessionId, activeChat.id, mediaType, { + base64: currentAttachment.base64, + mimetype: currentAttachment.mimetype, + filename: currentAttachment.filename, + caption: mediaType !== 'audio' ? textToSend : undefined, + }); + } else if (currentReplyingTo) { + result = await messageApi.reply(selectedSessionId, { + chatId: activeChat.id, + quotedMessageId: currentReplyingTo.waMessageId || currentReplyingTo.id, + text: textToSend, + }); + } else { + result = await messageApi.sendText(selectedSessionId, activeChat.id, textToSend); + } + + setMessages(prev => + prev.map(m => + m.id === tempId + ? { ...m, id: result.messageId, waMessageId: result.messageId, status: 'sent' } + : m, + ), + ); + + // Update sidebar chat list + setChats(prevChats => { + const chatIndex = prevChats.findIndex(c => c.id === activeChat.id); + if (chatIndex > -1) { + const updatedChats = [...prevChats]; + const target = { ...updatedChats[chatIndex] }; + target.lastMessage = { + id: result.messageId, + body: currentAttachment ? `[${currentAttachment.mimetype.split('/')[0]}]` : textToSend, + type: currentAttachment ? currentAttachment.mimetype.split('/')[0] : 'text', + timestamp: Math.floor(Date.now() / 1000), + fromMe: true, + }; + target.timestamp = Math.floor(Date.now() / 1000); + + updatedChats.splice(chatIndex, 1); + let insertIndex = 0; + if (!target.pinned) { + insertIndex = updatedChats.findIndex(c => !c.pinned); + if (insertIndex === -1) insertIndex = updatedChats.length; + } + updatedChats.splice(insertIndex, 0, target); + return updatedChats; + } + return prevChats; + }); + } catch (err) { + console.error('Failed to send message:', err); + setMessages(prev => + prev.map(m => (m.id === tempId ? { ...m, status: 'failed' } : m)), + ); + } finally { + setSending(false); + } + }; + + // Helper formats + const formatTime = (timestamp?: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }; + + const formatLastMessageSnippet = (chat: Chat) => { + if (!chat.lastMessage) return ''; + const prefix = chat.lastMessage.fromMe ? 'You: ' : ''; + if (chat.lastMessage.type !== 'text') { + return `${prefix}[${chat.lastMessage.type}]`; + } + return `${prefix}${chat.lastMessage.body}`; + }; + + const formatChatTime = (timestamp?: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + }; + + const filteredChats = chats.filter( + c => + c.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + c.id.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + return ( +
+ + + {loadingSessions ? ( +
+ +

{t('common.loading')}

+
+ ) : sessions.length === 0 ? ( +
+ +

Tidak ada sesi terhubung

+

Silakan hubungkan/koneksikan sesi WhatsApp Anda di menu Sessions terlebih dahulu agar dapat menggunakan fitur chat.

+
+ ) : ( +
+ {/* LEFT SIDEBAR: Session & Chat Rooms */} + + + {/* RIGHT VIEW: Active Chat Room */} +
+ {activeChat ? ( +
+ {/* Room Header */} +
+
+ {activeChat.isGroup ? : } +
+
+

{activeChat.name || activeChat.id.split('@')[0]}

+ {activeChat.id} +
+
+ + {/* Messages Body */} +
+ {loadingMessages ? ( +
+ + Loading messages... +
+ ) : messages.length === 0 ? ( +
+ + Belum ada pesan. Kirim pesan pertama untuk memulai! +
+ ) : ( + messages.map(msg => { + const isMe = msg.direction === 'outgoing'; + const formattedTime = formatTime(msg.timestamp || Math.floor(new Date(msg.createdAt).getTime() / 1000)); + + // Highlight media messages differently if desired + const isMediaMessage = msg.type !== 'text'; + + const mediaInfo = msg.metadata?.media || (msg as any).media; + + const renderMedia = () => { + if (msg.type === 'revoked') return null; + if (!mediaInfo) return null; + const mediaSrc = getMediaSrc(mediaInfo); + if (!mediaSrc) return null; + + switch (msg.type) { + case 'image': + case 'sticker': + return ( +
+ {mediaInfo.filename +
+ ); + case 'video': + return ( +
+
+ ); + case 'audio': + case 'voice': + case 'ptt': + return ( +
+
+ ); + case 'document': + default: + return ( + + ); + } + }; + + const reactions = msg.metadata?.reactions || {}; + const hasReactions = Object.keys(reactions).length > 0; + + return ( +
+
+
+ {/* Quoted message display */} + {msg.metadata?.quotedMessage && ( +
+
+ {msg.metadata.quotedMessage.body} +
+
+ )} + + {renderMedia()} + + {msg.body && (!mediaInfo || msg.body !== mediaInfo.filename) && ( +
{msg.body}
+ )} + +
+ {formattedTime} + {isMe && ( + + {msg.status === 'pending' && '🕒'} + {msg.status === 'sent' && '✓'} + {msg.status === 'delivered' && '✓✓'} + {msg.status === 'read' && '✓✓'} + {msg.status === 'failed' && '⚠️'} + + )} +
+ + {/* Reactions display */} + {hasReactions && ( +
+ {Object.values(reactions).slice(0, 3).map((emoji, idx) => ( + {emoji as string} + ))} + {Object.keys(reactions).length > 1 && ( + {Object.keys(reactions).length} + )} +
+ )} +
+ + {/* Message actions menu (hover) */} + {msg.type !== 'revoked' && ( +
+ + +
+ +
+ {['👍', '❤️', '😂', '😮', '😢', '🙏'].map(emoji => ( + + ))} +
+
+ + {isMe && msg.status !== 'pending' && ( + + )} +
+ )} +
+
+ ); + }) + )} +
+
+ + {/* Attachment Preview Banner */} + {attachment && ( +
+ {previewUrl ? ( + Preview + ) : ( +
📎
+ )} +
+ {attachment.filename} + ({(attachment.file.size / 1024).toFixed(1)} KB) +
+ +
+ )} + + {/* Popular Emojis panel */} + {showEmojiPicker && ( +
+
+ {popularEmojis.map(emoji => ( + + ))} +
+
+ )} + + {/* Replying preview banner */} + {replyingTo && ( +
+
+
+ Membalas ke {replyingTo.direction === 'outgoing' ? 'Anda' : (activeChat.name || activeChat.id.split('@')[0])} +
+
+ {replyingTo.type !== 'text' ? `[${replyingTo.type}]` : replyingTo.body} +
+
+ +
+ )} + + {/* Message Input bar */} +
+
+ {/* File Input */} + + + {/* Attachment Button */} + + + {/* Emoji Button */} + + + setMessageInput(e.target.value)} + disabled={!canWrite || sending} + className="message-text-input" + /> + +
+
+
+ ) : ( +
+ +

Mulai Mengirim Pesan

+

Pilih salah satu obrolan aktif dari sidebar kiri untuk mulai membaca dan mengirim pesan WhatsApp.

+
+ )} +
+
+ )} +
+ ); +} diff --git a/dashboard/src/pages/Sessions.tsx b/dashboard/src/pages/Sessions.tsx index ac448e5b..98c9bd52 100644 --- a/dashboard/src/pages/Sessions.tsx +++ b/dashboard/src/pages/Sessions.tsx @@ -26,7 +26,7 @@ export function Sessions() { const [selectedSession, setSelectedSession] = useState(null); const [deleteConfirmId, setDeleteConfirmId] = useState(null); - useWebSocket({ + const { subscribe, unsubscribe } = useWebSocket({ onSessionStatus: useCallback( (event: { sessionId: string; status: string }) => { setSessions(prev => @@ -59,6 +59,14 @@ export function Sessions() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + subscribe('*', ['session.status']); + return () => { + unsubscribe('*'); + }; + }, [subscribe, unsubscribe]); + + const qrRefreshInterval = useRef | null>(null); const currentSessionName = useRef(''); diff --git a/dashboard/src/services/api.ts b/dashboard/src/services/api.ts index 80972ae3..c79cb95c 100644 --- a/dashboard/src/services/api.ts +++ b/dashboard/src/services/api.ts @@ -18,6 +18,25 @@ export interface Session { updatedAt: string; } +export interface ChatLastMessage { + id: string; + body: string; + type: string; + timestamp: number; + fromMe: boolean; +} + +export interface Chat { + id: string; + name: string; + isGroup: boolean; + unreadCount: number; + timestamp: number; + pinned: boolean; + lastMessage: ChatLastMessage | null; +} + + export interface SessionStats { total: number; active: number; @@ -189,6 +208,7 @@ export const sessionApi = { getQR: (id: string) => request<{ qrCode: string; status: string }>(`/sessions/${id}/qr`), getStats: () => request('/sessions/stats/overview'), getGroups: (id: string) => request<{ id: string; name: string }[]>(`/sessions/${id}/groups`), + getChats: (id: string) => request(`/sessions/${id}/chats`), }; // ============================================================================= @@ -290,6 +310,31 @@ export const messageApi = { method: 'POST', body: JSON.stringify({ chatId, url, filename }), }), + sendMedia: ( + sessionId: string, + chatId: string, + type: 'image' | 'video' | 'audio' | 'document', + data: { url?: string; base64?: string; mimetype?: string; filename?: string; caption?: string } + ) => + request(`/sessions/${sessionId}/messages/send-${type}`, { + method: 'POST', + body: JSON.stringify({ chatId, ...data }), + }), + reply: (sessionId: string, data: { chatId: string; quotedMessageId: string; text: string }) => + request(`/sessions/${sessionId}/messages/reply`, { + method: 'POST', + body: JSON.stringify(data), + }), + react: (sessionId: string, data: { chatId: string; messageId: string; emoji: string }) => + request(`/sessions/${sessionId}/messages/react`, { + method: 'POST', + body: JSON.stringify(data), + }), + delete: (sessionId: string, data: { chatId: string; messageId: string; forEveryone?: boolean }) => + request(`/sessions/${sessionId}/messages/delete`, { + method: 'POST', + body: JSON.stringify(data), + }), }; // ============================================================================= diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index b4d8b7cd..78535f81 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -17,6 +17,11 @@ export default defineConfig({ changeOrigin: true, secure: false, }, + '/socket.io': { + target: 'http://localhost:2785', + ws: true, + changeOrigin: true, + }, }, }, }); diff --git a/package-lock.json b/package-lock.json index f3f6b17a..41d3f016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -745,7 +745,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1251,7 +1250,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz", "integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==", "license": "MIT", - "peer": true, "dependencies": { "redis-info": "^3.1.0" }, @@ -1290,7 +1288,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz", "integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==", "license": "MIT", - "peer": true, "dependencies": { "@bull-board/api": "7.1.5" } @@ -1580,7 +1577,6 @@ "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" @@ -1612,7 +1608,6 @@ "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", @@ -2880,7 +2875,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "2.8.1" }, @@ -2955,7 +2949,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3126,7 +3119,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.21.tgz", "integrity": "sha512-YV1HYDGsm2rnR0vrLKidtrG6jYX5yqiIjeur1j8++dKGqhhsJ6cjMs0RfQRSTUH7IjgDemA59/znQ8nRrE0D9g==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.4", "iterare": "1.2.1", @@ -3174,7 +3166,6 @@ "integrity": "sha512-fqo0BHgny3MOuAL8GSfG3ZUKFVVBaBQD/0iyibnwTONT5vPexjQxJzu+945iloVvBDmrnAaRWxC1gqCDEs/AXQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3235,7 +3226,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.21.tgz", "integrity": "sha512-lA3ViycOnz4Df3EstIKpuAVFhqxQixTnjAVk0M+LRyNBlGM6VSCaNJaAIrb9Pcry39T4hTHpNVbRqGLSvhL8gA==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3257,7 +3247,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.21.tgz", "integrity": "sha512-Tq5JgaVS+auD3DXuRBy8UMU3mf69HJO8Ep+BuRS9GYMXGd/5sdMHqIQvXlXkGih9tQXdeeG9WoqURe/+IjPKng==", "license": "MIT", - "peer": true, "dependencies": { "socket.io": "4.8.3", "tslib": "2.8.1" @@ -3443,7 +3432,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.1.tgz", "integrity": "sha512-8rw/nKT0S+L+MkzgE9F2/mox7mAgsPlwfzmW9gsESN1lmQtIrVEfiiBwC2O8+guS1jBfQehJIdcdUj2OAp4VUQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -4040,7 +4028,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4160,7 +4147,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -4382,7 +4368,6 @@ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", @@ -5096,7 +5081,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5186,7 +5170,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5734,7 +5717,6 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -6037,7 +6019,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6128,7 +6109,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.76.10.tgz", "integrity": "sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", @@ -6455,15 +6435,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7183,8 +7161,7 @@ "version": "0.0.1581282", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz", "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7683,7 +7660,6 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -7740,7 +7716,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9202,7 +9177,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", @@ -9487,7 +9461,6 @@ "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.4.2", "@jest/types": "30.4.1", @@ -11413,6 +11386,7 @@ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -11792,7 +11766,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -12085,7 +12058,6 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12774,8 +12746,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-directory": { "version": "2.1.1", @@ -12925,7 +12896,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13452,7 +13422,6 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -13975,7 +13944,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14329,7 +14297,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14514,7 +14481,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.29.tgz", "integrity": "sha512-wwPEX/df4l72gCmOsrs0otJZYLGA9lLQkUZCkukbsymEycV4zXv2KM7wU7v2r8L01TaCgY9ApSSqHQWBOUhEoQ==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -14734,7 +14700,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15153,6 +15118,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -15171,6 +15137,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -15184,6 +15151,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -15198,6 +15166,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -15207,7 +15176,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -15215,6 +15185,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/src/engine/adapters/whatsapp-web-js.adapter.ts b/src/engine/adapters/whatsapp-web-js.adapter.ts index 727a3db3..4787662d 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.ts @@ -194,6 +194,36 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin this.callbacks.onMessageAck?.(msg.id._serialized, ack); }); + this.client.on('message_revoke_everyone', async (after, before) => { + try { + const payload = { + id: after.id._serialized, + chatId: after.from === this.client!.info.wid._serialized ? after.to : after.from, + from: after.from, + to: after.to, + body: '🚫 Pesan ini telah dihapus', + type: 'revoked', + timestamp: after.timestamp, + }; + this.callbacks.onMessageRevoked?.(payload); + } catch (error) { + this.logger.error('Error processing message_revoke_everyone', String(error)); + } + }); + + this.client.on('message_reaction', (reaction) => { + try { + this.callbacks.onMessageReaction?.({ + messageId: reaction.msgId._serialized, + chatId: reaction.id.remote, + reaction: reaction.reaction, + senderId: reaction.senderId, + }); + } catch (error) { + this.logger.error('Error processing message_reaction', String(error)); + } + }); + this.client.on('disconnected', reason => { this.setStatus(EngineStatus.DISCONNECTED); this.callbacks.onDisconnected?.(reason); @@ -913,8 +943,41 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin throw new Error('sendCatalog not yet implemented in whatsapp-web.js adapter'); } + async getChats(): Promise { + this.ensureReady(); + const chats = await this.client!.getChats(); + return chats.map(c => ({ + id: c.id._serialized, + name: c.name, + isGroup: c.isGroup, + unreadCount: c.unreadCount, + timestamp: c.timestamp, + pinned: c.pinned, + lastMessage: c.lastMessage ? { + id: c.lastMessage.id._serialized, + body: c.lastMessage.body, + type: c.lastMessage.type, + timestamp: c.lastMessage.timestamp, + fromMe: c.lastMessage.fromMe, + } : null, + })); + } + + async sendSeen(chatId: string): Promise { + this.ensureReady(); + try { + const chat = await this.client!.getChatById(chatId); + await chat.sendSeen(); + return true; + } catch (error) { + this.logger.error(`Error marking chat ${chatId} as read`, String(error)); + return false; + } + } + /* eslint-enable @typescript-eslint/require-await, @typescript-eslint/no-unused-vars */ + private ensureReady(): void { if (this.status !== EngineStatus.READY || !this.client) { throw new Error('WhatsApp client is not ready'); diff --git a/src/engine/interfaces/whatsapp-engine.interface.ts b/src/engine/interfaces/whatsapp-engine.interface.ts index f3e5de62..0eec31b9 100644 --- a/src/engine/interfaces/whatsapp-engine.interface.ts +++ b/src/engine/interfaces/whatsapp-engine.interface.ts @@ -199,6 +199,8 @@ export interface EngineEventCallbacks { onReady?: (phone: string, pushName: string) => void; onMessage?: (message: IncomingMessage) => void; onMessageAck?: (messageId: string, ack: number) => void; + onMessageRevoked?: (message: { id: string; chatId: string; from: string; to: string; body: string; type: string; timestamp: number }) => void; + onMessageReaction?: (event: { messageId: string; chatId: string; reaction: string; senderId: string }) => void; onDisconnected?: (reason: string) => void; onStateChanged?: (state: EngineStatus) => void; } @@ -293,4 +295,7 @@ export interface IWhatsAppEngine { getProduct(productId: string): Promise; sendProduct(chatId: string, productId: string, body?: string): Promise; sendCatalog(chatId: string, body?: string): Promise; + getChats(): Promise; + sendSeen(chatId: string): Promise; } + diff --git a/src/modules/events/dto/ws-messages.dto.ts b/src/modules/events/dto/ws-messages.dto.ts index eadd9445..846757e9 100644 --- a/src/modules/events/dto/ws-messages.dto.ts +++ b/src/modules/events/dto/ws-messages.dto.ts @@ -16,6 +16,7 @@ export const SUBSCRIBABLE_EVENTS = [ 'message.sent', 'message.ack', 'message.revoked', + 'message.reaction', 'session.status', 'session.qr', 'session.authenticated', diff --git a/src/modules/events/events.gateway.ts b/src/modules/events/events.gateway.ts index 55425e60..f26d4f90 100644 --- a/src/modules/events/events.gateway.ts +++ b/src/modules/events/events.gateway.ts @@ -236,6 +236,20 @@ export class EventsGateway implements OnGatewayInit, OnGatewayConnection, OnGate this.emitToRooms(sessionId, 'message.ack', data); } + /** + * Emit message revoked notification + */ + emitMessageRevoked(sessionId: string, message: Record) { + this.emitToRooms(sessionId, 'message.revoked', message); + } + + /** + * Emit message reaction notification + */ + emitMessageReaction(sessionId: string, data: Record) { + this.emitToRooms(sessionId, 'message.reaction', data); + } + /** * Emit webhook delivery status (broadcast to all - no session context) */ diff --git a/src/modules/message/message.service.ts b/src/modules/message/message.service.ts index cd245bd5..6fede37e 100644 --- a/src/modules/message/message.service.ts +++ b/src/modules/message/message.service.ts @@ -91,6 +91,13 @@ export class MessageService { chatId: dto.chatId, body: dto.caption || '', type: 'image', + metadata: { + media: { + mimetype: dto.mimetype, + filename: dto.filename, + data: dto.base64 || dto.url, + } + } }); try { @@ -122,6 +129,13 @@ export class MessageService { chatId: dto.chatId, body: dto.caption || '', type: 'video', + metadata: { + media: { + mimetype: dto.mimetype, + filename: dto.filename, + data: dto.base64 || dto.url, + } + } }); try { @@ -152,6 +166,13 @@ export class MessageService { const message = await this.saveOutgoingMessage(sessionId, { chatId: dto.chatId, type: 'audio', + metadata: { + media: { + mimetype: dto.mimetype, + filename: dto.filename, + data: dto.base64 || dto.url, + } + } }); try { @@ -183,6 +204,13 @@ export class MessageService { chatId: dto.chatId, body: dto.filename || '', type: 'document', + metadata: { + media: { + mimetype: dto.mimetype, + filename: dto.filename, + data: dto.base64 || dto.url, + } + } }); try { @@ -313,6 +341,13 @@ export class MessageService { const message = await this.saveOutgoingMessage(sessionId, { chatId: dto.chatId, type: 'sticker', + metadata: { + media: { + mimetype: dto.mimetype, + filename: dto.filename, + data: dto.base64 || dto.url, + } + } }); try { @@ -341,11 +376,29 @@ export class MessageService { ): Promise { const engine = this.getEngine(sessionId); + let quotedBody = ''; + try { + const quoted = await this.messageRepository.findOne({ + where: { sessionId, waMessageId: dto.quotedMessageId } + }); + if (quoted) { + quotedBody = quoted.body || ''; + } + } catch (e) { + // ignore + } + // Save message as pending BEFORE sending const message = await this.saveOutgoingMessage(sessionId, { chatId: dto.chatId, body: dto.text, type: 'text', + metadata: { + quotedMessage: { + id: dto.quotedMessageId, + body: quotedBody, + } + } }); try { @@ -426,6 +479,7 @@ export class MessageService { type: string; timestamp?: number; status?: MessageStatus; + metadata?: Record; }, ): Promise { const session = await this.sessionService.findOne(sessionId); @@ -440,6 +494,7 @@ export class MessageService { direction: MessageDirection.OUTGOING, timestamp: data.timestamp, status: data.status ?? MessageStatus.PENDING, + metadata: data.metadata, }); return this.messageRepository.save(message); } @@ -464,6 +519,15 @@ export class MessageService { ): Promise { const engine = this.getEngine(sessionId); await engine.deleteMessage(dto.chatId, dto.messageId, dto.forEveryone ?? true); + + try { + await this.messageRepository.update( + { sessionId, waMessageId: dto.messageId }, + { body: '🚫 Pesan ini telah dihapus', type: 'revoked' } + ); + } catch (err) { + // ignore + } } private getEngine(sessionId: string) { diff --git a/src/modules/session/session.controller.ts b/src/modules/session/session.controller.ts index 5003c9aa..129b23aa 100644 --- a/src/modules/session/session.controller.ts +++ b/src/modules/session/session.controller.ts @@ -166,6 +166,37 @@ export class SessionController { return this.sessionService.getGroups(id); } + @Get(':id/chats') + @ApiOperation({ summary: 'Get active chats for a session' }) + @ApiParam({ name: 'id', description: 'Session ID' }) + @ApiResponse({ + status: 200, + description: 'List of active chats', + }) + @ApiResponse({ status: 400, description: 'Session not ready' }) + @ApiResponse({ status: 404, description: 'Session not found' }) + async getChats(@Param('id') id: string): Promise { + return this.sessionService.getChats(id); + } + + @Post(':id/chats/read') + @ApiOperation({ summary: 'Mark chat as read/seen' }) + @ApiParam({ name: 'id', description: 'Session ID' }) + @ApiResponse({ + status: 200, + description: 'Chat marked as read successfully', + }) + @ApiResponse({ status: 400, description: 'Session not ready' }) + @ApiResponse({ status: 404, description: 'Session not found' }) + async sendSeen( + @Param('id') id: string, + @Body() dto: { chatId: string }, + ): Promise<{ success: boolean }> { + const success = await this.sessionService.sendSeen(id, dto.chatId); + return { success }; + } + + @Get('stats/overview') @ApiOperation({ summary: 'Get session statistics for multi-session monitoring', diff --git a/src/modules/session/session.module.ts b/src/modules/session/session.module.ts index d358ad68..7f589a66 100644 --- a/src/modules/session/session.module.ts +++ b/src/modules/session/session.module.ts @@ -1,12 +1,13 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Session } from './entities/session.entity'; +import { Message } from '../message/entities/message.entity'; import { SessionService } from './session.service'; import { SessionController } from './session.controller'; import { WebhookModule } from '../webhook/webhook.module'; @Module({ - imports: [TypeOrmModule.forFeature([Session], 'data'), forwardRef(() => WebhookModule)], + imports: [TypeOrmModule.forFeature([Session, Message], 'data'), forwardRef(() => WebhookModule)], controllers: [SessionController], providers: [SessionService], exports: [SessionService], diff --git a/src/modules/session/session.service.ts b/src/modules/session/session.service.ts index 671599db..0618a105 100644 --- a/src/modules/session/session.service.ts +++ b/src/modules/session/session.service.ts @@ -9,6 +9,7 @@ import { import { InjectRepository, InjectDataSource } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; import { Session, SessionStatus } from './entities/session.entity'; +import { Message, MessageDirection, MessageStatus } from '../message/entities/message.entity'; import { CreateSessionDto } from './dto'; import { EngineFactory } from '../../engine/engine.factory'; import { IWhatsAppEngine, EngineStatus } from '../../engine/interfaces/whatsapp-engine.interface'; @@ -37,6 +38,8 @@ export class SessionService implements OnModuleDestroy, OnModuleInit { constructor( @InjectRepository(Session, 'data') private readonly sessionRepository: Repository, + @InjectRepository(Message, 'data') + private readonly messageRepository: Repository, @InjectDataSource('data') private readonly dataSource: DataSource, private readonly engineFactory: EngineFactory, @@ -306,12 +309,134 @@ export class SessionService implements OnModuleDestroy, OnModuleInit { return; } + const incoming = finalMessage as any; + const metadata: Record = {}; + if (incoming.media) { + metadata.media = incoming.media; + } + if (incoming.quotedMessage) { + metadata.quotedMessage = incoming.quotedMessage; + } + + const dbMessage = this.messageRepository.create({ + sessionId: id, + waMessageId: incoming.id, + chatId: incoming.chatId, + from: incoming.from, + to: incoming.to, + body: incoming.body, + type: incoming.type, + direction: incoming.fromMe ? MessageDirection.OUTGOING : MessageDirection.INCOMING, + timestamp: incoming.timestamp, + status: MessageStatus.SENT, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }); + + void this.messageRepository.save(dbMessage).catch(err => { + this.logger.error(`Failed to save incoming message ${incoming.id} to database`, String(err)); + }); + // Dispatch to webhooks with potentially modified message void this.webhookService.dispatch(id, 'message.received', finalMessage as Record); // Emit real-time event to WebSocket clients this.eventsGateway.emitMessage(id, finalMessage as Record); }); }, + onMessageAck: (messageId, ack): void => { + this.logger.debug(`Message ACK received: ${messageId} -> ${ack}`, { + sessionId: id, + messageId, + ack, + action: 'message_ack_received', + }); + + const ackNames: Record = { + [-1]: 'FAILED', + [0]: 'PENDING', + [1]: 'SENT', + [2]: 'DELIVERED', + [3]: 'READ', + [4]: 'PLAYED', + }; + const ackName = ackNames[ack] || 'UNKNOWN'; + + const statusMap: Record = { + [-1]: MessageStatus.FAILED, + [0]: MessageStatus.PENDING, + [1]: MessageStatus.SENT, + [2]: MessageStatus.DELIVERED, + [3]: MessageStatus.READ, + [4]: MessageStatus.READ, + }; + const status = statusMap[ack]; + + if (status) { + void this.messageRepository.update( + { sessionId: id, waMessageId: messageId }, + { status } + ).then(async () => { + const updatedMsg = await this.messageRepository.findOne({ + where: { sessionId: id, waMessageId: messageId } + }); + this.eventsGateway.emitMessageAck(id, { + messageId, + ack, + ackName, + chatId: updatedMsg?.chatId, + } as any); + }).catch(err => { + this.logger.error(`Failed to update message ACK status: ${messageId}`, String(err)); + }); + } + }, + onMessageRevoked: (message): void => { + this.logger.debug(`Message revoked: ${message.id}`, { + sessionId: id, + messageId: message.id, + action: 'message_revoked', + }); + + void this.messageRepository.update( + { sessionId: id, waMessageId: message.id }, + { body: message.body, type: message.type } + ).then(() => { + this.eventsGateway.emitMessageRevoked(id, message); + }).catch(err => { + this.logger.error(`Failed to update revoked message: ${message.id}`, String(err)); + }); + }, + onMessageReaction: (event): void => { + this.logger.debug(`Message reaction received: ${event.messageId} -> ${event.reaction}`, { + sessionId: id, + messageId: event.messageId, + action: 'message_reaction_received', + }); + + void this.messageRepository.findOne({ + where: { sessionId: id, waMessageId: event.messageId } + }).then(async (msg) => { + if (!msg) return; + const metadata = msg.metadata || {}; + const reactions = metadata.reactions as Record || {}; + + if (!event.reaction) { + delete reactions[event.senderId]; + } else { + reactions[event.senderId] = event.reaction; + } + + metadata.reactions = reactions; + msg.metadata = metadata; + await this.messageRepository.save(msg); + + this.eventsGateway.emitMessageReaction(id, { + ...event, + reactions, + }); + }).catch(err => { + this.logger.error(`Failed to update message reaction: ${event.messageId}`, String(err)); + }); + }, onDisconnected: (reason: string): void => { this.logger.warn(`Session disconnected: ${reason}`, { sessionId: id, @@ -479,6 +604,29 @@ export class SessionService implements OnModuleDestroy, OnModuleInit { })); } + async getChats(id: string): Promise { + await this.findOne(id); // Verify session exists + const engine = this.engines.get(id); + + if (!engine) { + throw new BadRequestException('Session is not started'); + } + + return engine.getChats(); + } + + async sendSeen(id: string, chatId: string): Promise { + await this.findOne(id); // Verify session exists + const engine = this.engines.get(id); + + if (!engine) { + throw new BadRequestException('Session is not started'); + } + + return engine.sendSeen(chatId); + } + + private async updateStatus(id: string, status: SessionStatus): Promise { await this.sessionRepository.update(id, { status }); this.logger.debug(`Session status updated to ${status}`, { From cf3167bb55d06e9335b8824e9743293c7d1034a4 Mon Sep 17 00:00:00 2001 From: akbarxleqi Date: Thu, 28 May 2026 15:38:55 +0700 Subject: [PATCH 2/2] fix(dashboard): resolve session card buttons CSS layout collision with Plugins page --- dashboard/src/pages/Sessions.css | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/dashboard/src/pages/Sessions.css b/dashboard/src/pages/Sessions.css index cb6a57a7..e0fb19e7 100644 --- a/dashboard/src/pages/Sessions.css +++ b/dashboard/src/pages/Sessions.css @@ -194,7 +194,7 @@ flex-wrap: wrap; } -.btn-action { +.card-actions .btn-action { display: flex; align-items: center; gap: 0.375rem; @@ -207,18 +207,20 @@ background: var(--bg-light); border: 1px solid var(--border); color: var(--text-secondary); + width: auto; + height: auto; } -.btn-action:hover { +.card-actions .btn-action:hover { background: var(--bg-white); color: var(--text-primary); } -.btn-action.danger { +.card-actions .btn-action.danger { color: #dc2626; } -.btn-action.danger:hover { +.card-actions .btn-action.danger:hover { background: #fee2e2; border-color: #fecaca; }