import { io, Socket } from 'socket.io-client'; import type { ChatMessage, OnlineUser, ChatNotification, ChatConnectionStatus, DirectMessage, SendMessageParams, SendDMParams, } from './types'; export interface ChatSocketConfig { /** Server origin, e.g. "https://dubs-server-prod-xxx.herokuapp.com" */ host: string; /** JWT token for authentication */ token: string; } export interface ChatSocketListeners { onConnectionChange?: (status: ChatConnectionStatus) => void; // Global chat onNewMessage?: (message: ChatMessage) => void; onOnlineUsers?: (users: OnlineUser[]) => void; onOnlineCount?: (count: number) => void; onTyping?: (data: { userId: number; username: string; walletAddress: string; isTyping: boolean }) => void; onReactionAdded?: (data: { messageId: number; userId: number; reaction: string; walletAddress: string }) => void; onReactionRemoved?: (data: { messageId: number; userId: number; reaction: string }) => void; // Notifications onNotification?: (notification: ChatNotification) => void; onUnreadCount?: (count: number) => void; // DMs onDMNewMessage?: (message: DirectMessage) => void; onDMMessageSent?: (message: DirectMessage) => void; onDMNotification?: (data: { senderId: number; senderUsername: string; senderAvatar: string | null; senderWallet: string; preview: string; createdAt: string }) => void; onDMMessagesRead?: (data: { readBy: number; readByWallet: string }) => void; // Social onFriendRequestAccepted?: (data: { requestId: number; acceptedBy: number; acceptedByUsername: string }) => void; onFriendRequestDeclined?: (data: { requestId: number; declinedBy: number }) => void; /** Recipient-side: the original sender cancelled a pending friend request */ onFriendRequestCancelled?: (data: { requestId: number; cancelledBy: number }) => void; onFriendRemoved?: (data: { removedBy: number }) => void; // Errors onError?: (error: { message: string }) => void; } /** * Manages the Socket.IO connection to the Dubs chat namespace. */ export class ChatSocket { private socket: Socket | null = null; private listeners: ChatSocketListeners = {}; /** Set event listeners. Call before or after connect. */ setListeners(listeners: ChatSocketListeners): void { this.listeners = listeners; } /** Connect to the /chat namespace */ connect(config: ChatSocketConfig): void { if (this.socket?.connected) { console.log('[Dubs:ChatSocket] Already connected'); return; } this.listeners.onConnectionChange?.('connecting'); console.log(`[Dubs:ChatSocket] Connecting to ${config.host}/chat`); this.socket = io(`${config.host}/chat`, { auth: { token: config.token }, transports: ['websocket'], reconnection: true, reconnectionDelay: 1000, reconnectionAttempts: 10, }); this.socket.on('connect', () => { console.log('[Dubs:ChatSocket] Connected'); this.listeners.onConnectionChange?.('connected'); }); this.socket.on('disconnect', (reason) => { console.log('[Dubs:ChatSocket] Disconnected:', reason); this.listeners.onConnectionChange?.('disconnected'); }); this.socket.on('connect_error', (error) => { console.error('[Dubs:ChatSocket] Connection error:', error.message); this.listeners.onConnectionChange?.('error'); }); // Global chat events this.socket.on('new_message', (msg: ChatMessage) => this.listeners.onNewMessage?.(msg)); this.socket.on('online_users', (users: OnlineUser[]) => this.listeners.onOnlineUsers?.(users)); this.socket.on('online_count', (count: number) => this.listeners.onOnlineCount?.(count)); this.socket.on('user_typing', (data: any) => this.listeners.onTyping?.(data)); this.socket.on('reaction_added', (data: any) => this.listeners.onReactionAdded?.(data)); this.socket.on('reaction_removed', (data: any) => this.listeners.onReactionRemoved?.(data)); // Notifications this.socket.on('notification', (n: ChatNotification) => this.listeners.onNotification?.(n)); this.socket.on('unread_count', (count: number) => this.listeners.onUnreadCount?.(count)); // DM events this.socket.on('dm:new_message', (msg: DirectMessage) => this.listeners.onDMNewMessage?.(msg)); this.socket.on('dm:message_sent', (msg: DirectMessage) => this.listeners.onDMMessageSent?.(msg)); this.socket.on('dm:notification', (data: any) => this.listeners.onDMNotification?.(data)); this.socket.on('dm:messages_read', (data: any) => this.listeners.onDMMessagesRead?.(data)); // Social events this.socket.on('friend_request_accepted', (data: any) => this.listeners.onFriendRequestAccepted?.(data)); this.socket.on('friend_request_declined', (data: any) => this.listeners.onFriendRequestDeclined?.(data)); this.socket.on('friend_request_cancelled', (data: any) => this.listeners.onFriendRequestCancelled?.(data)); this.socket.on('friend_removed', (data: any) => this.listeners.onFriendRemoved?.(data)); // Error this.socket.on('error', (err: { message: string }) => this.listeners.onError?.(err)); } /** Disconnect from the chat namespace */ disconnect(): void { if (this.socket) { console.log('[Dubs:ChatSocket] Disconnecting'); this.socket.removeAllListeners(); this.socket.disconnect(); this.socket = null; } } /** Whether the socket is currently connected */ isConnected(): boolean { return this.socket?.connected ?? false; } // ── Global Chat Actions ── /** Send a message to global chat */ sendMessage(params: SendMessageParams): void { this.socket?.emit('send_message', params); } /** Emit typing indicator */ sendTyping(isTyping: boolean): void { this.socket?.emit('typing', isTyping); } /** Add an emoji reaction to a message */ addReaction(messageId: number, reaction: string): void { this.socket?.emit('add_reaction', { messageId, reaction }); } /** Remove an emoji reaction from a message */ removeReaction(messageId: number, reaction: string): void { this.socket?.emit('remove_reaction', { messageId, reaction }); } // ── DM Actions ── /** Join a DM room to receive messages in real-time */ joinDM(recipientWallet: string): void { this.socket?.emit('dm:join', { recipientWallet }); } /** Leave a DM room */ leaveDM(recipientWallet: string): void { this.socket?.emit('dm:leave', { recipientWallet }); } /** Send a direct message via socket */ sendDM(params: SendDMParams): void { this.socket?.emit('dm:send', params); } /** Mark DMs from a sender as read */ markDMRead(senderWallet: string): void { this.socket?.emit('dm:mark_read', { senderWallet }); } }