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:
@@ -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 A–Z, 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" });
|
||||
});
|
||||
});
|
||||
@@ -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 A–Z.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user