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:
jcoffey-dev committed 2026-09-19 14:11:50 -07:00
commit f8119fafbf
20 files changed
+532 -70

No files matched your search

+74 -22
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
import { AlertOctagon, Archive, ArrowDown, ArrowUp, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
import { useMail } from "@/store/mail";
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/mailbox/emptyFolder";
import { labelTree, visibleLabels } from "@/lib/mailbox/labelTree";
@@ -14,6 +14,7 @@ import { toast } from "@/ui/toast";
import { MailboxPicker } from "./MailboxPicker";
import { loadRaw, saveJson } from "@/lib/storage";
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/mailbox/folderMove";
import { canPlaceFolder, compareFolders, neighbour, placeFolder, type Placement } from "@/lib/mailbox/folderOrder";
import { haptic, useTouchRow } from "@/lib/input/touch";
import { plural, t } from "@/lib/i18n";
import { mailboxDisplayName } from "@/lib/mailbox/mailboxName";
@@ -78,8 +79,32 @@ export function MailboxTree() {
}
};
// Tree: A–Z at every level (Inbox pinned to the top of the root), subfolders nested and
// collapsed by default. Expansion state is remembered per folder.
/** Whether the folder in flight may go just above or below this folder. */
const canPlace = (targetId: Id, placement: Placement): boolean => Boolean(draggingId) && canPlaceFolder(mailboxes, draggingId!, targetId, placement);
/** Put a folder just above or below another: a drag between rows, or Move up / Move down. */
const placeFolderAt = async (id: Id, targetId: Id, placement: Placement) => {
setDraggingId(null);
const updates = placeFolder(mailboxes, id, targetId, placement);
if (!updates) return;
try {
await useMail.getState().arrangeMailboxes(updates);
const parentId = updates[id]?.parentId;
if (parentId) {
const next = { ...expanded, [parentId]: true };
setExpanded(next);
saveJson("mbx-expanded", next);
}
} catch (err) {
toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(mailboxes[id]!), reason: (err as Error).message }));
}
};
const shown = (m: Mailbox) => showHidden || m.isSubscribed || m.role === "inbox";
// Tree: in `compareFolders` order at every level (Inbox, then any order the
// user has dragged into place, then the special folders, then A–Z),
// subfolders nested and collapsed by default. Expansion state is remembered
// per folder.
const [expanded, setExpanded] = useState<Record<Id, boolean>>(() => loadRaw("mbx-expanded", {}));
const toggle = (id: Id) => {
const next = { ...expanded, [id]: !expanded[id] };
@@ -87,17 +112,13 @@ export function MailboxTree() {
saveJson("mbx-expanded", next);
};
const { rows, childrenOf, subtreeUnread } = useMemo(() => {
const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox");
const all = Object.values(mailboxes).filter(shown);
const byParent = new Map<Id | null, Mailbox[]>();
for (const m of all) {
const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null;
byParent.set(p, [...(byParent.get(p) ?? []), m]);
}
const cmp = (a: Mailbox, b: Mailbox) => {
if ((a.role === "inbox") !== (b.role === "inbox")) return a.role === "inbox" ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
};
for (const list of byParent.values()) list.sort(cmp);
for (const list of byParent.values()) list.sort(compareFolders);
const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = [];
const unreadBelow = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + unreadBelow(c.id), 0);
const walk = (parent: Id | null, depth: number) => {
@@ -207,6 +228,8 @@ export function MailboxTree() {
onFolderDragStart={() => {}}
onFolderDragEnd={() => {}}
onFolderDrop={() => {}}
canPlace={() => false}
onFolderPlace={() => {}}
/>
</>
)}
@@ -229,6 +252,8 @@ export function MailboxTree() {
onFolderDragStart={() => setDraggingId(m.id)}
onFolderDragEnd={() => { setDraggingId(null); setRootDrop(false); }}
onFolderDrop={(id) => void moveFolder(id, m.id)}
canPlace={(placement) => canPlace(m.id, placement) && !(placement === "after" && open && hasChildren)}
onFolderPlace={(id, placement) => void placeFolderAt(id, m.id, placement)}
/>
))}
{/* Labels are a flat list that belongs to the mailbox, not to whichever
@@ -260,7 +285,11 @@ export function MailboxTree() {
)}
</nav>
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} />}
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} onStep={(direction) => {
menu.close();
const to = neighbour(mailboxes, menuTarget.id, direction, shown);
if (to) void placeFolderAt(menuTarget.id, to.targetId, to.placement);
}} canStep={(direction) => Boolean(neighbour(mailboxes, menuTarget.id, direction, shown))} />}
</Popover>
{moveTarget && (
<MailboxPicker
@@ -280,8 +309,9 @@ export function MailboxTree() {
);
}
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void }) {
const [dropping, setDropping] = useState(false);
function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, onDrillIn, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop, canPlace, onFolderPlace }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => void; onDrillIn?: () => void; currentId?: string; onMenu: (m: Mailbox, e: { currentTarget: Element }) => void; dragging: boolean; acceptsFolder: boolean; onFolderDragStart: () => void; onFolderDragEnd: () => void; onFolderDrop: (id: Id) => void; canPlace: (placement: Placement) => boolean; onFolderPlace: (id: Id, placement: Placement) => void }) {
/** Where a drop here would land: in this folder, or just above or below it. */
const [drop, setDrop] = useState<"into" | Placement | null>(null);
/** Expanding in place and drilling in are the same relationship; only one shows. */
const twisty = hasChildren && !onDrillIn;
// Scheduled counts like Drafts: everything in it is already read, so the
@@ -297,19 +327,37 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
// Subscribed, not read once: picking a color has to repaint the row.
const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id));
/*
* A folder dropped on the top or bottom quarter of a row goes above or below
* it; anywhere else, into it. Where "into" isn't allowed -- a special folder,
* which can be reordered but never nested -- the whole row reorders, by
* whichever half the pointer is in.
*/
const folderZone = (e: DragEvent): "into" | Placement | null => {
const r = e.currentTarget.getBoundingClientRect();
const y = e.clientY - r.top;
const edge = r.height / 4;
const zone = y < edge ? "before" : y > r.height - edge ? "after" : "into";
if (zone !== "into" && canPlace(zone)) return zone;
if (acceptsFolder) return "into";
const half = y < r.height / 2 ? "before" : "after";
return canPlace(half) ? half : null;
};
const onDragOver = (e: DragEvent) => {
const folder = e.dataTransfer.types.includes(FOLDER_MIME);
if (folder ? !acceptsFolder : !e.dataTransfer.types.includes("application/x-ihasmail-emails")) return;
const zone = e.dataTransfer.types.includes(FOLDER_MIME) ? folderZone(e) : e.dataTransfer.types.includes("application/x-ihasmail-emails") ? "into" : null;
if (!zone) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dropping) setDropping(true);
if (drop !== zone) setDrop(zone);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setDropping(false);
setDrop(null);
const folderId = e.dataTransfer.getData(FOLDER_MIME);
if (folderId) {
if (acceptsFolder) onFolderDrop(folderId);
const zone = folderZone(e);
if (zone === "into") onFolderDrop(folderId);
else if (zone) onFolderPlace(folderId, zone);
return;
}
const raw = e.dataTransfer.getData("application/x-ihasmail-emails");
@@ -348,16 +396,17 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
return (
<Link
href={`/mail/${m.id}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${dropping ? "drop-target" : ""} ${dragging ? "dragging" : ""}`}
className={`nav-item folder-row depth-${Math.min(depth, 4)} ${currentId === m.id ? "active" : ""} ${unread ? "unread" : ""} ${drop === "into" ? "drop-target" : drop ? `drop-${drop}` : ""} ${dragging ? "dragging" : ""}`}
title={label}
{...press}
// Dragging a folder is a mouse gesture; on a touchscreen the browser
// starts it from the same long press that now opens the menu.
draggable={movable(m) && !isTouch}
// starts it from the same long press that now opens the menu. Special
// folders drag too, to be reordered; only Inbox, always first, stays put.
draggable={m.role !== "inbox" && !isTouch}
onDragStart={onDragStart}
onDragEnd={onFolderDragEnd}
onDragOver={onDragOver}
onDragLeave={() => setDropping(false)}
onDragLeave={() => setDrop(null)}
onDrop={onDrop}
onContextMenu={(e) => {
e.preventDefault();
@@ -424,7 +473,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
);
}
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void }) {
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove, onStep, canStep }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void; onStep: (direction: "up" | "down") => void; canStep: (direction: "up" | "down") => boolean }) {
const shared = Object.keys(m.shareWith ?? {}).length > 0;
const [, navigate] = useLocation();
const colors = useSettings((s) => s.settings.folderColors);
@@ -490,6 +539,9 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: {
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={onMove} disabled={!movable(m) || !m.myRights.mayRename} />
{/* The way to reorder without a drag: from the keyboard, and on touch. */}
<MenuItem icon={<ArrowUp size={16} />} label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} />
<MenuItem icon={<ArrowDown size={16} />} label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} />
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("Show in list")} onClick={() => void useMail.getState().updateMailbox(m.id, { isSubscribed: !m.isSubscribed })} disabled={m.role === "inbox"} />
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
stores the share, and it never reaches the other account -- its own
@@ -0,0 +1,112 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MailboxTree } from "../MailboxTree";
import { useMail } from "@/store/mail";
import { useSettings } from "@/store/settings";
import type { Mailbox, MailboxRole } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/**
* Reordering folders in the sidebar (#402), driven through the real tree.
*
* The placement arithmetic has its own tests in lib/mailbox; these are about
* the row deciding where a drop lands from where the pointer is, which only
* the component knows.
*/
window.matchMedia = ((q: string) => ({ matches: false, media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true };
const box = (id: string, name: string, parentId: string | null, role: MailboxRole = null): Mailbox => ({
id, name, parentId, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: rights, isSubscribed: true,
});
const MAILBOXES = {
zeta: box("zeta", "Zeta", null),
trash: box("trash", "Deleted Items", null, "trash"),
sent: box("sent", "Sent", null, "sent"),
inbox: box("inbox", "Inbox", null, "inbox"),
alpha: box("alpha", "Alpha", null),
drafts: box("drafts", "Drafts", null, "drafts"),
};
/** jsdom has no DataTransfer; this is the part of one the tree touches. */
function transfer() {
const data: Record<string, string> = {};
return {
get types() { return Object.keys(data); },
setData: (k: string, v: string) => { data[k] = v; },
getData: (k: string) => data[k] ?? "",
effectAllowed: "", dropEffect: "",
};
}
function fire(el: Element, type: string, dataTransfer: ReturnType<typeof transfer>, clientY = 0) {
const e = new Event(type, { bubbles: true, cancelable: true });
Object.assign(e, { dataTransfer, clientY });
act(() => { el.dispatchEvent(e); });
}
describe("reordering folders in the tree", () => {
let host: HTMLDivElement;
let root: Root;
const arrange = vi.fn(async (_updates: Record<string, Partial<Mailbox>>) => {});
const updateMailbox = vi.fn(async (_id: string, _patch: Partial<Mailbox>) => {});
const rows = () => Array.from(document.querySelectorAll(".nav-item.folder-row")).map((r) => r.querySelector(".nav-label")?.textContent);
const rowFor = (name: string) => Array.from(document.querySelectorAll<HTMLElement>(".nav-item.folder-row")).find((r) => r.querySelector(".nav-label")?.textContent === name)!;
/** Drag `from` over `to` at a fraction of its height, drop, and say what the row showed. */
function drag(from: string, to: string, frac: number) {
const dt = transfer();
const target = rowFor(to);
// Every row is 36px tall, from 100px down the page.
target.getBoundingClientRect = () => ({ top: 100, height: 36, bottom: 136, left: 0, right: 200, width: 200, x: 0, y: 100, toJSON() {} });
fire(rowFor(from), "dragstart", dt);
fire(target, "dragover", dt, 100 + 36 * frac);
const shown = /drop-(before|after|target)/.exec(target.className)?.[1] ?? null;
fire(target, "drop", dt, 100 + 36 * frac);
return shown;
}
beforeEach(() => {
arrange.mockClear();
updateMailbox.mockClear();
window.history.replaceState({}, "", "/mail/inbox");
useMail.setState({ mailboxes: MAILBOXES, mailboxesLoaded: true, arrangeMailboxes: arrange, updateMailbox });
useSettings.setState((s) => ({ settings: { ...s.settings, showHiddenFolders: false, labelsSidebar: false } }));
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
act(() => root.render(<MailboxTree />));
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
it("lists special folders under Inbox before the rest, until something is dragged", () => {
expect(rows()).toEqual(["Inbox", "Drafts", "Sent", "Deleted Items", "Alpha", "Zeta"]);
});
it("puts a folder above the row when it's dropped on the row's top edge", () => {
expect(drag("Zeta", "Drafts", 0.1)).toBe("before");
expect(arrange).toHaveBeenCalledWith({
zeta: { sortOrder: 20 }, drafts: { sortOrder: 30 }, sent: { sortOrder: 40 }, trash: { sortOrder: 50 }, alpha: { sortOrder: 60 }, inbox: { sortOrder: 10 },
});
});
it("nests a folder dropped on the middle of an ordinary folder, as before", () => {
expect(drag("Zeta", "Alpha", 0.5)).toBe("target");
expect(updateMailbox).toHaveBeenCalledWith("zeta", { parentId: "alpha" });
expect(arrange).not.toHaveBeenCalled();
});
it("reorders a special folder by the nearer half, since it can't be nested", () => {
expect(drag("Deleted Items", "Drafts", 0.4)).toBe("before");
expect(Object.keys(arrange.mock.calls[0]![0])).toContain("trash");
});
it("drops nothing above Inbox", () => {
expect(drag("Sent", "Inbox", 0.1)).toBeNull();
expect(arrange).not.toHaveBeenCalled();
});
});
@@ -183,6 +183,7 @@ export function GeneralSettings() {
<Switch locked={isEnforced("includeQuote")} checked={s.includeQuote} onChange={(v) => update({ includeQuote: v })} label={t("Quote original message in replies")} />
<Switch locked={isEnforced("signatureAboveQuote")} checked={s.signatureAboveQuote} onChange={(v) => update({ signatureAboveQuote: v })} label={t("Place signature above quoted text")} />
<Switch locked={isEnforced("spellcheck")} checked={s.spellcheck} onChange={(v) => update({ spellcheck: v })} label={t("Spell check while typing")} />
<Switch locked={isEnforced("composeMaximized")} checked={s.composeMaximized} onChange={(v) => update({ composeMaximized: v })} label={t("Open the composer full screen")} />
<h2>{t("Locale")}</h2>
<div className="field-row">