"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { fetchChatMessages } from "@/entities/chats/actions"; import type { IChatMessage } from "@/entities/chats/types"; import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout"; import { useSocketEvent } from "@/hooks/socket/useSocketEvent"; import { useChatStore } from "@/providers/chat-store-provider"; import { ESocketStatus, useSocketEmit, useSocketEntity, useSocketStatus, } from "@/services/socket"; import type { ICurrentBalance, IRefillModals, ISessionStarted, } from "@/services/socket/events"; const PAGE_LIMIT = 50; interface UseChatSocketOptions { initialMessages?: IChatMessage[]; initialTotal?: number; onNewMessage?: (message: IChatMessage) => void; } export const useChatSocket = ( chatId: string, options: UseChatSocketOptions = {} ) => { const socket = useSocketEntity(); const status = useSocketStatus(); const emit = useSocketEmit(); const [messages, setMessages] = useState( () => options.initialMessages || [] ); const [page, setPage] = useState(1); const [totalCount, _setTotalCount] = useState( options.initialTotal ?? null ); const [isLoadingOlder, setIsLoadingOlder] = useState(false); const [balance, setBalance] = useState(null); const [session, setSession] = useState(null); const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false); const [isSessionExpired, setIsSessionExpired] = useState(false); const [refillModals, setRefillModals] = useState(null); const { suggestions, setSuggestions } = useChatStore(state => state); const isLoadingAdvisorMessage = useMemo(() => { return ( messages.length > 0 && (messages[0].role !== "assistant" || (Object.hasOwn(messages[0], "isLast") && !messages[0].isLast)) ); }, [messages]); const unreadMessagesCount = useMemo(() => { return messages.filter(m => !m.isRead && m.role === "assistant").length; }, [messages]); const joinChat = useCallback( () => emit("join_chat", { chatId }), [emit, chatId] ); const leaveChat = useCallback( () => emit("leave_chat", { chatId }), [emit, chatId] ); const send = useCallback( (text: string) => { const sendingMessage: IChatMessage = { id: `sending-message-${Date.now()}`, role: "user", text, createdDate: new Date().toISOString(), isRead: false, type: "text", }; setMessages(prev => [sendingMessage, ...prev]); if (options.onNewMessage) { options.onNewMessage(sendingMessage); } setIsLoadingSelfMessage(true); emit("send_message", { chatId, message: text }); }, [options, emit, chatId] ); const read = useCallback( (ids: string[]) => { emit("read_message", { messages: ids }); // setMessages(prev => // prev.map(m => (ids.includes(m.id) ? { ...m, isRead: true } : m)) // ); }, [emit] ); const startSession = useCallback( () => emit("start_session", { chatId }), [emit, chatId] ); const endSession = useCallback( () => emit("end_session", { chatId }), [emit, chatId] ); const fetchBalance = useCallback(() => { emit("fetch_balance", { chatId }); }, [emit, chatId]); // Auto top-up: use existing single checkout flow const { handleSingleCheckout, isLoading: isAutoTopUpLoading } = useSingleCheckout({ onSuccess: fetchBalance, onError: () => { // eslint-disable-next-line no-console console.error("Auto top-up payment failed"); // Release in-flight lock on error so a future event can retry autoTopUpInProgressRef.current = false; }, returnUrl: typeof window !== "undefined" ? window.location.href : "", }); // Auto top-up: silent flow (no UI prompt) const autoTopUpInProgressRef = useRef(false); const balancePollId = useRef(null); // Avoid immediate leave_chat right after join in React 18 StrictMode (dev) double-invoke // We debounce leave so that a quick remount cancels it, but real navigation (or chat switch) proceeds. const leaveTimeoutRef = useRef(null); const lastChatIdRef = useRef(chatId); const startBalancePolling = useCallback(() => { if (balancePollId.current) return; balancePollId.current = setInterval(fetchBalance, 5_000); }, [fetchBalance]); const stopBalancePolling = useCallback(() => { if (balancePollId.current) { clearInterval(balancePollId.current); balancePollId.current = null; } }, []); const hasMoreOlderMessages = totalCount === null ? false : messages.length < totalCount; const loadOlder = useCallback(async () => { if (isLoadingOlder || !hasMoreOlderMessages) return; setIsLoadingOlder(true); try { const nextPage = page + 1; const { data } = await fetchChatMessages(chatId, { limit: PAGE_LIMIT, page: nextPage, }); if (!data) return; const { messages: msgs } = data; setMessages(prev => { const ids = new Set(prev.map(m => m.id)); return [...prev, ...msgs.filter(m => !ids.has(m.id))]; }); setPage(nextPage); } catch (e) { // eslint-disable-next-line no-console console.error("Failed to load older messages:", e); } finally { setIsLoadingOlder(false); } }, [isLoadingOlder, hasMoreOlderMessages, page, chatId]); useSocketEvent("receive_message", data => { if (!data?.length) return; if (data[0].role === "user") setIsLoadingSelfMessage(false); setMessages(prev => { const map = new Map(); prev .filter(m => !m.id.startsWith("sending-message-")) .forEach(m => map.set(m.id, m)); data.forEach(d => map.set(d.id, d)); return Array.from(map.values()).sort( (a, b) => new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime() ); }); if (options.onNewMessage) { options.onNewMessage(data[0]); } }); useSocketEvent("current_balance", b => { setBalance(b.data); // If auto top-up was in-flight, release the lock only after balance became positive if (autoTopUpInProgressRef.current && b?.data?.balance > 0) { autoTopUpInProgressRef.current = false; } }); useSocketEvent("balance_updated", b => { setBalance(prev => (prev ? { ...prev, balance: b.data.balance } : null)); if (autoTopUpInProgressRef.current && b?.data?.balance > 0) { autoTopUpInProgressRef.current = false; } }); useSocketEvent("session_started", s => setSession(s.data)); useSocketEvent("session_ended", () => setSession(null)); useSocketEvent("show_refill_modals", r => setRefillModals(r.data)); useSocketEvent("auto_topup_request", r => { if (!r?.data) return; if (isAutoTopUpLoading) return; // Prevent concurrent or rapid duplicate attempts if (autoTopUpInProgressRef.current) return; autoTopUpInProgressRef.current = true; // Trigger checkout silently handleSingleCheckout(r.data); }); useEffect(() => { if (!session?.maxFinishedAt) return; const finishAt = new Date(session.maxFinishedAt).getTime(); const now = Date.now(); const delay = finishAt - now; if (delay <= 0) { setIsSessionExpired(true); return; } const id = setTimeout(() => setIsSessionExpired(true), delay); return () => clearTimeout(id); }, [session?.maxFinishedAt]); useEffect(() => { if (!socket || status !== ESocketStatus.CONNECTED) return; // If a leave was scheduled for the same chat (StrictMode remount), cancel it if (leaveTimeoutRef.current && lastChatIdRef.current === chatId) { clearTimeout(leaveTimeoutRef.current); leaveTimeoutRef.current = null; } joinChat(); fetchBalance(); lastChatIdRef.current = chatId; return () => { // Debounce leave to avoid leaving room between StrictMode unmount/mount cycle // For real chat switch (different chatId), we won't cancel this in the next effect leaveTimeoutRef.current = setTimeout(() => { leaveChat(); }, 300); }; }, [socket, status, joinChat, leaveChat, fetchBalance, chatId]); // Re-join chat on socket reconnects while staying on the chat page useEffect(() => { if (!socket) return; const handleConnect = () => { // Ensure current chat is tracked and we actually reconnect to it lastChatIdRef.current = chatId; joinChat(); fetchBalance(); }; socket.on("connect", handleConnect); return () => { socket.off("connect", handleConnect); }; }, [socket, chatId, joinChat, fetchBalance]); useEffect(() => { setSuggestions(messages[0]?.suggestions); }, [messages, setSuggestions]); useEffect(() => { if (session && status === ESocketStatus.CONNECTED) { startBalancePolling(); } else { stopBalancePolling(); } return () => { stopBalancePolling(); }; }, [session, status, startBalancePolling, stopBalancePolling]); useEffect(() => { if (!balance) return; const hasBalance = balance.balance > 0; const hasSession = !!session; if (hasBalance && !hasSession) startSession(); if (!hasBalance && hasSession) endSession(); }, [balance, session, startSession, endSession]); useEffect(() => { if (!session) return; return () => { endSession(); }; }, [session, endSession]); const isAvailableChatting = !!balance?.balance && !!session && !isSessionExpired; return useMemo( () => ({ messages, balance, session, refillModals, suggestions, unreadMessagesCount, send, read, startSession, endSession, isLoadingSelfMessage, isLoadingAdvisorMessage, isAvailableChatting, isConnected: status === ESocketStatus.CONNECTED, loadOlder, isLoadingOlder, hasMoreOlderMessages, }), [ messages, balance, session, refillModals, suggestions, unreadMessagesCount, isLoadingSelfMessage, isLoadingAdvisorMessage, isAvailableChatting, status, loadOlder, isLoadingOlder, hasMoreOlderMessages, send, read, startSession, endSession, ] ); };