Files
ihasmail/web/src/store/files.ts
T
jcoffey-dev 0a9218f622 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.
2026-08-26 08:19:57 -07:00

231 lines
9.5 KiB
TypeScript

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";
interface FilesState {
accountId: Id | null;
available: boolean;
nodes: Record<Id, FileNode>;
children: Record<string, Id[]>; // parentId ("root" for null) → ids
loading: boolean;
error: string | null;
uploads: Array<{ id: string; name: string; progress: number; error: string | null }>;
init(): Promise<void>;
loadChildren(parentId: Id | null): Promise<void>;
mkdir(parentId: Id | null, name: string): Promise<Id>;
upload(parentId: Id | null, files: File[]): Promise<void>;
rename(id: Id, name: string): Promise<void>;
move(id: Id, parentId: Id | null): Promise<void>;
destroy(ids: Id[]): Promise<void>;
pathTo(id: Id | null): FileNode[];
applyChanges(types: Set<string>): void;
}
/** Whether the server supports parentId/isTopLevel query filters (detected at runtime). */
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[] = [];
if (queryOmitsDirectories()) {
// Query would hand back files only, so every folder — including one just
// created — would be missing with nothing to say why. Ask get for the lot.
const res = await client.call<GetResponse<FileNode>>("FileNode/get", { accountId, ids: null, properties: fileNodeProps() });
all.push(...normalizeFileNodes(res.list));
} else {
let position = 0;
for (let guard = 0; guard < 100; guard++) {
const res = await client.chain([
["FileNode/query", { accountId, position, limit: 500, calculateTotal: true }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
]);
const q = res.get("q")?.[0] as unknown as QueryResponse;
const g = res.get("g")?.[0] as unknown as GetResponse<FileNode>;
all.push(...normalizeFileNodes(g.list));
position += q.ids.length;
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 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 visible) children[n.id] ??= [];
set(() => ({ nodes, children, loading: false, error: null }));
}
export const useFiles = create<FilesState>((set, get) => ({
accountId: null,
available: false,
nodes: {},
children: {},
loading: false,
error: null,
uploads: [],
async init() {
const accountId = useSession.getState().accountFor(CAP.filenode);
const available = Boolean(accountId && client.hasCapability(CAP.filenode));
if (accountId !== get().accountId) set({ accountId, nodes: {}, children: {} });
set({ available });
},
async loadChildren(parentId) {
const accountId = get().accountId;
if (!accountId) return;
set({ loading: true });
try {
if (!filtersSupported || queryOmitsDirectories()) {
await loadAllNodes(accountId, set);
return;
}
const filter = parentId ? { parentId } : { isTopLevel: true };
const res = await client.chain([
["FileNode/query", { accountId, filter, sort: [{ property: "nodeType", isAscending: false }, { property: "name", isAscending: true }], limit: 1000 }, "q"],
["FileNode/get", { accountId, "#ids": { resultOf: "q", name: "FileNode/query", path: "/ids" }, properties: fileNodeProps() }, "g"],
]);
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 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
// fetching every node and building the tree client-side.
if (err instanceof JmapMethodError && (err.type === "unsupportedFilter" || err.type === "unsupportedSort")) {
filtersSupported = false;
try {
await loadAllNodes(accountId, set);
return;
} catch (err2) {
set({ loading: false, error: (err2 as Error).message });
return;
}
}
set({ loading: false, error: (err as Error).message });
}
},
async mkdir(parentId, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse<FileNode>>("FileNode/set", { accountId, create: { d: directoryCreate(parentId, name) } });
const err = res.notCreated?.d;
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(parentId);
return res.created!.d!.id;
},
async upload(parentId, files) {
const accountId = get().accountId!;
for (const f of files) {
const id = `${Date.now()}-${f.name}`;
set((s) => ({ uploads: [...s.uploads, { id, name: f.name, progress: 0, error: null }] }));
try {
const up = await client.upload(accountId, f, {
type: f.type || "application/octet-stream",
onProgress: (l, t) => set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, progress: Math.round((l / t) * 100) } : u)) })),
});
const res = await client.call<SetResponse<FileNode>>("FileNode/set", {
accountId,
create: { f: fileCreate(parentId, f.name, up.blobId, f.type || "application/octet-stream") },
});
const err = res.notCreated?.f;
if (err) throw new Error(setErrorMessage(err));
set((s) => ({ uploads: s.uploads.filter((u) => u.id !== id) }));
} catch (err) {
set((s) => ({ uploads: s.uploads.map((u) => (u.id === id ? { ...u, error: (err as Error).message } : u)) }));
}
}
await get().loadChildren(parentId);
},
async rename(id, name) {
const accountId = get().accountId!;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { name } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await get().loadChildren(get().nodes[id]?.parentId ?? null);
},
async move(id, parentId) {
const accountId = get().accountId!;
const from = get().nodes[id]?.parentId ?? null;
const res = await client.call<SetResponse>("FileNode/set", { accountId, update: { [id]: { parentId } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
await Promise.all([get().loadChildren(from), get().loadChildren(parentId)]);
},
async destroy(ids) {
const accountId = get().accountId!;
const parents = new Set(ids.map((id) => get().nodes[id]?.parentId ?? null));
const res = await client.call<SetResponse>("FileNode/set", { accountId, destroy: ids, onDestroyRemoveChildren: true });
const failed = Object.values(res.notDestroyed ?? {})[0];
if (failed) throw new Error(setErrorMessage(failed));
for (const p of parents) await get().loadChildren(p);
},
pathTo(id) {
const out: FileNode[] = [];
let cur = id ? get().nodes[id] : undefined;
let guard = 0;
while (cur && guard++ < 50) {
out.unshift(cur);
cur = cur.parentId ? get().nodes[cur.parentId] : undefined;
}
return out;
},
applyChanges(types) {
if (types.has("FileNode")) {
for (const key of Object.keys(get().children)) void get().loadChildren(key === "root" ? null : key);
}
},
}));
useSession.subscribe((s) => {
if (s.status !== "authenticated") useFiles.setState({ accountId: null, nodes: {}, children: {} });
});