import { lazy, Suspense, useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; 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"; import { isScheduledMailbox } from "@/store/scheduled"; import { useSettings } from "@/store/settings"; import type { Id, Mailbox } from "@/jmap/types"; import { MenuItem, MenuSep, MenuTitle, Popover, useMenu } from "@/ui/popover"; import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc"; import { confirmDialog, promptDialog } from "@/ui/dialog"; 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"; // Loaded when first opened: it is not needed to show mail, and it is not small. const ShareDialog = lazy(() => import("../settings/ShareDialog").then((m) => ({ default: m.ShareDialog }))); const ROLE_ICONS: Record = { inbox: , drafts: , sent: , trash: , junk: , archive: , all: , flagged: , important: , }; /** Its own drag type, so a folder can only be dropped where folders belong. */ const FOLDER_MIME = "application/x-ihasmail-folder"; export function MailboxTree() { const mailboxes = useMail((s) => s.mailboxes); const loaded = useMail((s) => s.mailboxesLoaded || s.mailboxesCached); const [location] = useLocation(); const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined; const showHidden = useSettings((s) => s.settings.showHiddenFolders); const labels = useSettings((s) => s.settings.labels); const labelsSidebar = useSettings((s) => s.settings.labelsSidebar); const labelCounts = useMail((s) => s.labelCounts); const shownLabels = useMemo(() => visibleLabels(labelTree(labels, labelCounts)), [labels, labelCounts]); const menu = useMenu(); const [menuTarget, setMenuTarget] = useState(null); const [shareTarget, setShareTarget] = useState(null); /** The folder being moved from its menu -- the way to move one without a drag, and on touch the only way. */ const [moveTarget, setMoveTarget] = useState(null); /** * The folder being dragged. Held here rather than read from the drag itself: * dataTransfer.getData is blocked during dragover, so a row cannot ask what * is over it, and every row needs to know whether it is a legal target. */ const [draggingId, setDraggingId] = useState(null); const [rootDrop, setRootDrop] = useState(false); /** Whether the folder in flight may be dropped on this folder, or on the root. */ const canDropOn = (targetId: Id | null): boolean => Boolean(draggingId) && canDropFolder(mailboxes, draggingId!, targetId); const moveFolder = async (id: Id, parentId: Id | null) => { const m = mailboxes[id]; setDraggingId(null); try { await useMail.getState().updateMailbox(id, { parentId }); // Show where it landed rather than leaving it hidden in a closed parent. if (parentId) { const next = { ...expanded, [parentId]: true }; setExpanded(next); saveJson("mbx-expanded", next); } toast.success(parentId ? t("“{name}” moved into “{parent}”", { name: mailboxDisplayName(m), parent: mailboxDisplayName(mailboxes[parentId]) }) : t("“{name}” moved to the top level", { name: mailboxDisplayName(m) })); } catch (err) { toast.error(t("Could not move “{name}”: {reason}", { name: mailboxDisplayName(m), reason: (err as Error).message })); } }; /** 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>(() => loadRaw("mbx-expanded", {})); const toggle = (id: Id) => { const next = { ...expanded, [id]: !expanded[id] }; setExpanded(next); saveJson("mbx-expanded", next); }; const { rows, childrenOf, subtreeUnread } = useMemo(() => { const all = Object.values(mailboxes).filter(shown); const byParent = new Map(); for (const m of all) { const p = m.parentId && mailboxes[m.parentId] ? m.parentId : null; byParent.set(p, [...(byParent.get(p) ?? []), m]); } 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) => { for (const m of byParent.get(parent) ?? []) { const kids = byParent.get(m.id) ?? []; const open = Boolean(expanded[m.id]); const childUnread = kids.length ? unreadBelow(m.id) : 0; out.push({ m, depth, hasChildren: kids.length > 0, open, hiddenUnread: kids.length && !open ? childUnread : 0, childUnread }); if (kids.length && open) walk(m.id, depth + 1); } }; walk(null, 0); return { rows: out, childrenOf: (id: Id | null) => byParent.get(id) ?? [], subtreeUnread: unreadBelow }; }, [mailboxes, showHidden, expanded]); /* * On a phone the tree is a drill-down instead: one level at a time, a back * row above it, no indent. The tree earns its indent on a wide sidebar and * cannot pay for it in a 300px drawer -- four levels down, the 16px steps and * the 18px twisty left a folder 85px to print its name in, and the twisty had * walked far enough right to be hard to hit at all. Width picks the mode, not * the pointer: this is a layout that does not fit, not a target that is small. */ const isMobile = useIsMobile(); const [drillId, setDrillId] = useState(null); const drill = drillId && mailboxes[drillId] ? mailboxes[drillId] : null; /* * Follow the reader into whichever folder they opened, so the drawer comes * back at the level they were last looking at rather than at the root they * would have to walk down from again. */ useEffect(() => { if (!isMobile || !currentId) return; const m = mailboxes[currentId]; if (m) setDrillId(m.parentId && mailboxes[m.parentId] ? m.parentId : null); }, [isMobile, currentId, mailboxes]); const createFolder = async (parentId: Id | null) => { const name = await promptDialog({ title: parentId ? t("New subfolder") : t("New folder"), placeholder: t("Folder name") }); if (!name?.trim()) return; try { await useMail.getState().createMailbox(name.trim(), parentId); toast.success(t("Folder “{name}” created", { name: name.trim() })); } catch (err) { toast.error((err as Error).message); } }; if (!loaded) { return (
{[...Array(6)].map((_, i) => (
))}
); } return ( <> {menuTarget && 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))} />} {moveTarget && ( canMoveFolderTo(mailboxes, moveTarget.id, id)} root={canMoveFolderTo(mailboxes, moveTarget.id, null) ? { label: t("Top level"), onPick: () => { setMoveTarget(null); void moveFolder(moveTarget.id, null); } } : undefined} onClose={() => setMoveTarget(null)} onPick={(id) => { setMoveTarget(null); void moveFolder(moveTarget.id, id); }} /> )} {shareTarget && setShareTarget(null)} />} ); } 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 // useful number is how many messages are waiting, not how many are unseen. const scheduled = isScheduledMailbox(m); const own = m.role === "drafts" || scheduled ? m.totalEmails : m.unreadEmails; const count = own + hiddenUnread; // Bold when this folder has unread mail, or any folder beneath it does (parent + child both bold). const unread = m.role !== "drafts" && m.role !== "trash" && m.role !== "junk" && m.role !== "sent" && !scheduled ? m.unreadEmails + childUnread > 0 : m.unreadEmails > 0 && m.role !== "drafts" && !scheduled; const icon = m.role && ROLE_ICONS[m.role] ? ROLE_ICONS[m.role] : scheduled ? : ; // A chosen color tints the icon only; the label keeps the tree's own // contrast, which a dozen arbitrary colors would not reliably give it. // 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 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 (drop !== zone) setDrop(zone); }; const onDrop = (e: DragEvent) => { e.preventDefault(); setDrop(null); const folderId = e.dataTransfer.getData(FOLDER_MIME); if (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"); if (!raw) return; try { const ids = JSON.parse(raw) as string[]; void useMail.getState().move(ids, m.id); } catch { /* ignore */ } }; /* * Hold a folder for its menu, which is the same menu the ⋮ opens. * * The button is already visible where there is no hover, so this is not the * only way in — but a 24px target beside a folder name is not what a thumb * aims at, and a right-click has no touchscreen equivalent to inherit. */ const isTouch = useIsTouch(); const press = useTouchRow({ enabled: isTouch, onLongPress: (target) => { haptic(15); onMenu(m, { currentTarget: target }); }, }); const onDragStart = (e: DragEvent) => { e.dataTransfer.setData(FOLDER_MIME, m.id); e.dataTransfer.effectAllowed = "move"; // A folder row is a link, and a link drag would otherwise carry its URL. e.stopPropagation(); onFolderDragStart(); }; return ( setDrop(null)} onDrop={onDrop} onContextMenu={(e) => { e.preventDefault(); onMenu(m, { currentTarget: e.currentTarget }); }} > {/* Drilling replaces expanding, so the twisty goes with it -- two controls for one relationship, on opposite ends of the same row, is worse than either alone. */} { e.preventDefault(); e.stopPropagation(); onToggle(); } : undefined } > {twisty ? open ? : : null} {icon} {label} {count > 0 && {count > 9999 ? "9999+" : count}} {count > 0 && } {/* Drilling in is a separate control from opening the folder, and sits at the right edge where it is the same size and the same place on every row -- unlike the twisty, which walks right with the indent and shrinks the name as it goes. Tapping the row still opens the folder, which is what a folder is for; this only changes what the list underneath shows. */} {onDrillIn && ( )} ); } 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); const update = useSettings((s) => s.update); const hasChildren = useMail((s) => Object.values(s.mailboxes).some((x) => (x.parentId ?? null) === m.id)); const subUnread = useMail((s) => { const all = Object.values(s.mailboxes); let n = 0; const walk = (parent: Id) => { for (const x of all) if ((x.parentId ?? null) === parent) { n += x.unreadEmails; walk(x.id); } }; walk(m.id); return n; }); const rename = async () => { const name = await // The server's own name, never the localized one: this box writes // back whatever it is prefilled with. promptDialog({ title: t("Rename folder"), defaultValue: m.name }); if (!name?.trim() || name.trim() === m.name) return; try { await useMail.getState().updateMailbox(m.id, { name: name.trim() }); } catch (err) { toast.error((err as Error).message); } }; const remove = async () => { const ok = await confirmDialog({ title: t("Delete “{name}”?", { name: mailboxDisplayName(m) }), message: plural(m.totalEmails, { one: "This permanently deletes the folder and its {n} message.", other: "This permanently deletes the folder and its {n} messages." }), confirmLabel: t("Delete"), danger: true }); if (!ok) return; try { await useMail.getState().destroyMailbox(m.id, true); toast.success(t("Folder deleted")); navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`); } catch (err) { toast.error((err as Error).message); } }; const empty = () => confirmAndEmpty({ id: m.id, name: mailboxDisplayName(m), role: m.role, totalEmails: m.totalEmails }); const isSpecial = Boolean(m.role) && m.role !== "subscribed"; const color = folderColor(colors, m.id); const setColor = (c: string | null) => { onClose(); const next = { ...colors }; if (c) next[m.id] = c; else delete next[m.id]; update({ folderColors: next }); }; return ( <> } label={t("Mark all as read")} onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} /> {hasChildren && ( } label={t("Mark all as read, incl. subfolders")} kbd={m.unreadEmails + subUnread ? String(m.unreadEmails + subUnread) : undefined} onClick={() => void useMail.getState().markMailboxRead(m.id, true)} disabled={!m.unreadEmails && !subUnread} /> )} } label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} /> } label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} /> } 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. */} } label={t("Move up")} onClick={() => onStep("up")} disabled={!canStep("up")} /> } label={t("Move down")} onClick={() => onStep("down")} disabled={!canStep("down")} /> : } 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 docs list calendars, address books and files as shareable and not mail folders. Offering it produced shares that looked real and did nothing. One that already exists can still be cleared here, which is the only reason this entry survives at all. */} {shared && } label={t("Stop sharing")} onClick={onShare} />} {t("Color")}
{CALENDAR_COLORS.map((c) => (
{color && } label={t("Use the default color")} onClick={() => setColor(null)} />} {canEmpty(m.role) && } label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />} } label={t("Delete folder")} onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} /> ); }