/** * What every "Move to" list shows: the context menu (and the desktop app's * native copy of it), the reader's Move button, the phone panel, the * selection toolbar and the Move dialog. One model, so they can't drift. * * - The message's own account at the top. Other accounts a move can reach * (shared ones) come after it, each under its own name. * - System folders first, in the order mail clients use (Inbox, Drafts, * Sent, Archive, Spam, Trash), then the user's folders by name, sorted * for the interface language. The server's sortOrder doesn't count here: * the sidebar honours it, a Move list is for finding a folder by name. * - Nesting kept. The open folder stays in the list, disabled, when the * messages are all in it (#1452: a search hit from elsewhere can move in). * - The last few folders moved to, per account, as "Recent". */ import type { Email, Mailbox } from "./jmap/types"; import { emailInMailbox } from "./move-targets"; export const SYSTEM_FOLDER_ORDER = ["inbox", "drafts", "sent", "archive", "junk", "trash"] as const; /** Past this many folders to pick from, the list gets a filter field. */ export const MOVE_FILTER_THRESHOLD = 12; /** How many recent folders show. */ export const MOVE_RECENT_SHOWN = 3; export interface MoveMenuNode { /** The mailbox id, as the store knows it. */ id: string; name: string; role?: string | null; /** Can be picked. */ enabled: boolean; /** The open folder, which the messages are already in. */ current: boolean; /** Names of the folders above it, outermost first. */ path: string[]; children: MoveMenuNode[]; } export interface MoveMenuAccount { accountId: string; name: string; nodes: MoveMenuNode[]; } export interface MoveMenuModel { /** The account the messages are in; recents are kept per account. */ accountId?: string; nodes: MoveMenuNode[]; /** Other accounts the messages can be moved to, each a submenu. */ others: MoveMenuAccount[]; recent: MoveMenuNode[]; /** Pickable folders in the own account. */ targetCount: number; } export interface MoveMenuOptions { currentMailboxId?: string | null; /** The account the messages are in (resolveMoveOwnerAccountId). */ ownerAccountId?: string; /** The messages being moved; without them the open folder counts as theirs. */ emails?: Array>; /** The folders last moved to in an account, most recent first (readRecentMoveTargets). */ recentIds?: (accountId: string | undefined) => string[]; /** The interface language, for sorting names. */ locale?: string; } function rolePosition(role: string | null | undefined): number { if (!role) return SYSTEM_FOLDER_ORDER.length; const normal = role === "spam" ? "junk" : role; const at = (SYSTEM_FOLDER_ORDER as readonly string[]).indexOf(normal); return at < 0 ? SYSTEM_FOLDER_ORDER.length : at; } export function compareMoveNodes(collator: Intl.Collator) { return (a: Pick, b: Pick): number => { const byRole = rolePosition(a.role) - rolePosition(b.role); if (byRole !== 0) return byRole; return collator.compare(a.name, b.name); }; } function makeCollator(locale?: string): Intl.Collator { try { return new Intl.Collator(locale || undefined, { numeric: true, sensitivity: "base" }); } catch { return new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); } } /** A target: may take mail, isn't Drafts, isn't a virtual header. */ function isTarget(mailbox: Mailbox): boolean { return mailbox.role !== "drafts" && !mailbox.id.startsWith("shared-") && !!mailbox.myRights?.mayAddItems; } /** One account's folders as a sorted tree, with only the branches that lead somewhere. */ function accountTree( boxes: Mailbox[], currentId: string | undefined, hideCurrent: boolean, collator: Intl.Collator, ): MoveMenuNode[] { const ids = new Set(boxes.map((m) => m.id)); const childrenOf = new Map(); const roots: Mailbox[] = []; for (const box of boxes) { if (box.parentId && ids.has(box.parentId) && box.parentId !== box.id) { const list = childrenOf.get(box.parentId) ?? []; list.push(box); childrenOf.set(box.parentId, list); } else { roots.push(box); } } const compare = compareMoveNodes(collator); const seen = new Set(); const build = (list: Mailbox[], path: string[]): MoveMenuNode[] => { const out: MoveMenuNode[] = []; for (const box of list) { if (seen.has(box.id)) continue; seen.add(box.id); const current = box.id === currentId; const children = build(childrenOf.get(box.id) ?? [], [...path, box.name]); const enabled = isTarget(box) && !(current && hideCurrent); // A folder that can't take mail stays as a step to the ones below it; // the open folder stays so the list reads like the sidebar. if (!enabled && !children.length && !(current && isTarget(box))) continue; out.push({ id: box.id, name: box.name, role: box.role ?? null, enabled, current, path, children }); } return out.sort(compare); }; return build(roots, []); } function walk(nodes: MoveMenuNode[], visit: (node: MoveMenuNode) => void): void { for (const node of nodes) { visit(node); walk(node.children, visit); } } export function countTargets(nodes: MoveMenuNode[]): number { let count = 0; walk(nodes, (node) => { if (node.enabled) count++; }); return count; } export function buildMoveMenu(mailboxes: Mailbox[], opts: MoveMenuOptions = {}): MoveMenuModel { const collator = makeCollator(opts.locale); const current = mailboxes.find((m) => m.id === opts.currentMailboxId); const emails = opts.emails ?? []; const hideCurrent = !!current && (emails.length === 0 || emails.every((e) => emailInMailbox(e, current))); // Folders by account: the login's own, then each shared account. const groups = new Map(); const OWN = "__own_account__"; for (const box of mailboxes) { if (box.id.startsWith("shared-")) continue; const key = box.isShared ? box.accountId ?? "unknown" : OWN; let group = groups.get(key); if (!group) { group = { name: box.accountName || box.accountId || "", shared: !!box.isShared, boxes: [] }; groups.set(key, group); } if (!group.name && box.accountName) group.name = box.accountName; group.boxes.push(box); } const ownAccountId = groups.get(OWN)?.boxes.find((m) => m.accountId)?.accountId; const ownerKey = opts.ownerAccountId && groups.get(opts.ownerAccountId)?.shared ? opts.ownerAccountId : OWN; const treeOf = (key: string) => { const group = groups.get(key); return group ? accountTree(group.boxes, current?.id, hideCurrent, collator) : []; }; const nodes = treeOf(ownerKey); const others: MoveMenuAccount[] = []; for (const [key, group] of groups) { if (key === ownerKey) continue; const tree = treeOf(key); if (countTargets(tree) === 0) continue; others.push({ accountId: key === OWN ? ownAccountId ?? "" : key, name: group.name, nodes: tree }); } // The login's own account first among the others, then shared ones by name. others.sort((a, b) => { const aOwn = a.accountId === ownAccountId ? 0 : 1; const bOwn = b.accountId === ownAccountId ? 0 : 1; return aOwn - bOwn || collator.compare(a.name, b.name); }); const accountId = ownerKey === OWN ? ownAccountId : ownerKey; const byId = new Map(); walk(nodes, (node) => byId.set(node.id, node)); for (const other of others) walk(other.nodes, (node) => byId.set(node.id, node)); const recent: MoveMenuNode[] = []; for (const id of opts.recentIds?.(accountId) ?? []) { const node = byId.get(id); if (node?.enabled && !recent.includes(node)) recent.push(node); if (recent.length >= MOVE_RECENT_SHOWN) break; } return { accountId, nodes, others, recent, targetCount: countTargets(nodes) }; } /** * The nodes whose name has the query in it, with the folders above them kept * so a match still shows where it lives. Case and accents don't count. */ export function filterMoveNodes(nodes: MoveMenuNode[], query: string, labelOf: (node: MoveMenuNode) => string, locale?: string): MoveMenuNode[] { const q = fold(query.trim(), locale); if (!q) return nodes; const keep = (list: MoveMenuNode[]): MoveMenuNode[] => list.reduce((acc, node) => { const children = keep(node.children); if (fold(labelOf(node), locale).includes(q) || children.length) acc.push({ ...node, children }); return acc; }, []); return keep(nodes); } function fold(text: string, locale?: string): string { let lower: string; try { lower = text.toLocaleLowerCase(locale || undefined); } catch { lower = text.toLowerCase(); } return lower.normalize("NFD").replace(/\p{M}/gu, ""); } /** The first folder that can be picked, in list order. */ export function firstEnabled(nodes: MoveMenuNode[]): MoveMenuNode | undefined { for (const node of nodes) { if (node.enabled) return node; const below = firstEnabled(node.children); if (below) return below; } return undefined; } /** * Any folder tree in Move-list order (system folders first, then by name), * for pickers that build their own tree, such as the Rules menu's. */ export function sortFolderTree(nodes: T[], locale?: string): T[] { const compare = compareMoveNodes(makeCollator(locale)); const sort = (list: T[]): T[] => [...list].map((node) => ({ ...node, children: sort(node.children) })).sort(compare); return sort(nodes); }