Merge public ihasmail: folder reordering, full-screen composer, Dutch update

- Reorder folders by dragging, with special folders first (#402, #405).
- Open the composer full screen, as a setting (#401, #404).
- Dutch translation update (#403).

FEATURES.md stays deleted here, as in bb25355.
This commit is contained in:
2026-09-19 14:11:50 -07:00
20 changed files with 532 additions and 70 deletions
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, siblingsOf } from "../folderOrder";
import type { Id, Mailbox } from "@/jmap/types";
const RIGHTS = { mayRename: true, mayCreateChild: true } as Mailbox["myRights"];
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null, sortOrder = 0, over: Partial<Mailbox> = {}): Mailbox =>
({ id, name, parentId, role, sortOrder, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true, myRights: RIGHTS, ...over });
const tree = (...list: Mailbox[]): Record<Id, Mailbox> => Object.fromEntries(list.map((m) => [m.id, m]));
/** As Stalwart hands it over before anybody orders anything: every sortOrder 0. */
const fresh = tree(
mb("zeta", "Zeta", null),
mb("trash", "Deleted Items", null, "trash"),
mb("sent", "Sent Items", null, "sent"),
mb("inbox", "Inbox", null, "inbox"),
mb("alpha", "Alpha", null),
mb("junk", "Junk Mail", null, "junk"),
mb("drafts", "Drafts", null, "drafts"),
mb("work", "Work", null),
mb("clients", "Clients", "work"),
);
const names = (all: Record<Id, Mailbox>, parentId: Id | null = null) => siblingsOf(all, parentId).map((m) => m.id);
/** Apply what `placeFolder` asks for, as the server would. */
function apply(all: Record<Id, Mailbox>, updates: Record<Id, Partial<Mailbox>> | null): Record<Id, Mailbox> {
const next = { ...all };
for (const [id, patch] of Object.entries(updates ?? {})) next[id] = { ...next[id]!, ...patch };
return next;
}
describe("compareFolders", () => {
it("lists Inbox, then the special folders in mail-client order, then the rest AZ, when nothing is ordered yet", () => {
// #402: Sent landed fourth from the bottom among the reporter's 88 folders.
expect(names(fresh)).toEqual(["inbox", "drafts", "sent", "junk", "trash", "alpha", "work", "zeta"]);
});
it("puts a saved order ahead of the special-folder default", () => {
const ordered = apply(fresh, { zeta: { sortOrder: 10 }, sent: { sortOrder: 20 }, alpha: { sortOrder: 30 }, drafts: { sortOrder: 40 }, junk: { sortOrder: 50 }, trash: { sortOrder: 60 }, work: { sortOrder: 70 } });
expect(names(ordered)).toEqual(["inbox", "zeta", "sent", "alpha", "drafts", "junk", "trash", "work"]);
});
it("keeps Inbox first whatever its sortOrder says", () => {
const a = mb("inbox", "Inbox", null, "inbox", 99);
const b = mb("alpha", "Alpha", null, null, 1);
expect(compareFolders(a, b)).toBeLessThan(0);
});
it("sorts names numerically, not by character", () => {
const all = tree(mb("f10", "Folder 10", null), mb("f9", "Folder 9", null));
expect(names(all)).toEqual(["f9", "f10"]);
});
});
describe("placeFolder", () => {
it("numbers the whole level 10 apart, with the folder where it was dropped", () => {
const next = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
expect(names(next)).toEqual(["inbox", "zeta", "drafts", "sent", "junk", "trash", "alpha", "work"]);
expect(siblingsOf(next, null).map((m) => m.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70, 80]);
});
it("writes only the folders whose number changes", () => {
const once = apply(fresh, placeFolder(fresh, "zeta", "drafts", "before"));
// Swapping the last two leaves everything above them where it was.
expect(Object.keys(placeFolder(once, "work", "alpha", "before")!).sort()).toEqual(["alpha", "work"]);
});
it("asks for nothing when the folder is dropped where it already is", () => {
expect(placeFolder(fresh, "sent", "drafts", "after")).toBeNull();
expect(placeFolder(fresh, "sent", "junk", "before")).toBeNull();
});
it("moves a folder to another level, and gives it a place there", () => {
const updates = placeFolder(fresh, "alpha", "clients", "before")!;
expect(updates.alpha).toEqual({ sortOrder: 10, parentId: "work" });
expect(names(apply(fresh, updates), "work")).toEqual(["alpha", "clients"]);
});
});
describe("canPlaceFolder", () => {
it("lets a special folder be reordered among its siblings", () => {
expect(canPlaceFolder(fresh, "sent", "alpha", "after")).toBe(true);
});
it("does not let a special folder move to another level", () => {
expect(canPlaceFolder(fresh, "sent", "clients", "before")).toBe(false);
});
it("puts nothing above Inbox", () => {
expect(canPlaceFolder(fresh, "sent", "inbox", "before")).toBe(false);
expect(canPlaceFolder(fresh, "sent", "inbox", "after")).toBe(true);
});
it("does not put a folder inside its own subtree", () => {
expect(canPlaceFolder(fresh, "work", "clients", "before")).toBe(false);
});
it("needs the right to rename, which RFC 8621 folds moving into", () => {
const locked = apply(fresh, { alpha: { myRights: { ...RIGHTS, mayRename: false } } });
expect(canPlaceFolder(locked, "alpha", "zeta", "after")).toBe(false);
});
});
describe("neighbour", () => {
it("steps past the folder above or below", () => {
expect(neighbour(fresh, "alpha", "up")).toEqual({ targetId: "trash", placement: "before" });
expect(neighbour(fresh, "alpha", "down")).toEqual({ targetId: "work", placement: "after" });
});
it("has nowhere to go past either end, or above Inbox", () => {
expect(neighbour(fresh, "zeta", "down")).toBeNull();
expect(neighbour(fresh, "drafts", "up")).toBeNull();
});
it("skips folders that aren't on screen, so every step visibly moves", () => {
const hidden = apply(fresh, { trash: { isSubscribed: false } });
expect(neighbour(hidden, "alpha", "up", (m) => m.isSubscribed)).toEqual({ targetId: "junk", placement: "before" });
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { Id, Mailbox } from "@/jmap/types";
import { ROLE_ORDER } from "@/store/mail/mailboxes";
import { canDropFolder, descendantIds } from "./folderMove";
/**
* The order folders are listed in, at every level of the tree (#402).
*
* Inbox always comes first. After that the folder's own `sortOrder` decides,
* which is where a folder dragged into place keeps its position, and where
* any other JMAP client that orders folders keeps its choice too. Stalwart
* gives every folder 0 until somebody orders it, so for everyone who never
* has, the tie-breaks decide: special folders first, in the usual mail-client
* order (Drafts, Sent, Archive, Junk, Trash), then the rest AZ.
*/
export function compareFolders(a: Mailbox, b: Mailbox): number {
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
const ra = roleRank(a);
const rb = roleRank(b);
if (ra !== rb) return ra - rb;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
}
function roleRank(m: Mailbox): number {
return m.role && m.role in ROLE_ORDER ? ROLE_ORDER[m.role]! : Number.MAX_SAFE_INTEGER;
}
/** Every folder under `parentId` (null: the top level), in list order. */
export function siblingsOf(mailboxes: Record<Id, Mailbox>, parentId: Id | null): Mailbox[] {
return Object.values(mailboxes)
.filter((m) => (m.parentId && mailboxes[m.parentId] ? m.parentId : null) === parentId)
.sort(compareFolders);
}
export type Placement = "before" | "after";
/**
* Whether `draggedId` may be put just above or below `targetId`.
*
* Special folders can be reordered but not reparented, so they may only land
* among their own siblings. Nothing goes above Inbox, which stays first.
*/
export function canPlaceFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): boolean {
const dragged = mailboxes[draggedId];
const target = mailboxes[targetId];
if (!dragged || !target || draggedId === targetId) return false;
if (!dragged.myRights.mayRename) return false;
if (target.role === "inbox" && placement === "before") return false;
if (descendantIds(mailboxes, draggedId).has(targetId)) return false;
const from = parentOf(mailboxes, dragged);
const to = parentOf(mailboxes, target);
return from === to || canDropFolder(mailboxes, draggedId, to);
}
/**
* The updates that put `draggedId` just above or below `targetId`, or null when
* it is already there.
*
* The new level is numbered afresh, 10 apart, so that another client can put
* a folder between two of them without renumbering. Only folders whose number
* actually changes are written.
*/
export function placeFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, targetId: Id, placement: Placement): Record<Id, Partial<Mailbox>> | null {
const dragged = mailboxes[draggedId]!;
const parentId = parentOf(mailboxes, mailboxes[targetId]!);
const reparent = parentOf(mailboxes, dragged) !== parentId;
const current = siblingsOf(mailboxes, parentId);
const order = current.filter((m) => m.id !== draggedId);
const at = order.findIndex((m) => m.id === targetId) + (placement === "after" ? 1 : 0);
order.splice(at, 0, dragged);
// Dropped where it already was. Renumbering would change nothing anyone sees.
if (!reparent && order.every((m, i) => m.id === current[i]!.id)) return null;
const updates: Record<Id, Partial<Mailbox>> = {};
order.forEach((m, i) => {
const sortOrder = (i + 1) * 10;
if (m.sortOrder !== sortOrder) updates[m.id] = { sortOrder };
});
if (reparent) updates[draggedId] = { ...updates[draggedId], parentId };
return updates;
}
/**
* The neighbour to place a folder against for "Move up" / "Move down", if it
* has one. Only folders on screen count (`shown`), so each step visibly moves
* the folder rather than passing a hidden one.
*/
export function neighbour(mailboxes: Record<Id, Mailbox>, id: Id, direction: "up" | "down", shown: (m: Mailbox) => boolean = () => true): { targetId: Id; placement: Placement } | null {
const m = mailboxes[id];
if (!m) return null;
const level = siblingsOf(mailboxes, parentOf(mailboxes, m)).filter((x) => x.id === id || shown(x));
const i = level.findIndex((x) => x.id === id);
const other = level[direction === "up" ? i - 1 : i + 1];
if (!other) return null;
const placement = direction === "up" ? "before" : "after";
return canPlaceFolder(mailboxes, id, other.id, placement) ? { targetId: other.id, placement } : null;
}
function parentOf(mailboxes: Record<Id, Mailbox>, m: Mailbox): Id | null {
return m.parentId && mailboxes[m.parentId] ? m.parentId : null;
}