mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
The Move lists (message menu, reader Move button and its phone panel, selection toolbar, Move dialog) now share one model in lib/move-menu.ts. In the desktop app the native copy of the menu lost the shared accounts' headers and every folder's nesting, so Inbox, Sent, Spam and Trash showed once per account with nothing to tell them apart. - Only the message's own account at the top; other accounts a move can reach are submenus under their names. - System folders first (Inbox, Sent, Archive, Spam, Trash), then the user's folders by name; subfolders nest, as submenus natively. - The open folder shows greyed instead of missing. - A Recent group, a filter field on long lists, and "Choose folder…" for the dialog, which has the filter too. Tests changed: the open folder is now shown disabled rather than left out, the own account of a shared-mailbox message is a submenu, and the selection toolbar's filter field has its own label.
254 lines
9.6 KiB
TypeScript
254 lines
9.6 KiB
TypeScript
/**
|
|
* 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<Pick<Email, "mailboxIds" | "sourceAccountId">>;
|
|
/** 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<MoveMenuNode, "role" | "name">, b: Pick<MoveMenuNode, "role" | "name">): 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<string, Mailbox[]>();
|
|
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<string>();
|
|
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<string, { name: string; shared: boolean; boxes: Mailbox[] }>();
|
|
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<string, MoveMenuNode>();
|
|
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<MoveMenuNode[]>((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<T extends { role?: string | null; name: string; children: T[] }>(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);
|
|
}
|