diff --git a/README.md b/README.md index 0cc9a78..c9fdc01 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/web/src/App.tsx b/web/src/App.tsx index f289129..1524e31 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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; diff --git a/web/src/lib/__tests__/settingsSync.test.ts b/web/src/lib/__tests__/settingsSync.test.ts new file mode 100644 index 0000000..bb2f9be --- /dev/null +++ b/web/src/lib/__tests__/settingsSync.test.ts @@ -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).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); + }); +}); diff --git a/web/src/lib/appFolder.ts b/web/src/lib/appFolder.ts new file mode 100644 index 0000000..bcfbac4 --- /dev/null +++ b/web/src/lib/appFolder.ts @@ -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): 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 { + const res = await client.call>("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 { + 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).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).list); + } + } + const existing = list.find(isAppFolder); + if (existing) return existing.id; + const set = await client.call>("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 { + if (!id) return undefined; + try { + const res = await client.call>("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 { + 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).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); +} diff --git a/web/src/lib/settingsSync.ts b/web/src/lib/settingsSync.ts new file mode 100644 index 0000000..bfe4b27 --- /dev/null +++ b/web/src/lib/settingsSync.ts @@ -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 | null = null; +let inFlight: Promise | 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 | 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; + } 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): 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 { + 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): Promise { + 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>("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>("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 | 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(); + }); +} diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 5eeece6..7b7a574 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -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 { - 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>("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).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).list); - } - } - const existing = list.find((n) => n.name === FOLDER && n.nodeType === "directory" && !n.parentId); - if (existing) return existing.id; - const set = await client.call>("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 { 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 { - if (!id) return undefined; - try { - const res = await client.call>("FileNode/get", { accountId, ids: [id], properties: ["id", "blobId"] }); - return res.list[0]?.blobId ?? undefined; - } catch { - return undefined; - } -} - export type { QueryResponse }; diff --git a/web/src/store/__tests__/app-folder-hidden.test.ts b/web/src/store/__tests__/app-folder-hidden.test.ts new file mode 100644 index 0000000..d6318b7 --- /dev/null +++ b/web/src/store/__tests__/app-folder-hidden.test.ts @@ -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); + }); +}); diff --git a/web/src/store/files.ts b/web/src/store/files.ts index cb8f7f7..91d6d18 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -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(); + 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) => void): Promise { 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 = {}; const children: Record = { 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((set, get) => ({ ]); const q = res.get("q")?.[0] as unknown as QueryResponse; const g = res.get("g")?.[0] as unknown as GetResponse; + 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 diff --git a/web/src/store/session.ts b/web/src/store/session.ts index bd79cf5..aafadbc 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -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((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) => void) { client.onUnauthenticated(() => { push.stop(); + stopSettingsSync(); client.session = null; useSession.setState({ status: "anonymous", session: null, accountId: null }); }); diff --git a/web/src/store/settings.ts b/web/src/store/settings.ts index 8bd8e5d..29a6e85 100644 --- a/web/src/store/settings.ts +++ b/web/src/store/settings.ts @@ -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 = new Set([ + "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 { + const out: Record = {}; + for (const key of Object.keys(s) as Array) { + 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): Partial { + const out: Record = {}; + 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; +} + interface SettingsState { settings: Settings; update(patch: Partial): void; reset(): void; exportJson(): string; importJson(json: string): boolean; + /** Apply the account's settings file over the cached ones. */ + hydrate(remote: Record): void; } const initialSettings = loadJson("settings", DEFAULT_SETTINGS); @@ -160,12 +208,18 @@ export const useSettings = create((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((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 {