import { useMemo, useState, type DragEvent, type ReactNode } from "react"; import { Link, useLocation } from "wouter"; import { AlertOctagon, Archive, ChevronDown, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X } from "lucide-react"; import { useMail } from "@/store/mail"; import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder"; 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 } from "@/ui/misc"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ShareDialog } from "../settings/ShareDialog"; import { loadRaw, saveJson } from "@/lib/storage"; import { canDropFolder, folderColor, movable } from "@/lib/folderMove"; 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); 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 menu = useMenu(); const [menuTarget, setMenuTarget] = useState(null); const [shareTarget, setShareTarget] = 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 ? `“${m?.name}” moved into “${mailboxes[parentId]?.name}”` : `“${m?.name}” moved to the top level`); } catch (err) { toast.error(`Could not move “${m?.name}”: ${(err as Error).message}`); } }; // 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. 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 = useMemo(() => { const all = Object.values(mailboxes).filter((m) => showHidden || m.isSubscribed || m.role === "inbox"); 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]); } 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 }); }; const out: Array<{ m: Mailbox; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number }> = []; const subtreeUnread = (id: Id): number => (byParent.get(id) ?? []).reduce((n, c) => n + c.unreadEmails + subtreeUnread(c.id), 0); const walk = (parent: Id | null, depth: number) => { for (const m of (byParent.get(parent) ?? []).sort(cmp)) { const kids = byParent.get(m.id) ?? []; const open = Boolean(expanded[m.id]); const childUnread = kids.length ? subtreeUnread(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 out; }, [mailboxes, showHidden, expanded]); const createFolder = async (parentId: Id | null) => { const name = await promptDialog({ title: parentId ? "New subfolder" : "New folder", placeholder: "Folder name" }); if (!name?.trim()) return; try { await useMail.getState().createMailbox(name.trim(), parentId); toast.success(`Folder “${name.trim()}” created`); } catch (err) { toast.error((err as Error).message); } }; if (!loaded) { return (
{[...Array(6)].map((_, i) => (
))}
); } return ( <> {menuTarget && void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />} {shareTarget && setShareTarget(null)} />} ); } function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread, childUnread, onToggle, currentId, onMenu, dragging, acceptsFolder, onFolderDragStart, onFolderDragEnd, onFolderDrop }: { mailbox: Mailbox; label: string; depth: number; hasChildren: boolean; open: boolean; hiddenUnread: number; childUnread: number; onToggle: () => 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); // 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 colour tints the icon only; the label keeps the tree's own // contrast, which a dozen arbitrary colours would not reliably give it. // Subscribed, not read once: picking a colour has to repaint the row. const tint = useSettings((s) => folderColor(s.settings.folderColors, m.id)); const onDragOver = (e: DragEvent) => { const folder = e.dataTransfer.types.includes(FOLDER_MIME); if (folder ? !acceptsFolder : !e.dataTransfer.types.includes("application/x-ihasmail-emails")) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (!dropping) setDropping(true); }; const onDrop = (e: DragEvent) => { e.preventDefault(); setDropping(false); const folderId = e.dataTransfer.getData(FOLDER_MIME); if (folderId) { if (acceptsFolder) onFolderDrop(folderId); 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 */ } }; 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 ( setDropping(false)} onDrop={onDrop} onContextMenu={(e) => { e.preventDefault(); onMenu(m, { currentTarget: e.currentTarget }); }} > { e.preventDefault(); e.stopPropagation(); onToggle(); } : undefined } > {hasChildren ? open ? : : null} {icon} {label} {count > 0 && {count > 9999 ? "9999+" : count}} {count > 0 && } ); } function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) { 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 promptDialog({ title: "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: `Delete “${m.name}”?`, message: `This permanently deletes the folder and its ${m.totalEmails} message(s).`, confirmLabel: "Delete", danger: true }); if (!ok) return; try { await useMail.getState().destroyMailbox(m.id, true); toast.success("Folder deleted"); navigate(`/mail/${useMail.getState().roleId("inbox") ?? ""}`); } catch (err) { toast.error((err as Error).message); } }; const empty = () => confirmAndEmpty({ id: m.id, name: m.name, 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="Mark all as read" onClick={() => void useMail.getState().markMailboxRead(m.id)} disabled={!m.unreadEmails} /> {hasChildren && ( } label="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="New subfolder" onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} /> } label="Rename" onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} /> : } label={m.isSubscribed ? "Hide from list" : "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="Stop sharing" onClick={onShare} />} Colour
{CALENDAR_COLORS.map((c) => (
{color && } label="Use the default colour" onClick={() => setColor(null)} />} {canEmpty(m.role) && } label={emptyLabel(m)} onClick={() => void empty()} danger disabled={!m.totalEmails} />} } label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} /> ); }