diff --git a/web/src/lib/__tests__/filenode.test.ts b/web/src/lib/__tests__/filenode.test.ts index c350d5e..c932c94 100644 --- a/web/src/lib/__tests__/filenode.test.ts +++ b/web/src/lib/__tests__/filenode.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { client } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, withNodeType } from "../filenode"; +import { directoryCreate, fileCreate, fileNodeProps, supportsNodeType, normalizeFileNodes } from "../filenode"; import type { FileNode, JmapSession } from "@/jmap/types"; /** @@ -33,7 +33,7 @@ describe("on Stalwart 0.16 and newer", () => { it("leaves what the server reported alone", () => { client.session = session(NEW_SERVER); const nodes = [{ id: "1", name: "x", nodeType: "directory" }] as Partial[]; - expect(withNodeType(nodes)).toEqual(nodes); + expect(normalizeFileNodes(nodes)).toEqual(nodes); }); }); @@ -62,7 +62,7 @@ describe("on Stalwart before 0.16", () => { it("works out nodeType from the file properties, so folders stay folders", () => { client.session = session(OLD_SERVER); - const out = withNodeType([ + const out = normalizeFileNodes([ { id: "1", name: "Documents", blobId: null, size: null, type: null }, { id: "2", name: "notes.txt", blobId: "b1", size: 11, type: "text/plain" }, { id: "3", name: "empty.txt", blobId: "b2", size: 0, type: null }, @@ -72,7 +72,7 @@ describe("on Stalwart before 0.16", () => { it("does not overwrite a nodeType that did come back", () => { client.session = session(OLD_SERVER); - const out = withNodeType([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial[]); + const out = normalizeFileNodes([{ id: "1", name: "x", nodeType: "symlink", blobId: "b1" }] as Partial[]); expect(out[0]!.nodeType).toBe("symlink"); }); }); @@ -81,3 +81,39 @@ it("assumes the older shape when there is no session yet", () => { client.session = null; expect(supportsNodeType()).toBe(false); }); + +/** + * Rights were split up in 0.16. Before that a node carried mayRead / mayWrite / + * mayShare, with mayWrite covering everything the newer release names + * separately — so Rename and Delete sat permanently greyed out, doing nothing + * and saying nothing. + */ +describe("rights on a pre-0.16 server", () => { + const oldRights = (mayWrite: boolean) => ({ mayRead: true, mayWrite, mayShare: false }); + + it("widens mayWrite into the rights the UI gates on", () => { + client.session = session(OLD_SERVER); + const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(true) }] as unknown as Partial[]); + expect(node!.myRights).toMatchObject({ mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: true, mayModifyContent: true, mayShare: false }); + }); + + it("does not hand out rights the server withheld", () => { + client.session = session(OLD_SERVER); + const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: oldRights(false) }] as unknown as Partial[]); + expect(node!.myRights).toMatchObject({ mayRename: false, mayDelete: false, mayModifyContent: false }); + }); + + it("leaves rights that already use the newer names untouched", () => { + client.session = session(OLD_SERVER); + const newer = { mayRead: true, mayAddChildren: true, mayRename: true, mayDelete: false, mayModifyContent: true, mayShare: true }; + const [node] = normalizeFileNodes([{ id: "1", name: "x", myRights: newer }] as unknown as Partial[]); + expect(node!.myRights).toEqual(newer); + }); + + it("copes with a node that reported no rights at all", () => { + client.session = session(OLD_SERVER); + const [node] = normalizeFileNodes([{ id: "1", name: "x" }] as Partial[]); + expect(node!.myRights).toBeUndefined(); + expect(node!.nodeType).toBe("directory"); + }); +}); diff --git a/web/src/lib/filenode.ts b/web/src/lib/filenode.ts index 39e38b6..a4270a3 100644 --- a/web/src/lib/filenode.ts +++ b/web/src/lib/filenode.ts @@ -43,12 +43,30 @@ export function fileCreate(parentId: Id | null, name: string, blobId: Id, type: } /** - * Fill in `nodeType` where the server does not report it, so everything - * downstream — icons, sorting, "is this a folder" — can rely on it. + * Fill in what an older server does not report, so everything downstream — + * icons, sorting, "may I delete this" — can read the 0.16 shape. + * + * Rights were split up in 0.16. Before that a node carried `mayRead`, + * `mayWrite` and `mayShare`, with the one `mayWrite` covering everything the + * newer release names separately. Without translating it, the Rename and + * Delete menu items sit permanently greyed out: no error, just nothing. */ -export function withNodeType>(nodes: T[]): T[] { +export function normalizeFileNodes>(nodes: T[]): T[] { if (supportsNodeType()) return nodes; - return nodes.map((n) => (n.nodeType ? n : { ...n, nodeType: isFile(n) ? "file" : "directory" })); + return nodes.map((n) => ({ + ...n, + nodeType: n.nodeType ?? (isFile(n) ? "file" : "directory"), + myRights: widenRights(n.myRights), + })); +} + +type Rights = FileNode["myRights"]; + +function widenRights(rights: Rights | undefined): Rights | undefined { + if (!rights) return rights; + const r = rights as Rights & { mayWrite?: boolean }; + if (r.mayDelete !== undefined || r.mayWrite === undefined) return rights; // already the newer shape + return { ...r, mayAddChildren: r.mayWrite, mayRename: r.mayWrite, mayDelete: r.mayWrite, mayModifyContent: r.mayWrite }; } function isFile(n: Partial): boolean { diff --git a/web/src/lib/signatureImages.ts b/web/src/lib/signatureImages.ts index 9f73fe2..52d4d8c 100644 --- a/web/src/lib/signatureImages.ts +++ b/web/src/lib/signatureImages.ts @@ -6,7 +6,7 @@ */ import { CAP, client, setErrorMessage } from "@/jmap/client"; import type { FileNode, GetResponse, QueryResponse, SetResponse } from "@/jmap/types"; -import { directoryCreate, fileCreate, supportsNodeType, withNodeType } from "@/lib/filenode"; +import { directoryCreate, fileCreate, supportsNodeType, normalizeFileNodes } from "@/lib/filenode"; import { useSession } from "@/store/session"; import { toast } from "@/ui/toast"; @@ -22,14 +22,14 @@ async function ensureFolder(accountId: string): Promise { ["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 = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); + list = normalizeFileNodes((res.get("g")?.[0] as unknown as GetResponse).list); } catch { // Older servers: no filter support — scan everything. 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 = withNodeType((res.get("g")?.[0] as unknown as GetResponse).list); + 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; diff --git a/web/src/store/files.ts b/web/src/store/files.ts index 21ba126..df6821d 100644 --- a/web/src/store/files.ts +++ b/web/src/store/files.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import { CAP, JmapMethodError, client, setErrorMessage } from "@/jmap/client"; -import { directoryCreate, fileCreate, fileNodeProps, withNodeType } from "@/lib/filenode"; +import { directoryCreate, fileCreate, fileNodeProps, normalizeFileNodes } from "@/lib/filenode"; import type { FileNode, GetResponse, Id, QueryResponse, SetResponse } from "@/jmap/types"; import { useSession } from "./session"; @@ -41,7 +41,7 @@ async function loadAllNodes(accountId: Id, set: (fn: (s: FilesState) => Partial< ]); const q = res.get("q")?.[0] as unknown as QueryResponse; const g = res.get("g")?.[0] as unknown as GetResponse; - all.push(...withNodeType(g.list)); + all.push(...normalizeFileNodes(g.list)); position += q.ids.length; if (!q.ids.length || (q.total != null && position >= q.total)) break; } @@ -90,7 +90,7 @@ export const useFiles = create((set, get) => ({ const g = res.get("g")?.[0] as unknown as GetResponse; set((s) => { const nodes = { ...s.nodes }; - for (const n of withNodeType(g.list)) nodes[n.id] = n; + for (const n of normalizeFileNodes(g.list)) nodes[n.id] = n; return { nodes, children: { ...s.children, [parentId ?? "root"]: q.ids }, loading: false, error: null }; }); } catch (err) {