Merge pull request #357 from Coffey-Labs/feat/move-folder-picker
Move a folder from its menu, with the same picker as moving mail
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { canDropFolder, descendantIds, folderColor, movable } from "../folderMove";
|
import { canDropFolder, canMoveFolderTo, descendantIds, folderColor, movable } from "../folderMove";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
import type { Id, Mailbox } from "@/jmap/types";
|
||||||
|
|
||||||
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
|
const mb = (id: string, name: string, parentId: string | null, role: Mailbox["role"] = null): Mailbox =>
|
||||||
@@ -68,6 +68,30 @@ describe("canDropFolder", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canMoveFolderTo", () => {
|
||||||
|
const rights = (r: Partial<Mailbox["myRights"]>) => ({ mayRename: true, mayCreateChild: true, ...r }) as Mailbox["myRights"];
|
||||||
|
const owned: Record<Id, Mailbox> = Object.fromEntries(Object.values(tree).map((m) => [m.id, { ...m, myRights: rights({}) }]));
|
||||||
|
|
||||||
|
it("agrees with a drop when every right is granted", () => {
|
||||||
|
expect(canMoveFolderTo(owned, "news", "work")).toBe(true);
|
||||||
|
expect(canMoveFolderTo(owned, "eu", null)).toBe(true);
|
||||||
|
expect(canMoveFolderTo(owned, "work", "eu")).toBe(false);
|
||||||
|
expect(canMoveFolderTo(owned, "news", null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a folder the user may not rename, top level included", () => {
|
||||||
|
const locked = { ...owned, eu: { ...owned.eu!, myRights: rights({ mayRename: false }) } };
|
||||||
|
expect(canMoveFolderTo(locked, "eu", "news")).toBe(false);
|
||||||
|
expect(canMoveFolderTo(locked, "eu", null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a destination that may not hold new subfolders", () => {
|
||||||
|
const closed = { ...owned, work: { ...owned.work!, myRights: rights({ mayCreateChild: false }) } };
|
||||||
|
expect(canMoveFolderTo(closed, "news", "work")).toBe(false);
|
||||||
|
expect(canMoveFolderTo(closed, "eu", null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("folderColor", () => {
|
describe("folderColor", () => {
|
||||||
it("returns the colour chosen for that folder, and null for the rest", () => {
|
it("returns the colour chosen for that folder, and null for the rest", () => {
|
||||||
const colors = { work: "#7c3aed" };
|
const colors = { work: "#7c3aed" };
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ export function canDropFolder(mailboxes: Record<Id, Mailbox>, draggedId: Id, tar
|
|||||||
return !descendantIds(mailboxes, draggedId).has(targetId);
|
return !descendantIds(mailboxes, draggedId).has(targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `id` may be moved under `targetId` (null: the top level) from the
|
||||||
|
* folder picker.
|
||||||
|
*
|
||||||
|
* Stricter than a drop, because the picker lists every folder at once rather
|
||||||
|
* than letting the server refuse one drag: moving needs `mayRename` on the
|
||||||
|
* folder itself -- RFC 8621 folds reparenting into that right -- and
|
||||||
|
* `mayCreateChild` on the destination. Both only ever say no on shared mail.
|
||||||
|
*/
|
||||||
|
export function canMoveFolderTo(mailboxes: Record<Id, Mailbox>, id: Id, targetId: Id | null): boolean {
|
||||||
|
if (!mailboxes[id]?.myRights.mayRename) return false;
|
||||||
|
if (!canDropFolder(mailboxes, id, targetId)) return false;
|
||||||
|
return targetId === null || Boolean(mailboxes[targetId]?.myRights.mayCreateChild);
|
||||||
|
}
|
||||||
|
|
||||||
/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */
|
/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */
|
||||||
export function folderColor(colors: Record<string, string>, id: Id): string | null {
|
export function folderColor(colors: Record<string, string>, id: Id): string | null {
|
||||||
return colors[id] ?? null;
|
return colors[id] ?? null;
|
||||||
|
|||||||
@@ -1017,6 +1017,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "Ordner",
|
"folder": "Ordner",
|
||||||
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
|
"“{name}” moved into “{parent}”": "„{name}“ wurde nach „{parent}“ verschoben",
|
||||||
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
|
"“{name}” moved to the top level": "„{name}“ wurde auf die oberste Ebene verschoben",
|
||||||
|
"Move “{name}” to…": "„{name}“ verschieben nach…",
|
||||||
|
"Top level": "Oberste Ebene",
|
||||||
"Could not move “{name}”: {reason}": "„{name}“ konnte nicht verschoben werden: {reason}",
|
"Could not move “{name}”: {reason}": "„{name}“ konnte nicht verschoben werden: {reason}",
|
||||||
"Delete “{name}”?": "„{name}“ löschen?",
|
"Delete “{name}”?": "„{name}“ löschen?",
|
||||||
"Rename folder": "Ordner umbenennen",
|
"Rename folder": "Ordner umbenennen",
|
||||||
|
|||||||
@@ -937,6 +937,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "carpeta",
|
"folder": "carpeta",
|
||||||
"“{name}” moved into “{parent}”": "«{name}» se ha movido a «{parent}»",
|
"“{name}” moved into “{parent}”": "«{name}» se ha movido a «{parent}»",
|
||||||
"“{name}” moved to the top level": "«{name}» se ha movido al nivel superior",
|
"“{name}” moved to the top level": "«{name}» se ha movido al nivel superior",
|
||||||
|
"Move “{name}” to…": "Mover «{name}» a…",
|
||||||
|
"Top level": "Nivel superior",
|
||||||
"Could not move “{name}”: {reason}": "No se ha podido mover «{name}»: {reason}",
|
"Could not move “{name}”: {reason}": "No se ha podido mover «{name}»: {reason}",
|
||||||
"Delete “{name}”?": "¿Eliminar «{name}»?",
|
"Delete “{name}”?": "¿Eliminar «{name}»?",
|
||||||
"Rename folder": "Cambiar el nombre de la carpeta",
|
"Rename folder": "Cambiar el nombre de la carpeta",
|
||||||
|
|||||||
@@ -942,6 +942,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "dossier",
|
"folder": "dossier",
|
||||||
"“{name}” moved into “{parent}”": "« {name} » a été déplacé dans « {parent} »",
|
"“{name}” moved into “{parent}”": "« {name} » a été déplacé dans « {parent} »",
|
||||||
"“{name}” moved to the top level": "« {name} » a été déplacé au niveau supérieur",
|
"“{name}” moved to the top level": "« {name} » a été déplacé au niveau supérieur",
|
||||||
|
"Move “{name}” to…": "Déplacer « {name} » vers…",
|
||||||
|
"Top level": "Niveau supérieur",
|
||||||
"Could not move “{name}”: {reason}": "Impossible de déplacer « {name} » : {reason}",
|
"Could not move “{name}”: {reason}": "Impossible de déplacer « {name} » : {reason}",
|
||||||
"Delete “{name}”?": "Supprimer « {name} » ?",
|
"Delete “{name}”?": "Supprimer « {name} » ?",
|
||||||
"Rename folder": "Renommer le dossier",
|
"Rename folder": "Renommer le dossier",
|
||||||
|
|||||||
@@ -998,6 +998,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "フォルダー",
|
"folder": "フォルダー",
|
||||||
"“{name}” moved into “{parent}”": "「{name}」を「{parent}」に移動しました",
|
"“{name}” moved into “{parent}”": "「{name}」を「{parent}」に移動しました",
|
||||||
"“{name}” moved to the top level": "「{name}」を最上位に移動しました",
|
"“{name}” moved to the top level": "「{name}」を最上位に移動しました",
|
||||||
|
"Move “{name}” to…": "「{name}」の移動先…",
|
||||||
|
"Top level": "最上位",
|
||||||
"Could not move “{name}”: {reason}": "「{name}」を移動できませんでした: {reason}",
|
"Could not move “{name}”: {reason}": "「{name}」を移動できませんでした: {reason}",
|
||||||
"Delete “{name}”?": "「{name}」を削除しますか?",
|
"Delete “{name}”?": "「{name}」を削除しますか?",
|
||||||
"Rename folder": "フォルダー名を変更",
|
"Rename folder": "フォルダー名を変更",
|
||||||
|
|||||||
@@ -933,6 +933,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "map",
|
"folder": "map",
|
||||||
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
|
"“{name}” moved into “{parent}”": "“{name}” is verplaatst naar “{parent}”",
|
||||||
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
|
"“{name}” moved to the top level": "“{name}” is naar het hoogste niveau verplaatst",
|
||||||
|
"Move “{name}” to…": "“{name}” verplaatsen naar…",
|
||||||
|
"Top level": "Hoogste niveau",
|
||||||
"Could not move “{name}”: {reason}": "Kon “{name}” niet verplaatsen: {reason}",
|
"Could not move “{name}”: {reason}": "Kon “{name}” niet verplaatsen: {reason}",
|
||||||
"Delete “{name}”?": "“{name}” verwijderen?",
|
"Delete “{name}”?": "“{name}” verwijderen?",
|
||||||
"Rename folder": "Map hernoemen",
|
"Rename folder": "Map hernoemen",
|
||||||
|
|||||||
@@ -940,6 +940,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "pasta",
|
"folder": "pasta",
|
||||||
"“{name}” moved into “{parent}”": "“{name}” foi movida para “{parent}”",
|
"“{name}” moved into “{parent}”": "“{name}” foi movida para “{parent}”",
|
||||||
"“{name}” moved to the top level": "“{name}” foi movida para o nível superior",
|
"“{name}” moved to the top level": "“{name}” foi movida para o nível superior",
|
||||||
|
"Move “{name}” to…": "Mover “{name}” para…",
|
||||||
|
"Top level": "Nível superior",
|
||||||
"Could not move “{name}”: {reason}": "Não foi possível mover “{name}”: {reason}",
|
"Could not move “{name}”: {reason}": "Não foi possível mover “{name}”: {reason}",
|
||||||
"Delete “{name}”?": "Excluir “{name}”?",
|
"Delete “{name}”?": "Excluir “{name}”?",
|
||||||
"Rename folder": "Renomear a pasta",
|
"Rename folder": "Renomear a pasta",
|
||||||
|
|||||||
@@ -939,6 +939,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "папка",
|
"folder": "папка",
|
||||||
"“{name}” moved into “{parent}”": "«{name}» перемещена в «{parent}»",
|
"“{name}” moved into “{parent}”": "«{name}» перемещена в «{parent}»",
|
||||||
"“{name}” moved to the top level": "«{name}» перемещена на верхний уровень",
|
"“{name}” moved to the top level": "«{name}» перемещена на верхний уровень",
|
||||||
|
"Move “{name}” to…": "Переместить «{name}» в…",
|
||||||
|
"Top level": "Верхний уровень",
|
||||||
"Could not move “{name}”: {reason}": "Не удалось переместить «{name}»: {reason}",
|
"Could not move “{name}”: {reason}": "Не удалось переместить «{name}»: {reason}",
|
||||||
"Delete “{name}”?": "Удалить «{name}»?",
|
"Delete “{name}”?": "Удалить «{name}»?",
|
||||||
"Rename folder": "Переименовать папку",
|
"Rename folder": "Переименовать папку",
|
||||||
|
|||||||
@@ -933,6 +933,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "тека",
|
"folder": "тека",
|
||||||
"“{name}” moved into “{parent}”": "«{name}» переміщено до «{parent}»",
|
"“{name}” moved into “{parent}”": "«{name}» переміщено до «{parent}»",
|
||||||
"“{name}” moved to the top level": "«{name}» переміщено на верхній рівень",
|
"“{name}” moved to the top level": "«{name}» переміщено на верхній рівень",
|
||||||
|
"Move “{name}” to…": "Перемістити «{name}» до…",
|
||||||
|
"Top level": "Верхній рівень",
|
||||||
"Could not move “{name}”: {reason}": "Не вдалося перемістити «{name}»: {reason}",
|
"Could not move “{name}”: {reason}": "Не вдалося перемістити «{name}»: {reason}",
|
||||||
"Delete “{name}”?": "Видалити «{name}»?",
|
"Delete “{name}”?": "Видалити «{name}»?",
|
||||||
"Rename folder": "Перейменувати теку",
|
"Rename folder": "Перейменувати теку",
|
||||||
|
|||||||
@@ -997,6 +997,8 @@ export const catalog: Catalog = {
|
|||||||
"folder": "文件夹",
|
"folder": "文件夹",
|
||||||
"“{name}” moved into “{parent}”": "「{name}」已移入「{parent}」",
|
"“{name}” moved into “{parent}”": "「{name}」已移入「{parent}」",
|
||||||
"“{name}” moved to the top level": "「{name}」已移至顶层",
|
"“{name}” moved to the top level": "「{name}」已移至顶层",
|
||||||
|
"Move “{name}” to…": "将「{name}」移动到…",
|
||||||
|
"Top level": "顶层",
|
||||||
"Could not move “{name}”: {reason}": "无法移动「{name}」:{reason}",
|
"Could not move “{name}”: {reason}": "无法移动「{name}」:{reason}",
|
||||||
"Delete “{name}”?": "删除「{name}」?",
|
"Delete “{name}”?": "删除「{name}」?",
|
||||||
"Rename folder": "重命名文件夹",
|
"Rename folder": "重命名文件夹",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Folder, Inbox } from "lucide-react";
|
import { Folder, FolderUp, Inbox } from "lucide-react";
|
||||||
import { useMail } from "@/store/mail";
|
import { useMail } from "@/store/mail";
|
||||||
import { Dialog } from "@/ui/dialog";
|
import { Dialog } from "@/ui/dialog";
|
||||||
import type { Id, Mailbox } from "@/jmap/types";
|
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
|
* 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
|
* distinction only shows up on shared mail, which is exactly where getting
|
||||||
* it wrong would be invisible to whoever wrote the code.
|
* 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 mailboxes = useMail((s) => s.mailboxes);
|
||||||
const mailboxPath = useMail((s) => s.mailboxPath);
|
const mailboxPath = useMail((s) => s.mailboxPath);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const list = useMemo(() => {
|
const list = useMemo(() => {
|
||||||
const all = Object.values(mailboxes)
|
const all = Object.values(mailboxes)
|
||||||
.filter((m) => !exclude?.includes(m.id) && m.myRights[need])
|
.filter((m) => !exclude?.includes(m.id) && m.myRights[need] && (!allow || allow(m.id)))
|
||||||
.map((m) => ({ m, path: mailboxDisplayPath(m, mailboxes) }))
|
.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)));
|
.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();
|
const ql = q.trim().toLowerCase();
|
||||||
return ql ? all.filter((x) => x.path.toLowerCase().includes(ql)) : all;
|
return ql ? rows.filter((x) => x.path.toLowerCase().includes(ql)) : rows;
|
||||||
}, [mailboxes, mailboxPath, q, exclude]);
|
}, [mailboxes, mailboxPath, q, exclude, need, allow, root, onPick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onClose={onClose} title={title} size="sm">
|
<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));
|
setActive((a) => Math.max(0, a - 1));
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const m = list[active]?.m;
|
list[active]?.pick();
|
||||||
if (m) onPick(m.id);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
|
<div style={{ maxHeight: 360, overflowY: "auto", marginTop: 8 }} role="listbox">
|
||||||
{list.map(({ m, path }, i) => (
|
{list.map(({ m, path, pick }, i) => (
|
||||||
<PickerRow key={m.id} m={m} path={path} active={i === active} onClick={() => onPick(m.id)} onHover={() => setActive(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>}
|
{!list.length && <div className="empty" style={{ padding: 24 }}>{t("No matching folders")}</div>}
|
||||||
</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 (
|
return (
|
||||||
<button className={`menu-item ${active ? "active" : ""}`} onClick={onClick} onMouseEnter={onHover} role="option" aria-selected={active}>
|
<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="grow truncate">{path}</span>
|
||||||
<span className="menu-kbd">{m.totalEmails}</span>
|
{m && <span className="menu-kbd">{m.totalEmails}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type DragEvent, type ReactNode } from "react";
|
||||||
import { Link, useLocation } from "wouter";
|
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 { useMail } from "@/store/mail";
|
||||||
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
import { canEmpty, confirmAndEmpty, emptyLabel } from "@/lib/emptyFolder";
|
||||||
import { labelTree, visibleLabels } from "@/lib/labelTree";
|
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 { confirmDialog, promptDialog } from "@/ui/dialog";
|
||||||
import { toast } from "@/ui/toast";
|
import { toast } from "@/ui/toast";
|
||||||
import { ShareDialog } from "../settings/ShareDialog";
|
import { ShareDialog } from "../settings/ShareDialog";
|
||||||
|
import { MailboxPicker } from "./MailboxPicker";
|
||||||
import { loadRaw, saveJson } from "@/lib/storage";
|
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 { haptic, useTouchRow } from "@/lib/touch";
|
||||||
import { plural, t } from "@/lib/i18n";
|
import { plural, t } from "@/lib/i18n";
|
||||||
import { mailboxDisplayName } from "@/lib/mailboxName";
|
import { mailboxDisplayName } from "@/lib/mailboxName";
|
||||||
@@ -46,6 +47,8 @@ export function MailboxTree() {
|
|||||||
const menu = useMenu();
|
const menu = useMenu();
|
||||||
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
const [menuTarget, setMenuTarget] = useState<Mailbox | null>(null);
|
||||||
const [shareTarget, setShareTarget] = 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:
|
* 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
|
* dataTransfer.getData is blocked during dragover, so a row cannot ask what
|
||||||
@@ -255,8 +258,21 @@ export function MailboxTree() {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<Popover anchor={menu.anchor} onClose={menu.close} width={300}>
|
<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>
|
</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)} />}
|
{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 shared = Object.keys(m.shareWith ?? {}).length > 0;
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const colors = useSettings((s) => s.settings.folderColors);
|
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={<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={<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"} />
|
<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
|
{/* Sharing a mail folder is withdrawn, not removed: Stalwart accepts and
|
||||||
stores the share, and it never reaches the other account -- its own
|
stores the share, and it never reaches the other account -- its own
|
||||||
|
|||||||
Reference in New Issue
Block a user