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 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) {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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