diff --git a/src/app/hooks/useMessaging.tsx b/src/app/hooks/useMessaging.tsx index bf6bcfa6..ee289e6c 100644 --- a/src/app/hooks/useMessaging.tsx +++ b/src/app/hooks/useMessaging.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useMessagingStore } from '@/app/store/messagingStore'; import type { Attachment } from '@/app/store/messagingStore'; +import { useWebSocket } from '@/hooks/useWebSocket'; export function useMessaging() { const { @@ -36,6 +37,10 @@ export function useMessaging() { const typingTimeoutRef = useRef(null); + const { status } = useWebSocket('messaging', { + onDisconnect: () => disconnectSocket(), + }); + // Initialize socket on mount useEffect(() => { initializeSocket(); @@ -147,6 +152,8 @@ export function useMessaging() { currentConversation, messages, isConnected, + isReconnecting: status.isReconnecting, + connectionError: status.lastError, isTyping, typingUsers, isLoadingMessages, diff --git a/src/app/store/messagingStore.ts b/src/app/store/messagingStore.ts index cef5313a..e22e0339 100644 --- a/src/app/store/messagingStore.ts +++ b/src/app/store/messagingStore.ts @@ -1,6 +1,7 @@ import { DEFAULT_SOCKET_URL } from '@/constants/app.constants'; import { create } from 'zustand'; -import io from 'socket.io-client'; +import type { Socket } from 'socket.io-client'; +import { wsManager } from '@/lib/websocketManager'; export interface Attachment { id: string; @@ -43,11 +44,10 @@ export interface Conversation { } interface MessagingState { - // State conversations: Conversation[]; currentConversation: Conversation | null; messages: Message[]; - socket: ReturnType | null; + socket: Socket | null; isConnected: boolean; isTyping: boolean; typingUsers: Set; @@ -59,7 +59,6 @@ interface MessagingState { selectedFiles: File[]; uploadingFiles: boolean; - // Actions setConversations: (conversations: Conversation[]) => void; setCurrentConversation: (conversation: Conversation | null) => void; addMessage: (message: Message) => void; @@ -71,368 +70,9 @@ interface MessagingState { removeTypingUser: (userId: string) => void; initializeSocket: () => void; disconnectSocket: () => void; - loadConversations: () => void; - loadMessages: (conversationId: string, page?: number) => void; - loadMoreMessages: () => void; - setSearchQuery: (query: string) => void; - setSelectedFiles: (files: File[]) => void; - removeSelectedFile: (index: number) => void; - uploadAttachments: (files: File[]) => Promise; - createConversation: (participantId: string) => void; - getTotalUnreadCount: () => number; } -// Mock data for demonstration -const MOCK_PARTICIPANTS: Participant[] = [ - { - id: 'instructor-1', - name: 'Dr. Sarah Chen', - avatar: '', - role: 'instructor', - online: true, - }, - { - id: 'instructor-2', - name: 'Prof. James Wilson', - avatar: '', - role: 'instructor', - online: false, - lastSeen: new Date(Date.now() - 3600000), - }, - { - id: 'student-1', - name: 'Alex Johnson', - avatar: '', - role: 'student', - online: true, - }, - { - id: 'student-2', - name: 'Maria Garcia', - avatar: '', - role: 'student', - online: false, - lastSeen: new Date(Date.now() - 7200000), - }, - { - id: 'instructor-3', - name: 'Dr. Emily Park', - avatar: '', - role: 'instructor', - online: true, - }, -]; - -const MOCK_CONVERSATIONS: Conversation[] = [ - { - id: 'conv-1', - participants: [ - { id: 'current-user', name: 'You', avatar: '', role: 'student', online: true }, - MOCK_PARTICIPANTS[0], - ], - lastMessage: { - id: 'msg-1', - conversationId: 'conv-1', - content: 'Great question! Let me explain the concept in more detail...', - senderId: 'instructor-1', - senderName: 'Dr. Sarah Chen', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 300000), - read: false, - delivered: true, - }, - unreadCount: 2, - createdAt: new Date(Date.now() - 86400000 * 7), - updatedAt: new Date(Date.now() - 300000), - }, - { - id: 'conv-2', - participants: [ - { id: 'current-user', name: 'You', avatar: '', role: 'student', online: true }, - MOCK_PARTICIPANTS[1], - ], - lastMessage: { - id: 'msg-2', - conversationId: 'conv-2', - content: 'Your assignment submission looks good. I have a few suggestions...', - senderId: 'instructor-2', - senderName: 'Prof. James Wilson', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 3600000), - read: true, - delivered: true, - }, - unreadCount: 0, - createdAt: new Date(Date.now() - 86400000 * 14), - updatedAt: new Date(Date.now() - 3600000), - }, - { - id: 'conv-3', - participants: [ - { id: 'current-user', name: 'You', avatar: '', role: 'student', online: true }, - MOCK_PARTICIPANTS[2], - ], - lastMessage: { - id: 'msg-3', - conversationId: 'conv-3', - content: 'Hey, want to form a study group for the upcoming exam?', - senderId: 'student-1', - senderName: 'Alex Johnson', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 7200000), - read: false, - delivered: true, - }, - unreadCount: 1, - createdAt: new Date(Date.now() - 86400000 * 3), - updatedAt: new Date(Date.now() - 7200000), - }, - { - id: 'conv-4', - participants: [ - { id: 'current-user', name: 'You', avatar: '', role: 'student', online: true }, - MOCK_PARTICIPANTS[3], - ], - lastMessage: { - id: 'msg-4', - conversationId: 'conv-4', - content: 'Thanks for sharing those notes! Really helpful 📚', - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'student-2', - timestamp: new Date(Date.now() - 86400000), - read: true, - delivered: true, - }, - unreadCount: 0, - createdAt: new Date(Date.now() - 86400000 * 5), - updatedAt: new Date(Date.now() - 86400000), - }, - { - id: 'conv-5', - participants: [ - { id: 'current-user', name: 'You', avatar: '', role: 'student', online: true }, - MOCK_PARTICIPANTS[4], - ], - lastMessage: { - id: 'msg-5', - conversationId: 'conv-5', - content: 'Office hours are available Tuesday 2-4pm if you need help.', - senderId: 'instructor-3', - senderName: 'Dr. Emily Park', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 86400000 * 2), - read: true, - delivered: true, - }, - unreadCount: 0, - createdAt: new Date(Date.now() - 86400000 * 10), - updatedAt: new Date(Date.now() - 86400000 * 2), - }, -]; - -const MOCK_MESSAGES: Record = { - 'conv-1': [ - { - id: 'msg-1-1', - conversationId: 'conv-1', - content: 'Hi Dr. Chen, I had a question about the machine learning assignment.', - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'instructor-1', - timestamp: new Date(Date.now() - 3600000), - read: true, - delivered: true, - }, - { - id: 'msg-1-2', - conversationId: 'conv-1', - content: 'Of course! What specifically are you having trouble with?', - senderId: 'instructor-1', - senderName: 'Dr. Sarah Chen', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 3000000), - read: true, - delivered: true, - }, - { - id: 'msg-1-3', - conversationId: 'conv-1', - content: - "I'm confused about the difference between supervised and unsupervised learning in the context of our project. The instructions mention using both approaches but I'm not sure when to apply each one.", - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'instructor-1', - timestamp: new Date(Date.now() - 2400000), - read: true, - delivered: true, - }, - { - id: 'msg-1-4', - conversationId: 'conv-1', - content: - "

Great question! Here's a quick breakdown:

  • Supervised learning: Use this when you have labeled data. For our project, this applies to the classification task.
  • Unsupervised learning: Use this for the clustering portion where we don't have predefined categories.

Does that help clarify things?

", - senderId: 'instructor-1', - senderName: 'Dr. Sarah Chen', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 1800000), - read: true, - delivered: true, - }, - { - id: 'msg-1-5', - conversationId: 'conv-1', - content: - 'Yes! That makes much more sense now. One more thing - for the evaluation metrics, should we use accuracy or F1 score?', - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'instructor-1', - timestamp: new Date(Date.now() - 600000), - read: true, - delivered: true, - }, - { - id: 'msg-1-6', - conversationId: 'conv-1', - content: 'Great question! Let me explain the concept in more detail...', - senderId: 'instructor-1', - senderName: 'Dr. Sarah Chen', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 300000), - read: false, - delivered: true, - }, - ], - 'conv-2': [ - { - id: 'msg-2-1', - conversationId: 'conv-2', - content: - "Prof. Wilson, I've submitted my assignment. Could you take a look when you have time?", - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'instructor-2', - timestamp: new Date(Date.now() - 86400000), - read: true, - delivered: true, - }, - { - id: 'msg-2-2', - conversationId: 'conv-2', - content: - 'Your assignment submission looks good. I have a few suggestions for improvement in the methodology section.', - senderId: 'instructor-2', - senderName: 'Prof. James Wilson', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 3600000), - read: true, - delivered: true, - }, - ], - 'conv-3': [ - { - id: 'msg-3-1', - conversationId: 'conv-3', - content: 'Hey, want to form a study group for the upcoming exam?', - senderId: 'student-1', - senderName: 'Alex Johnson', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 7200000), - read: false, - delivered: true, - }, - ], - 'conv-4': [ - { - id: 'msg-4-1', - conversationId: 'conv-4', - content: "Hi Maria! Do you have the notes from last week's lecture?", - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'student-2', - timestamp: new Date(Date.now() - 172800000), - read: true, - delivered: true, - }, - { - id: 'msg-4-2', - conversationId: 'conv-4', - content: 'Sure! Here are the notes I took. Let me know if you need anything else.', - senderId: 'student-2', - senderName: 'Maria Garcia', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 129600000), - read: true, - delivered: true, - attachments: [ - { - id: 'att-1', - name: 'lecture_notes_week5.pdf', - url: '#', - type: 'application/pdf', - size: 2457600, - }, - ], - }, - { - id: 'msg-4-3', - conversationId: 'conv-4', - content: 'Thanks for sharing those notes! Really helpful 📚', - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'student-2', - timestamp: new Date(Date.now() - 86400000), - read: true, - delivered: true, - }, - ], - 'conv-5': [ - { - id: 'msg-5-1', - conversationId: 'conv-5', - content: 'Dr. Park, when are your office hours this week?', - senderId: 'current-user', - senderName: 'You', - senderAvatar: '', - receiverId: 'instructor-3', - timestamp: new Date(Date.now() - 86400000 * 3), - read: true, - delivered: true, - }, - { - id: 'msg-5-2', - conversationId: 'conv-5', - content: 'Office hours are available Tuesday 2-4pm if you need help.', - senderId: 'instructor-3', - senderName: 'Dr. Emily Park', - senderAvatar: '', - receiverId: 'current-user', - timestamp: new Date(Date.now() - 86400000 * 2), - read: true, - delivered: true, - }, - ], -}; - export const useMessagingStore = create((set, get) => ({ - // Initial state conversations: [], currentConversation: null, messages: [], @@ -451,31 +91,12 @@ export const useMessagingStore = create((set, get) => ({ setConversations: (conversations) => set({ conversations }), setCurrentConversation: (conversation) => { - set({ currentConversation: conversation, messages: [], currentPage: 1 }); - if (conversation) { - get().loadMessages(conversation.id); - get().markConversationAsRead(conversation.id); - } + set({ currentConversation: conversation }); }, addMessage: (message) => { set((state) => ({ messages: [...state.messages, message], - conversations: state.conversations.map((conv) => - conv.id === message.conversationId - ? { - ...conv, - lastMessage: message, - unreadCount: - message.senderId !== 'current-user' - ? conv.id === state.currentConversation?.id - ? conv.unreadCount - : conv.unreadCount + 1 - : conv.unreadCount, - updatedAt: new Date(), - } - : conv, - ), })); }, @@ -483,114 +104,58 @@ export const useMessagingStore = create((set, get) => ({ const state = get(); if (!state.currentConversation) return; - const otherParticipant = state.currentConversation.participants.find( - (p) => p.id !== 'current-user', - ); - - const newMessage: Message = { - id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + const message: Message = { + id: `${Date.now()}`, conversationId: state.currentConversation.id, content, senderId: 'current-user', senderName: 'You', senderAvatar: '', - receiverId: otherParticipant?.id || '', + receiverId: '', timestamp: new Date(), read: false, delivered: true, attachments, }; - get().addMessage(newMessage); + get().addMessage(message); - // Emit via socket if connected if (state.socket) { - state.socket.emit('message', newMessage); - } - - // Simulate delivery receipt - setTimeout(() => { - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === newMessage.id ? { ...msg, delivered: true } : msg, - ), - })); - }, 500); - - // Simulate read receipt for demo - setTimeout(() => { - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === newMessage.id ? { ...msg, read: true } : msg, - ), - })); - }, 3000); - - // Simulate reply for demo purposes - if (otherParticipant) { - setTimeout(() => { - get().addTypingUser(otherParticipant.id); - }, 1500); - - setTimeout(() => { - get().removeTypingUser(otherParticipant.id); - const replies = [ - "That's a great point! Let me think about that.", - "Thanks for sharing. I'll get back to you shortly.", - 'Interesting! Would you like to discuss this further?', - "Got it, I'll review this and respond soon.", - "Great question! Here's what I think...", - ]; - const replyMessage: Message = { - id: `msg-${Date.now()}-reply`, - conversationId: state.currentConversation!.id, - content: replies[Math.floor(Math.random() * replies.length)], - senderId: otherParticipant.id, - senderName: otherParticipant.name, - senderAvatar: otherParticipant.avatar, - receiverId: 'current-user', - timestamp: new Date(), - read: false, - delivered: true, - }; - get().addMessage(replyMessage); - }, 4000); + state.socket.emit('message', message); } - // Stop typing indicator after sending get().setTyping(false); }, markMessageAsRead: (messageId) => { set((state) => ({ - messages: state.messages.map((msg) => (msg.id === messageId ? { ...msg, read: true } : msg)), + messages: state.messages.map((msg) => + msg.id === messageId ? { ...msg, read: true } : msg + ), })); - const socket = get().socket; - if (socket) { - socket.emit('read', { messageId }); - } + get().socket?.emit('read', { messageId }); }, markConversationAsRead: (conversationId) => { set((state) => ({ conversations: state.conversations.map((conv) => - conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv, - ), - messages: state.messages.map((msg) => - msg.conversationId === conversationId && msg.senderId !== 'current-user' - ? { ...msg, read: true } - : msg, + conv.id === conversationId + ? { ...conv, unreadCount: 0 } + : conv ), })); }, setTyping: (isTyping) => { set({ isTyping }); + const socket = get().socket; - if (socket && get().currentConversation) { + const conversation = get().currentConversation; + + if (socket && conversation) { socket.emit('typing', { - conversationId: get().currentConversation!.id, + conversationId: conversation.id, isTyping, }); } @@ -604,19 +169,24 @@ export const useMessagingStore = create((set, get) => ({ removeTypingUser: (userId) => { set((state) => { - const newTypingUsers = new Set(state.typingUsers); - newTypingUsers.delete(userId); - return { typingUsers: newTypingUsers }; + const updated = new Set(state.typingUsers); + updated.delete(userId); + + return { typingUsers: updated }; }); }, initializeSocket: () => { - // For demo/offline mode, we just load mock data without actually connecting - get().loadConversations(); + if (get().socket) return; try { - const socket = io(process.env.NEXT_PUBLIC_WEBSOCKET_URL || DEFAULT_SOCKET_URL, { - autoConnect: false, // Don't auto-connect for demo + const socket = wsManager.connect('messaging', { + url: + process.env.NEXT_PUBLIC_WEBSOCKET_URL || + DEFAULT_SOCKET_URL, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + heartbeatInterval: 30000, }); socket.on('connect', () => { @@ -631,120 +201,42 @@ export const useMessagingStore = create((set, get) => ({ get().addMessage(message); }); - socket.on('typing', ({ userId, isTyping }: { userId: string; isTyping: boolean }) => { - if (isTyping) { - get().addTypingUser(userId); - } else { - get().removeTypingUser(userId); + socket.on( + 'typing', + ({ + userId, + isTyping, + }: { + userId: string; + isTyping: boolean; + }) => { + if (isTyping) { + get().addTypingUser(userId); + } else { + get().removeTypingUser(userId); + } } - }); + ); - socket.on('read', ({ messageId }: { messageId: string }) => { - get().markMessageAsRead(messageId); - }); + socket.on( + 'read', + ({ messageId }: { messageId: string }) => { + get().markMessageAsRead(messageId); + } + ); set({ socket }); } catch { - // Socket connection failed, continue in offline/demo mode + set({ isConnected: false }); } }, disconnectSocket: () => { - const socket = get().socket; - if (socket) { - socket.disconnect(); - set({ socket: null, isConnected: false }); - } - }, - - loadConversations: () => { - set({ isLoadingConversations: true }); - // Simulate API call - setTimeout(() => { - set({ - conversations: MOCK_CONVERSATIONS, - isLoadingConversations: false, - }); - }, 300); - }, - - loadMessages: (conversationId, page = 1) => { - set({ isLoadingMessages: true }); - // Simulate API call with pagination - setTimeout(() => { - const allMessages = MOCK_MESSAGES[conversationId] || []; - const pageSize = 20; - const start = Math.max(0, allMessages.length - page * pageSize); - const end = allMessages.length - (page - 1) * pageSize; - const pageMessages = allMessages.slice(start, end); - - set((state) => ({ - messages: page === 1 ? pageMessages : [...pageMessages, ...state.messages], - isLoadingMessages: false, - hasMoreMessages: start > 0, - currentPage: page, - })); - }, 300); - }, - - loadMoreMessages: () => { - const state = get(); - if (state.currentConversation && state.hasMoreMessages && !state.isLoadingMessages) { - get().loadMessages(state.currentConversation.id, state.currentPage + 1); - } - }, + wsManager.disconnect('messaging'); - setSearchQuery: (query) => set({ searchQuery: query }), - - setSelectedFiles: (files) => set({ selectedFiles: files }), - - removeSelectedFile: (index) => { - set((state) => ({ - selectedFiles: state.selectedFiles.filter((_, i) => i !== index), - })); - }, - - uploadAttachments: async (files) => { - set({ uploadingFiles: true }); - // Simulate file upload - return new Promise((resolve) => { - setTimeout(() => { - const attachments: Attachment[] = files.map((file, index) => ({ - id: `att-${Date.now()}-${index}`, - name: file.name, - url: URL.createObjectURL(file), - type: file.type, - size: file.size, - })); - set({ uploadingFiles: false, selectedFiles: [] }); - resolve(attachments); - }, 1000); + set({ + socket: null, + isConnected: false, }); }, - - createConversation: (participantId) => { - const participant = MOCK_PARTICIPANTS.find((p) => p.id === participantId); - if (!participant) return; - - const newConversation: Conversation = { - id: `conv-${Date.now()}`, - participants: [ - { id: 'current-user', name: 'You', avatar: '', role: 'student', online: true }, - participant, - ], - unreadCount: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - set((state) => ({ - conversations: [newConversation, ...state.conversations], - currentConversation: newConversation, - messages: [], - })); - }, - - getTotalUnreadCount: () => { - return get().conversations.reduce((total, conv) => total + conv.unreadCount, 0); - }, -})); +})); \ No newline at end of file diff --git a/src/hooks/useWebSocket.tsx b/src/hooks/useWebSocket.tsx new file mode 100644 index 00000000..c7a110f7 --- /dev/null +++ b/src/hooks/useWebSocket.tsx @@ -0,0 +1,203 @@ +'use client'; + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { Socket } from 'socket.io-client'; +import { wsManager, WebSocketConfig, ConnectionStatus } from '@/lib/websocketManager'; + +export interface UseWebSocketOptions extends Omit { + url?: string; + enabled?: boolean; + onConnect?: () => void; + onDisconnect?: (reason: string) => void; + onError?: (error: string) => void; + onReconnect?: () => void; +} + +export interface UseWebSocketReturn { + socket: Socket | null; + status: ConnectionStatus; + isConnected: boolean; + isReconnecting: boolean; + reconnectAttempts: number; + lastError?: string; + emit: (event: string, data?: unknown) => void; + on: (event: string, handler: (...args: unknown[]) => void) => void; + off: (event: string, handler?: (...args: unknown[]) => void) => void; + reconnect: () => void; + disconnect: () => void; +} + +export function useWebSocket( + connectionKey: string, + options: UseWebSocketOptions = {} +): UseWebSocketReturn { + const { + url = process.env.NEXT_PUBLIC_WEBSOCKET_URL || 'http://localhost:3001', + enabled = true, + namespace, + reconnectionAttempts = 5, + reconnectionDelay = 1000, + heartbeatInterval = 30000, + timeout = 20000, + onConnect, + onDisconnect, + onError, + onReconnect, + } = options; + + const [status, setStatus] = useState({ + isConnected: false, + isReconnecting: false, + reconnectAttempts: 0, + }); + + const socketRef = useRef(null); + const listenersRef = useRef void>>>(new Map()); + const statusCheckInterval = useRef(null); + + const updateStatus = useCallback(() => { + const currentStatus = wsManager.getStatus(connectionKey); + setStatus(currentStatus); + }, [connectionKey]); + + useEffect(() => { + if (!enabled) return; + + const config: WebSocketConfig = { + url, + namespace, + reconnectionAttempts, + reconnectionDelay, + heartbeatInterval, + timeout, + }; + + const socket = wsManager.connect(connectionKey, config); + socketRef.current = socket; + + const handleConnect = () => { + updateStatus(); + onConnect?.(); + }; + + const handleDisconnect = (reason: string) => { + updateStatus(); + onDisconnect?.(reason); + }; + + const handleConnectError = (error: Error) => { + updateStatus(); + onError?.(error.message); + }; + + const handleReconnect = () => { + updateStatus(); + onReconnect?.(); + }; + + socket.on('connect', handleConnect); + socket.on('disconnect', handleDisconnect); + socket.on('connect_error', handleConnectError); + socket.on('reconnect', handleReconnect); + + statusCheckInterval.current = setInterval(updateStatus, 1000); + updateStatus(); + + return () => { + socket.off('connect', handleConnect); + socket.off('disconnect', handleDisconnect); + socket.off('connect_error', handleConnectError); + socket.off('reconnect', handleReconnect); + + if (statusCheckInterval.current) { + clearInterval(statusCheckInterval.current); + } + + listenersRef.current.forEach((handlers, event) => { + handlers.forEach((handler) => { + socket.off(event, handler as (...args: unknown[]) => void); + }); + }); + listenersRef.current.clear(); + + wsManager.disconnect(connectionKey); + }; + }, [ + enabled, + connectionKey, + url, + namespace, + reconnectionAttempts, + reconnectionDelay, + heartbeatInterval, + timeout, + onConnect, + onDisconnect, + onError, + onReconnect, + updateStatus, + ]); + + const emit = useCallback((event: string, data?: unknown) => { + const socket = socketRef.current; + if (socket && socket.connected) { + socket.emit(event, data); + } + }, []); + + const on = useCallback((event: string, handler: (...args: unknown[]) => void) => { + const socket = socketRef.current; + if (!socket) return; + + socket.on(event, handler); + + if (!listenersRef.current.has(event)) { + listenersRef.current.set(event, new Set()); + } + listenersRef.current.get(event)!.add(handler); + }, []); + + const off = useCallback((event: string, handler?: (...args: unknown[]) => void) => { + const socket = socketRef.current; + if (!socket) return; + + if (handler) { + socket.off(event, handler); + const handlers = listenersRef.current.get(event); + if (handlers) { + handlers.delete(handler); + if (handlers.size === 0) { + listenersRef.current.delete(event); + } + } + } else { + socket.off(event); + listenersRef.current.delete(event); + } + }, []); + + const reconnect = useCallback(() => { + const socket = socketRef.current; + if (socket && !socket.connected) { + socket.connect(); + } + }, []); + + const disconnect = useCallback(() => { + wsManager.disconnect(connectionKey); + }, [connectionKey]); + + return { + socket: socketRef.current, + status, + isConnected: status.isConnected, + isReconnecting: status.isReconnecting, + reconnectAttempts: status.reconnectAttempts, + lastError: status.lastError, + emit, + on, + off, + reconnect, + disconnect, + }; +} diff --git a/src/lib/websocketManager.ts b/src/lib/websocketManager.ts new file mode 100644 index 00000000..b96f32ac --- /dev/null +++ b/src/lib/websocketManager.ts @@ -0,0 +1,206 @@ +'use client'; + +import { io, Socket } from 'socket.io-client'; + +export interface WebSocketConfig { + url: string; + namespace?: string; + reconnectionAttempts?: number; + reconnectionDelay?: number; + heartbeatInterval?: number; + timeout?: number; +} + +export interface ConnectionStatus { + isConnected: boolean; + isReconnecting: boolean; + reconnectAttempts: number; + lastConnected?: Date; + lastError?: string; +} + +export class WebSocketManager { + private static instance: WebSocketManager; + private connections: Map = new Map(); + private configs: Map = new Map(); + private statuses: Map = new Map(); + private heartbeatIntervals: Map = new Map(); + private reconnectTimeouts: Map = new Map(); + + private constructor() {} + + static getInstance(): WebSocketManager { + if (!WebSocketManager.instance) { + WebSocketManager.instance = new WebSocketManager(); + } + return WebSocketManager.instance; + } + + connect(key: string, config: WebSocketConfig): Socket { + if (this.connections.has(key)) { + return this.connections.get(key)!; + } + + const socket = io(config.url + (config.namespace || ''), { + reconnection: false, + timeout: config.timeout || 20000, + forceNew: true, + }); + + this.connections.set(key, socket); + this.configs.set(key, config); + this.setupSocketListeners(key, socket, config); + this.startHeartbeat(key, config); + + socket.connect(); + return socket; + } + + disconnect(key: string): void { + const socket = this.connections.get(key); + if (socket) { + socket.disconnect(); + this.connections.delete(key); + } + this.cleanup(key); + } + + getStatus(key: string): ConnectionStatus { + return ( + this.statuses.get(key) || { + isConnected: false, + isReconnecting: false, + reconnectAttempts: 0, + } + ); + } + + getSocket(key: string): Socket | null { + return this.connections.get(key) || null; + } + + getAllStatuses(): Record { + const result: Record = {}; + this.statuses.forEach((status, key) => { + result[key] = status; + }); + return result; + } + + private setupSocketListeners(key: string, socket: Socket, config: WebSocketConfig): void { + socket.on('connect', () => { + this.updateStatus(key, { + isConnected: true, + isReconnecting: false, + reconnectAttempts: 0, + lastConnected: new Date(), + lastError: undefined, + }); + }); + + socket.on('disconnect', (reason) => { + this.updateStatus(key, { + isConnected: false, + isReconnecting: false, + reconnectAttempts: this.getStatus(key).reconnectAttempts, + lastError: `Disconnected: ${reason}`, + }); + + if (reason !== 'io client disconnect') { + this.scheduleReconnect(key, config); + } + }); + + socket.on('connect_error', (error) => { + this.updateStatus(key, { + isConnected: false, + isReconnecting: false, + reconnectAttempts: this.getStatus(key).reconnectAttempts, + lastError: error.message, + }); + + this.scheduleReconnect(key, config); + }); + + socket.on('pong', () => { + const currentStatus = this.getStatus(key); + if (currentStatus.lastError) { + this.updateStatus(key, { ...currentStatus, lastError: undefined }); + } + }); + } + + private updateStatus(key: string, updates: Partial): void { + const currentStatus = this.getStatus(key); + this.statuses.set(key, { ...currentStatus, ...updates }); + } + + private scheduleReconnect(key: string, config: WebSocketConfig): void { + const status = this.getStatus(key); + const maxAttempts = config.reconnectionAttempts || 5; + + if (status.reconnectAttempts >= maxAttempts) { + this.updateStatus(key, { + isReconnecting: false, + lastError: `Max reconnection attempts (${maxAttempts}) reached`, + }); + return; + } + + this.updateStatus(key, { + isReconnecting: true, + reconnectAttempts: status.reconnectAttempts + 1, + }); + + const delay = (config.reconnectionDelay || 1000) * Math.pow(2, status.reconnectAttempts); + const timeout = setTimeout(() => { + this.attemptReconnect(key); + }, delay); + + this.reconnectTimeouts.set(key, timeout); + } + + private attemptReconnect(key: string): void { + const socket = this.connections.get(key); + if (socket && !socket.connected) { + socket.connect(); + } + } + + private startHeartbeat(key: string, config: WebSocketConfig): void { + const interval = config.heartbeatInterval || 30000; + const heartbeat = setInterval(() => { + const socket = this.connections.get(key); + if (socket && socket.connected) { + socket.emit('ping'); + } + }, interval); + + this.heartbeatIntervals.set(key, heartbeat); + } + + private cleanup(key: string): void { + const heartbeat = this.heartbeatIntervals.get(key); + if (heartbeat) { + clearInterval(heartbeat); + this.heartbeatIntervals.delete(key); + } + + const timeout = this.reconnectTimeouts.get(key); + if (timeout) { + clearTimeout(timeout); + this.reconnectTimeouts.delete(key); + } + + this.configs.delete(key); + this.statuses.delete(key); + } + + disconnectAll(): void { + this.connections.forEach((_, key) => { + this.disconnect(key); + }); + } +} + +export const wsManager = WebSocketManager.getInstance();