v4: settings are stored on the server, starting with the style, the color scheme and the digest switches — every setting lived in the browser's localStorage, so it didn't follow an account to another browser and the server couldn't read it, which the digests need (whether to send each, and in which style). user_settings (migration 0019) holds one row per user who changed anything, the defaults otherwise, and GET/PATCH /api/users/me/settings read and change it (settings-service, model/settings, validators/settings, shared/settings). AuthProvider reads the settings into the session alongside the user, on mount and on focus, so a change made elsewhere arrives; useSettings changes them at once and puts them back if the server refuses. ThemeProvider now sits inside AuthProvider: a signed-in user's choice comes from their settings and is mirrored to localStorage for index.html's first-paint script, a visitor's stays in the browser, and an account with no choice yet takes the one this browser had, once. Until anyone picks a style, the instance's GERBEUR_DEFAULT_STYLE applies, written into index.html's default-style tag along with email-enabled (src/config/instance.ts), and a browser no longer stores the default as if it had been chosen, so changing the instance's style reaches everyone who never picked one. The settings tab gets an Email section with the weekly digest and monthly recap switches, disabled with a note when the server has no SMTP. The style and scheme names moved to shared/theme.ts in the previous commit; the default feed tab and sounds stay in the browser for now. 223 backend tests (new: settings defaults, patches, isolation, refusals and cascade; index.html's new tags) and 658 frontend tests (new: useSettings, the account's theme winning and a browser's carried over, settings read into the session, the digest switches) pass, and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-04 21:20:57 +00:00
parent f20ca83f0c
commit 89014639cb
38 changed files with 923 additions and 128 deletions

View File

@@ -0,0 +1,50 @@
import { describe, expect, test, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { ProfileSettingsTab } from "../../../components/profile/ProfileSettingsTab.tsx";
import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts";
import { calls, route } from "../../fetch.ts";
import { makeSession } from "../../fixtures.ts";
import { renderApp } from "../../render.tsx";
vi.mock("../../../config/instance.ts", () => ({
DEFAULT_STYLE: "smooth",
EMAIL_ENABLED: true,
}));
describe("ProfileSettingsTab", () => {
test("the digest switches save to the account", async () => {
route("GET /api/users/me/settings", () => ({
...DEFAULT_SETTINGS,
digestMonthly: false,
}));
route(
"PATCH /api/users/me/settings",
(req) => ({ ...DEFAULT_SETTINGS, ...(req.body as object) }),
);
const user = userEvent.setup();
renderApp(<ProfileSettingsTab />, { session: makeSession() });
const row = (label: string) =>
screen.getByText(label).closest(".profile-appearance-row") as HTMLElement;
const weeklyOff = await waitFor(() => {
const button = within(row("Weekly digest")).getByRole("button", {
name: "Off",
});
expect(button).toBeEnabled();
return button;
});
expect(
within(row("Monthly recap")).getByRole("button", { name: "Off" }),
).toHaveClass("active");
expect(screen.queryByText(/doesn't send email/)).toBeNull();
await user.click(weeklyOff);
await waitFor(() =>
expect(calls("PATCH /api/users/me/settings")[0]?.body).toEqual({
digestWeekly: false,
})
);
expect(weeklyOff).toHaveClass("active");
});
});

View File

@@ -120,4 +120,21 @@ describe("AuthProvider", () => {
second.token,
);
});
test("reads the settings from the server into the session", async () => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/users/me/settings", () => ({
style: "nyt",
colorScheme: null,
digestWeekly: false,
digestMonthly: true,
}));
const { result } = renderHook(useAuth, { wrapper: authWrapper });
await waitFor(() => expect(result.current.settings?.style).toBe("nyt"));
expect(
JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).settings.digestWeekly,
).toBe(false);
});
});

View File

