From c2ef80c7dfcf3b256f412f71819506c928f413e1 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 17 Apr 2026 21:17:28 +0300 Subject: [PATCH] fix(inbox): reliable unread reset + sidebar green-dot indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues: 1. Unread count didn't always clear when opening / viewing a conversation. The reset effect keyed off conversationId only, so a new inbound message arriving while the thread was already open would get the server to bump unread_count to 1, but the reset effect wouldn't re-fire. Split the fetch and unread-reset effects: fetch stays keyed on conversationId (no unnecessary refetches on every new message), and a second effect keyed on [conversationId, hasUnread] flips unread back to 0 whenever it surfaces on the active conversation. The hasUnread gate still prevents the reset→update→realtime→re-run loop. 2. No indicator elsewhere in the app when new messages arrive. Added useTotalUnread() hook — one-time query + realtime subscription on the conversations table — and a pulsing green dot on the sidebar Inbox entry when totalUnread > 0. Dot is hidden while on /inbox itself (redundant with the per-conversation badges there). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/inbox/message-thread.tsx | 40 ++++++++----- src/components/layout/sidebar.tsx | 20 ++++++- src/hooks/use-total-unread.ts | 74 +++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 src/hooks/use-total-unread.ts diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index f499428..a4a402e 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -114,6 +114,10 @@ export function MessageThread({ const conversationId = conversation?.id; const hasUnread = (conversation?.unread_count ?? 0) > 0; + // Fetch messages whenever the selected conversation changes. Kept + // separate from the unread-reset effect so that incoming messages + // arriving while the thread is open don't trigger a full refetch — + // they only flip hasUnread, which only the reset effect listens to. useEffect(() => { if (!conversationId) return; @@ -137,29 +141,35 @@ export function MessageThread({ onMessagesLoadedRef.current(data ?? []); } - // Only issue the unread_count reset when there's actually something - // to reset. Unconditional updates fire a realtime UPDATE event every - // time, which — combined with the parent's conversation-event handler - // — used to retrigger this effect in a loop. - if (hasUnread) { - await supabase - .from("conversations") - .update({ unread_count: 0 }) - .eq("id", conversationId); - } - if (!cancelled) setLoading(false); })(); return () => { cancelled = true; }; - // Re-fetch only when the selected conversation changes. `hasUnread` - // is used inside but is a boolean derived from conversation; the - // conversationId dep is the real trigger. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [conversationId]); + // Reset the server-side unread_count to 0 whenever an unread count + // surfaces on the active conversation — covers both (a) opening a + // conversation that had unread messages and (b) new messages arriving + // while the user is already viewing the thread (webhook server-bumps + // unread_count to N+1; the realtime UPDATE propagates it into the + // client, which re-runs this effect and flips it back to 0). + // + // Guarding on hasUnread prevents the eq-update loop: once unread_count + // is 0 the condition is false, so no further UPDATE is issued. + useEffect(() => { + if (!conversationId || !hasUnread) return; + const supabase = createClient(); + supabase + .from("conversations") + .update({ unread_count: 0 }) + .eq("id", conversationId) + .then(({ error }) => { + if (error) console.error("Failed to reset unread_count:", error); + }); + }, [conversationId, hasUnread]); + // Auto-scroll to bottom on new messages useEffect(() => { if (scrollRef.current) { diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index d7cf3b6..bf058ff 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; +import { useTotalUnread } from "@/hooks/use-total-unread"; import { LayoutDashboard, MessageSquare, @@ -31,6 +32,7 @@ const bottomNavItems = [ export function Sidebar() { const pathname = usePathname(); const { profile, signOut } = useAuth(); + const totalUnread = useTotalUnread(); return (