import { useState, useEffect, useRef, useCallback } from "react"; import { getOrCreateKeyPair, storePeerPublicKey, getPeerPublicKey, encryptMessage, decryptMessage, isE2EAvailable, } from "./cryptoUtils"; const WS_BASE = import.meta.env.VITE_CHAT_WS_URL || (window.location.protocol === "https:" ? "wss://" : "ws://") + window.location.host + "/chat/ws"; const API_BASE = import.meta.env.VITE_CHAT_API_URL || "/chat/api"; export function useChat({ username, enabled = true, onNewMessage }) { const [connected, setConnected] = useState(false); const [messages, setMessages] = useState({}); // { recipientUsername: Message[] } const [typing, setTyping] = useState({}); // { username: boolean } const [presence, setPresence] = useState({}); // { username: boolean } const [conversations, setConversations] = useState([]); const [e2eReady, setE2eReady] = useState(false); const wsRef = useRef(null); const reconnectTimer = useRef(null); const reconnectAttempts = useRef(0); const keyPairRef = useRef(null); const usernameRef = useRef(username); const enabledRef = useRef(enabled); const onNewMessageRef = useRef(onNewMessage); // Keep refs updated useEffect(() => { usernameRef.current = username; }, [username]); useEffect(() => { enabledRef.current = enabled; }, [enabled]); useEffect(() => { onNewMessageRef.current = onNewMessage; }, [onNewMessage]); // Initialize E2E encryption keys useEffect(() => { if (!isE2EAvailable()) { console.warn("[E2E] Web Crypto API not available"); return; } getOrCreateKeyPair().then((kp) => { keyPairRef.current = kp; setE2eReady(true); console.log("[E2E] Key pair ready"); }).catch((err) => { console.error("[E2E] Failed to initialize keys", err); }); }, []); // Send public key to a peer for key exchange const sendPublicKey = useCallback((recipientUsername) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; if (!keyPairRef.current?.publicKeyExport) return; const msg = { type: "key_exchange", payload: { recipient_username: recipientUsername, public_key: keyPairRef.current.publicKeyExport, }, }; wsRef.current.send(JSON.stringify(msg)); }, []); // Handle incoming WebSocket messages (uses ref to avoid dependency changes) const handleMessage = useCallback((data) => { const currentUsername = usernameRef.current; // Parse payload if it's a string (backwards compat), otherwise use as-is const parsePayload = (payload) => { if (typeof payload === "string") { try { return JSON.parse(payload); } catch { return payload; } } return payload; }; switch (data.type) { case "message": { const msg = parsePayload(data.payload); // Case-insensitive comparison for username matching const isMySent = msg.sender_name?.toLowerCase() === currentUsername?.toLowerCase(); const otherUser = isMySent ? msg.recipient_name : msg.sender_name; const isIncoming = !isMySent; console.log("[Chat] Received message from", msg.sender_name, "to", msg.recipient_name); setMessages((prev) => { const existing = prev[otherUser] || []; // Avoid duplicates (check by id or by temp-id prefix) if (existing.some((m) => m.id === msg.id || (m.id?.toString().startsWith("temp-") && m.content === msg.content && m.sender_name === msg.sender_name))) { // Replace temp message with real one return { ...prev, [otherUser]: existing.map((m) => m.id?.toString().startsWith("temp-") && m.content === msg.content && m.sender_name === msg.sender_name ? msg : m ), }; } return { ...prev, [otherUser]: [...existing, msg] }; }); // Notify parent component of new incoming message if (isIncoming && onNewMessageRef.current) { onNewMessageRef.current(msg); } break; } case "typing": { const payload = parsePayload(data.payload); setTyping((prev) => ({ ...prev, [payload.username]: payload.is_typing, })); // Clear typing indicator after 3 seconds setTimeout(() => { setTyping((prev) => ({ ...prev, [payload.username]: false })); }, 3000); break; } case "presence": { const payload = parsePayload(data.payload); setPresence((prev) => ({ ...prev, [payload.username]: payload.online, })); break; } default: console.log("[Chat] Unknown message type", data.type); } }, []); // No dependencies - uses refs // Connect to WebSocket (uses refs to avoid dependency changes) const connect = useCallback(() => { const currentUsername = usernameRef.current; const currentEnabled = enabledRef.current; if (!currentEnabled || !currentUsername) return; // Don't reconnect if already connected if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { console.log("[Chat] Already connected"); return; } const wsUrl = `${WS_BASE}?username=${encodeURIComponent(currentUsername)}`; console.log("[Chat] Connecting to", wsUrl); try { const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log("[Chat] WebSocket connected as", currentUsername); setConnected(true); reconnectAttempts.current = 0; }; ws.onclose = (e) => { console.log("[Chat] WebSocket closed", e.code, e.reason); setConnected(false); wsRef.current = null; // Reconnect with exponential backoff (check ref values) if (enabledRef.current && reconnectAttempts.current < 10) { const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); reconnectAttempts.current++; console.log("[Chat] Reconnecting in", delay, "ms (attempt", reconnectAttempts.current, ")"); reconnectTimer.current = setTimeout(connect, delay); } }; ws.onerror = (e) => { console.error("[Chat] WebSocket error", e); }; ws.onmessage = (e) => { try { const data = JSON.parse(e.data); handleMessage(data); } catch (err) { console.error("[Chat] Parse error", err); } }; wsRef.current = ws; } catch (err) { console.error("[Chat] Connection error", err); } }, [handleMessage]); // Only depends on stable handleMessage // Send a chat message const sendMessage = useCallback((recipientUsername, content) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { console.error("[Chat] WebSocket not connected"); return false; } const currentUsername = usernameRef.current; // Optimistic update - add message to local state immediately const optimisticMsg = { id: `temp-${Date.now()}`, sender_name: currentUsername, recipient_name: recipientUsername, content: content, created_at: new Date().toISOString(), }; setMessages((prev) => { const existing = prev[recipientUsername] || []; return { ...prev, [recipientUsername]: [...existing, optimisticMsg] }; }); const msg = { type: "message", payload: { recipient_username: recipientUsername, content: content, }, }; wsRef.current.send(JSON.stringify(msg)); return true; }, []); // Send typing indicator const sendTyping = useCallback((recipientUsername, isTyping) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; const msg = { type: "typing", payload: { recipient_username: recipientUsername, is_typing: isTyping, }, }; wsRef.current.send(JSON.stringify(msg)); }, []); // Mark messages as read const markAsRead = useCallback((senderUsername) => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; const msg = { type: "read", payload: { sender_username: senderUsername }, }; wsRef.current.send(JSON.stringify(msg)); }, []); // Fetch message history const fetchMessages = useCallback(async (otherUsername) => { if (!username || !otherUsername) return []; try { const res = await fetch(`${API_BASE}/messages?username=${encodeURIComponent(username)}&other=${encodeURIComponent(otherUsername)}`); const data = await res.json(); if (data.ok && data.messages) { setMessages((prev) => ({ ...prev, [otherUsername]: data.messages })); return data.messages; } } catch (err) { console.error("[Chat] Fetch messages error", err); } return []; }, [username]); // Fetch conversations list const fetchConversations = useCallback(async () => { if (!username) return []; try { const res = await fetch(`${API_BASE}/conversations?username=${encodeURIComponent(username)}`); const data = await res.json(); if (data.ok && data.conversations) { setConversations(data.conversations); // Update presence from conversations const newPresence = {}; data.conversations.forEach((c) => { newPresence[c.username] = c.online; }); setPresence((prev) => ({ ...prev, ...newPresence })); return data.conversations; } } catch (err) { console.error("[Chat] Fetch conversations error", err); } return []; }, [username]); // Connect on mount / when enabled or username changes useEffect(() => { if (enabled && username) { // Only connect if not already connected if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { connect(); } } else { // Disconnect if disabled or no username if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } } }, [enabled, username, connect]); // Reconnect when page becomes visible (mobile app resume) useEffect(() => { if (!enabled || !username) return; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { console.log('[Chat] Page visible, checking connection...'); // Check if WebSocket is disconnected or in bad state if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { console.log('[Chat] Reconnecting after visibility change...'); reconnectAttempts.current = 0; // Reset attempts connect(); } } }; document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, [enabled, username, connect]); // Cleanup on unmount only useEffect(() => { return () => { if (reconnectTimer.current) { clearTimeout(reconnectTimer.current); } if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } }; }, []); return { connected, messages, typing, presence, conversations, sendMessage, sendTyping, markAsRead, fetchMessages, fetchConversations, }; }