"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; import type { Conversation, Message, Contact, ConversationStatus } from "@/types"; import { useRealtime } from "@/hooks/use-realtime"; import { ConversationList } from "@/components/inbox/conversation-list"; import { MessageThread } from "@/components/inbox/message-thread"; import { ContactSidebar } from "@/components/inbox/contact-sidebar"; import { toast } from "sonner"; import { WifiOff } from "lucide-react"; import { cn } from "@/lib/utils"; export default function InboxPage() { const router = useRouter(); const searchParams = useSearchParams(); /** * `?c=` deep-link support. Used when landing here from the * dashboard's recent-conversations list so the right thread opens * automatically instead of showing the empty center panel. */ const deepLinkConvId = searchParams.get("c"); const [conversations, setConversations] = useState([]); const [activeConversation, setActiveConversation] = useState(null); const [activeContact, setActiveContact] = useState(null); const [messages, setMessages] = useState([]); const [whatsappConnected, setWhatsappConnected] = useState( null ); /** * Bumped whenever we want children (ConversationList, MessageThread) * to refetch from the DB — used as a safety net against missed * realtime events. Bumped on WS reconnect and on tab visibility → * visible. The initial mount fetches don't depend on this; they fire * once on conversationId-change as usual. */ const [resyncToken, setResyncToken] = useState(0); // Fire the deep-link auto-select exactly once per URL — subsequent // list refreshes (realtime, manual refetch) must not snap the user // back to the deep-linked conversation if they've already clicked // elsewhere. const autoSelectedForDeepLinkRef = useRef(null); // Tracks conversations whose hydrate fetch is currently in flight. The // conv-INSERT and the first-message-INSERT events both call into // hydrateConversation; the dedupe here keeps it at one refetch per // new conversation even when both events arrive within milliseconds. const hydratingConvIdsRef = useRef>(new Set()); /** * Synchronous mirror of the conversation ids currently in `conversations` * state. Event handlers need to know "do we already have this conv?" * without waiting for a setState updater to run — updaters fire during * reconciliation, *after* the synchronous handler code returns, so a * `let foundInList = false; setState(p => { foundInList = ...; return ... })` * flag reads as `false` in the same tick (this exact bug shipped in #105 * and caused #106: every incoming message and every status flip fired a * redundant DB hydrate, swamping the supabase client and starving the * realtime channel). The ref is kept in sync via the effect below. */ const knownConvIdsRef = useRef>(new Set()); useEffect(() => { const next = new Set(); for (const c of conversations) next.add(c.id); knownConvIdsRef.current = next; }, [conversations]); // Pull the conversation row with its `contact` joined and merge it // into state. Needed because Supabase Realtime payloads only carry the // row's own columns — a brand-new conversation arrives without a // contact, which surfaced as "Unknown" names, empty avatars, and // (when the conv-INSERT event was delayed past the message-INSERT) // conversations stuck on "No messages yet" until the user reloaded. // Also self-heals if a realtime event was missed: callers can invoke // this whenever they reference a conversation id they don't recognise. const hydrateConversation = useCallback(async (convId: string) => { if (hydratingConvIdsRef.current.has(convId)) return; hydratingConvIdsRef.current.add(convId); try { const supabase = createClient(); const { data, error } = await supabase .from("conversations") .select("*, contact:contacts(*)") .eq("id", convId) .maybeSingle(); if (error) { // Supabase errors have non-enumerable properties — log fields // explicitly so the console message isn't just `{}`. console.error("Failed to hydrate conversation:", { message: error.message, details: error.details, hint: error.hint, code: error.code, }); return; } if (!data) return; const fetched = data as Conversation; setConversations((prev) => { const existing = prev.find((c) => c.id === fetched.id); if (existing) { // Already in state — keep its fields (a realtime UPDATE may // have landed while the fetch was in flight and patched // last_message_text / unread_count to fresher values than // the row we just read). Only backfill `contact`, which the // realtime payloads never carry. return prev.map((c) => c.id === fetched.id ? { ...c, contact: c.contact ?? fetched.contact } : c, ); } return [fetched, ...prev]; }); } finally { hydratingConvIdsRef.current.delete(convId); } }, []); // Check WhatsApp connection status on mount useEffect(() => { const checkConnection = async () => { const supabase = createClient(); const { data: { session }, } = await supabase.auth.getSession(); const user = session?.user; if (!user) return; // Table is `whatsapp_config` (singular) — the previous "whatsapp_configs" // query always returned no rows, so the banner always showed "not connected". const { data } = await supabase .from("whatsapp_config") .select("status") .eq("user_id", user.id) .maybeSingle(); setWhatsappConnected(data?.status === "connected"); }; checkConnection(); }, []); // Handle realtime message events const handleMessageEvent = useCallback( (event: { eventType: string; new: Message; old: Partial }) => { const newMsg = event.new; if (event.eventType === "INSERT") { // Add to messages if it belongs to active conversation if ( activeConversation && newMsg.conversation_id === activeConversation.id ) { setMessages((prev) => { // Avoid duplicates if (prev.some((m) => m.id === newMsg.id)) return prev; // Replace optimistic message if it exists const withoutOptimistic = prev.filter( (m) => !m.id.startsWith("temp-") ); return [...withoutOptimistic, newMsg]; }); } // Update conversation list preview. We need to know *synchronously* // whether the conv is already in state to decide between patching // the preview and triggering a hydrate — see the comment on // knownConvIdsRef for why a closure flag inside the updater would // always read false here. if (knownConvIdsRef.current.has(newMsg.conversation_id)) { setConversations((prev) => prev.map((c) => c.id === newMsg.conversation_id ? { ...c, last_message_text: newMsg.content_text ?? "", last_message_at: newMsg.created_at, unread_count: activeConversation?.id === newMsg.conversation_id ? 0 : c.unread_count + 1, } : c, ), ); } else { // First time we're seeing this conv: the conv-INSERT event // hasn't landed yet, or was missed. Hydrate from the DB so // the row surfaces with its `contact` joined; the conv-UPDATE // event the webhook emits right after the message INSERT will // converge state when it arrives. hydrateConversation(newMsg.conversation_id); } } if (event.eventType === "UPDATE") { // Update message status setMessages((prev) => prev.map((m) => (m.id === newMsg.id ? { ...m, ...newMsg } : m)) ); } }, [activeConversation, hydrateConversation] ); // Handle realtime conversation events const handleConversationEvent = useCallback( (event: { eventType: string; new: Conversation; old: Partial; }) => { const conv = event.new; if (event.eventType === "INSERT") { // Prepend immediately for snappy UX so the new conv shows in the // list right away, then hydrate to fill in the `contact` join // (realtime payloads never include joins). Skip both if we // already have the row — that shouldn't happen normally, but // out-of-order delivery would have us prepending a duplicate. if (!knownConvIdsRef.current.has(conv.id)) { setConversations((prev) => { if (prev.some((c) => c.id === conv.id)) return prev; return [conv, ...prev]; }); hydrateConversation(conv.id); } } if (event.eventType === "UPDATE") { if (knownConvIdsRef.current.has(conv.id)) { setConversations((prev) => prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c)), ); } else { // UPDATE arrived before the INSERT (or after a missed INSERT) // — fetch the row so it surfaces with its contact joined. The // patch contained in `conv` will already be reflected in what // the hydrate fetch returns. hydrateConversation(conv.id); } // Update active conversation if it changed if (activeConversation && conv.id === activeConversation.id) { setActiveConversation((prev) => prev ? { ...prev, ...conv } : prev ); } } }, [activeConversation, hydrateConversation] ); // Subscribe to realtime. The `isConnected` flag below feeds the // reconnect resync: realtime is best-effort and events sent while the // WS was disconnected (laptop sleep, network blip, background-tab // throttle) are simply lost. We need a way to catch up. const { isConnected } = useRealtime({ channelName: "inbox-realtime", onMessageEvent: handleMessageEvent, onConversationEvent: handleConversationEvent, enabled: true, }); /** * Bump `resyncToken` whenever the realtime channel transitions from * disconnected → connected *after* the initial connect. The initial * connect is covered by the children's on-mount fetches; only later * reconnects need a manual refetch to fill the gap. * * Tracked via a `was-connected` ref rather than a count so that React * strict-mode's dev-only effect double-fire doesn't read as a * reconnect. */ const wasConnectedRef = useRef(false); const initialConnectDoneRef = useRef(false); useEffect(() => { if (isConnected && !wasConnectedRef.current) { // false → true transition if (initialConnectDoneRef.current) { setResyncToken((n) => n + 1); } else { initialConnectDoneRef.current = true; } } wasConnectedRef.current = isConnected; }, [isConnected]); /** * Refetch when the tab regains focus. Background tabs may have their * WS throttled by the browser even without a full disconnect, so a * visibilitychange → visible is a reliable signal that we may have * missed events. Cheap to fire; the children dedupe on their own. */ useEffect(() => { const onVisibility = () => { if (document.visibilityState === "visible") { setResyncToken((n) => n + 1); } }; document.addEventListener("visibilitychange", onVisibility); return () => { document.removeEventListener("visibilitychange", onVisibility); }; }, []); /** * Manual refresh trigger for the thread-header refresh button. * Bumps the same resyncToken the reconnect / visibility paths use, * so it goes through the existing dedupe & refetch plumbing — no * separate code path to keep in sync. */ const handleManualRefresh = useCallback(() => { setResyncToken((n) => n + 1); }, []); const handleConversationsLoaded = useCallback( (loaded: Conversation[]) => { setConversations(loaded); // Resolve a pending deep-link here rather than in an effect — this // is an event handler, so the setState calls below are allowed by // react-hooks/set-state-in-effect. Runs once per ?c= URL value // via the ref, so realtime refreshes of the list can't snap the // user back to the deep-linked thread after they've navigated. if ( deepLinkConvId && autoSelectedForDeepLinkRef.current !== deepLinkConvId && loaded.length > 0 ) { autoSelectedForDeepLinkRef.current = deepLinkConvId; // If the deep-linked conversation is already the active one // (e.g. because the user clicked it in the list and we // router.replace()'d the URL, which made the ConversationList // refetch and land us back here), do NOT re-apply it. Doing so // would setMessages([]) on a thread whose messages have // already been loaded by MessageThread — and because // conversationId didn't change, MessageThread wouldn't // refetch. The thread would read "No messages yet" until a // full page reload rehydrated state from scratch. if (activeConversation?.id === deepLinkConvId) return; const match = loaded.find((c) => c.id === deepLinkConvId); if (match) { setActiveConversation(match); setActiveContact(match.contact ?? null); setMessages([]); } } }, [deepLinkConvId, activeConversation?.id] ); const handleSelectConversation = useCallback( (conv: Conversation) => { // Re-clicking the already-active conversation would clear the // messages array, but the fetch effect in MessageThread only re-runs // when conversationId changes — so messages would stay empty until // the user navigated away and back. Bail out early instead. if (activeConversation?.id === conv.id) return; setActiveConversation(conv); setActiveContact(conv.contact ?? null); setMessages([]); // Record the selection on the deep-link ref BEFORE we change the // URL. The router.replace below flips `deepLinkConvId`, which can // in turn cause ConversationList to refetch and eventually call // handleConversationsLoaded again. Without this line, the ref // still points at the previous value, the auto-select block // sees `ref !== deepLinkConvId`, fires a second time, and // clobbers the messages MessageThread just fetched. autoSelectedForDeepLinkRef.current = conv.id; // Reflect the selection in the URL so a refresh lands the user // back in the same thread, and so copy-paste links work. Use // replace() to avoid polluting browser history with every click. router.replace(`/inbox?c=${conv.id}`, { scroll: false }); }, [activeConversation?.id, router] ); // Mobile "back" — deselect the conversation so the list pane comes // back. Also clears the ?c= param so a refresh lands on the list // instead of re-opening the thread the user just backed out of. const handleCloseConversation = useCallback(() => { setActiveConversation(null); setActiveContact(null); setMessages([]); // Clearing the ref lets the deep-link auto-selector fire again if // the user later visits /inbox?c= — desirable UX. autoSelectedForDeepLinkRef.current = null; router.replace("/inbox", { scroll: false }); }, [router]); const handleMessagesLoaded = useCallback((loaded: Message[]) => { setMessages(loaded); }, []); const handleNewMessage = useCallback((msg: Message) => { setMessages((prev) => { if (prev.some((m) => m.id === msg.id)) return prev; return [...prev, msg]; }); }, []); const handleUpdateMessage = useCallback( (id: string, updates: Partial) => { setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, ...updates } : m)) ); }, [] ); const handleStatusChange = useCallback( (conversationId: string, status: ConversationStatus) => { setConversations((prev) => prev.map((c) => (c.id === conversationId ? { ...c, status } : c)) ); if (activeConversation?.id === conversationId) { setActiveConversation((prev) => (prev ? { ...prev, status } : prev)); } }, [activeConversation] ); const handleAssignChange = useCallback( (conversationId: string, assignedAgentId: string | null) => { setConversations((prev) => prev.map((c) => c.id === conversationId ? { ...c, assigned_agent_id: assignedAgentId ?? undefined } : c ) ); if (activeConversation?.id === conversationId) { setActiveConversation((prev) => prev ? { ...prev, assigned_agent_id: assignedAgentId ?? undefined } : prev ); } }, [activeConversation] ); // On mobile ( {/* WhatsApp connection banner — in the flex column, not absolute, so it pushes the panels down instead of overlapping them. */} {whatsappConnected === false && (

WhatsApp® is not connected. Go to Settings to connect your account.

)}
{/* Left panel: Conversation list. Hidden on mobile when a conversation is selected so the thread can occupy the full width. Always visible on lg+. */}
{/* Center panel: Message thread. Hidden on mobile when no conversation is selected so the list can occupy the full width. Always visible on lg+ (shows its own empty-state if no thread is picked yet). */}
{/* Right panel: Contact sidebar — desktop only. */}
); }