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, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2 } from "lucide-react"; import { useMail } from "@/store/mail"; import { isScheduledMailbox } from "@/store/scheduled"; import { useSettings } from "@/store/settings"; import type { Id, Mailbox } from "@/jmap/types"; import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover"; import { confirmDialog, promptDialog } from "@/ui/dialog"; import { toast } from "@/ui/toast"; import { ShareDialog } from "../settings/ShareDialog"; import { loadRaw, saveJson } from "@/lib/storage"; const ROLE_ICONS: Record = { inbox: , drafts: , sent: , trash: , junk: , archive: , all: , flagged: , important: , }; 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); // 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 }: { 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 }) { 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 ? : ; const onDragOver = (e: DragEvent) => { if (!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 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 */ } }; 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, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) { const [, navigate] = useLocation(); 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 = async () => { const ok = await confirmDialog({ title: `Empty “${m.name}”?`, message: `All ${m.totalEmails} messages will be permanently deleted.`, confirmLabel: "Empty folder", danger: true }); if (ok) await useMail.getState().emptyMailbox(m.id); }; const isSpecial = Boolean(m.role) && m.role !== "subscribed"; 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"} /> } label="Share…" onClick={onShare} /> {m.role === "trash" && } label="Empty folder" onClick={() => void empty()} danger />} } label="Delete folder" onClick={() => void remove()} danger disabled={isSpecial || !m.myRights.mayDelete} /> ); }