- Tags can now be removed straight from the email header - Tagging control now allows the user to (de)select multiple tags in one go
413 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|