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
10 changes: 8 additions & 2 deletions web/src/app/community/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import Link from "next/link";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { FIRMWARE_NOTICE_COOKIE, firmwareNoticeDismissed } from "@/lib/community/firmwareNotice";
import { isAdminSession } from "@/lib/community/server/admin";
import { getAuth } from "@/lib/community/server/auth";
import { communityEnabled, communityHomeUrl } from "@/lib/community/server/db";
Expand Down Expand Up @@ -74,14 +75,19 @@ export default async function CommunityLayout({ children }: { children: React.Re
: 0;
const username =
(session?.user as { username?: string } | undefined)?.username ?? null;
// Read here so the firmware notice is in the server's HTML exactly when it
// will stay on screen (FirmwareNotice.tsx).
const noticeDismissed = firmwareNoticeDismissed(
(await cookies()).get(FIRMWARE_NOTICE_COOKIE)?.value,
);

return (
<main className={styles.page}>
<div className={styles.inner}>
{/* Above the header on purpose. A board owner needs to see this once;
putting it below the nav would let it sit under the fold on the
page they actually came for. */}
<FirmwareNotice />
<FirmwareNotice serverDismissed={noticeDismissed} />
<header className={styles.pageHeader}>
<div className={styles.brandBlock}>
<Link href="/" prefetch={false} className={styles.brand}>
Expand Down
103 changes: 103 additions & 0 deletions web/src/components/community/FirmwareNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// The server drew no firmware notice, because only the browser knew whether
// it had been dismissed. For everyone who had not, the banner came in at
// hydration and pushed the page down, 119px on a phone (CLS 0.141 at 390px,
// 26 October). A dismissal now goes into a cookie as well, and the community
// layout reads it, so the banner is in the server's HTML when it will stay.

import { act, fireEvent, render, screen } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CommunityLayout from "@/app/community/layout";
import { FIRMWARE_NOTICE_COOKIE as KEY, FIRMWARE_NOTICE_VERSION as LATEST } from "@/lib/community/firmwareNotice";
import FirmwareNotice from "./FirmwareNotice";

const jar = vi.hoisted(() => new Map<string, string>());

vi.mock("next/headers", () => ({
headers: async () => new Headers({ host: "localhost:3100" }),
cookies: async () => ({
get: (name: string) => (jar.has(name) ? { name, value: jar.get(name) } : undefined),
}),
}));
vi.mock("@/lib/community/server/db", () => ({
communityEnabled: () => true,
communityHomeUrl: () => "http://localhost:3100",
}));
vi.mock("@/lib/community/server/auth", () => ({
getAuth: () => ({ api: { getSession: async () => null } }),
}));
vi.mock("@/lib/community/server/admin", () => ({ isAdminSession: () => false }));
vi.mock("@/lib/community/server/queries", () => ({
countOpenReports: async () => 0,
countUnreadNotifications: async () => 0,
}));
// The rest of the header has tests of its own.
vi.mock("@/components/community/AuthStatus", () => ({ default: () => null }));
vi.mock("@/components/community/CommunityNav", () => ({ default: () => null }));
vi.mock("@/components/community/DeckDock", () => ({ default: () => null }));
vi.mock("@/components/community/DeckPanel", () => ({ default: () => null }));
vi.mock("@/components/community/MobileTabBar", () => ({ default: () => null }));

const BANNER = "Pick an edition";

async function serverHtml(): Promise<string> {
return renderToString(await CommunityLayout({ children: <p>wall</p> }));
}

beforeEach(() => {
// The cookie is scoped to /community, so document.cookie only shows it on
// a community page.
window.history.replaceState(null, "", "/community/patterns");
});

afterEach(() => {
jar.clear();
document.cookie = `${KEY}=; Path=/community; Max-Age=0`;
window.localStorage.clear();
});

describe("the community layout's HTML", () => {
it("has the notice when there is no cookie", async () => {
const html = await serverHtml();
expect(html).toContain(BANNER);
expect(html).toContain("wall");
});

it("leaves it out when the cookie dismisses this release", async () => {
jar.set(KEY, LATEST);
expect(await serverHtml()).not.toContain(BANNER);
});

it("has it again for a cookie an earlier release left", async () => {
jar.set(KEY, "v3.10.4");
expect(await serverHtml()).toContain(BANNER);
});
});

describe("dismissing", () => {
it("writes the cookie the layout reads, and localStorage", () => {
render(<FirmwareNotice serverDismissed={false} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss until the next release" }));

expect(screen.queryByText(BANNER)).toBeNull();
expect(document.cookie.split("; ")).toContain(`${KEY}=${LATEST}`);
expect(window.localStorage.getItem(KEY)).toBe(LATEST);
});

it("from before the cookie still counts, and the cookie is written for next time", async () => {
// Dismissed in localStorage alone: the server drew the banner, and
// hydration takes it away.
window.localStorage.setItem(KEY, LATEST);
const container = document.createElement("div");
container.innerHTML = renderToString(<FirmwareNotice serverDismissed={false} />);
document.body.appendChild(container);
expect(container.textContent).toContain(BANNER);

await act(async () => {
render(<FirmwareNotice serverDismissed={false} />, { container, hydrate: true });
});

expect(container.textContent).not.toContain(BANNER);
expect(document.cookie.split("; ")).toContain(`${KEY}=${LATEST}`);
});
});
70 changes: 48 additions & 22 deletions web/src/components/community/FirmwareNotice.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"use client";

import Link from "next/link";
import { useSyncExternalStore } from "react";
import manifest from "../../../public/flash/manifest.json";
import { useEffect, useSyncExternalStore } from "react";
import {
FIRMWARE_NOTICE_COOKIE as KEY,
FIRMWARE_NOTICE_VERSION as LATEST,
firmwareNoticeDismissed,
} from "@/lib/community/firmwareNotice";
import styles from "./Community.module.css";

// "There is a new firmware" — said here, because there is nowhere else to say
Expand All @@ -22,39 +26,61 @@ import styles from "./Community.module.css";
// Dismissal is per version, not forever. Dismiss this and it stays gone until
// there is a different release to mention — it cannot know whether you updated
// (see above), so the honest fallback is to ask once per version and drop it.
//
// The server draws the banner only for someone who will keep it. Until 26
// October only the browser knew about a dismissal, so the server drew nothing
// and the banner arrived at hydration, pushing the whole page down: 119px on
// a phone, CLS 0.141 at 390px. A dismissal now goes into a cookie as well, and
// the layout reads it and passes it in.

function dismissedHere(): boolean {
if (document.cookie.split("; ").includes(`${KEY}=${LATEST}`)) return true;
try {
return firmwareNoticeDismissed(window.localStorage.getItem(KEY));
} catch {
return false; /* private mode */
}
}

const LATEST = manifest.version;
const KEY = "pf-firmware-notice";
/** A year, and only on the community's pages: the notice is drawn nowhere
* else, so no other request needs to carry the cookie. */
function remember() {
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${KEY}=${LATEST}; Path=/community; Max-Age=31536000; SameSite=Lax${secure}`;
try {
window.localStorage.setItem(KEY, LATEST);
} catch {
/* private mode: the cookie still holds it */
}
}

/** Read once per render from localStorage, with a server snapshot of "not
* dismissed" so the markup matches and the banner never flashes in late. */
function useDismissed(): boolean {
/** Read once per render from the cookie and localStorage. The server snapshot
* is the cookie as the layout read it, so the markup matches. */
function useDismissed(serverDismissed: boolean): boolean {
return useSyncExternalStore(
(onChange) => {
window.addEventListener("storage", onChange);
return () => window.removeEventListener("storage", onChange);
},
() => {
try {
return window.localStorage.getItem(KEY) === LATEST;
} catch {
return false; /* private mode */
}
},
() => true, // server: assume dismissed, so nothing appears before hydration
dismissedHere,
() => serverDismissed,
);
}

export default function FirmwareNotice() {
const dismissed = useDismissed();
export default function FirmwareNotice({ serverDismissed }: { serverDismissed: boolean }) {
const dismissed = useDismissed(serverDismissed);

// Dismissed before the cookie existed, in localStorage alone: the server
// drew the banner and it left at hydration. Writing the cookie now keeps
// that to this one page.
useEffect(() => {
if (dismissed && !serverDismissed) remember();
}, [dismissed, serverDismissed]);

if (dismissed) return null;

const dismiss = () => {
try {
window.localStorage.setItem(KEY, LATEST);
} catch {
/* private mode — it will ask again, which is the lesser failure */
}
remember();
window.dispatchEvent(new Event("storage"));
};

Expand Down
19 changes: 19 additions & 0 deletions web/src/lib/community/firmwareNotice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import manifest from "../../../public/flash/manifest.json";

// The firmware notice's dismissal, in a cookie the server can read. Kept out
// of FirmwareNotice.tsx because that file is a client component, and the
// community layout reads these on the server: a server component importing a
// client module gets references, not these strings.

/** The release the notice is about, from the flasher manifest. */
export const FIRMWARE_NOTICE_VERSION = manifest.version;

/** Holds the version that was dismissed. The same name is the localStorage
* key, where dismissals were kept before the server needed to see them. */
export const FIRMWARE_NOTICE_COOKIE = "pf-firmware-notice";

/** True when the cookie (or stored value) dismisses this release. A value
* left by an earlier release does not: the notice asks once per version. */
export function firmwareNoticeDismissed(value: string | null | undefined): boolean {
return value === FIRMWARE_NOTICE_VERSION;
}
Loading