fix(inbox): reliable unread reset + sidebar green-dot indicator

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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-17 21:17:28 +03:00
parent a5f2a40b37
commit c2ef80c7df
No known key found for this signature in database
3 changed files with 118 additions and 16 deletions

View File

@ -114,6 +114,10 @@ export function MessageThread({
const conversationId = conversation?.id; const conversationId = conversation?.id;
const hasUnread = (conversation?.unread_count ?? 0) > 0; 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(() => { useEffect(() => {
if (!conversationId) return; if (!conversationId) return;
@ -137,29 +141,35 @@ export function MessageThread({
onMessagesLoadedRef.current(data ?? []); 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); if (!cancelled) setLoading(false);
})(); })();
return () => { return () => {
cancelled = true; 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]); }, [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 // Auto-scroll to bottom on new messages
useEffect(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {

View File

@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread";
import { import {
LayoutDashboard, LayoutDashboard,
MessageSquare, MessageSquare,
@ -31,6 +32,7 @@ const bottomNavItems = [
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { profile, signOut } = useAuth(); const { profile, signOut } = useAuth();
const totalUnread = useTotalUnread();
return ( return (
<aside className="flex h-screen w-60 flex-col border-r border-slate-800 bg-slate-900"> <aside className="flex h-screen w-60 flex-col border-r border-slate-800 bg-slate-900">
@ -50,6 +52,13 @@ export function Sidebar() {
pathname === item.href || pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href)); (item.href !== "/dashboard" && pathname.startsWith(item.href));
// Green dot on the Inbox entry when there are unread
// conversations AND the user isn't currently in /inbox (they'd
// be seeing the unread badges on the conversation list there,
// so the sidebar dot would be redundant).
const showUnreadDot =
item.href === "/inbox" && totalUnread > 0 && !isActive;
return ( return (
<li key={item.href}> <li key={item.href}>
<Link <Link
@ -62,7 +71,16 @@ export function Sidebar() {
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
{item.label} <span className="flex-1">{item.label}</span>
{showUnreadDot && (
<span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
className="relative flex h-2 w-2"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
)}
</Link> </Link>
</li> </li>
); );

View File

@ -0,0 +1,74 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createClient } from "@/lib/supabase/client";
import type { Conversation } from "@/types";
/**
* Count of conversations with at least one unread inbound message for
* the current user. Used by the sidebar to surface a green dot on the
* Inbox nav entry when the user is elsewhere in the app.
*
* Lives on its own realtime channel (distinct from the inbox page's
* "inbox-realtime") so both can coexist without sharing state.
*/
export function useTotalUnread(): number {
const [total, setTotal] = useState(0);
// Keep a live local mirror of {id: unread_count} so INSERT/UPDATE/DELETE
// events can adjust the total in O(1) without refetching.
const countsRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
const supabase = createClient();
let cancelled = false;
// Initial load. RLS scopes this to the signed-in user automatically —
// no explicit user_id filter needed here.
(async () => {
const { data, error } = await supabase
.from("conversations")
.select("id, unread_count");
if (cancelled || error || !data) return;
const map = new Map<string, number>();
let sum = 0;
for (const row of data as { id: string; unread_count: number }[]) {
const n = row.unread_count ?? 0;
map.set(row.id, n);
if (n > 0) sum += 1;
}
countsRef.current = map;
setTotal(sum);
})();
const channel = supabase
.channel("total-unread-realtime")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "conversations" },
(payload) => {
const map = countsRef.current;
if (payload.eventType === "DELETE") {
const oldRow = payload.old as Partial<Conversation>;
if (oldRow.id) map.delete(oldRow.id);
} else {
const row = payload.new as Conversation;
map.set(row.id, row.unread_count ?? 0);
}
// Recompute — cheap, conversations per user stay small.
let sum = 0;
for (const n of map.values()) if (n > 0) sum += 1;
setTotal(sum);
},
)
.subscribe();
return () => {
cancelled = true;
supabase.removeChannel(channel);
};
}, []);
return total;
}