Keep settings with the account, not the browser

Every setting lived in localStorage, so none of them travelled between
devices. The sharpest edge is the default identity: with none set the
address that sorts first wins, so mail goes out from an address the
recipient may not recognise -- and someone who sets it at work finds it
unset at home, with nothing to say so. Reported in #54.

They now live in a settings.json in the account's own JMAP Files, beside
the signature images already kept there. ihasmail itself stays stateless:
no volume, no database, nothing to back up separately, and the settings
are covered by whatever backs up the mail store.

localStorage stays as a cache rather than the source of truth, so the
first frame is painted from it and the file corrects it a moment later.
A private window has no cache and shows defaults for that one frame,
which is the trade for not gating the whole app on a network round trip.

Not everything should follow the account. A list-pane width picked on a
27" monitor is wrong on a laptop, and the notification toggles track a
permission the browser grants per-device, so claiming it elsewhere would
be a lie. Those stay local, written as a list of exceptions so that a
setting added later syncs by default -- which is what adding one almost
always means.

Writes are coalesced: update() fires on every frame of a splitter drag,
so a change waits 3s and the newest value wins. A tab going away flushes
first, as does signing out, so a setting changed seconds before either
is not lost.

The ihasmail folder is now hidden from the Files view, contents and all.
Hiding the folder alone would have been worse than showing it: the tree
attaches a node whose parent is missing to the root, so the signature
images would have spilled into the top level as if the user had put them
there. Those images have been visible since signatures shipped.

Requires 0.16 -- FileNode/query cannot see directories before that. On
0.15 settings stay local exactly as they were.

