diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index f7e531d..a5f9501 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -163,6 +163,16 @@ export default function InboxPage() { 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); @@ -171,7 +181,7 @@ export default function InboxPage() { } } }, - [deepLinkConvId] + [deepLinkConvId, activeConversation?.id] ); const handleSelectConversation = useCallback( @@ -184,6 +194,14 @@ export default function InboxPage() { 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. diff --git a/src/components/inbox/conversation-list.tsx b/src/components/inbox/conversation-list.tsx index fe9ae48..b582a53 100644 --- a/src/components/inbox/conversation-list.tsx +++ b/src/components/inbox/conversation-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { createClient } from "@/lib/supabase/client"; import { cn } from "@/lib/utils"; import type { Conversation, ConversationStatus } from "@/types"; @@ -46,32 +46,49 @@ export function ConversationList({ const [filter, setFilter] = useState("all"); const [loading, setLoading] = useState(true); - const fetchConversations = useCallback(async () => { - const supabase = createClient(); - const { data, error } = await supabase - .from("conversations") - .select("*, contact:contacts(*)") - .order("last_message_at", { ascending: false }); - - if (error) { - // Supabase errors have non-enumerable properties — log fields explicitly - console.error("Failed to fetch conversations:", { - message: error.message, - details: error.details, - hint: error.hint, - code: error.code, - }); - setLoading(false); - return; - } - - onConversationsLoaded(data ?? []); - setLoading(false); - }, [onConversationsLoaded]); + // Keep the latest callback in a ref so the fetch effect below can + // have a stable, empty-dep identity. Previously the fetch useCallback + // depended on `onConversationsLoaded`, which depends on the parent's + // `deepLinkConvId` — so every URL change (including one the parent + // triggered via router.replace after a click) caused a fresh + // conversations fetch. That extra refetch was the trigger for the + // deep-link auto-select running a second time and wiping the active + // thread's messages. + const onConversationsLoadedRef = useRef(onConversationsLoaded); + onConversationsLoadedRef.current = onConversationsLoaded; useEffect(() => { - fetchConversations(); - }, [fetchConversations]); + const supabase = createClient(); + let cancelled = false; + + (async () => { + const { data, error } = await supabase + .from("conversations") + .select("*, contact:contacts(*)") + .order("last_message_at", { ascending: false }); + + if (cancelled) return; + + if (error) { + // Supabase errors have non-enumerable properties — log fields explicitly + console.error("Failed to fetch conversations:", { + message: error.message, + details: error.details, + hint: error.hint, + code: error.code, + }); + setLoading(false); + return; + } + + onConversationsLoadedRef.current(data ?? []); + setLoading(false); + })(); + + return () => { + cancelled = true; + }; + }, []); const filtered = useMemo(() => { let result = conversations;