veza/apps/web/src/features/chat/hooks/useChat.ts
senke e4711d684b state-ownership: replace all useAuthStore().user with useUser() hook
- Migrated all hooks: useAuth, useChat, useLogin
- Migrated all components: Header, ProfileForm, FollowButton, LikeButton, PlaylistFollowButton, ChatMessage, ChatMessages, CommentThread, CommentSection, PlaylistList, ChatSidebar, SettingsPage, DashboardPage
- Updated storeSelectors.ts useAuthUser() to use React Query
- All production code now uses useUser() hook instead of Zustand store
- Action 4.1.1.3 and 4.1.1.4 complete
2026-01-14 01:45:42 +01:00

360 lines
11 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useUser } from '@/features/auth/hooks/useUser';
import { useChatStore } from '../store/chatStore';
import { apiClient } from '@/services/api/client';
import { OutgoingMessage, IncomingMessage } from '../types';
import { v4 as uuidv4 } from 'uuid'; // For message IDs
import type { UseChatReturn } from '@/hooks/types';
import { logger } from '@/utils/logger';
/**
* Hook pour gérer le chat WebSocket
* FE-TYPE-012: Fully typed hook return
*/
export const useChat = (): UseChatReturn => {
const { data: user } = useUser();
const userId = user?.id;
// const _username = user?.username;
const {
wsToken,
wsUrl,
wsStatus,
setWsStatus,
addMessage,
currentConversationId,
loadMessages,
addReaction,
removeReaction,
setUserTyping,
} = useChatStore();
const ws = useRef<WebSocket | null>(null);
// CRITIQUE FIX #9: Utiliser un useRef pour stocker le compteur d'erreurs de manière persistante
// au lieu de le stocker sur ws.current qui peut être réinitialisé
const errorCountRef = useRef(0);
// Queue for messages to send (reserved for future use)
const [_messagesToSend, setMessagesToSend] = useState<OutgoingMessage[]>([]);
const connect = useCallback(() => {
if (!wsToken || !wsUrl || ws.current?.readyState === WebSocket.OPEN) return;
// CRITIQUE FIX #9: Vérifier si le serveur WebSocket est disponible avant de tenter la connexion
// En développement, si le serveur n'est pas démarré, limiter les tentatives
if (import.meta.env.DEV && wsUrl.includes('127.0.0.1:8081')) {
// En dev, vérifier si on a déjà eu trop d'erreurs de connexion
if (errorCountRef.current >= 3) {
// Trop d'erreurs, ne pas essayer de se connecter pour éviter le spam console
setWsStatus('disconnected');
return;
}
}
// CRITIQUE FIX #39: Nettoyer la connexion précédente si elle existe
if (ws.current) {
ws.current.onopen = null;
ws.current.onmessage = null;
ws.current.onclose = null;
ws.current.onerror = null;
if (
ws.current.readyState === WebSocket.OPEN ||
ws.current.readyState === WebSocket.CONNECTING
) {
ws.current.close();
}
}
setWsStatus('connecting');
const fullWsUrl = `${wsUrl}?token=${wsToken}`; // Assuming WS server is at root of wsUrl
ws.current = new WebSocket(fullWsUrl);
// CRITIQUE FIX #39: Stocker les références aux handlers pour pouvoir les nettoyer
const handleOpen = () => {
setWsStatus('connected');
// CRITIQUE FIX #9: Réinitialiser le compteur d'erreurs en cas de succès
errorCountRef.current = 0;
// WebSocket connection successful - no logging needed in production
// Send any queued messages
setMessagesToSend((prev) => {
prev.forEach((msg) => ws.current?.send(JSON.stringify(msg)));
return [];
});
};
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.type === 'NewMessage') {
const message: IncomingMessage = data;
if (
message.conversation_id === currentConversationId &&
message.message_id &&
message.sender_id &&
message.content &&
message.created_at
) {
addMessage({
id: message.message_id,
conversation_id: message.conversation_id,
sender_id: message.sender_id,
sender_username: message.sender_username || 'Unknown',
content: message.content,
created_at: message.created_at,
attachments: message.attachments,
});
}
} else if (data.type === 'ReactionAdded') {
const reaction: IncomingMessage = data;
if (reaction.message_id && reaction.user_id && reaction.emoji) {
addReaction(
reaction.conversation_id,
reaction.message_id,
reaction.user_id,
reaction.emoji,
);
}
} else if (data.type === 'ReactionRemoved') {
const reaction: IncomingMessage = data;
if (reaction.message_id && reaction.user_id) {
removeReaction(
reaction.conversation_id,
reaction.message_id,
reaction.user_id,
);
}
} else if (data.type === 'UserTyping') {
const typing: IncomingMessage = data;
if (typing.user_id) {
setUserTyping(
typing.conversation_id,
typing.user_id,
typing.is_typing ?? false,
);
}
}
// Handle other incoming message types (ActionConfirmed, Error, Pong)
};
const handleClose = () => {
setWsStatus('disconnected');
// WebSocket disconnected - no logging needed in production
// Optional: Reconnect logic
};
const handleError = (error: Event) => {
setWsStatus('error');
// CRITIQUE FIX #9: Limiter les logs d'erreur pour éviter le spam console
// En développement, si le serveur n'est pas démarré, limiter à 3 erreurs max
errorCountRef.current += 1;
if (errorCountRef.current <= 3) {
if (import.meta.env.DEV) {
logger.warn(
`[WebSocket] Connexion échouée (${errorCountRef.current}/3). Le serveur WebSocket n'est peut-être pas démarré.`,
{
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
},
);
} else {
logger.error('WebSocket error', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
// Don't close immediately - let onclose handle it
// ws.current?.close();
};
// CRITIQUE FIX #39: Attacher les handlers
ws.current.onopen = handleOpen;
ws.current.onmessage = handleMessage;
ws.current.onclose = handleClose;
ws.current.onerror = handleError;
}, [
wsToken,
wsUrl,
setWsStatus,
addMessage,
currentConversationId,
addReaction,
removeReaction,
setUserTyping,
]);
const disconnect = useCallback(() => {
// CRITIQUE FIX #39: Nettoyer proprement les event handlers avant de fermer
if (ws.current) {
ws.current.onopen = null;
ws.current.onmessage = null;
ws.current.onclose = null;
ws.current.onerror = null;
if (
ws.current.readyState === WebSocket.OPEN ||
ws.current.readyState === WebSocket.CONNECTING
) {
ws.current.close();
}
ws.current = null;
setWsStatus('disconnected');
}
}, [setWsStatus]);
// FE-BUG-003: Add a ref to track reconnection attempts and avoid infinite loops
const reconnectCount = useRef(0);
const maxReconnects = 5;
useEffect(() => {
let timer: NodeJS.Timeout | undefined;
if (
wsToken &&
wsUrl &&
wsStatus === 'disconnected' &&
reconnectCount.current < maxReconnects
) {
timer = setTimeout(
() => {
reconnectCount.current++;
connect();
},
1000 * Math.pow(2, reconnectCount.current),
); // Exponential backoff
}
if (wsStatus === 'connected') {
reconnectCount.current = 0; // Reset on success
}
return () => {
if (timer) {
clearTimeout(timer);
}
};
}, [wsToken, wsUrl, wsStatus, connect]);
useEffect(() => {
// Clean up on unmount
return () => {
disconnect();
};
}, [disconnect]);
const sendMessage = useCallback(
(content: string, attachments?: import('../types').MessageAttachment[]) => {
if (
!ws.current ||
ws.current.readyState !== WebSocket.OPEN ||
!currentConversationId ||
!userId
) {
// WebSocket not ready - message will be queued
logger.warn(
'WebSocket not open or missing conversation/user ID. Message queued.',
{
conversationId: currentConversationId,
userId,
},
);
setMessagesToSend((prev) => [
...prev,
{
type: 'SendMessage',
conversation_id: currentConversationId || uuidv4(),
content,
parent_message_id: null,
attachments,
} as OutgoingMessage,
]);
return;
}
const message: OutgoingMessage = {
type: 'SendMessage',
conversation_id: currentConversationId,
content,
parent_message_id: null,
attachments,
};
ws.current.send(JSON.stringify(message));
},
[currentConversationId, userId],
);
// TODO: Add fetchHistory function
const fetchHistory = useCallback(
async (conversationId: string) => {
try {
const response = await apiClient.get(
`/conversations/${conversationId}/history`,
);
loadMessages(conversationId, response.data.messages);
} catch (error) {
logger.error('Failed to fetch chat history', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
conversationId,
});
}
},
[loadMessages],
);
const addReactionFunc = useCallback(
(messageId: string, emoji: string) => {
if (ws.current?.readyState === WebSocket.OPEN && currentConversationId) {
ws.current.send(
JSON.stringify({
type: 'AddReaction',
conversation_id: currentConversationId,
message_id: messageId,
emoji,
} as OutgoingMessage),
);
}
},
[currentConversationId],
);
const removeReactionFunc = useCallback(
(messageId: string) => {
if (ws.current?.readyState === WebSocket.OPEN && currentConversationId) {
ws.current.send(
JSON.stringify({
type: 'RemoveReaction',
conversation_id: currentConversationId,
message_id: messageId,
} as OutgoingMessage),
);
}
},
[currentConversationId],
);
const setTyping = useCallback(
(isTyping: boolean) => {
if (ws.current?.readyState === WebSocket.OPEN && currentConversationId) {
ws.current.send(
JSON.stringify({
type: 'Typing',
conversation_id: currentConversationId,
is_typing: isTyping,
} as OutgoingMessage),
);
}
},
[currentConversationId],
);
return {
wsStatus,
connect,
disconnect,
sendMessage,
fetchHistory,
addReaction: addReactionFunc,
removeReaction: removeReactionFunc,
setTyping,
};
};