mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import type { ComponentType } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { emailLinkSource } from "@/stores/email-store";
|
|
import { useArchiveActionEnabled } from "@/lib/archive-gate";
|
|
import { buildMailPath } from "@/lib/deep-links";
|
|
import { useCopyLink } from "@/hooks/use-copy-link";
|
|
import type { Email } from "@/lib/jmap/types";
|
|
import {
|
|
Reply,
|
|
ReplyAll,
|
|
Forward,
|
|
Mail,
|
|
MailOpen,
|
|
Star,
|
|
Pin,
|
|
PinOff,
|
|
Trash2,
|
|
Archive,
|
|
FolderInput,
|
|
FolderX,
|
|
Tag,
|
|
ShieldAlert,
|
|
ShieldCheck,
|
|
CalendarClock,
|
|
CalendarX,
|
|
Paperclip,
|
|
Link as LinkIcon,
|
|
MessagesSquare,
|
|
Copy,
|
|
Search,
|
|
Pencil,
|
|
Filter,
|
|
Download,
|
|
Printer,
|
|
Code,
|
|
} from "@/components/icons";
|
|
|
|
type Icon = ComponentType<{ className?: string }>;
|
|
|
|
/** The entries that open a list of their own: a submenu, or a sub-view of the phone panel. */
|
|
export type MessageSubmenuKey = "move" | "copy_to_account" | "tag" | "rules";
|
|
|
|
export type MessageActionKey =
|
|
| "reschedule"
|
|
| "cancel_and_edit"
|
|
| "cancel_send"
|
|
| "edit_draft"
|
|
| "reply"
|
|
| "reply_all"
|
|
| "forward"
|
|
| "forward_as_attachment"
|
|
| "copy_message_link"
|
|
| "copy_conversation_link"
|
|
| "archive"
|
|
| "delete"
|
|
| "read"
|
|
| "remove_from_folder"
|
|
| "star"
|
|
| "pin"
|
|
| "spam"
|
|
| "find_from_sender"
|
|
| "export"
|
|
| "print"
|
|
| "view_source"
|
|
| MessageSubmenuKey
|
|
// A host's own entries (the reader's view toggles), appended after the list.
|
|
| `extra:${string}`;
|
|
|
|
export type MessageActionEntry =
|
|
| {
|
|
kind: "item";
|
|
key: MessageActionKey;
|
|
icon: Icon;
|
|
label: string;
|
|
run: () => void;
|
|
disabled?: boolean;
|
|
destructive?: boolean;
|
|
testId?: string;
|
|
}
|
|
| { kind: "submenu"; key: MessageSubmenuKey; icon: Icon; label: string; testId?: string }
|
|
| { kind: "separator" };
|
|
|
|
export interface MessageActionOptions {
|
|
email: Email;
|
|
currentMailboxRole?: string;
|
|
/** Folders to move to exist and the host can move. */
|
|
canMove?: boolean;
|
|
/** The message is also in another folder and the list shows one folder. */
|
|
canRemoveFromFolder?: boolean;
|
|
/** Another connected account can take a copy. */
|
|
canCopyToAccount?: boolean;
|
|
/** Offer the Rules submenu (the host decides whether it can show it). */
|
|
rules?: boolean;
|
|
onReply?: () => void;
|
|
onReplyAll?: () => void;
|
|
onForward?: () => void;
|
|
onForwardAsAttachment?: () => void;
|
|
onEditDraft?: () => void;
|
|
onArchive?: () => void;
|
|
onDelete?: () => void;
|
|
onMarkAsRead?: (read: boolean) => void;
|
|
onToggleStar?: () => void;
|
|
onTogglePinned?: () => void;
|
|
onRemoveFromFolder?: () => void;
|
|
onMarkAsSpam?: () => void;
|
|
onUndoSpam?: () => void;
|
|
/** Mountain View: search for the mail this sender has sent. */
|
|
onFindFromSender?: () => void;
|
|
onRescheduleScheduled?: () => void;
|
|
onCancelScheduledForEdit?: () => void;
|
|
onCancelScheduledSend?: () => void;
|
|
/** Save the message as .eml. */
|
|
onExport?: () => void;
|
|
onPrint?: () => void;
|
|
onViewSource?: () => void;
|
|
/** Entries the host already shows elsewhere (the reader's toolbar). */
|
|
omit?: ReadonlySet<MessageActionKey>;
|
|
/** The host's own entries, after the message's own and a separator. */
|
|
extras?: MessageActionEntry[];
|
|
}
|
|
|
|
const SEPARATOR: MessageActionEntry = { kind: "separator" };
|
|
|
|
/** No separator first, last or twice in a row. */
|
|
export function tidySeparators(entries: MessageActionEntry[]): MessageActionEntry[] {
|
|
const out: MessageActionEntry[] = [];
|
|
for (const entry of entries) {
|
|
if (entry.kind === "separator" && (out.length === 0 || out[out.length - 1].kind === "separator")) continue;
|
|
out.push(entry);
|
|
}
|
|
while (out.length > 0 && out[out.length - 1].kind === "separator") out.pop();
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The actions for one message (R6): the row's right-click and long-press
|
|
* menu, the reader's ⋮ menu and the phone's More panel all show this list,
|
|
* in this order, leaving out only what the host shows elsewhere. Under
|
|
* Mountain View the order is Gmail's.
|
|
*/
|
|
export function useMessageActionList(o: MessageActionOptions): MessageActionEntry[] {
|
|
const t = useTranslations("context_menu");
|
|
const tViewer = useTranslations("email_viewer");
|
|
const tDeepLink = useTranslations("deep_link");
|
|
const copyLink = useCopyLink();
|
|
const archiveEnabled = useArchiveActionEnabled();
|
|
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
|
|
const { email } = o;
|
|
|
|
const isUnread = !email.keywords?.$seen;
|
|
const isStarred = !!email.keywords?.$flagged;
|
|
const isPinned = email.keywords?.["$pinned"] === true;
|
|
const isDraft = email.keywords?.["$draft"] === true;
|
|
const isScheduled = email.isScheduled === true;
|
|
const inJunk = o.currentMailboxRole === "junk";
|
|
// Marking your own outgoing mail as spam makes no sense.
|
|
const spamApplicable = !["sent", "drafts", "scheduled"].includes(o.currentMailboxRole || "");
|
|
|
|
const item = (
|
|
key: MessageActionKey,
|
|
icon: Icon,
|
|
label: string,
|
|
run: (() => void) | undefined,
|
|
extra: { disabled?: boolean; destructive?: boolean; testId?: string } = {},
|
|
): MessageActionEntry => ({
|
|
kind: "item",
|
|
key,
|
|
icon,
|
|
label,
|
|
run: () => run?.(),
|
|
disabled: extra.disabled ?? !run,
|
|
destructive: extra.destructive,
|
|
testId: extra.testId,
|
|
});
|
|
|
|
let list: MessageActionEntry[];
|
|
|
|
if (isScheduled) {
|
|
const canCancel = email.scheduledUndoStatus === "pending";
|
|
list = canCancel
|
|
? [
|
|
item("reschedule", CalendarClock, t("reschedule_send"), o.onRescheduleScheduled),
|
|
item(
|
|
"cancel_and_edit",
|
|
Pencil,
|
|
email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit"),
|
|
o.onCancelScheduledForEdit,
|
|
),
|
|
// Back into Drafts. An S/MIME message can't go back (it is
|
|
// encrypted), so it only offers "Cancel and compose again".
|
|
...(o.onCancelScheduledSend && !email.isSmimeScheduled
|
|
? [item("cancel_send", CalendarX, t("cancel_scheduled_send"), o.onCancelScheduledSend, { testId: "ctx-cancel-scheduled-send" })]
|
|
: []),
|
|
]
|
|
: [];
|
|
} else {
|
|
const draft = isDraft && o.onEditDraft ? [item("edit_draft", Pencil, t("edit_draft"), o.onEditDraft), SEPARATOR] : [];
|
|
const replies = [
|
|
item("reply", Reply, t("reply"), o.onReply),
|
|
item("reply_all", ReplyAll, t("reply_all"), o.onReplyAll),
|
|
item("forward", Forward, t("forward"), o.onForward),
|
|
item("forward_as_attachment", Paperclip, tViewer("forward_as_attachment"), o.onForwardAsAttachment, {
|
|
disabled: !o.onForwardAsAttachment || !email.blobId,
|
|
}),
|
|
SEPARATOR,
|
|
];
|
|
// Permalinks (#733). The conversation entry only when the message
|
|
// belongs to a thread worth linking to.
|
|
const links = [
|
|
item("copy_message_link", LinkIcon, tDeepLink("copy_message"), () => {
|
|
void copyLink(buildMailPath({ mailboxId: null, emailId: email.id, threadId: null, ...emailLinkSource(email) }));
|
|
}),
|
|
...(email.threadId
|
|
? [item("copy_conversation_link", MessagesSquare, tDeepLink("copy_conversation"), () => {
|
|
void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId!, ...emailLinkSource(email) }));
|
|
})]
|
|
: []),
|
|
SEPARATOR,
|
|
];
|
|
const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : [];
|
|
const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" });
|
|
const read = item("read", isUnread ? MailOpen : Mail, isUnread ? t("mark_read") : t("mark_unread"), () => o.onMarkAsRead?.(isUnread), {
|
|
disabled: false,
|
|
testId: isUnread ? "ctx-mark-read" : "ctx-mark-unread",
|
|
});
|
|
const move: MessageActionEntry[] = [
|
|
...(o.canMove ? [{ kind: "submenu", key: "move", icon: FolderInput, label: t("move_to"), testId: "ctx-move-to" } as const] : []),
|
|
...(o.canRemoveFromFolder && o.onRemoveFromFolder
|
|
? [item("remove_from_folder", FolderX, t("remove_from_folder"), o.onRemoveFromFolder, { testId: "ctx-remove-from-folder" })]
|
|
: []),
|
|
];
|
|
const copy: MessageActionEntry[] = o.canCopyToAccount
|
|
? [{ kind: "submenu", key: "copy_to_account", icon: Copy, label: t("copy_to_account"), testId: "ctx-copy-to" }]
|
|
: [];
|
|
const rules: MessageActionEntry[] = o.rules !== false
|
|
? [{ kind: "submenu", key: "rules", icon: Filter, label: t("rules.title"), testId: "ctx-rules" }]
|
|
: [];
|
|
const star = item("star", Star, isStarred ? t("unstar") : t("star"), o.onToggleStar, { testId: isStarred ? "ctx-unstar" : "ctx-star" });
|
|
const pin = o.onTogglePinned ? [item("pin", isPinned ? PinOff : Pin, isPinned ? t("unpin") : t("pin"), o.onTogglePinned)] : [];
|
|
const tag: MessageActionEntry = { kind: "submenu", key: "tag", icon: Tag, label: t("tag"), testId: "ctx-tag" };
|
|
const spamHandler = inJunk ? o.onUndoSpam : o.onMarkAsSpam;
|
|
const spam = spamApplicable
|
|
? [SEPARATOR, item("spam", inJunk ? ShieldCheck : ShieldAlert, inJunk ? t("not_spam") : t("mark_as_spam"), spamHandler, {
|
|
destructive: !inJunk && !mountainView,
|
|
testId: inJunk ? "ctx-not-spam" : "ctx-spam",
|
|
})]
|
|
: [];
|
|
const sender = email.from?.[0];
|
|
const find = o.onFindFromSender && sender?.email
|
|
? [SEPARATOR, item("find_from_sender", Search, t("find_emails_from", { name: sender.name || sender.email }), o.onFindFromSender, { testId: "ctx-find-from" })]
|
|
: [];
|
|
// Download/Export, Print, View source (R6: after Copy, before the end).
|
|
const file = [
|
|
...(o.onExport ? [item("export", Download, tViewer("export_email"), o.onExport, { disabled: !email.blobId, testId: "ctx-export" })] : []),
|
|
...(o.onPrint ? [item("print", Printer, tViewer("print"), o.onPrint, { testId: "ctx-print" })] : []),
|
|
...(o.onViewSource ? [item("view_source", Code, tViewer("view_source"), o.onViewSource, { disabled: !email.blobId, testId: "ctx-view-source" })] : []),
|
|
];
|
|
|
|
list = mountainView
|
|
? [
|
|
...draft, ...replies, ...archive, del, read, SEPARATOR,
|
|
...move, tag, ...rules, ...copy, SEPARATOR,
|
|
star, ...pin, ...spam, ...find, SEPARATOR,
|
|
...links, ...file,
|
|
]
|
|
: [
|
|
...draft, ...replies, ...links, ...archive, del, SEPARATOR,
|
|
...move, ...copy, ...rules, star, ...pin, tag, ...spam, SEPARATOR,
|
|
read, SEPARATOR, ...file,
|
|
];
|
|
}
|
|
|
|
if (o.omit && o.omit.size > 0) {
|
|
const omit = o.omit;
|
|
list = list.filter((entry) => entry.kind === "separator" || !omit.has(entry.key));
|
|
}
|
|
if (o.extras && o.extras.length > 0) list = [...list, SEPARATOR, ...o.extras];
|
|
return tidySeparators(list);
|
|
}
|