import { useCallback, useEffect, useRef, useState } from "react"; import { ApiError, fetchAPI } from "@/lib/fetch"; import type { Message } from "@/types/type"; // Poll cadence for new messages. Staggered away from the ride-status poll // (3s) and the call poll (2s) so the two tabs don't hammer the DB in lockstep. const POLL_MS = 2500; type UseChatResult = { messages: Message[]; loading: boolean; sending: boolean; error: string | null; sendMessage: (body: string) => Promise; reload: () => Promise; }; // Ride-scoped chat. Fetches the full history once, then polls for messages // with id greater than the last one seen. Optimistic on send: the row the // server returns is appended immediately, so the bubble appears before the // next poll. `role` is the caller's role ("rider" | "driver") and is only // used by the screen to align bubbles — the hook itself doesn't need it, but // it takes it so the screen has one source of truth for the conversation. export const useChat = ( rideId: number | null, role: "rider" | "driver" | null, ): UseChatResult => { const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [sending, setSending] = useState(false); const [error, setError] = useState(null); // Last message id we've seen — the polling cursor. Kept in a ref so the // interval closure always reads the latest value without re-arming. const cursorRef = useRef(0); const loadInitial = useCallback(async (id: number) => { setLoading(true); setError(null); try { const res = await fetchAPI(`/(api)/ride/${id}/messages`); const rows = (res.data ?? []) as Message[]; setMessages(rows); cursorRef.current = rows.length ? rows[rows.length - 1].id : 0; } catch (err) { console.log("[CHAT_LOAD]: ", err); setError("chat.loadError"); } finally { setLoading(false); } }, []); const poll = useCallback(async (id: number) => { try { const res = await fetchAPI( `/(api)/ride/${id}/messages?since=${cursorRef.current}`, ); const rows = (res.data ?? []) as Message[]; if (rows.length) { setMessages((prev) => [...prev, ...rows]); cursorRef.current = rows[rows.length - 1].id; } } catch (err) { // Swallow poll errors — a transient blip shouldn't wipe the list or // flash an error banner; the next tick will retry. console.log("[CHAT_POLL]: ", err); } }, []); // Initial load + polling lifecycle. Re-arms when the ride id changes. useEffect(() => { if (rideId === null) { setMessages([]); cursorRef.current = 0; return; } void loadInitial(rideId); const timer = setInterval(() => void poll(rideId), POLL_MS); return () => clearInterval(timer); }, [rideId, loadInitial, poll]); const sendMessage = useCallback( async (body: string) => { if (rideId === null) return; const text = body.trim(); if (!text || sending) return; setSending(true); try { const res = await fetchAPI(`/(api)/ride/${rideId}/messages`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: text }), }); const message = res.data as Message; setMessages((prev) => [...prev, message]); cursorRef.current = Math.max(cursorRef.current, message.id); } catch (err) { if (err instanceof ApiError && err.status === 409) { // Ride no longer active — surface that specifically so the UI can // disable the input instead of retrying forever. setError("chat.cannotMessage"); } else { console.log("[CHAT_SEND]: ", err); setError("chat.sendError"); } } finally { setSending(false); } }, [rideId, sending], ); const reload = useCallback(async () => { if (rideId !== null) await loadInitial(rideId); }, [rideId, loadInitial]); // `role` is accepted for API symmetry but the hook doesn't read it; keep the // param so the screen's single conversation object carries the caller's role. void role; return { messages, loading, sending, error, sendMessage, reload }; };