diff --git a/web/src/app/community/layout.tsx b/web/src/app/community/layout.tsx index 65d80ace4..a3fe58ff8 100644 --- a/web/src/app/community/layout.tsx +++ b/web/src/app/community/layout.tsx @@ -66,8 +66,12 @@ export default async function CommunityLayout({ children }: { children: React.Re const isAdmin = isAdminSession(session); const openReports = isAdmin ? await countOpenReports() : 0; // Read fresh on every page load — that is the whole notification system's - // idea of "delivery" on a server-rendered site. - const unread = session ? await countUnreadNotifications(session.user.id) : 0; + // idea of "delivery" on a server-rendered site. A layout is not rendered + // again when you move between the pages under it, so the alerts page asks + // for a refresh once it has marked everything read. + const unread = session + ? await countUnreadNotifications(session.user.id, { moderator: isAdmin }) + : 0; const username = (session?.user as { username?: string } | undefined)?.username ?? null; diff --git a/web/src/app/community/notifications/page.tsx b/web/src/app/community/notifications/page.tsx index 6874befec..9c373c634 100644 --- a/web/src/app/community/notifications/page.tsx +++ b/web/src/app/community/notifications/page.tsx @@ -10,7 +10,8 @@ import styles from "@/components/community/Community.module.css"; // What happened to your things while you were away: comments on your work, // threads you joined, forks, and decks your patterns entered. Opening this // page marks everything read (the client fires that after render — never -// during it, or a link prefetch would eat the unread state). +// during it, or a link prefetch would eat the unread state), then refreshes +// so the badge in the header drops to nothing. export const dynamic = "force-dynamic"; diff --git a/web/src/components/community/NotificationsList.test.tsx b/web/src/components/community/NotificationsList.test.tsx new file mode 100644 index 000000000..9c46493e3 --- /dev/null +++ b/web/src/components/community/NotificationsList.test.tsx @@ -0,0 +1,96 @@ +// The alerts page marks everything read once it is on screen. The header +// badge lives in the community layout, which a move between the pages under +// it does not render again, so "Alerts 1" stayed on this page and on every +// page after it until a reload. The list now asks for a refresh once the +// server has said yes, and keeps the rows it came in with highlighted. + +import { render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import NotificationsList, { type NotificationView } from "./NotificationsList"; + +// One router for the whole page, as the app router hands out. +const router = vi.hoisted(() => ({ refresh: vi.fn(), push: vi.fn() })); +vi.mock("next/navigation", () => ({ useRouter: () => router })); +vi.mock("./PatternCanvas", () => ({ default: () => null })); + +const alert = (id: string, unread: boolean): NotificationView => ({ + id, + type: "comment", + targetType: "pattern", + targetId: `p-${id}`, + targetTitle: `Tide ${id}`, + snippet: null, + unread, + createdAt: "2026-10-26T00:00:00.000Z", + actor: "bea", +}); + +let fetchMock: ReturnType; + +beforeEach(() => { + router.refresh.mockReset(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const ok = () => Promise.resolve(new Response(JSON.stringify({ ok: true }), { status: 200 })); +const row = (title: string) => screen.getByText(`“${title}”`, { exact: false }).closest("a"); + +it("refreshes once the server has marked the alerts read", async () => { + let answer: (response: Response) => void = () => {}; + fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve))); + render(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toContain("/api/community/notifications/read"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "POST" }); + // Not before the answer: a refresh then would count the same unread row. + expect(router.refresh).not.toHaveBeenCalled(); + + answer(new Response(JSON.stringify({ ok: true }), { status: 200 })); + await waitFor(() => expect(router.refresh).toHaveBeenCalledTimes(1)); +}); + +it("keeps the badge when the server refuses", async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve(new Response(JSON.stringify({ error: "Sign in first." }), { status: 401 })), + ); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await Promise.resolve(); + expect(router.refresh).not.toHaveBeenCalled(); +}); + +it("keeps the badge when the call never reaches the server", async () => { + fetchMock.mockImplementationOnce(() => Promise.reject(new TypeError("Failed to fetch"))); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await Promise.resolve(); + expect(router.refresh).not.toHaveBeenCalled(); +}); + +it("does not refresh when nothing was unread", async () => { + fetchMock.mockImplementation(ok); + render(); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await Promise.resolve(); + expect(router.refresh).not.toHaveBeenCalled(); +}); + +it("keeps the rows that were unread highlighted after the refresh, and marks read once", async () => { + fetchMock.mockImplementation(ok); + const { rerender } = render(); + await waitFor(() => expect(router.refresh).toHaveBeenCalledTimes(1)); + + // What the refresh brings back: the same rows, all read now. + rerender(); + + expect(row("Tide 1")?.getAttribute("data-unread")).toBe("true"); + expect(row("Tide 2")?.getAttribute("data-unread")).toBe("false"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(router.refresh).toHaveBeenCalledTimes(1); +}); diff --git a/web/src/components/community/NotificationsList.tsx b/web/src/components/community/NotificationsList.tsx index 94a373b31..9a12516d3 100644 --- a/web/src/components/community/NotificationsList.tsx +++ b/web/src/components/community/NotificationsList.tsx @@ -1,14 +1,16 @@ "use client"; import Link from "next/link"; -import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import PatternCanvas from "./PatternCanvas"; import styles from "./Community.module.css"; // The alerts list. Unread rows keep their marker for this visit — the -// mark-read call fires once after mount and nothing re-renders, so what you -// came to see stays highlighted until you leave. +// mark-read call fires once after mount, and the refresh after it brings +// every row back read, so the list remembers which ones were unread when +// you arrived. What you came to see stays highlighted until you leave. export type NotificationView = { id: string; @@ -68,13 +70,33 @@ function describe(item: NotificationView): string { } export default function NotificationsList({ items }: { items: NotificationView[] }) { + const router = useRouter(); + const [unreadOnArrival] = useState( + () => new Set(items.filter((item) => item.unread).map((item) => item.id)), + ); + // Once per visit. The refresh hands this list new props, and a second + // mark-read would ask for a second refresh. + const marked = useRef(false); + useEffect(() => { - // Fire-and-forget: the badge clears on the next server-rendered page. + // The header badge belongs to the community layout, and a layout is not + // rendered again when you move between the pages under it. Until 26 + // October 2026 "Alerts 1" stayed on this page and on every page after it + // until a reload. Once the server has marked everything read, refresh: + // the layout counts again and the badge goes, on whichever page you are + // on by then. With nothing unread there is no badge to clear. A failed + // call leaves the badge as it was, which is what is true. + if (marked.current) return; + marked.current = true; void fetch(communityApiUrl("/api/community/notifications/read"), { method: "POST", ...COMMUNITY_FETCH_INIT, - }).catch(() => undefined); - }, []); + }) + .then((response) => { + if (response.ok && unreadOnArrival.size > 0) router.refresh(); + }) + .catch(() => undefined); + }, [router, unreadOnArrival]); if (items.length === 0) { return ( @@ -99,7 +121,7 @@ export default function NotificationsList({ items }: { items: NotificationView[]