Fix notification badge sticking after "Mark read"

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 23:46:39 +07:00
parent 244cfcf787
commit 96e2939451
2 changed files with 32 additions and 7 deletions

View file

@ -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.

View file

@ -13,9 +13,19 @@ export function useNotificationsRead() {
const key = user ? `notifications-last-read:${user.pubkey}` : 'notifications-last-read:anon';
const [lastRead, setLastRead] = useLocalStorage<number>(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<number>(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 };
}