From 96e29394510a82fd20fc47b44cef7d935034e3cd Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 23:46:39 +0700 Subject: [PATCH] Fix notification badge sticking after "Mark read" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge counted ALL actionable crew items (invites, join requests, pending confirmations) regardless of read state, so "Mark read" (and even opening the bell) never cleared it. Now the badge counts only items newer than a per-account "seen at" timestamp that "Mark read"/open updates. Actionable crew cards still render in their tab until acted upon — only the red badge clears — which is the expected behavior. Co-authored-by: shakespeare.diy --- src/components/NotificationBell.tsx | 21 ++++++++++++++++++--- src/hooks/useNotificationsRead.ts | 18 ++++++++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/components/NotificationBell.tsx b/src/components/NotificationBell.tsx index d0e1804..a3c81c1 100644 --- a/src/components/NotificationBell.tsx +++ b/src/components/NotificationBell.tsx @@ -203,18 +203,33 @@ export function NotificationBell({ className }: { className?: string }) { const { user, invites, listedCrews, joinRequests, collectiveRequests, isLoading: crewLoading } = useCrewNotifications(); const { notifications: activity, isLoading: activityLoading } = useActivityNotifications(); - const { lastRead, markAllRead } = useNotificationsRead(); + const { lastRead, seenAt, markAllRead } = useNotificationsRead(); // Newsletter mailbox — only nags subscribers, but always browsable in the tab. const { data: newsletterIssues = [], isLoading: newsletterLoading } = useNewsletterIssues(); const { lastRead: newsletterLastRead, markNewsletterRead } = useNewsletterRead(); const { isSubscribed } = useNewsletterSubscription(); + // Total actionable crew items — these always RENDER in the Crews/All tabs + // until the user acts on them (accept/decline/confirm). const crewTotal = useMemo( () => invites.length + listedCrews.length + joinRequests.length + collectiveRequests.length, [invites, listedCrews, joinRequests, collectiveRequests], ); + // Crew items that count toward the BADGE = ones the user hasn't acknowledged + // yet (newer than the last "Mark read"/open). This lets "Mark read" clear the + // badge while the actionable cards stay visible in their tab. + const unreadCrew = useMemo(() => { + const ts = (n: { createdAt?: number }) => n.createdAt ?? 0; + let count = 0; + for (const inv of invites) if (ts(inv) > seenAt) count++; + for (const crew of listedCrews) if (ts(crew) > seenAt) count++; + for (const { request } of joinRequests) if (ts(request) > seenAt) count++; + for (const req of collectiveRequests) if (ts(req) > seenAt) count++; + return count; + }, [invites, listedCrews, joinRequests, collectiveRequests, seenAt]); + // Unread social items = activity newer than the last time the bell was opened. const unreadActivity = useMemo( () => activity.filter((n) => n.createdAt > lastRead).length, @@ -230,8 +245,8 @@ export function NotificationBell({ className }: { className?: string }) { [isSubscribed, newsletterIssues, newsletterLastRead], ); - // Crew items are always "actionable" so they always count toward the badge. - const badgeTotal = crewTotal + unreadActivity + unreadNewsletter; + // Badge = only UNACKNOWLEDGED items, so "Mark read" actually clears it. + const badgeTotal = unreadCrew + unreadActivity + unreadNewsletter; // Opening the bell marks social + crew as read (and newsletter, since the // News tab is right there). Newsletter read state also clears on the /newsletter page. diff --git a/src/hooks/useNotificationsRead.ts b/src/hooks/useNotificationsRead.ts index 34df4cc..df57155 100644 --- a/src/hooks/useNotificationsRead.ts +++ b/src/hooks/useNotificationsRead.ts @@ -13,9 +13,19 @@ export function useNotificationsRead() { const key = user ? `notifications-last-read:${user.pubkey}` : 'notifications-last-read:anon'; const [lastRead, setLastRead] = useLocalStorage(key, 0); - const markAllRead = useCallback(() => { - setLastRead(Math.floor(Date.now() / 1000)); - }, [setLastRead]); + // Separately track when the user last dismissed the BADGE for actionable + // items (crew invites, join requests, pending confirmations). Those items + // stay visible in their tab until acted upon, but "Mark read" should still + // clear the red badge — otherwise the badge sticks forever and "Mark read" + // appears broken. We compare item timestamps against this to decide badging. + const seenKey = user ? `notifications-seen-at:${user.pubkey}` : 'notifications-seen-at:anon'; + const [seenAt, setSeenAt] = useLocalStorage(seenKey, 0); - return { lastRead, markAllRead }; + const markAllRead = useCallback(() => { + const now = Math.floor(Date.now() / 1000); + setLastRead(now); + setSeenAt(now); + }, [setLastRead, setSeenAt]); + + return { lastRead, seenAt, markAllRead }; }