Merge pull request #55 from LINUXexpert-org/settings-server-side
Keep settings with the account, not the browser
This commit is contained in:
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user