Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions web/src/app/community/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion web/src/app/community/notifications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
96 changes: 96 additions & 0 deletions web/src/components/community/NotificationsList.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;

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<Response>((resolve) => (answer = resolve)));
render(<NotificationsList items={[alert("1", true), alert("2", false)]} />);

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(<NotificationsList items={[alert("1", true)]} />);
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(<NotificationsList items={[alert("1", true)]} />);
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(<NotificationsList items={[alert("1", false)]} />);
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(<NotificationsList items={[alert("1", true), alert("2", false)]} />);
await waitFor(() => expect(router.refresh).toHaveBeenCalledTimes(1));

// What the refresh brings back: the same rows, all read now.
rerender(<NotificationsList items={[alert("1", false), alert("2", false)]} />);

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);
});
36 changes: 29 additions & 7 deletions web/src/components/community/NotificationsList.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 (
Expand All @@ -99,7 +121,7 @@ export default function NotificationsList({ items }: { items: NotificationView[]
<Link
href={targetHref(item)}
className={styles.notifyRow}
data-unread={item.unread}
data-unread={item.unread || unreadOnArrival.has(item.id)}
>
<span className={styles.notifyMark} aria-hidden="true" />
{/* Which pattern it happened to, before the sentence saying what.
Expand Down
163 changes: 163 additions & 0 deletions web/src/lib/community/server/queries/notifications.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, expect, it } from "vitest";

// The header badge and the alerts page read the same rows. The badge was a
// bare COUNT and the page dropped rows after reading them, so on 26 October
// bea's header said "Alerts 1" over an empty page: the pattern she had
// commented on went private, and its owner answered her there. A workshop
// alert left in the table made it "Alerts 2". Driven through the real comment
// and edit routes, with real sessions, on a database of its own.

const ENV_KEYS = ["COMMUNITY_DB_PATH", "COMMUNITY_ENABLED", "BETTER_AUTH_SECRET"] as const;
const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
let tmp = "";

let ada: { id: string; cookie: string };
let bea: { id: string; cookie: string };
let comment: (cookie: string, patternId: string, body: string) => Promise<number>;
let edit: (cookie: string, patternId: string, body: Record<string, unknown>) => Promise<number>;
let leaveWorkshopAlert: (userId: string, actorId: string) => Promise<void>;
/** What the header would print, and how many unread rows the page would list. */
let seen: (userId: string, moderator?: boolean) => Promise<{ badge: number; unread: number }>;

beforeAll(async () => {
// The database is a lazy singleton keyed off these variables, so they are
// set before anything imports it.
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-notifications-"));
process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db");
process.env.COMMUNITY_ENABLED = "1";
process.env.BETTER_AUTH_SECRET = "notifications-test-secret-not-a-real-one";

const { eq } = await import("drizzle-orm");
const { getAuth } = await import("@/lib/community/server/auth");
const { getDb } = await import("@/lib/community/server/db");
const schema = await import("@/lib/community/server/schema");
const { newId } = await import("@/lib/community/server/queries");
const { countUnreadNotifications, listNotifications } = await import("./notifications");
const commentRoute = await import("@/app/api/community/patterns/[id]/comments/route");
const patternRoute = await import("@/app/api/community/patterns/[id]/route");

const db = getDb();
const auth = getAuth();

const enrol = async (username: string) => {
const response = await auth.api.signUpEmail({
body: {
email: `${username}@patternflow.local`,
password: "notifications-test-password",
name: username,
username,
},
asResponse: true,
});
const setCookie = response.headers.get("set-cookie");
if (!setCookie) throw new Error(`no session cookie for ${username}`);
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1);
return { id: row!.id, cookie: setCookie.split(";")[0] };
};

ada = await enrol("ada");
bea = await enrol("bea");

const now = new Date();
await db.insert(schema.patterns).values({
id: "p-tide",
userId: ada.id,
title: "Tide",
code: "function draw() {}",
license: "CC-BY-SA-4.0",
visibility: "public",
createdAt: now,
updatedAt: now,
});

// No Origin header below, which is the same-origin case originBlocked()
// lets through.
comment = async (cookie, patternId, body) => {
const response = await commentRoute.POST(
new Request(`http://localhost:3000/api/community/patterns/${patternId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({ body }),
}),
{ params: Promise.resolve({ id: patternId }) },
);
return response.status;
};

edit = async (cookie, patternId, body) => {
const response = await patternRoute.PATCH(
new Request(`http://localhost:3000/api/community/patterns/${patternId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ id: patternId }) },
);
return response.status;
};

// Nothing writes these any more. One left over from before 26 October is
// how a workshop alert reaches the table now.
leaveWorkshopAlert = async (userId, actorId) => {
await db.insert(schema.notifications).values({
id: newId(),
userId,
type: "comment",
actorId,
targetType: "post",
targetId: "post-1",
targetTitle: "Show your build",
createdAt: new Date(),
});
};

seen = async (userId, moderator = false) => ({
badge: await countUnreadNotifications(userId, { moderator }),
unread: (await listNotifications(userId, { moderator })).filter((row) => row.readAt === null)
.length,
});
}, 30_000);

afterAll(() => {
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
});

it("counts a comment on a public pattern, and the page lists it", async () => {
expect(await comment(bea.cookie, "p-tide", "Lovely swell.")).toBe(201);
expect(await seen(ada.id)).toEqual({ badge: 1, unread: 1 });
}, 30_000);

it("stops counting an alert once its pattern goes private, as the page stops listing it", async () => {
expect(await edit(ada.cookie, "p-tide", { visibility: "private" })).toBe(200);
// Ada can still open her own private pattern, and answers bea under it.
expect(await comment(ada.cookie, "p-tide", "Thank you!")).toBe(201);

// Bea's alert points at a page that would 404 for her.
expect(await seen(bea.id)).toEqual({ badge: 0, unread: 0 });
// Ada's own alert on her own pattern stays.
expect(await seen(ada.id)).toEqual({ badge: 1, unread: 1 });
// A moderator opens every private page, so theirs would stay too.
expect(await seen(bea.id, true)).toEqual({ badge: 1, unread: 1 });
}, 30_000);

it("does not count a workshop alert, which has nowhere left to link", async () => {
await leaveWorkshopAlert(bea.id, ada.id);
expect(await seen(bea.id)).toEqual({ badge: 0, unread: 0 });
expect(await seen(bea.id, true)).toEqual({ badge: 1, unread: 1 });
}, 30_000);

it("counts the pattern's alerts again once it is public again", async () => {
expect(await edit(ada.cookie, "p-tide", { visibility: "public" })).toBe(200);
expect(await seen(bea.id)).toEqual({ badge: 1, unread: 1 });
}, 30_000);
Loading
Loading