@@ -1,8 +1,12 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { ThemeProvider } from "../../../contexts/theme/ThemeProvider.tsx";
import { AuthProvider } from "../../../contexts/auth/AuthProvider.tsx";
import { useTheme } from "../../../hooks/theme/useTheme.ts";
import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts";
import { calls, route } from "../../fetch.ts";
import { makeSession, storeSession, wire } from "../../fixtures.ts";
describe("ThemeProvider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
@@ -43,4 +47,57 @@ describe("ThemeProvider", () => {
"smooth",
);
});
describe("signed in", () => {
const authWrapper = ({ children }: { children: ReactNode }) => (
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
);
const signIn = (settings: typeof DEFAULT_SETTINGS) => {
const session = makeSession();
storeSession(session);
route("GET /api/users/me", () => wire(session.user));
route("GET /api/users/me/settings", () => settings);
route(
"PATCH /api/users/me/settings",
(req) => ({ ...settings, ...(req.body as object) }),
);
};
test("the account's choice wins over this browser's, and is kept here", async () => {
localStorage.setItem("style", "nyt");
signIn({ ...DEFAULT_SETTINGS, style: "kanagawa", colorScheme: "dark" });
const { result } = renderHook(useTheme, { wrapper: authWrapper });
await waitFor(() => expect(result.current.style).toBe("kanagawa"));
expect(result.current.colorScheme).toBe("dark");
expect(root.getAttribute("data-style")).toBe("kanagawa");
expect(localStorage.getItem("style")).toBe("kanagawa");
expect(calls("PATCH /api/users/me/settings")).toHaveLength(0);
act(() => result.current.setStyle("melange"));
await waitFor(() =>
expect(calls("PATCH /api/users/me/settings")[0]?.body).toEqual({
style: "melange",
})
);
expect(result.current.style).toBe("melange");
act(() => result.current.setStyle("smooth"));
act(() => result.current.setColorScheme("auto"));
});
test("an account without a choice takes this browser's, once", async () => {
localStorage.setItem("style", "brutalist");
signIn({ ...DEFAULT_SETTINGS });
const { result } = renderHook(useTheme, { wrapper: authWrapper });
await waitFor(() =>
expect(calls("PATCH /api/users/me/settings")).toHaveLength(1)
);
expect(calls("PATCH /api/users/me/settings")[0].body).toEqual({
style: "brutalist",
});
expect(result.current.style).toBe("brutalist");
act(() => result.current.setStyle("smooth"));
});
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { type ReactNode, useState } from "react";
import { AuthContext } from "../../../contexts/auth/AuthContext.ts";
import type { Session } from "../../../utils/auth/storage.ts";
import { useSettings } from "../../../hooks/auth/useSettings.ts";
import { DEFAULT_SETTINGS } from "../../../../shared/settings.ts";
import { calls, deferred, fail, ok, route } from "../../fetch.ts";
import { makeSession } from "../../fixtures.ts";
function wrapperFor(initial: Session | null) {
return function Wrapper({ children }: { children: ReactNode }) {
const [authResponse, setAuthResponse] = useState(initial);
return (
<AuthContext.Provider value={{ authResponse, setAuthResponse }}>
{children}
</AuthContext.Provider>
);
};
}
describe("useSettings", () => {
test("null when signed out, or before the server has answered", () => {
expect(
renderHook(useSettings, { wrapper: wrapperFor(null) }).result.current
.settings,
).toBeNull();
expect(
renderHook(useSettings, { wrapper: wrapperFor(makeSession()) }).result
.current.settings,
).toBeNull();
});
test("a change shows at once, and the server's answer is kept", async () => {
const reply = deferred();
route("PATCH /api/users/me/settings", () => reply.promise);
const session = { ...makeSession(), settings: { ...DEFAULT_SETTINGS } };
const { result } = renderHook(useSettings, {
wrapper: wrapperFor(session),
});
let saving!: Promise<void>;
act(() => {
saving = result.current.updateSettings({ digestWeekly: false });
});
expect(result.current.settings?.digestWeekly).toBe(false);
expect(calls("PATCH /api/users/me/settings")[0].body).toEqual({
digestWeekly: false,
});
await act(async () => {
reply.resolve(
ok({ ...DEFAULT_SETTINGS, digestWeekly: false, style: "nyt" }),
);
await saving;
});
expect(result.current.settings?.style).toBe("nyt");
});
test("a refusal puts the previous settings back and throws", async () => {
route("PATCH /api/users/me/settings", () => fail("nope", 500));
const session = { ...makeSession(), settings: { ...DEFAULT_SETTINGS } };
const { result } = renderHook(useSettings, {
wrapper: wrapperFor(session),
});
await act(async () => {
await expect(result.current.updateSettings({ digestMonthly: false }))
.rejects.toThrow("nope");
});
expect(result.current.settings?.digestMonthly).toBe(true);
});
});

View File

@@ -310,8 +310,17 @@ describe("UserPublicProfile", () => {
);
await user.click(screen.getByRole("button", { name: "New" }));
expect(localStorage.getItem("default-feed-tab")).toBe("new");
await user.click(screen.getByRole("button", { name: "Off" }));
const sounds = screen.getByText("Sounds").closest(
".profile-appearance-row",
) as HTMLElement;
await user.click(within(sounds).getByRole("button", { name: "Off" }));
expect(localStorage.getItem("sounds")).toBe("off");
// Without SMTP on the server, the digests can't be switched.
const weekly = screen.getByText("Weekly digest").closest(
".profile-appearance-row",
) as HTMLElement;
expect(within(weekly).getByRole("button", { name: "On" })).toBeDisabled();
expect(screen.getByText(/doesn't send email/)).toBeInTheDocument();
document.documentElement.removeAttribute("data-style");
document.documentElement.removeAttribute("data-color-scheme");
});

View File

@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import type { Session } from "../utils/auth/storage.ts";
import type { Category } from "../../shared/category.ts";
import { DEFAULT_SETTINGS } from "../../shared/settings.ts";
import { AuthContext } from "../contexts/auth/AuthContext.ts";
import { AuthProvider } from "../contexts/auth/AuthProvider.tsx";
import {
@@ -219,6 +220,11 @@ export function defaultRoutes(
() => JSON.parse(JSON.stringify(session?.user ?? null)),
);
route("GET /api/users/me/media-token", () => ({ mediaToken: "media-token" }));
route(
"GET /api/users/me/settings",
() => ({ ...DEFAULT_SETTINGS }),
fallback,
);
}
/**
@@ -235,8 +241,8 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
const entries = Array.isArray(initial) ? initial : [initial];
const Wrapper = ({ children }: { children: ReactNode }) => (
<I18nProvider i18n={i18n}>
<ThemeProvider>
<AuthProvider>
<AuthProvider>
<ThemeProvider>
<PlayerProvider>
<WSProvider>
<FollowProvider>
@@ -257,8 +263,8 @@ export function renderApp(ui: ReactElement, opts: AppOptions = {}) {
</FollowProvider>
</WSProvider>
</PlayerProvider>
</AuthProvider>
</ThemeProvider>
</ThemeProvider>
</AuthProvider>
</I18nProvider>
);
return render(ui, { wrapper: Wrapper });