Files
webmail/components/layout/mailbox-context-menu.tsx
T
Linus Rath 0a09fec123 fix: offer Empty Trash and Empty Spam only there, with the message count, and drop folder actions that can't apply
The folder menu tests no longer expect "Empty folder" on a folder of
your own: emptying is offered on Trash and Spam only now. The Mountain
View list test expects the entry to read "Empty Trash".
2026-11-04 11:30:11 +01:00

407 lines
14 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.
"use client";
import { useTranslations } from "next-intl";
import { Mailbox } from "@/lib/jmap/types";
import { getMailboxPath } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import {
CheckCheck,
ChevronRight,
MailOpen,
Mails,
MoreHorizontal,
Trash2,
FolderPlus,
Pencil,
FolderX,
RefreshCw,
Upload,
Download,
Users,
EyeOff,
Bell,
BellOff,
RotateCcw,
} from "@/components/icons";
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useConnectionState } from "@/hooks/use-connection-state";
interface Position {
x: number;
y: number;
}
export type MailboxContextTarget =
/** `sharedAccountId`: the folder is a shared account's inbox standing for the
* whole account (compact shared mailboxes, #1342), so the account's entries
* are added. */
| { kind: "mailbox"; mailbox: Mailbox; hasChildren: boolean; sharedAccountId?: string }
| { kind: "folders-section"; accountId?: string };
const PATH_SEPARATOR = " › ";
/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */
export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined {
let total = 0;
const seen = new Set<string>();
const visit = (box: Mailbox): boolean => {
if (seen.has(box.id)) return true;
seen.add(box.id);
if (typeof box.unreadEmails !== "number") return false;
total += box.unreadEmails;
return mailboxes.filter((child) => child.parentId === box.id).every(visit);
};
return visit(mailbox) ? total : undefined;
}
const MAX_PATH_LENGTH = 40;
const MAX_SEGMENT_LENGTH = 16;
function truncateSegment(name: string): string {
return name.length > MAX_SEGMENT_LENGTH
? `${name.slice(0, MAX_SEGMENT_LENGTH - 1)}…`
: name;
}
function renderPathSegments(segments: string[]): React.ReactNode {
return (
<span className="inline-flex items-center gap-1 align-middle">
{segments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3.5 h-3.5 opacity-60" />}
{seg === "…" ? <MoreHorizontal className="w-3.5 h-3.5" /> : <span>{seg}</span>}
</span>
))}
</span>
);
}
function renderShortenedPath(fullPath: string): React.ReactNode {
const segments = fullPath.split(PATH_SEPARATOR);
if (fullPath.length <= MAX_PATH_LENGTH) {
return renderPathSegments(segments);
}
if (segments.length <= 2) {
return renderPathSegments(segments.map(truncateSegment));
}
return renderPathSegments([
truncateSegment(segments[0]),
"…",
truncateSegment(segments[segments.length - 1]),
]);
}
/**
* Per-account and per-folder notification mutes (#1169). An `accountId` of
* undefined is the login's own account, as on the folders-section target.
*/
export interface MailboxNotificationActions {
isMailboxMuted: (mailbox: Mailbox) => boolean;
isAccountMuted: (accountId?: string) => boolean;
/** True when some folder of the account has its own setting. */
hasFolderSettings: (accountId?: string) => boolean;
setMailboxMuted: (mailbox: Mailbox, muted: boolean) => void;
setAccountMuted: (accountId: string | undefined, muted: boolean) => void;
resetFolderSettings: (accountId?: string) => void;
}
interface MailboxContextMenuProps {
target: MailboxContextTarget | null;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
onMarkFolderRead?: (mailboxId: string) => void;
onMarkFolderTreeRead?: (mailboxId: string) => void;
onMarkAllFoldersRead?: () => void;
onEmptyFolder?: (mailboxId: string) => void;
onCreateSubfolder?: (parentId: string) => void;
onCreateFolder?: (accountId?: string) => void;
onRenameFolder?: (mailboxId: string) => void;
onDeleteFolder?: (mailboxId: string) => void;
/** Share the folder with other principals (mail:share); omitted when the server lacks it. */
onShareFolder?: (mailboxId: string) => void;
onImportEmail?: (mailboxId: string) => void;
onRefresh?: () => void;
/** Hide a shared account's mail folders from the sidebar (#1228). */
onHideSharedAccount?: (accountId: string) => void;
/** Omitted when muting has no effect (no emailPush filter on the server). */
notifications?: MailboxNotificationActions;
}
export function MailboxContextMenu({
target,
position,
isOpen,
onClose,
menuRef,
mailboxes,
onMarkFolderRead,
onMarkFolderTreeRead,
onMarkAllFoldersRead,
onEmptyFolder,
onCreateSubfolder,
onCreateFolder,
onRenameFolder,
onDeleteFolder,
onShareFolder,
onImportEmail,
onRefresh,
onHideSharedAccount,
notifications,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
const tP2 = useTranslations("mail_p2_folders");
const tSidebar = useTranslations("sidebar");
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
const exportOff = useDevicePolicy().noExport;
// Folders, rules and sharing need the server. Marking read waits in the outbox
// where the account keeps its mail on this device.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const offlineHeader = offline ? <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader> : null;
const handleAction = (action: () => void) => {
action();
onClose();
};
if (!target) return null;
// An account's notifications and, for a shared one, hiding it: on the
// account's heading, and on a compact shared mailbox's row.
const accountItems = (accountId?: string) => (
<>
{notifications && (() => {
const accountMuted = notifications.isAccountMuted(accountId);
return (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={accountMuted ? Bell : BellOff}
label={accountMuted ? t("unmute_account_notifications") : t("mute_account_notifications")}
onClick={() => handleAction(() => notifications.setAccountMuted(accountId, !accountMuted))}
testId="mailbox-mute-account"
/>
{notifications.hasFolderSettings(accountId) && (
<ContextMenuItem
icon={RotateCcw}
label={t("reset_folder_notifications")}
onClick={() => handleAction(() => notifications.resetFolderSettings(accountId))}
testId="mailbox-reset-folder-mutes"
/>
)}
</>
);
})()}
{accountId && onHideSharedAccount && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={EyeOff}
label={t("hide_shared_account")}
onClick={() => handleAction(() => onHideSharedAccount(accountId))}
testId="mailbox-hide-shared-account"
/>
</>
)}
</>
);
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
{offlineHeader}
{!target.accountId && (
<>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead || !canChangeMail}
/>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh || offline}
/>
{accountItems(target.accountId)}
</ContextMenu>
);
}
const mailbox = target.mailbox;
// Emptying is offered where people expect it, as "Empty Trash" and "Empty
// Spam"; rules and notifications mean nothing there (mail-20, pass 2 p2-14).
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
["inbox", "sent", "drafts", "trash", "junk", "archive"].includes(mailbox.role);
const canRename = mailbox.myRights?.mayRename !== false && !isSystem;
const canDelete = mailbox.myRights?.mayDelete !== false && !isSystem;
const canCreateChild = mailbox.myRights?.mayCreateChild !== false;
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const nothingUnread = mailbox.unreadEmails === 0;
const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0;
// Sharing needs the mail:share extension (handler present) and, on a folder
// someone else shared with us, their permission to re-share it.
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
// A compact shared mailbox's row is named after its account, so is its menu.
const fullPath = target.sharedAccountId && mailbox.accountName
? mailbox.accountName
: mailbox.role && !mailbox.parentId
? localizeMailboxName(mailbox.role, mailbox.name, (k) => tSidebar(`mailboxes.${k}`))
: getMailboxPath(mailbox, mailboxes);
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuHeader>
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
</ContextMenuHeader>
{offlineHeader}
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen || !canChangeMail || nothingUnreadBelow}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
{/* The server's own folders can't be renamed or deleted: not shown greyed out. */}
{!isSystem && (
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
)}
{onShareFolder && (
<ContextMenuItem
icon={Users}
label={t("share")}
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
disabled={!canShare || offline}
testId="mailbox-share"
/>
)}
{!isTrashOrJunk && <FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />}
{notifications && !isTrashOrJunk && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
<ContextMenuItem
icon={muted ? Bell : BellOff}
label={muted ? t("unmute_notifications") : t("mute_notifications")}
onClick={() => handleAction(() => notifications.setMailboxMuted(mailbox, !muted))}
testId="mailbox-mute"
/>
);
})()}
{target.sharedAccountId && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
{accountItems(target.sharedAccountId)}
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Upload}
label={tImport("menu_import")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems || offline}
/>
{!exportOff && (
<ContextMenuItem
icon={Download}
label={tExport("menu_export")}
onClick={() => handleAction(() => openMailExport({ mailboxId: mailbox.id }))}
disabled={mailbox.totalEmails === 0 || mailbox.myRights?.mayReadItems === false}
testId="mailbox-export"
/>
)}
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh || offline}
/>
{/* Destructive entries last (R6). */}
{(isTrashOrJunk || !isSystem) && <ContextMenuSeparator />}
{isTrashOrJunk && (
<ContextMenuItem
icon={FolderX}
label={mailbox.role === "trash" ? tP2("empty_trash") : tP2("empty_spam")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
testId="mailbox-empty"
/>
)}
{!isSystem && (
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
)}
</ContextMenu>
);
}