"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 ); // 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()); // 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. If the conv isn't in state // yet (its INSERT event was missed, delayed, or this is the // first message for a conversation created out-of-band) the // map() below is a no-op and the preview would never appear — // hydrate the row from the DB instead so the inbox self-heals. let foundInList = false; setConversations((prev) => { if (!prev.some((c) => c.id === newMsg.conversation_id)) { return prev; } foundInList = true; return 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, ); }); if (!foundInList) { 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, then hydrate to fill in // the `contact` join (realtime payloads omit it). The dedupe // inside hydrateConversation keeps the result idempotent if // the first-message-INSERT handler also triggers a hydrate. setConversations((prev) => { if (prev.some((c) => c.id === conv.id)) return prev; return [conv, ...prev]; }); hydrateConversation(conv.id); } if (event.eventType === "UPDATE") { let foundInList = false; setConversations((prev) => { if (!prev.some((c) => c.id === conv.id)) return prev; foundInList = true; return prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c)); }); if (!foundInList) { // UPDATE arrived before the INSERT (or after a missed // INSERT) — fetch the row so it surfaces with its contact. 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 useRealtime({ channelName: "inbox-realtime", onMessageEvent: handleMessageEvent, onConversationEvent: handleConversationEvent, enabled: true, }); 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. */}
); }