Verified against the mock end to end: folder create, blob upload, node
create, read back, update, re-read. Not yet exercised against the live
0.16.19.
This commit is contained in:
2026-08-26 08:19:57 -07:00
parent abd2269581
commit b7e0fc0c7d
10 changed files with 520 additions and 55 deletions
+2 -1
View File
@@ -83,6 +83,7 @@ seconds of downtime with nothing lost.
- **Self-service credentials** in Settings Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings Appearance
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
- **Settings follow the account, not the browser** (Stalwart 0.16+): they are kept in a `settings.json` in the account's own JMAP Files, so the default identity, locale, date and time formats, theme, labels, templates, folder colours and the rest are the same wherever you sign in — including a private window. ihasmail still stores nothing itself; the file lives in the mail store and is backed up with it. Settings that describe *this* screen or browser stay local, because syncing them would be wrong rather than helpful: list-pane sizes, density, font size, sidebar state, and the notification toggles (which track a permission the browser grants per-device). localStorage is kept as a cache so the first frame is already right, and the file corrects it a moment later. On Stalwart 0.15 nothing changes — settings stay local, as before
**Platform**
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
@@ -101,7 +102,7 @@ browser ──(same-origin /api/*)──► ihasmail server (Node + Hono) ─
- `web/` — Vite + React 19 + TypeScript SPA. `src/jmap` (client, push, types), `src/store` (zustand stores: session, mail, compose, contacts, calendar, files, sieve, settings), `src/views` (mail, compose, calendar, contacts, files, settings), `src/lib` (sanitiser, search parser, Sieve codec, dates and locale-aware formatting, vCard, …).
- `server/` — tiny Node/Hono backend: authenticates against Stalwart's JMAP session endpoint, stores the credentials sealed with a key derived from the cookie secret (the server never persists plaintext passwords), proxies JMAP/blob/SSE calls, serves the SPA with a strict CSP. Also contains `src/mock/` — an in-memory fake Stalwart for local development and demos.
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale). Features degrade gracefully when a capability is missing.
Stalwart capabilities used: `core`, `mail`, `submission`, `vacationresponse`, `sieve`, `contacts`(+`parse`), `calendars`(+`parse`), `principals`(+`availability`), `quota`, `blob`, `filenode`, EventSource push, plus Stalwart's own `urn:stalwart:jmap` (read-only, for the account locale and to tell the generations apart). Features degrade gracefully when a capability is missing.
## Quick start (Docker)
+22 -1
View File
@@ -17,7 +17,8 @@ import { AppShell } from "@/views/AppShell";
import { MailView } from "@/views/mail/MailView";
import { ComposerDock } from "@/views/compose/ComposerDock";
import { setUnreadBadge } from "@/lib/notify";
import { useSettings } from "@/store/settings";
import { useSettings, syncedPart } from "@/store/settings";
import { armSettingsSync, loadRemoteSettings, queueSettingsPush, settingsSyncAvailable } from "@/lib/settingsSync";
const ContactsView = lazy(() => import("@/views/contacts/ContactsView").then((m) => ({ default: m.ContactsView })));
const CalendarView = lazy(() => import("@/views/calendar/CalendarView").then((m) => ({ default: m.CalendarView })));
@@ -51,6 +52,26 @@ function AuthedApp() {
const accountId = useSession((s) => s.accountId);
const [location] = useLocation();
// Settings that live with the account rather than the browser. The cached
// ones have already painted, so this only has to correct them (issue #54).
useEffect(() => {
if (!accountId) return;
let cancelled = false;
void (async () => {
const remote = await loadRemoteSettings();
if (cancelled) return;
if (remote) useSettings.getState().hydrate(remote);
// Pushes were held back until now so they could not race the load.
armSettingsSync();
// No file yet — seed one from what this browser has, so the next device
// to sign in starts from these rather than from the defaults.
if (!remote && settingsSyncAvailable()) queueSettingsPush(syncedPart(useSettings.getState().settings));
})();
return () => {
cancelled = true;
};
}, [accountId]);
// Initial data + push wiring
useEffect(() => {
if (!accountId) return;
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, syncedPart, type Settings } from "@/store/settings";
import { isAppFolder } from "../appFolder";
/**
* Settings used to live only in localStorage, so nothing followed the user
* between devices — issue #54, whose sharpest case is the default identity:
* with none set, the address that sorts first wins, so mail goes out from an
* address the recipient may not recognise.
*
* The split is written as a list of exceptions, which means the interesting
* test is not "does this key sync" but "does a key added later sync without
* anyone remembering to add it".
*/
describe("which settings follow the account", () => {
it("syncs everything that is not explicitly device-local", () => {
const synced = syncedPart(DEFAULT_SETTINGS);
const expected = (Object.keys(DEFAULT_SETTINGS) as Array<keyof Settings>).filter((k) => !DEVICE_KEYS.has(k));
expect(Object.keys(synced).sort()).toEqual(expected.sort());
});
it("keeps this screen's and this browser's settings out of the file", () => {
const synced = syncedPart(DEFAULT_SETTINGS);
// A pane width picked on a monitor is wrong on a laptop, and the
// notification toggles track a per-browser permission grant.
for (const key of ["listPaneWidth", "listPaneHeight", "density", "fontSize", "sidebarCollapsed", "desktopNotifications", "notificationSound"]) {
expect(synced, key).not.toHaveProperty(key);
}
});
it("syncs the default identity, which is what #54 was actually about", () => {
const settings: Settings = { ...DEFAULT_SETTINGS, defaultIdentityByAccount: { a1: "i7" } };
expect(syncedPart(settings).defaultIdentityByAccount).toEqual({ a1: "i7" });
});
it("syncs theme and reading pane", () => {
const synced = syncedPart({ ...DEFAULT_SETTINGS, theme: "dark", readingPane: "bottom" });
expect(synced.theme).toBe("dark");
expect(synced.readingPane).toBe("bottom");
});
});
describe("applying a settings file", () => {
it("takes known, non-device keys", () => {
const applied = acceptRemote({ theme: "dark", weekStart: 0, locale: "de-DE" });
expect(applied).toEqual({ theme: "dark", weekStart: 0, locale: "de-DE" });
});
it("ignores keys it has never heard of", () => {
// A newer ihasmail's settings, or a hand-edited file.
expect(acceptRemote({ theme: "dark", somethingNewer: 42 })).toEqual({ theme: "dark" });
});
it("refuses device keys even when the file carries them", () => {
// An earlier build wrote the whole settings object up; that file must not
// now drag one machine's pane width onto every other one.
expect(acceptRemote({ theme: "dark", listPaneWidth: 900, fontSize: "large" })).toEqual({ theme: "dark" });
});
it("does not invent keys from an empty file", () => {
expect(acceptRemote({})).toEqual({});
});
it("keeps a false or zero value, which is not the same as absent", () => {
const applied = acceptRemote({ conversationMode: false, markReadDelay: 0 });
expect(applied).toEqual({ conversationMode: false, markReadDelay: 0 });
});
});
describe("the client's own folder", () => {
it("is the top-level ihasmail directory", () => {
expect(isAppFolder({ name: "ihasmail", parentId: null, nodeType: "directory" })).toBe(true);
});
it("is not a folder of that name someone made inside another one", () => {
expect(isAppFolder({ name: "ihasmail", parentId: "n1", nodeType: "directory" })).toBe(false);
});
it("is not a file that happens to be called that", () => {
expect(isAppFolder({ name: "ihasmail", parentId: null, nodeType: "file" })).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* The `ihasmail` folder in JMAP Files, where the client keeps its own state:
* signature images and over-sized signature HTML (Stalwart caps a signature at
* 2 KB), and the synced settings file.
*
* It is a real folder in the user's account — that is the whole point, since it
* is what makes this state travel between devices without ihasmail storing
* anything server-side of its own — but it is housekeeping rather than
* something anyone filed there, so the Files view hides it. See `isAppFolder`.
*/
import { client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, Id, SetResponse } from "@/jmap/types";
import { directoryCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
export const APP_FOLDER = "ihasmail";
/** Just enough to find the folder, asking for nodeType only where it exists. */
export const folderProps = (): string[] =>
supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"];
/** The client's own folder, which the Files view does not show. */
export function isAppFolder(n: Pick<FileNode, "name" | "parentId" | "nodeType">): boolean {
return n.name === APP_FOLDER && !n.parentId && n.nodeType === "directory";
}
/** Every node in the account, for servers whose query cannot see directories. */
async function allNodes(accountId: Id, properties: string[]): Promise<FileNode[]> {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties });
return normalizeFileNodes(res.list);
}
/** Find the app folder, or make it. Returns its node id. */
export async function ensureFolder(accountId: Id): Promise<Id> {
const props = folderProps();
let list: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query cannot see a directory on these servers, so it would never find the
// folder and we would make a fresh one on every save. Ask get for the lot.
list = await allNodes(accountId, props);
} else {
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: APP_FOLDER }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch {
// Filters unsupported: scan everything and pick it out here.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
}
}
const existing = list.find(isAppFolder);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, APP_FOLDER) } });
const err = set.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
return set.created!.d!.id;
}
/** A node's persistent blobId, for servers that do not return one on create. */
export async function nodeBlobId(accountId: Id, id?: Id): Promise<Id | undefined> {
if (!id) return undefined;
try {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
return res.list[0]?.blobId ?? undefined;
} catch {
return undefined;
}
}
/** Find a file by name inside the app folder. */
export async function findInFolder(accountId: Id, folderId: Id, name: string): Promise<FileNode | undefined> {
const props = ["id", "name", "parentId", "blobId", "size", "type", ...(supportsNodeType() ? ["nodeType"] : [])];
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { parentId: folderId, name }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: props }, "g"],
]);
const list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
const hit = list.find((n) => n.name === name && n.parentId === folderId);
if (hit) return hit;
} catch {
/* filters unsupported: fall through to the full scan */
}
const list = await allNodes(accountId, props);
return list.find((n) => n.name === name && n.parentId === folderId);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Settings that follow the account rather than the browser.
*
* Everything used to live in localStorage, which meant no preference travelled
* between devices — most painfully the default identity, where the fallback is
* whichever address sorts first, so a forgotten setting sends mail from an
* address the recipient may not know (issue #54).
*
* The store is a `settings.json` in the account's own JMAP Files, beside the
* signature images that are already kept there. That keeps ihasmail itself
* stateless: no volume, no database, nothing to back up separately, and the
* settings are covered by whatever backs up the mail store.
*
* localStorage stays, demoted to a cache: it is what paints the first frame,
* and the file overwrites it once it lands. A browser with no cache (a private
* window) therefore shows defaults for one frame before the account's real
* settings arrive.
*
* Requires Stalwart 0.16: `FileNode/query` before that cannot see directories
* and the rights model differs. On an older server the settings simply stay
* local, exactly as they were.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, Id, SetResponse } from "@/jmap/types";
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
import { fileCreate, supportsNodeType } from "@/lib/filenode";
import { useSession } from "@/store/session";
const FILE = "settings.json";
const TYPE = "application/json";
/** How long a change sits before it is written up. */
const DEBOUNCE_MS = 3000;
let timer: number | null = null;
let pending: Record<string, unknown> | null = null;
let inFlight: Promise<void> | null = null;
/** Nothing is pushed before the first load has settled, or we would race it. */
let armed = false;
let listenersBound = false;
export function settingsSyncAvailable(): boolean {
return supportsNodeType() && client.hasCapability(CAP.filenode) && Boolean(useSession.getState().accountFor(CAP.filenode));
}
/**
* Read the account's settings file. Returns null when there is nothing to read
* — no file yet, no Files, an older server — which leaves the local cache in
* charge rather than wiping it.
*/
export async function loadRemoteSettings(): Promise<Record<string, unknown> | null> {
if (!settingsSyncAvailable()) return null;
const accountId = useSession.getState().accountFor(CAP.filenode)!;
try {
const folderId = await ensureFolder(accountId);
const node = await findInFolder(accountId, folderId, FILE);
if (!node?.blobId) return null;
const text = await client.fetchBlobText(accountId, node.blobId, TYPE);
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
return parsed as Record<string, unknown>;
} catch {
// A settings file we cannot read must not cost anyone their session; the
// cached settings are still perfectly good.
return null;
}
}
/** Allow pushes. Called once the first load has settled, either way. */
export function armSettingsSync(): void {
armed = true;
bindFlushListeners();
}
/** Stop syncing and drop anything queued (logout). */
export function stopSettingsSync(): void {
armed = false;
pending = null;
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
}
/**
* Queue the synced settings for writing. Called on every change — including
* each frame of a splitter drag — so it coalesces: the newest value wins and
* one request goes out once the changes stop.
*/
export function queueSettingsPush(synced: Record<string, unknown>): void {
if (!armed || !settingsSyncAvailable()) return;
pending = synced;
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
void flushSettingsPush();
}, DEBOUNCE_MS);
}
/** Write anything queued now, rather than waiting out the debounce. */
export async function flushSettingsPush(): Promise<void> {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
if (!pending || !armed) return;
const body = pending;
pending = null;
// Serialise: two overlapping writes could land in either order.
inFlight = (inFlight ?? Promise.resolve()).then(() => writeSettings(body)).catch(() => undefined);
await inFlight;
}
async function writeSettings(body: Record<string, unknown>): Promise<void> {
if (!settingsSyncAvailable()) return;
const accountId = useSession.getState().accountFor(CAP.filenode)!;
const json = JSON.stringify(body, null, 2);
// Byte length, not character count: a template or a signature with any
// non-ASCII in it would otherwise be reported shorter than it is.
const blob = new Blob([json], { type: TYPE });
const up = await client.upload(accountId, blob, { type: TYPE });
const folderId = await ensureFolder(accountId);
const existing = await findInFolder(accountId, folderId, FILE);
if (existing) {
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId,
update: { [existing.id]: { blobId: up.blobId, type: TYPE, size: blob.size } },
});
const err = res.notUpdated?.[existing.id];
if (err) throw new Error(setErrorMessage(err));
return;
}
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId,
create: { s: fileCreate(folderId, FILE, up.blobId, TYPE) },
});
const err = res.notCreated?.s;
if (err) throw new Error(setErrorMessage(err));
// Some servers hand back no blobId on create; ask, so the next read finds it.
await nodeBlobId(accountId, (res.created?.s as Partial<FileNode> | undefined)?.id as Id | undefined);
}
/**
* A debounce that outlives the page helps no one, so a tab going away writes
* first. `visibilitychange` is the one that fires reliably on mobile; `pagehide`
* covers the desktop close.
*/
function bindFlushListeners(): void {
if (listenersBound || typeof window === "undefined") return;
listenersBound = true;
const flush = () => {
if (pending) void flushSettingsPush();
};
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
}
+3 -48
View File
@@ -5,47 +5,12 @@
* turns such references into inline cid: parts when sending.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types";
import { directoryCreate, fileCreate, normalizeFileNodes, queryOmitsDirectories, supportsNodeType } from "@/lib/filenode";
import type { FileNode, QueryResponse, SetResponse } from "@/jmap/types";
import { fileCreate } from "@/lib/filenode";
import { ensureFolder, nodeBlobId } from "@/lib/appFolder";
import { useSession } from "@/store/session";
import { toast } from "@/ui/toast";
const FOLDER = "ihasmail";
/** Just enough to find the folder, asking for nodeType only where it exists. */
const folderProps = () => (supportsNodeType() ? ["id", "name", "nodeType", "parentId"] : ["id", "name", "parentId", "blobId", "size", "type"]);
async function ensureFolder(accountId: string): Promise<string> {
let list: FileNode[] = [];
if (queryOmitsDirectories()) {
// Query cannot see a directory on these servers, so it would never find the
// folder and we would make a fresh one on every save. Ask get for the lot.
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: folderProps() });
list = normalizeFileNodes(res.list);
} else {
try {
const res = await client.chain([
["FileNode/query", { accountId, filter: { isTopLevel: true, nodeType: "directory", name: FOLDER }, limit: 5 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
} catch {
// Filters unsupported: scan everything and pick it out here.
const res = await client.chain([
["FileNode/query", { accountId, limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: folderProps() }, "g"],
]);
list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse<FileNode>).list);
}
}
const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId);
if (existing) return existing.id;
const set = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(null, FOLDER) } });
const err = set.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
return set.created!.d!.id;
}
/** Upload an image for use in a signature; returns a same-origin blob URL. */
export async function uploadSignatureImage(file: File): Promise<string> {
const accountId = useSession.getState().accountFor(CAP.filenode);
@@ -117,14 +82,4 @@ export async function loadStoredSignature(blobId: string, type = "text/html"): P
return client.fetchBlobText(accountId, blobId, type);
}
async function nodeBlobId(accountId: string, id?: string): Promise<string | undefined> {
if (!id) return undefined;
try {
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] });
return res.list[0]?.blobId ?? undefined;
} catch {
return undefined;
}
}
export type { QueryResponse };
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { withoutAppFolder } from "../files";
import type { FileNode } from "@/jmap/types";
/**
* The `ihasmail` folder holds signature images and the synced settings file.
* They are real nodes in the account — that is what makes them travel — but
* they are the client's housekeeping, so Files does not show them.
*
* Hiding the folder alone is worse than showing it: the tree attaches a node
* whose parent is missing to the root, so the signature images would spill out
* into the top level looking like the user's own files.
*/
const node = (id: string, name: string, parentId: string | null, nodeType: "file" | "directory"): FileNode =>
({ id, name, parentId, nodeType, size: null, blobId: null, type: null }) as unknown as FileNode;
describe("hiding the client's folder", () => {
it("removes the folder and everything in it", () => {
const nodes = [
node("f1", "ihasmail", null, "directory"),
node("f2", "signature-1.html", "f1", "file"),
node("f3", "settings.json", "f1", "file"),
node("d1", "Documents", null, "directory"),
node("d2", "notes.txt", "d1", "file"),
];
expect(withoutAppFolder(nodes).map((n) => n.id)).toEqual(["d1", "d2"]);
});
it("removes nested contents, not just direct children", () => {
const nodes = [
node("f1", "ihasmail", null, "directory"),
node("f2", "images", "f1", "directory"),
node("f3", "logo.png", "f2", "file"),
];
expect(withoutAppFolder(nodes)).toEqual([]);
});
it("leaves a folder of the same name that the user made inside another", () => {
const nodes = [node("d1", "Projects", null, "directory"), node("d2", "ihasmail", "d1", "directory")];
expect(withoutAppFolder(nodes).map((n) => n.id)).toEqual(["d1", "d2"]);
});
it("leaves a top-level file that happens to be called ihasmail", () => {
const nodes = [node("x1", "ihasmail", null, "file")];
expect(withoutAppFolder(nodes).map((n) => n.id)).toEqual(["x1"]);
});
it("returns the list untouched when there is no such folder", () => {
const nodes = [node("d1", "Documents", null, "directory")];
expect(withoutAppFolder(nodes)).toBe(nodes);
});
});
+36 -5
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client";
import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes, queryOmitsDirectories } from "@/lib/filenode";
import { isAppFolder } from "@/lib/appFolder";
import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types";
import { useSession } from "./session";
@@ -30,6 +31,31 @@ let filtersSupported = true;
const byName = (a: FileNode, b: FileNode) => (a.nodeType === b.nodeType ? a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) : a.nodeType === "directory" ? -1 : 1);
/**
* Drop the client's own `ihasmail` folder, and everything inside it, from a
* listing. It holds signature images and the synced settings file — real nodes
* in the account, but housekeeping rather than anything the user filed.
*
* The contents have to go too: the tree attaches a node whose parent is missing
* to the root, so hiding the folder alone would spill its files into the top
* level, which is worse than showing the folder.
*/
export function withoutAppFolder(nodes: FileNode[]): FileNode[] {
const hidden = new Set<Id>();
for (const n of nodes) if (isAppFolder(n)) hidden.add(n.id);
if (!hidden.size) return nodes;
for (let grew = true; grew; ) {
grew = false;
for (const n of nodes) {
if (!hidden.has(n.id) && n.parentId && hidden.has(n.parentId)) {
hidden.add(n.id);
grew = true;
}
}
}
return nodes.filter((n) => !hidden.has(n.id));
}
/** Fetch all nodes (paged, no filter) and rebuild the full children map. */
async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<FilesState>) => void): Promise<void> {
const all: FileNode[] = [];
@@ -52,14 +78,17 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial<
if (!q.ids.length || (q.total != null && position >= q.total)) break;
}
}
// After the whole collection, not per page: the folder and its contents can
// land in different pages, and a half-filtered pass would spill the rest.
const visible = withoutAppFolder(all);
const nodes: Record<Id, FileNode> = {};
const children: Record<string, Id[]> = { root: [] };
for (const n of all) nodes[n.id] = n;
for (const n of all.sort(byName)) {
for (const n of visible) nodes[n.id] = n;
for (const n of visible.sort(byName)) {
const key = n.parentId && nodes[n.parentId] ? n.parentId : "root";
(children[key] ??= []).push(n.id);
}
for (const n of all) children[n.id] ??= [];
for (const n of visible) children[n.id] ??= [];
set(() => ({ nodes, children, loading: false, error: null }));
}
@@ -95,10 +124,12 @@ export const useFiles = create<FilesState>((set, get) => ({
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
const listed = withoutAppFolder(normalizeFileNodes(g.list));
const keep = new Set(listed.map((n) => n.id));
set((s) => {
const nodes = { ...s.nodes };
for (const n of normalizeFileNodes(g.list)) nodes[n.id] = n;
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null };
for (const n of listed) nodes[n.id] = n;
return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids.filter((id) => keep.has(id)) }, loading: false, error: null };
});
} catch (err) {
// Older Stalwart releases don't support parentId / isTopLevel filters: fall back to
+10
View File
@@ -3,6 +3,7 @@ import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types";
import { push, type PushState } from "@/jmap/push";
import { setServerLocale } from "@/lib/datetime";
import { flushSettingsPush, stopSettingsSync } from "@/lib/settingsSync";
export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -54,6 +55,14 @@ export const useSession = create<SessionState>((set, get) => ({
async logout() {
push.stop();
setServerLocale(null);
// Anything still sitting in the debounce is written while the session can
// still write it; a setting changed seconds before signing out is not lost.
try {
await flushSettingsPush();
} catch {
/* ignore */
}
stopSettingsSync();
try {
await apiFetch("/api/auth/logout", { method: "POST" });
} catch {
@@ -96,6 +105,7 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
client.onUnauthenticated(() => {
push.stop();
stopSettingsSync();
client.session = null;
useSession.setState({ status: "anonymous", session: null, accountId: null });
});
+62
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { create } from "zustand";
import { loadJson, saveJson } from "@/lib/storage";
import { queueSettingsPush } from "@/lib/settingsSync";
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
export type Theme = "system" | "light" | "dark";
@@ -141,12 +142,59 @@ export const DEFAULT_SETTINGS: Settings = {
defaultIdentityByAccount: {},
};
/**
* Settings that describe *this screen or this browser*, and so stay in
* localStorage: a list-pane width picked on a 27" monitor is wrong on a
* laptop, and the notification toggles track a permission the browser grants
* per-device, so syncing them would claim something untrue elsewhere.
*
* Everything else follows the account (issue #54). The list is written as the
* exceptions rather than the rule so that a setting added later syncs by
* default, which is what someone adding one almost always wants.
*/
export const DEVICE_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Settings>([
"density",
"fontSize",
"sidebarCollapsed",
"desktopNotifications",
"notificationSound",
"listPaneWidth",
"listPaneHeight",
]);
/** The part of the settings that is written to the account's settings file. */
export function syncedPart(s: Settings): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of Object.keys(s) as Array<keyof Settings>) {
if (!DEVICE_KEYS.has(key)) out[key] = s[key];
}
return out;
}
/**
* What of a settings file we are willing to apply: known keys only, and never
* a device one — an older ihasmail wrote the whole object up, and that file
* should not now drag another machine's pane width across.
*/
export function acceptRemote(remote: Record<string, unknown>): Partial<Settings> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(remote)) {
if (!(key in DEFAULT_SETTINGS)) continue;
if (DEVICE_KEYS.has(key as keyof Settings)) continue;
if (value === undefined) continue;
out[key] = value;
}
return out as Partial<Settings>;
}
interface SettingsState {
settings: Settings;
update(patch: Partial<Settings>): void;
reset(): void;
exportJson(): string;
importJson(json: string): boolean;
/** Apply the account's settings file over the cached ones. */
hydrate(remote: Record<string, unknown>): void;
}
const initialSettings = loadJson<Settings>("settings", DEFAULT_SETTINGS);
@@ -160,12 +208,18 @@ export const useSettings = create<SettingsState>((set, get) => ({
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
// Dragging a splitter changes a device key on every frame and must not put
// a request in the air; anything else is queued and coalesced.
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
queueSettingsPush(syncedPart(settings));
}
},
reset() {
saveJson("settings", DEFAULT_SETTINGS);
set({ settings: DEFAULT_SETTINGS });
applyTheme(DEFAULT_SETTINGS);
applyDateTimePrefs(DEFAULT_SETTINGS);
queueSettingsPush(syncedPart(DEFAULT_SETTINGS));
},
exportJson() {
return JSON.stringify(get().settings, null, 2);
@@ -179,6 +233,14 @@ export const useSettings = create<SettingsState>((set, get) => ({
return false;
}
},
hydrate(remote) {
const settings = { ...get().settings, ...acceptRemote(remote) };
// Cache it, so the next first frame on this browser is already right.
saveJson("settings", settings);
set({ settings });
applyTheme(settings);
applyDateTimePrefs(settings);
},
}));
function applyDateTimePrefs(s: Settings): void {