'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { useSession } from 'next-auth/react'; import { socketService, messagesService } from '@/services'; import type { Conversation, Message, CreateMessageDto, TypingIndicator, UserStatusChange, MessagesReadEvent, MessageType, MessageStatus, } from '@/types/messaging'; interface UseMessagingOptions { autoConnect?: boolean; } interface UseMessagingReturn { // State conversations: Conversation[]; currentConversation: Conversation | null; messages: Message[]; isConnected: boolean; isLoading: boolean; isLoadingMessages: boolean; typingUsers: Set; unreadCount: number; // Actions connect: () => Promise; disconnect: () => void; loadConversations: () => Promise; selectConversation: (conversationId: string) => Promise; startConversation: (body: { agentProfileId?: string; userId?: string }) => Promise; sendMessage: (content: string, messageType?: MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => Promise; loadMoreMessages: () => Promise; startTyping: () => void; stopTyping: () => void; markAsRead: () => Promise; markAllAsRead: () => Promise; deleteConversation: (conversationId: string) => Promise; clearMessages: () => void; updateUserStatus: (userId: string, isOnline: boolean) => void; hasMoreMessages: boolean; } export function useMessaging(options: UseMessagingOptions = {}): UseMessagingReturn { const { autoConnect = true } = options; const { data: session } = useSession(); // State const [conversations, setConversations] = useState([]); const [currentConversation, setCurrentConversation] = useState(null); const [messages, setMessages] = useState([]); const [isConnected, setIsConnected] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isLoadingMessages, setIsLoadingMessages] = useState(false); const [typingUsers, setTypingUsers] = useState>(new Set()); const [unreadCount, setUnreadCount] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [hasMoreMessages, setHasMoreMessages] = useState(true); // Refs const typingTimeoutRef = useRef(null); const currentConversationIdRef = useRef(null); const isInitializedRef = useRef(false); // Use localStorage token (same source as REST API, always fresh via interceptor) const getAccessToken = useCallback(() => { if (typeof window !== 'undefined') { return localStorage.getItem('accessToken'); } return null; }, []); // Check if user is authenticated (session exists) const isAuthenticated = !!session?.user; // Connect to WebSocket const connect = useCallback(async () => { const token = getAccessToken(); if (!token) { console.warn('No access token available for socket connection'); return; } try { await socketService.connect(token); } catch (error) { console.error('Failed to connect to socket:', error); } }, [getAccessToken]); // Disconnect from WebSocket const disconnect = useCallback(() => { socketService.disconnect(); }, []); // Load conversations const loadConversations = useCallback(async () => { if (!getAccessToken()) return; setIsLoading(true); try { const data = await messagesService.getConversations(); setConversations(data); } catch (error) { console.error('Failed to load conversations:', error); } finally { setIsLoading(false); } }, [getAccessToken]); // Select a conversation and load its messages const selectConversation = useCallback( async (conversationId: string) => { if (currentConversationIdRef.current === conversationId) return; // Leave previous conversation room if (currentConversationIdRef.current) { socketService.leaveConversation(currentConversationIdRef.current); } currentConversationIdRef.current = conversationId; setCurrentPage(1); setHasMoreMessages(true); setMessages([]); setTypingUsers(new Set()); setIsLoadingMessages(true); try { // Get conversation details const conversation = await messagesService.getConversation(conversationId); setCurrentConversation(conversation); // Join WebSocket room if (isConnected) { await socketService.joinConversation(conversationId); } // Load messages const { messages: loadedMessages, pagination } = await messagesService.getMessages(conversationId, 1, 50); setMessages(loadedMessages); setHasMoreMessages(pagination.page < pagination.pages); // Mark as read await messagesService.markAsRead(conversationId); // Update unread count in conversations list setConversations((prev) => prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c)) ); } catch (error) { console.error('Failed to load conversation:', error); } finally { setIsLoadingMessages(false); } }, [isConnected] ); // Start a new conversation with an agent (for users) or a user (for agents) const startConversation = useCallback( async (body: { agentProfileId?: string; userId?: string }): Promise => { const conversation = await messagesService.startConversation(body); // Add to conversations list if not exists setConversations((prev) => { const exists = prev.some((c) => c.id === conversation.id); if (exists) return prev; return [conversation, ...prev]; }); return conversation; }, [] ); // Send a message const sendMessage = useCallback( async (content: string, messageType: MessageType = 'TEXT' as MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => { if (!currentConversationIdRef.current) return; const dto: CreateMessageDto = { content, messageType, ...fileFields, }; // Stop typing indicator if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = null; } socketService.stopTyping(currentConversationIdRef.current); // Try WebSocket first, fallback to REST if (isConnected) { const result = await socketService.sendMessage( currentConversationIdRef.current, dto ); if (result.success && result.message) { // Immediately update UI with the sent message (optimistic update) setMessages((prev) => { // Avoid duplicates (in case broadcast also arrives) if (prev.some((m) => m.id === result.message!.id)) return prev; return [...prev, result.message!]; }); } else if (!result.success) { // Fallback to REST const message = await messagesService.sendMessage( currentConversationIdRef.current, dto ); setMessages((prev) => [...prev, message]); } } else { // REST fallback const message = await messagesService.sendMessage( currentConversationIdRef.current, dto ); setMessages((prev) => [...prev, message]); } // Update conversation in list const conversationId = currentConversationIdRef.current; const previewText = messageType === ('IMAGE' as MessageType) ? 'Sent an image' : messageType === ('FILE' as MessageType) ? 'Sent a file' : content; setConversations((prev) => prev.map((c) => c.id === conversationId ? { ...c, lastMessageAt: new Date().toISOString(), lastMessageText: previewText.length > 255 ? previewText.substring(0, 252) + '...' : previewText, } : c ) ); }, [isConnected] ); // Load more messages (pagination) with debouncing for smooth scrolling const loadMoreMessages = useCallback(async () => { if (!currentConversationIdRef.current || !hasMoreMessages || isLoadingMessages) return; setIsLoadingMessages(true); try { const nextPage = currentPage + 1; const { messages: moreMessages, pagination } = await messagesService.getMessages( currentConversationIdRef.current, nextPage, 50 ); // Prepend older messages smoothly setMessages((prev) => [...moreMessages, ...prev]); setCurrentPage(nextPage); setHasMoreMessages(pagination.page < pagination.pages); } catch (error) { console.error('Failed to load more messages:', error); } finally { setIsLoadingMessages(false); } }, [currentPage, hasMoreMessages, isLoadingMessages]); // Start typing indicator const startTyping = useCallback(() => { if (!currentConversationIdRef.current) return; socketService.startTyping(currentConversationIdRef.current); // Clear previous timeout if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } // Auto-stop typing after 3 seconds typingTimeoutRef.current = setTimeout(() => { if (currentConversationIdRef.current) { socketService.stopTyping(currentConversationIdRef.current); } }, 3000); }, []); // Stop typing indicator const stopTyping = useCallback(() => { if (!currentConversationIdRef.current) return; if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); typingTimeoutRef.current = null; } socketService.stopTyping(currentConversationIdRef.current); }, []); // Mark messages as read const markAsRead = useCallback(async () => { if (!currentConversationIdRef.current) return; if (isConnected) { await socketService.markAsRead(currentConversationIdRef.current); } else { await messagesService.markAsRead(currentConversationIdRef.current); } }, [isConnected]); // Mark all conversations as read const markAllAsRead = useCallback(async () => { const unreadConversations = conversations.filter((c) => c.unreadCount > 0); await Promise.all( unreadConversations.map((c) => messagesService.markAsRead(c.id)) ); setConversations((prev) => prev.map((c) => ({ ...c, unreadCount: 0 })) ); }, [conversations]); // Delete a conversation const deleteConversation = useCallback( async (conversationId: string) => { await messagesService.deleteConversation(conversationId); // Remove from list setConversations((prev) => prev.filter((c) => c.id !== conversationId)); // Clear current if it was the deleted one if (currentConversationIdRef.current === conversationId) { currentConversationIdRef.current = null; setCurrentConversation(null); setMessages([]); } }, [] ); // Clear messages from current conversation view (local only) const clearMessages = useCallback(() => { setMessages([]); }, []); // Update user online status (called from status change events) const updateUserStatus = useCallback((userId: string, isOnline: boolean) => { setConversations((prev) => prev.map((c) => { if (c.otherParty.userId === userId || c.otherParty.id === userId) { return { ...c, otherParty: { ...c.otherParty, isOnline }, }; } return c; }) ); // Update current conversation if applicable setCurrentConversation((prev) => { if (!prev) return prev; if ( prev.otherParty.userId === userId || prev.otherParty.id === userId ) { return { ...prev, otherParty: { ...prev.otherParty, isOnline }, }; } return prev; }); }, []); // Set up socket event listeners useEffect(() => { const unsubscribeConnection = socketService.onConnectionChange((connected) => { setIsConnected(connected); }); const unsubscribeMessage = socketService.onNewMessage((message) => { // Only add if it's for the current conversation if (message.conversationId === currentConversationIdRef.current) { setMessages((prev) => { // Avoid duplicates if (prev.some((m) => m.id === message.id)) return prev; return [...prev, message]; }); } // Update conversation list setConversations((prev) => prev.map((c) => { if (c.id === message.conversationId) { const isCurrentConversation = c.id === currentConversationIdRef.current; // Show friendly preview for non-text messages let previewText = message.content.substring(0, 255); if (message.messageType === 'IMAGE') { previewText = 'Sent an image'; } else if (message.messageType === 'FILE') { previewText = 'Sent a file'; } return { ...c, lastMessageAt: message.createdAt, lastMessageText: previewText, unreadCount: isCurrentConversation ? 0 : c.unreadCount + 1, }; } return c; }) ); }); const unsubscribeTypingStart = socketService.onTypingStart( (data: TypingIndicator) => { if (data.conversationId === currentConversationIdRef.current) { setTypingUsers((prev) => new Set(prev).add(data.userId)); } } ); const unsubscribeTypingStop = socketService.onTypingStop( (data: TypingIndicator) => { if (data.conversationId === currentConversationIdRef.current) { setTypingUsers((prev) => { const next = new Set(prev); next.delete(data.userId); return next; }); } } ); const unsubscribeStatus = socketService.onUserStatusChange( (data: UserStatusChange) => { updateUserStatus(data.userId, data.isOnline); } ); const unsubscribeRead = socketService.onMessagesRead( (data: MessagesReadEvent) => { if (data.conversationId === currentConversationIdRef.current) { // Update message statuses setMessages((prev) => prev.map((m) => ({ ...m, status: 'READ' as MessageStatus, readAt: data.readAt, })) ); } } ); // Auth error handling (token refresh + reconnect) is handled globally // by PresenceProvider — no duplicate handler needed here. return () => { unsubscribeConnection(); unsubscribeMessage(); unsubscribeTypingStart(); unsubscribeTypingStop(); unsubscribeStatus(); unsubscribeRead(); }; }, [updateUserStatus]); // Auto-connect on mount useEffect(() => { if (autoConnect && isAuthenticated && !isInitializedRef.current) { isInitializedRef.current = true; connect(); loadConversations(); // Get initial unread count messagesService.getUnreadCount().then(setUnreadCount).catch(console.error); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoConnect, isAuthenticated]); // Re-join conversation room when reconnecting + fetch missed messages useEffect(() => { if (isConnected && currentConversationIdRef.current) { const conversationId = currentConversationIdRef.current; const rejoinAndCatchUp = async (attempt = 1) => { // Re-join the room const result = await socketService.joinConversation(conversationId); if (!result.success && attempt < 3 && currentConversationIdRef.current === conversationId) { console.warn(`Room join attempt ${attempt} failed for ${conversationId}, retrying...`); setTimeout(() => rejoinAndCatchUp(attempt + 1), 1000 * attempt); return; } // Fetch recent messages to catch any missed during disconnect if (currentConversationIdRef.current === conversationId) { try { const { messages: recentMessages } = await messagesService.getMessages(conversationId, 1, 20); setMessages((prev) => { const existingIds = new Set(prev.map((m) => m.id)); const newMessages = recentMessages.filter((m) => !existingIds.has(m.id)); if (newMessages.length === 0) return prev; // Merge and sort by creation date const merged = [...prev, ...newMessages].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() ); return merged; }); } catch (err) { console.error('Failed to catch up messages after reconnect:', err); } } }; rejoinAndCatchUp(); } }, [isConnected]); return { conversations, currentConversation, messages, isConnected, isLoading, isLoadingMessages, typingUsers, unreadCount, connect, disconnect, loadConversations, selectConversation, startConversation, sendMessage, loadMoreMessages, startTyping, stopTyping, markAsRead, markAllAsRead, deleteConversation, clearMessages, updateUserStatus, hasMoreMessages, }; }