mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
a5f2a40b37
commit
c2ef80c7df
@ -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) {
|
||||
|
||||
@ -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 (
|
||||
<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 ||
|
||||
(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 (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
@ -62,7 +71,16 @@ export function Sidebar() {
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</li>
|
||||
);
|
||||
|
||||
74
src/hooks/use-total-unread.ts
Normal file
74
src/hooks/use-total-unread.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user