Files
inbuxa-webmail/web/src/views/mail/MailboxPicker.tsx
T
jcoffey-dev c64a23f9d9
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 2m29s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 1m23s
List folders in sidebar order in the move-to picker
The picker sorted folders A-Z by path, with Inbox first, so a folder
dragged into place in the sidebar turned up somewhere else when moving
mail. It now walks the tree in compareFolders order, the sidebar's
order with every folder expanded: Inbox, then the saved order, then
the special folders, then A-Z, with subfolders under their parent.

treeOrder lives beside compareFolders. A folder the walk from the top
cannot reach is appended rather than dropped, so it stays pickable as
it was before.

Closes #1

(cherry picked from commit ea03406646062359f74e16ad8a8aed074b4dc409)
2026-09-27 16:27:24 -07:00

82 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from "react";
import { Folder, FolderUp, Inbox } from "lucide-react";
import { useMail } from "@/store/mail";
import { Dialog } from "@/ui/dialog";
import type { Id, Mailbox } from "@/jmap/types";
import { t } from "@/lib/i18n";
import { mailboxDisplayPath } from "@/lib/mailbox/mailboxName";
import { treeOrder } from "@/lib/mailbox/folderOrder";
/**
* @param need which right a folder has to grant to be worth offering.
* `mayAddItems` for a move — a folder you cannot file into is not a
* destination — and `mayReadItems` for going somewhere, since a shared
* folder you may read but not write to is still somewhere you can go. The
* distinction only shows up on shared mail, which is exactly where getting
* it wrong would be invisible to whoever wrote the code.
* @param allow a further test a folder has to pass, for when a right alone
* does not settle it -- a folder cannot move into its own subtree.
* @param root a "top level" row above the folders, for the one kind of move
* that has somewhere to go which is not a folder.
*/
export function MailboxPicker({ title, onClose, onPick, exclude, need = "mayAddItems", allow, root }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[]; need?: "mayAddItems" | "mayReadItems"; allow?: (id: Id) => boolean; root?: { label: string; onPick: () => void } }) {
const mailboxes = useMail((s) => s.mailboxes);
const mailboxPath = useMail((s) => s.mailboxPath);
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const list = useMemo(() => {
// The sidebar's order, not A–Z by path: a folder dragged into place has to
// be found in the same place here.
const all = treeOrder(mailboxes)
.filter((m) => !exclude?.includes(m.id) && m.myRights[need] && (!allow || allow(m.id)))
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes), pick: () => onPick(m.id) }));
const rows: { m: Mailbox | null; path: string; pick: () => void }[] = root ? [{ m: null, path: root.label, pick: root.onPick }, ...all] : all;
const ql = q.trim().toLowerCase();
return ql ? rows.filter((x) => x.path.toLowerCase().includes(ql)) : rows;
}, [mailboxes, mailboxPath, q, exclude, need, allow, root, onPick]);
return (
<Dialog open onClose={onClose} title={title} size="sm">
<input
className="input"
autoFocus
placeholder={t("Type a folder name…")}
value={q}
onChange={(e) => {
setQ(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(list.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
} else if (e.key === "Enter") {
e.preventDefault();
list[active]?.pick();
}
}}
/>
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
{list.map(({ m, path, pick }, i) => (
<PickerRow key={m?.id ?? ""} m={m} path={path} active={i === active} onClick={pick} onHover={() => setActive(i)} />
))}
{!list.length && <div className="empty" style={{ padding: 24 }}>{t("No matching folders")}</div>}
</div>
</Dialog>
);
}
/** `m` is null for the top-level row, which has no icon of its own and nothing to count. */
function PickerRow({ m, path, active, onClick, onHover }: { m: Mailbox | null; path: string; active: boolean; onClick: () => void; onHover: () => void }) {
return (
<button className={`menu-item ${active ? "active" : ""}`} onClick={onClick} onMouseEnter={onHover} role="option" aria-selected={active}>
{!m ? <FolderUp size={16} /> : m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}
<span className="grow truncate">{path}</span>
{m && <span className="menu-kbd">{m.totalEmails}</span>}
</button>
);
}