Files
frontend/src/hooks/useMessaging.ts

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,
};
}