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 }; }