723 lines
24 KiB
TypeScript
723 lines
24 KiB
TypeScript
'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,
|
|
MessageDeliveredEvent,
|
|
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<string>;
|
|
unreadCount: number;
|
|
|
|
// Actions
|
|
connect: () => Promise<void>;
|
|
disconnect: () => void;
|
|
loadConversations: () => Promise<void>;
|
|
selectConversation: (conversationId: string) => Promise<void>;
|
|
startConversation: (body: { agentProfileId?: string; userId?: string }) => Promise<Conversation>;
|
|
sendMessage: (content: string, messageType?: MessageType, fileFields?: { fileUrl?: string; mimeType?: string; fileName?: string; fileSize?: number }) => Promise<void>;
|
|
loadMoreMessages: () => Promise<void>;
|
|
startTyping: () => void;
|
|
stopTyping: () => void;
|
|
markAsRead: () => Promise<void>;
|
|
markAllAsRead: () => Promise<void>;
|
|
deleteConversation: (conversationId: string) => Promise<void>;
|
|
clearMessages: () => void;
|
|
updateUserStatus: (userId: string, isOnline: boolean) => void;
|
|
toggleMute: (conversationId: string, muted: boolean) => Promise<void>;
|
|
toggleFavorite: (conversationId: string, favorited: boolean) => Promise<void>;
|
|
hasMoreMessages: boolean;
|
|
}
|
|
|
|
// Module-level cache to avoid re-fetching on tab switch
|
|
let _cachedConversations: Conversation[] | null = null;
|
|
let _cachedTimestamp = 0;
|
|
const CACHE_TTL = 30000; // 30 seconds
|
|
|
|
export function useMessaging(options: UseMessagingOptions = {}): UseMessagingReturn {
|
|
const { autoConnect = true } = options;
|
|
const { data: session } = useSession();
|
|
|
|
// State — initialize from cache if available and fresh
|
|
const hasFreshCache = _cachedConversations && (Date.now() - _cachedTimestamp < CACHE_TTL);
|
|
const [conversations, setConversations] = useState<Conversation[]>(hasFreshCache ? _cachedConversations! : []);
|
|
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(!hasFreshCache);
|
|
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
|
|
|
// Refs
|
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const currentConversationIdRef = useRef<string | null>(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);
|
|
// Update module-level cache
|
|
_cachedConversations = data;
|
|
_cachedTimestamp = Date.now();
|
|
} 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 (use socket so sender gets real-time blue tick)
|
|
if (isConnected) {
|
|
await socketService.markAsRead(conversationId);
|
|
} else {
|
|
await messagesService.markAsRead(conversationId);
|
|
}
|
|
|
|
// Update unread count in conversations list
|
|
setConversations((prev) =>
|
|
prev.map((c) => (c.id === conversationId ? { ...c, unreadCount: 0 } : c))
|
|
);
|
|
|
|
// Notify header to instantly refresh message + notification counts
|
|
window.dispatchEvent(new Event('messages-read'));
|
|
} 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<Conversation> => {
|
|
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);
|
|
}
|
|
window.dispatchEvent(new Event('messages-read'));
|
|
}, [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 }))
|
|
);
|
|
window.dispatchEvent(new Event('messages-read'));
|
|
}, [conversations]);
|
|
|
|
// Delete a conversation
|
|
const deleteConversation = useCallback(
|
|
async (conversationId: string) => {
|
|
await messagesService.deleteConversation(conversationId);
|
|
|
|
// Remove from list and invalidate cache
|
|
_cachedConversations = null;
|
|
_cachedTimestamp = 0;
|
|
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 (persisted to backend)
|
|
const clearMessages = useCallback(async () => {
|
|
const convId = currentConversationIdRef.current;
|
|
if (!convId) return;
|
|
|
|
try {
|
|
await messagesService.clearChat(convId);
|
|
setMessages([]);
|
|
// Update conversation preview in the list
|
|
setConversations((prev) =>
|
|
prev.map((c) =>
|
|
c.id === convId
|
|
? { ...c, lastMessageText: null, lastMessageAt: null, unreadCount: 0, userUnreadCount: 0, agentUnreadCount: 0 }
|
|
: c
|
|
)
|
|
);
|
|
setCurrentConversation((prev) =>
|
|
prev && prev.id === convId
|
|
? { ...prev, lastMessageText: null, lastMessageAt: null, unreadCount: 0, userUnreadCount: 0, agentUnreadCount: 0 }
|
|
: prev
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to clear chat:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Toggle mute status (only for current user's side)
|
|
const toggleMute = useCallback(async (conversationId: string, muted: boolean) => {
|
|
try {
|
|
await messagesService.toggleMute(conversationId, muted);
|
|
// Backend sets the correct field based on caller's role;
|
|
// optimistically update both fields since we don't know which one the server set,
|
|
// but the API response and next fetch will have the correct values.
|
|
// For the current user's perspective, both fields reflect "is this muted for me"
|
|
const update = (c: Conversation) => {
|
|
if (c.id !== conversationId) return c;
|
|
// Determine which field to update based on the user's role in the conversation
|
|
const userId = (session?.user as any)?.id;
|
|
const isUser = c.userId === userId;
|
|
return isUser
|
|
? { ...c, userMuted: muted }
|
|
: { ...c, agentMuted: muted };
|
|
};
|
|
setConversations((prev) => prev.map(update));
|
|
setCurrentConversation((prev) => prev ? update(prev) : prev);
|
|
} catch (err) {
|
|
console.error('Failed to toggle mute:', err);
|
|
}
|
|
}, [session]);
|
|
|
|
// Toggle favorite status (only for current user's side)
|
|
const toggleFavorite = useCallback(async (conversationId: string, favorited: boolean) => {
|
|
try {
|
|
await messagesService.toggleFavorite(conversationId, favorited);
|
|
const userId = (session?.user as any)?.id;
|
|
const update = (c: Conversation) => {
|
|
if (c.id !== conversationId) return c;
|
|
const isUser = c.userId === userId;
|
|
return isUser
|
|
? { ...c, userFavorited: favorited }
|
|
: { ...c, agentFavorited: favorited };
|
|
};
|
|
setConversations((prev) => {
|
|
const updated = prev.map(update);
|
|
return updated.sort((a, b) => {
|
|
const aFav = a.userId === userId ? a.userFavorited : a.agentFavorited;
|
|
const bFav = b.userId === userId ? b.userFavorited : b.agentFavorited;
|
|
if (aFav !== bFav) return aFav ? -1 : 1;
|
|
const aTime = a.lastMessageAt ? new Date(a.lastMessageAt).getTime() : 0;
|
|
const bTime = b.lastMessageAt ? new Date(b.lastMessageAt).getTime() : 0;
|
|
return bTime - aTime;
|
|
});
|
|
});
|
|
setCurrentConversation((prev) => prev ? update(prev) : prev);
|
|
} catch (err) {
|
|
console.error('Failed to toggle favorite:', err);
|
|
}
|
|
}, [session]);
|
|
|
|
// 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];
|
|
});
|
|
|
|
// Auto-mark as read if user is currently viewing this conversation
|
|
// (the other user sent a message while we're looking at the chat)
|
|
const currentUserId = (session?.user as any)?.id;
|
|
if (message.senderId !== currentUserId) {
|
|
if (isConnected) {
|
|
socketService.markAsRead(message.conversationId);
|
|
} else {
|
|
messagesService.markAsRead(message.conversationId).catch(() => {});
|
|
}
|
|
// Notify header to refresh counts since we auto-read the message
|
|
window.dispatchEvent(new Event('messages-read'));
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
const currentUserId = (session?.user as any)?.id;
|
|
// Only update status on messages SENT by current user (not received ones)
|
|
setMessages((prev) =>
|
|
prev.map((m) => {
|
|
if (m.senderId === currentUserId && m.status !== 'READ') {
|
|
return { ...m, status: 'READ' as MessageStatus, readAt: data.readAt };
|
|
}
|
|
return m;
|
|
})
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
const unsubscribeDelivered = socketService.onMessageDelivered(
|
|
(data: MessageDeliveredEvent) => {
|
|
if (data.conversationId === currentConversationIdRef.current) {
|
|
// Update single message or batch of messages to DELIVERED
|
|
const deliveredIds = data.messageIds || (data.messageId ? [data.messageId] : []);
|
|
if (deliveredIds.length > 0) {
|
|
setMessages((prev) =>
|
|
prev.map((m) => {
|
|
if (deliveredIds.includes(m.id) && m.status === 'SENT') {
|
|
return { ...m, status: 'DELIVERED' as MessageStatus, deliveredAt: data.deliveredAt };
|
|
}
|
|
return m;
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Conversation deleted by the other party — remove from local state
|
|
const unsubscribeConvDeleted = socketService.onConversationDeleted(
|
|
(data: Record<string, unknown>) => {
|
|
const convId = data.conversationId as string | undefined;
|
|
if (!convId) return;
|
|
setConversations((prev) => prev.filter((c) => c.id !== convId));
|
|
if (currentConversationIdRef.current === convId) {
|
|
currentConversationIdRef.current = null;
|
|
setMessages([]);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Auth error handling (token refresh + reconnect) is handled globally
|
|
// by PresenceProvider — no duplicate handler needed here.
|
|
|
|
return () => {
|
|
unsubscribeConnection();
|
|
unsubscribeMessage();
|
|
unsubscribeTypingStart();
|
|
unsubscribeTypingStop();
|
|
unsubscribeStatus();
|
|
unsubscribeRead();
|
|
unsubscribeDelivered();
|
|
unsubscribeConvDeleted();
|
|
// Clean up typing timeout to prevent memory leak
|
|
if (typingTimeoutRef.current) {
|
|
clearTimeout(typingTimeoutRef.current);
|
|
typingTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, [updateUserStatus]);
|
|
|
|
// Invalidate cache when session changes (logout/login)
|
|
useEffect(() => {
|
|
if (!isAuthenticated) {
|
|
_cachedConversations = null;
|
|
_cachedTimestamp = 0;
|
|
}
|
|
}, [isAuthenticated]);
|
|
|
|
// Auto-connect on mount
|
|
useEffect(() => {
|
|
if (autoConnect && isAuthenticated && !isInitializedRef.current) {
|
|
isInitializedRef.current = true;
|
|
connect();
|
|
|
|
// Only fetch conversations if cache is stale or empty
|
|
if (!_cachedConversations || Date.now() - _cachedTimestamp >= CACHE_TTL) {
|
|
loadConversations();
|
|
} else {
|
|
setIsLoading(false);
|
|
}
|
|
|
|
// 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,
|
|
toggleMute,
|
|
toggleFavorite,
|
|
hasMoreMessages,
|
|
};
|
|
}
|