Move a folder from its menu, with the same picker as moving mail
A folder could only be moved by dragging it, which is slow in a long list
and not offered at all on a touch screen. Its menu now has "Move to…",
which opens the searchable folder picker that moving messages already
uses, with a "Top level" row above the folders.
The picker lists only legal destinations: the same rules as a drop -- not
into itself, its own subtree or the parent it already has -- plus the
rights a picker has to check up front because it shows every folder at
once: mayRename on the folder being moved, which RFC 8621 uses for
reparenting, and mayCreateChild on the destination. Both only say no on
shared mail. The move itself goes through the same path as a drop, so the
toast and the expanded destination are unchanged.
"Move “{name}” to…" and "Top level" are in all nine catalogues.
Closes #355
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Folder, Inbox } from "lucide-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";
|
||||
@@ -13,20 +13,25 @@ import { mailboxDisplayPath } from "@/lib/mailboxName";
|
||||
* 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" }: { title: string; onClose: () => void; onPick: (id: Id) => void; exclude?: Id[]; need?: "mayAddItems" | "mayReadItems" }) {
|
||||
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(() => {
|
||||
const all = Object.values(mailboxes)
|
||||
.filter((m) => !exclude?.includes(m.id) && m.myRights[need])
|
||||
.map((m) => ({ m, path: mailboxDisplayPath(m, 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) }))
|
||||
.sort((a, b) => (a.m.role === "inbox" ? -1 : b.m.role === "inbox" ? 1 : a.path.localeCompare(b.path)));
|
||||
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 ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
||||
}, [mailboxes, mailboxPath, q, exclude]);
|
||||
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">
|
||||
@@ -48,14 +53,13 @@ export function MailboxPicker({ title, onClose, onPick, exclude, need = "mayAddI
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const m = list[active]?.m;
|
||||
if (m) onPick(m.id);
|
||||
list[active]?.pick();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
|
||||
{list.map(({ m, path }, i) => (
|
||||
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(i)} />
|
||||
{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>
|
||||
@@ -63,12 +67,13 @@ export function MailboxPicker({ title, onClose, onPick, exclude, need = "mayAddI
|
||||
);
|
||||
}
|
||||
|
||||
function PickerRow({ m, path, active, onClick, onHover }: { m: Mailbox; path: string; active: boolean; onClick: () => void; onHover: () => void }) {
|
||||
/** `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.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}
|
||||
{!m ? <FolderUp size={16} /> : m.role === "inbox" ? <Inbox size={16} /> : <Folder size={16} />}
|
||||
<span className="grow truncate">{path}</span>
|
||||
<span className="menu-kbd">{m.totalEmails}</span>
|
||||
{m && <span className="menu-kbd">{m.totalEmails}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { AlertOctagon, Archive, ChevronDown, ChevronLeft, 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 { AlertOctagon, Archive, ChevronDown, ChevronLeft, Clock, ChevronRight, File, Folder, FolderPlus, Inbox, Mail, MoreVertical, Palette, Send, Star, Tag, Trash2, Plus, Pencil, Eye, EyeOff, CheckCheck, Eraser, Share2, X, FolderInput } from "lucide-react";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||
import { labelTree, visibleLabels } from "@/lib/labelTree";
|
||||
@@ -12,8 +12,9 @@ import { CALENDAR_COLORS, useIsMobile, useIsTouch } from "@/ui/misc";
|
||||
import { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { MailboxPicker } from "./MailboxPicker";
|
||||
import { loadRaw, saveJson } from "@/lib/storage";
|
||||
import { canDropFolder, folderColor, movable } from "@/lib/folderMove";
|
||||
import { canDropFolder, canMoveFolderTo, folderColor, movable } from "@/lib/folderMove";
|
||||
import { haptic, useTouchRow } from "@/lib/touch";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||
@@ -46,6 +47,8 @@ export function MailboxTree() {
|
||||
const menu = useMenu();
|
||||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||||
const [shareTarget, setShareTarget] = useState<Mailbox | null>(null);
|
||||
/** The folder being moved from its menu -- the way to move one without a drag, and on touch the only way. */
|
||||
const [moveTarget, setMoveTarget] = useState<Mailbox | null>(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
|
||||
@@ -255,8 +258,21 @@ export function MailboxTree() {
|
||||
)}
|
||||
</nav>
|
||||
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
|
||||
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
|
||||
{menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} onMove={() => { menu.close(); setMoveTarget(menuTarget); }} />}
|
||||
</Popover>
|
||||
{moveTarget && (
|
||||
<MailboxPicker
|
||||
title={t("Move “{name}” to…", { name: mailboxDisplayName(moveTarget) })}
|
||||
need="mayReadItems"
|
||||
allow={(id) => canMoveFolderTo(mailboxes, moveTarget.id, id)}
|
||||
root={canMoveFolderTo(mailboxes, moveTarget.id, null) ? { label: t("Top level"), onPick: () => { setMoveTarget(null); void moveFolder(moveTarget.id, null); } } : undefined}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
onPick={(id) => {
|
||||
setMoveTarget(null);
|
||||
void moveFolder(moveTarget.id, id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
|
||||
</>
|
||||
);
|
||||
@@ -406,7 +422,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
|
||||
);
|
||||
}
|
||||
|
||||
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
|
||||
function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare, onMove }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void; onMove: () => void }) {
|
||||
const shared = Object.keys(m.shareWith ?? {}).length > 0;
|
||||
const [, navigate] = useLocation();
|
||||
const colors = useSettings((s) => s.settings.folderColors);
|
||||
@@ -471,6 +487,7 @@ function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox:
|
||||
)}
|
||||
<MenuItem icon={<FolderPlus size={16} />} label={t("New subfolder")} onClick={onCreateChild} disabled={!m.myRights.mayCreateChild} />
|
||||
<MenuItem icon={<Pencil size={16} />} label={t("Rename")} onClick={() => void rename()} disabled={isSpecial || !m.myRights.mayRename} />
|
||||
<MenuItem icon={<FolderInput size={16} />} label={t("Move to…")} onClick={onMove} disabled={!movable(m) || !m.myRights.mayRename} />
|
||||
<MenuItem icon={m.isSubscribed ? <EyeOff size={16} /> : <Eye size={16} />} label={m.isSubscribed ? t("Hide from list") : t("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
|
||||
|
||||
Reference in New Issue
Block a user