Files
SRCmail/components/email/email-context-menu.tsx
T
Mathy Vanvoorden d9d9f91a86 feat: Make it easier to handle multiple tags
- Tags can now be removed straight from the email header
- Tagging control now allows the user to (de)select multiple tags in one go
2026-07-29 17:18:42 +02:00

413 lines
12 KiB
TypeScript

"use client";
import { useTranslations } from "next-intl";
import { Email, Mailbox } from "@/lib/jmap/types";
import {
ContextMenu,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSubMenu,
ContextMenuHeader,
} from "@/components/ui/context-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import {
Reply,
ReplyAll,
Forward,
Mail,
MailOpen,
Star,
Pin,
PinOff,
Trash2,
Archive,
FolderInput,
Tag,
Inbox,
Send,
File,
Folder,
ShieldAlert,
ShieldCheck,
EditIcon,
CalendarClock,
XCircle,
Paperclip,
} from "lucide-react";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
interface Position {
x: number;
y: number;
}
interface EmailContextMenuProps {
email: Email;
position: Position;
isOpen: boolean;
onClose: () => void;
menuRef: React.RefObject<HTMLDivElement | null>;
mailboxes: Mailbox[];
selectedMailbox: string;
currentMailboxRole?: string;
isMultiSelect?: boolean;
selectedCount?: number;
// Single email actions
onReply?: () => void;
onReplyAll?: () => void;
onForward?: () => void;
onForwardAsAttachment?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetTag?: (tagId: string | null) => void;
onMoveToMailbox?: (mailboxId: string) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onEditDraft?: () => void;
onCancelScheduled?: () => void;
onCancelScheduledForEdit?: () => void;
onRescheduleScheduled?: () => void;
// Batch actions
onBatchMarkAsRead?: (read: boolean) => void;
onBatchDelete?: () => void;
onBatchArchive?: () => void;
onBatchMoveToMailbox?: (mailboxId: string) => void;
onBatchMarkAsSpam?: () => void;
onBatchUndoSpam?: () => void;
}
// Get mailbox icon based on role
const getMailboxIcon = (role?: string) => {
switch (role) {
case "inbox":
return Inbox;
case "sent":
return Send;
case "drafts":
return File;
case "trash":
return Trash2;
case "archive":
return Archive;
default:
return Folder;
}
};
export function EmailContextMenu({
email,
position,
isOpen,
onClose,
menuRef,
mailboxes,
selectedMailbox,
currentMailboxRole,
isMultiSelect = false,
selectedCount = 1,
onReply,
onReplyAll,
onForward,
onForwardAsAttachment,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetTag,
onMoveToMailbox,
onMarkAsSpam,
onUndoSpam,
onBatchMarkAsRead,
onBatchDelete,
onBatchArchive,
onBatchMoveToMailbox,
onBatchMarkAsSpam,
onBatchUndoSpam,
onEditDraft,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const tEmailViewer = useTranslations("email_viewer");
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentTagIds = getEmailTagIds(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
// Build mailbox tree for move-to submenu with proper hierarchy
const moveTargetIds = new Set(
mailboxes
.filter(
(m) =>
m.id !== selectedMailbox &&
m.role !== "drafts" &&
!m.id.startsWith("shared-") &&
m.myRights?.mayAddItems
)
.map((m) => m.id)
);
const mailboxTree = buildMailboxTree(mailboxes);
// Filter tree to only include branches that contain valid move targets
const filterTree = (nodes: MailboxNode[]): MailboxNode[] => {
return nodes.reduce<MailboxNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children);
if (moveTargetIds.has(node.id) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
};
const moveTree = filterTree(mailboxTree);
const handleAction = (action: () => void) => {
action();
onClose();
};
return (
<ContextMenu
ref={menuRef}
isOpen={isOpen}
position={position}
onClose={onClose}
>
{/* Batch header */}
{showBatchActions && (
<ContextMenuHeader>
{t("items_selected", { count: selectedCount })}
</ContextMenuHeader>
)}
{isScheduled && !showBatchActions && canCancelScheduled && (
<>
<ContextMenuItem
icon={CalendarClock}
label={t("reschedule_send")}
onClick={() => handleAction(onRescheduleScheduled!)}
disabled={!onRescheduleScheduled}
/>
<ContextMenuItem
icon={XCircle}
label={t("cancel_scheduled_send")}
onClick={() => handleAction(onCancelScheduled!)}
disabled={!onCancelScheduled}
/>
<ContextMenuItem
icon={EditIcon}
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
onClick={() => handleAction(onCancelScheduledForEdit!)}
disabled={!onCancelScheduledForEdit}
/>
</>
)}
{canCancelScheduled && <ContextMenuSeparator />}
{!isScheduled && (
<>
{/* Edit Draft - only for single draft emails */}
{!isScheduled && !showBatchActions && isDraft && onEditDraft && (
<>
<ContextMenuItem
icon={EditIcon}
label={t("edit_draft")}
onClick={() => handleAction(onEditDraft)}
/>
<ContextMenuSeparator />
</>
)}
{/* Single email actions - Reply, Reply All, Forward */}
{!isScheduled && !showBatchActions && (
<>
<ContextMenuItem
icon={Reply}
label={t("reply")}
onClick={() => handleAction(onReply!)}
disabled={!onReply}
/>
<ContextMenuItem
icon={ReplyAll}
label={t("reply_all")}
onClick={() => handleAction(onReplyAll!)}
disabled={!onReplyAll}
/>
<ContextMenuItem
icon={Forward}
label={t("forward")}
onClick={() => handleAction(onForward!)}
disabled={!onForward}
/>
<ContextMenuItem
icon={Paperclip}
label={tEmailViewer("forward_as_attachment")}
onClick={() => handleAction(onForwardAsAttachment!)}
disabled={!onForwardAsAttachment || !email.blobId}
/>
<ContextMenuSeparator />
</>
)}
{/* Archive */}
<ContextMenuItem
icon={Archive}
label={t("archive")}
onClick={() =>
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
}
disabled={showBatchActions ? !onBatchArchive : !onArchive}
/>
{/* Delete */}
<ContextMenuItem
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
disabled={showBatchActions ? !onBatchDelete : !onDelete}
destructive
/>
<ContextMenuSeparator />
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
const Icon = getMailboxIcon(node.role);
const isTarget = moveTargetIds.has(node.id);
const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
return (
<div key={node.id}>
{isTarget ? (
<ContextMenuItem
icon={Icon}
label={nodeLabel}
testId={`move-to:${node.id}`}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMoveToMailbox?.(node.id)
: onMoveToMailbox?.(node.id)
)
}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
<div className="ps-4">
{renderNodes(node.children)}
</div>
)}
</div>
);
});
};
return renderNodes(moveTree);
})()}
</ContextMenuSubMenu>
)}
{/* Star/Unstar - only for single email */}
{!showBatchActions && (
<ContextMenuItem
icon={Star}
label={isStarred ? t("unstar") : t("star")}
onClick={() => handleAction(onToggleStar!)}
disabled={!onToggleStar}
/>
)}
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
{!showBatchActions && onTogglePinned && (
<ContextMenuItem
icon={isPinned ? PinOff : Pin}
label={isPinned ? t("unpin") : t("pin")}
onClick={() => handleAction(onTogglePinned)}
/>
)}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("tag")}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={currentTagIds}
onToggle={(tagId) => onSetTag?.(tagId)}
/>
</div>
</ContextMenuSubMenu>
)}
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
{spamApplicable && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
</>
)}
<ContextMenuSeparator />
{/* Mark as read/unread */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
onClick={() =>
handleAction(() =>
showBatchActions
? onBatchMarkAsRead?.(isUnread)
: onMarkAsRead?.(isUnread)
)
}
/>
</>
)}
<PluginSlot name="context-menu-email" />
</ContextMenu>
);
}