Files
ihasmail/web/src/lib/settingsSync.ts
T
jcoffey-dev 635c4c7e52 Keep a settings change made before the first read, and wait for it
Two defects on the path that decides what language the app starts in.

**A change made before the settings file came back was thrown away.**
`queueSettingsPush` returned early while unarmed, dropping the value
instead of holding it, so a language picked in the second or so after a
page load was never written up: it survived until the next reload and no
further. That is a better account of "sometimes it takes several clicks"
than the remount race fixed in #160 — the click that stuck was one made
after the read had finished. Keeping it is safe because hydrate already
refuses to overwrite a key that is still queued.

Proof it was real: before this, no `ihasmail` folder was ever created in
the account's files, because the seed write never fired. After it, the
folder appears.

**Without a cached settings object the tree painted too early.** The
cache is not read on an untrusted device, and it is cleared by the
sign-out that every deploy causes, so in both cases the first frame is
the defaults — and the defaults are English. Anything computed in that
window is computed in the wrong language. The interface recovers, since
it is rebuilt when the catalogue lands, but a string emitted once does
not: this is why the stale-folder toast came out in English on an
otherwise German screen.

So without a cache the authenticated tree now waits for the account's
settings and their catalogue, which costs nothing — there was nothing
worth painting yet. With a cache it does not wait, and the first frame is
as quick as it was.

Neither fix makes the toast German yet: the account settings file is
neither written nor read successfully in the mock, and both failures are
swallowed. That is a third problem, and this commit does not touch it.
2026-08-31 15:03:40 -07:00

205 lines
8.0 KiB
TypeScript

/**
* 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.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { FileNode, Id, SetResponse } from "@/jmap/types";
import { ensureFolder, findInFolder, nodeBlobId } from "@/lib/appFolder";
import { fileCreate } 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 loadedFor: string | null = null;
let listenersBound = false;
export function settingsSyncAvailable(): boolean {
return client.hasCapability(CAP.filenode) && Boolean(useSession.getState().ownAccountFor(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().ownAccountFor(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;
}
}
/**
* Has this account's settings file already been read on this page load?
*
* Claims the account as a side effect, so two callers cannot both start a
* read. The subtree that does the reading is keyed on the language version
* and so is deliberately remounted whenever somebody picks a language;
* without this the remount re-reads a file written before the change and
* applies it, putting the old language back.
*
* Cleared by `stopSettingsSync`, so signing out and back in reads again.
*/
export function settingsAlreadyLoadedFor(accountId: string | null | undefined): boolean {
if (!accountId) return true;
if (loadedFor === accountId) return true;
loadedFor = accountId;
return false;
}
/** Allow pushes. Called once the first load has settled, either way. */
export function armSettingsSync(): void {
armed = true;
bindFlushListeners();
// A change made while the read was in flight has been waiting for this.
if (pending) void flushSettingsPush();
}
/** Stop syncing and drop anything queued (logout). */
export function stopSettingsSync(): void {
armed = false;
loadedFor = null;
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 (!settingsSyncAvailable()) return;
/*
* Held, not dropped, before the first load has settled.
*
* This used to return here, which silently threw the change away: a
* language picked in the second or so before the settings file came back
* was never written, so it survived until the next reload and no further.
* That is the other half of "sometimes it takes several clicks" -- the
* click that stuck was one made after the read had finished.
*
* Keeping it is safe because `hydrate` refuses to overwrite a key that is
* still queued, so the newer local change wins over the older file rather
* than racing it. `armSettingsSync` writes whatever is waiting.
*/
pending = synced;
if (!armed) return;
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
void flushSettingsPush();
}, DEBOUNCE_MS);
}
/**
* The keys of a change that has been made but not yet written up.
*
* `hydrate` needs these: a settings file read from the server is older than an
* unflushed local change by definition, so applying it wholesale hands the
* user back the value they just replaced. Switching language made that visible
* — it remounts the tree, the remount re-reads the file, and the file still
* says the old language — but the race is general and a slow read would lose
* any click made inside the debounce window.
*/
export function pendingSettingsKeys(): ReadonlySet<string> {
return new Set(pending ? Object.keys(pending) : []);
}
/** 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().ownAccountFor(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();
});
}