mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: order Mountain View's message menu like Gmail's and add Find emails from
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('../rules-menu', () => ({ RulesContextSubMenu: () => null }));
|
||||
vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
|
||||
|
||||
const { EmailContextMenu } = await import('../email-context-menu');
|
||||
|
||||
const rights = { mayAddItems: true } as Mailbox['myRights'];
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', myRights: rights },
|
||||
{ id: 'archive', name: 'Archive', role: 'archive', myRights: rights },
|
||||
] as unknown as Mailbox[];
|
||||
const email = {
|
||||
id: 'e1',
|
||||
keywords: { $seen: true },
|
||||
mailboxIds: { inbox: true },
|
||||
from: [{ name: 'Sarah Kim', email: 'sarah@example.com' }],
|
||||
} as unknown as Email;
|
||||
|
||||
function renderMenu(props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) {
|
||||
return render(
|
||||
<EmailContextMenu
|
||||
email={email}
|
||||
position={{ x: 0, y: 0 }}
|
||||
isOpen
|
||||
onClose={() => {}}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
onArchive={() => {}}
|
||||
onDelete={() => {}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu's entries by test id, top to bottom. */
|
||||
const order = () => [...document.querySelectorAll('[data-testid^="ctx-"]')].map((e) => e.getAttribute('data-testid'));
|
||||
|
||||
describe('message menu under Mountain View', () => {
|
||||
beforeEach(() => useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never));
|
||||
|
||||
it("puts Mark as read with Archive and Delete, before Move to, as Gmail does", () => {
|
||||
renderMenu();
|
||||
const ids = order();
|
||||
expect(ids.indexOf('ctx-delete')).toBeLessThan(ids.indexOf('ctx-mark-unread'));
|
||||
expect(ids.indexOf('ctx-mark-unread')).toBeLessThan(ids.indexOf('ctx-move-to'));
|
||||
});
|
||||
|
||||
it('finds the mail from the sender', () => {
|
||||
const onFindFromSender = vi.fn();
|
||||
renderMenu({ onFindFromSender });
|
||||
fireEvent.click(screen.getByTestId('ctx-find-from'));
|
||||
expect(onFindFromSender).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the default order outside Mountain View', () => {
|
||||
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
|
||||
renderMenu({ onFindFromSender: vi.fn() });
|
||||
const ids = order();
|
||||
expect(ids.indexOf('ctx-move-to')).toBeLessThan(ids.indexOf('ctx-mark-unread'));
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { Email, Mailbox } from "@/lib/jmap/types";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
Link as LinkIcon,
|
||||
MessagesSquare,
|
||||
Copy,
|
||||
Search,
|
||||
} from "@/components/icons";
|
||||
import { buildMailPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
@@ -82,6 +84,8 @@ interface EmailContextMenuProps {
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onForwardAsAttachment?: () => void;
|
||||
/** Mountain View: search for the mail this sender has sent. */
|
||||
onFindFromSender?: () => void;
|
||||
onMarkAsRead?: (read: boolean) => void;
|
||||
onToggleStar?: () => void;
|
||||
onTogglePinned?: () => void;
|
||||
@@ -148,6 +152,7 @@ export function EmailContextMenu({
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onForwardAsAttachment,
|
||||
onFindFromSender,
|
||||
onMarkAsRead,
|
||||
onToggleStar,
|
||||
onTogglePinned,
|
||||
@@ -173,6 +178,7 @@ export function EmailContextMenu({
|
||||
onCopyToAccount,
|
||||
}: EmailContextMenuProps) {
|
||||
const t = useTranslations("context_menu");
|
||||
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const tEmailViewer = useTranslations("email_viewer");
|
||||
const tDeepLink = useTranslations("deep_link");
|
||||
@@ -260,6 +266,238 @@ export function EmailContextMenu({
|
||||
// Stable, so the Rules entry does not re-resolve its account every render.
|
||||
const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]);
|
||||
|
||||
// The menu's entries, composed below in the default layout's order or,
|
||||
// under Mountain View, in Gmail's.
|
||||
const replyBlock = (
|
||||
<>
|
||||
{/* 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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const permalinkBlock = (
|
||||
<>
|
||||
{/* Permalinks (#733). The conversation entry only appears when the
|
||||
message actually belongs to a thread worth linking to. */}
|
||||
{!showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={LinkIcon}
|
||||
label={tDeepLink("copy_message")}
|
||||
onClick={() => handleAction(() => {
|
||||
void copyLink(buildMailPath({ mailboxId: null, emailId: email.id, threadId: null }));
|
||||
})}
|
||||
/>
|
||||
{email.threadId && (
|
||||
<ContextMenuItem
|
||||
icon={MessagesSquare}
|
||||
label={tDeepLink("copy_conversation")}
|
||||
onClick={() => handleAction(() => {
|
||||
void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId! }));
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const archiveItem = (
|
||||
<>
|
||||
{/* Archive */}
|
||||
<ContextMenuItem
|
||||
icon={Archive}
|
||||
label={t("archive")}
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchArchive! : onArchive!)
|
||||
}
|
||||
disabled={showBatchActions ? !onBatchArchive : !onArchive}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const deleteItem = (
|
||||
<>
|
||||
{/* Delete */}
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("delete")}
|
||||
testId="ctx-delete"
|
||||
onClick={() =>
|
||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||
}
|
||||
disabled={showBatchActions ? !onBatchDelete : !onDelete}
|
||||
destructive={!mountainView}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const readItem = (
|
||||
<>
|
||||
{/* 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)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const moveItem = (
|
||||
<>
|
||||
{/* Move to submenu */}
|
||||
{moveTree.length > 0 && (
|
||||
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
|
||||
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) =>
|
||||
showBatchActions
|
||||
? onBatchMoveToMailbox?.(mailboxId)
|
||||
: onMoveToMailbox?.(mailboxId)
|
||||
)}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const copyItem = (
|
||||
<>
|
||||
{/* Copy to another connected account, keeping the originals */}
|
||||
{onCopyToAccount && copyTrees.length > 0 && (
|
||||
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to">
|
||||
{copyTrees.map((target) => (
|
||||
<div key={target.accountId}>
|
||||
<ContextMenuHeader>{target.label}</ContextMenuHeader>
|
||||
{renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) =>
|
||||
onCopyToAccount(target.accountId, mailboxId)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const rulesItem = (
|
||||
<>
|
||||
{/* Rules: filter rules made from the sender(s), saved as Sieve */}
|
||||
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
const starItem = (
|
||||
<>
|
||||
{/* Star/Unstar - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuItem
|
||||
icon={Star}
|
||||
label={isStarred ? t("unstar") : t("star")}
|
||||
onClick={() => handleAction(onToggleStar!)}
|
||||
disabled={!onToggleStar}
|
||||
testId={isStarred ? "ctx-unstar" : "ctx-star"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const pinItem = (
|
||||
<>
|
||||
{/* 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const tagItem = (
|
||||
<>
|
||||
{/* Set tag submenu - the row's own tags, or the whole selection's (#1077) */}
|
||||
{(!showBatchActions || onBatchToggleTag) && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag">
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
{showBatchActions ? (
|
||||
<TagPicker
|
||||
selectedIds={batchTagIds ?? []}
|
||||
partialIds={batchPartialTagIds}
|
||||
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<TagPicker
|
||||
selectedIds={currentTagIds}
|
||||
onToggle={(tagId) => onSetTag?.(tagId)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const spamBlock = (
|
||||
<>
|
||||
{/* 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 && !mountainView}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const sender = email.from?.[0];
|
||||
const findBlock = !showBatchActions && onFindFromSender && sender?.email ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Search}
|
||||
label={t("find_emails_from", { name: sender.name || sender.email })}
|
||||
testId="ctx-find-from"
|
||||
onClick={() => handleAction(onFindFromSender)}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
@@ -308,190 +546,43 @@ export function EmailContextMenu({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Single email actions - Reply, Reply All, Forward */}
|
||||
{!isScheduled && !showBatchActions && (
|
||||
{mountainView ? (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
{replyBlock}
|
||||
{archiveItem}
|
||||
{deleteItem}
|
||||
{readItem}
|
||||
<ContextMenuSeparator />
|
||||
{moveItem}
|
||||
{tagItem}
|
||||
{rulesItem}
|
||||
{copyItem}
|
||||
<ContextMenuSeparator />
|
||||
{starItem}
|
||||
{pinItem}
|
||||
{spamBlock}
|
||||
{findBlock}
|
||||
<ContextMenuSeparator />
|
||||
{permalinkBlock}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{replyBlock}
|
||||
{permalinkBlock}
|
||||
{archiveItem}
|
||||
{deleteItem}
|
||||
<ContextMenuSeparator />
|
||||
{moveItem}
|
||||
{copyItem}
|
||||
{rulesItem}
|
||||
{starItem}
|
||||
{pinItem}
|
||||
{tagItem}
|
||||
{spamBlock}
|
||||
<ContextMenuSeparator />
|
||||
{readItem}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Permalinks (#733). The conversation entry only appears when the
|
||||
message actually belongs to a thread worth linking to. */}
|
||||
{!showBatchActions && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={LinkIcon}
|
||||
label={tDeepLink("copy_message")}
|
||||
onClick={() => handleAction(() => {
|
||||
void copyLink(buildMailPath({ mailboxId: null, emailId: email.id, threadId: null }));
|
||||
})}
|
||||
/>
|
||||
{email.threadId && (
|
||||
<ContextMenuItem
|
||||
icon={MessagesSquare}
|
||||
label={tDeepLink("copy_conversation")}
|
||||
onClick={() => handleAction(() => {
|
||||
void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId! }));
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<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">
|
||||
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) =>
|
||||
showBatchActions
|
||||
? onBatchMoveToMailbox?.(mailboxId)
|
||||
: onMoveToMailbox?.(mailboxId)
|
||||
)}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
{/* Copy to another connected account, keeping the originals */}
|
||||
{onCopyToAccount && copyTrees.length > 0 && (
|
||||
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to">
|
||||
{copyTrees.map((target) => (
|
||||
<div key={target.accountId}>
|
||||
<ContextMenuHeader>{target.label}</ContextMenuHeader>
|
||||
{renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) =>
|
||||
onCopyToAccount(target.accountId, mailboxId)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ContextMenuSubMenu>
|
||||
)}
|
||||
|
||||
{/* Rules: filter rules made from the sender(s), saved as Sieve */}
|
||||
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} />
|
||||
|
||||
{/* Star/Unstar - only for single email */}
|
||||
{!showBatchActions && (
|
||||
<ContextMenuItem
|
||||
icon={Star}
|
||||
label={isStarred ? t("unstar") : t("star")}
|
||||
onClick={() => handleAction(onToggleStar!)}
|
||||
disabled={!onToggleStar}
|
||||
testId={isStarred ? "ctx-unstar" : "ctx-star"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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 - the row's own tags, or the whole selection's (#1077) */}
|
||||
{(!showBatchActions || onBatchToggleTag) && (
|
||||
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag">
|
||||
<div className="w-56 max-w-[18rem]">
|
||||
{showBatchActions ? (
|
||||
<TagPicker
|
||||
selectedIds={batchTagIds ?? []}
|
||||
partialIds={batchPartialTagIds}
|
||||
onToggle={(tagId) => onBatchToggleTag?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<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)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ interface EmailListProps {
|
||||
selectedEmailId?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
/** Mountain View: search for the mail a sender has sent, from the row menu. */
|
||||
onFindFromSender?: (sender: { name: string; email: string }) => void;
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
hasMore?: boolean;
|
||||
@@ -75,6 +77,7 @@ export function EmailList({
|
||||
selectedEmailId,
|
||||
onEmailSelect,
|
||||
onEmailDoubleClick,
|
||||
onFindFromSender,
|
||||
className,
|
||||
isLoading = false,
|
||||
hasMore,
|
||||
@@ -798,6 +801,9 @@ export function EmailList({
|
||||
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
||||
onForward={() => onForward?.(contextMenuEmail!)}
|
||||
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
||||
onFindFromSender={onFindFromSender && contextMenuEmail.from?.[0]?.email
|
||||
? () => onFindFromSender({ name: contextMenuEmail!.from![0].name || '', email: contextMenuEmail!.from![0].email })
|
||||
: undefined}
|
||||
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
||||
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
||||
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
||||
|
||||
@@ -4517,6 +4517,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
onFindFromSender={mvShell ? (sender) => void handleSelectContactSuggestion(sender, 'from') : undefined}
|
||||
hideBatchToolbar={mvShell}
|
||||
emails={activeEmails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "لا توجد قواعد بعد",
|
||||
"new_mail_only": "للرسائل الجديدة فقط",
|
||||
"running": "جارٍ تشغيل «{name}» على {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "البحث عن رسائل من {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "تمييز المجلد كمقروء",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Encara no hi ha regles",
|
||||
"new_mail_only": "Només per a missatges nous",
|
||||
"running": "S'està executant «{name}» a {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Cerca correus de {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marca la carpeta com a llegida",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Zatím žádná pravidla",
|
||||
"new_mail_only": "Pouze pro nové zprávy",
|
||||
"running": "Spouštění „{name}“ ve složce {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Najít e-maily od {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Označit složku jako přečtenou",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Ingen regler endnu",
|
||||
"new_mail_only": "Kun for nye beskeder",
|
||||
"running": "Kører „{name}“ på {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Find e-mails fra {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Markér mappe som læst",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Noch keine Regeln",
|
||||
"new_mail_only": "Nur für neue Nachrichten",
|
||||
"running": "„{name}“ wird auf {folder} angewendet…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "E-Mails von {name} finden"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Ordner als gelesen markieren",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "No rules yet",
|
||||
"new_mail_only": "Only for new messages",
|
||||
"running": "Running \"{name}\" on {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Find emails from {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mark folder as read",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Aún no hay reglas",
|
||||
"new_mail_only": "Solo para mensajes nuevos",
|
||||
"running": "Ejecutando «{name}» en {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Buscar correos de {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marcar carpeta como leída",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "هنوز قانونی وجود ندارد",
|
||||
"new_mail_only": "فقط برای پیامهای جدید",
|
||||
"running": "در حال اجرای «{name}» روی {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "یافتن ایمیلهای {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "علامتگذاری پوشه خوانده شده",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Aucune règle pour l'instant",
|
||||
"new_mail_only": "Uniquement pour les nouveaux messages",
|
||||
"running": "Exécution de « {name} » sur {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Rechercher les e-mails de {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marquer le dossier comme lu",
|
||||
|
||||
@@ -2483,7 +2483,8 @@
|
||||
"none": "אין עדיין כללים",
|
||||
"new_mail_only": "רק להודעות חדשות",
|
||||
"running": "מריץ את „{name}“ על {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "חיפוש הודעות מ{name}"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "קיצורי מקלדת",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Még nincsenek szabályok",
|
||||
"new_mail_only": "Csak új üzenetekre",
|
||||
"running": "„{name}” futtatása itt: {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "{name} e-mailjeinek keresése"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Mappa olvasottnak jelölése",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Nessuna regola",
|
||||
"new_mail_only": "Solo per i nuovi messaggi",
|
||||
"running": "Esecuzione di «{name}» su {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Trova email da {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Segna cartella come letta",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "ルールはまだありません",
|
||||
"new_mail_only": "新着メッセージのみ",
|
||||
"running": "{folder} で「{name}」を実行中…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "{name} からのメールを検索"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "フォルダーを既読にする",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "아직 규칙이 없습니다",
|
||||
"new_mail_only": "새 메시지에만 적용",
|
||||
"running": "{folder}에서 \"{name}\" 실행 중…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "{name}님이 보낸 이메일 찾기"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "폴더를 읽음으로 표시",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Vēl nav kārtulu",
|
||||
"new_mail_only": "Tikai jaunām ziņām",
|
||||
"running": "Izpilda “{name}” mapē {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Atrast e-pastus no {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Atzīmēt mapi kā lasītu",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Одоогоор дүрэм алга",
|
||||
"new_mail_only": "Зөвхөн шинэ мессежүүдэд",
|
||||
"running": "{folder} дээр \"{name}\"-г ажиллуулж байна…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "{name}-с ирсэн имэйлүүдийг хайх"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Хавтсыг уншсан гэж тэмдэглэх",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Ingen regler ennå",
|
||||
"new_mail_only": "Bare for nye meldinger",
|
||||
"running": "Kjører «{name}» på {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Finn e-poster fra {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Merk mappen som lest",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Nog geen regels",
|
||||
"new_mail_only": "Alleen voor nieuwe berichten",
|
||||
"running": "\"{name}\" wordt uitgevoerd op {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "E-mails van {name} zoeken"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Map markeren als gelezen",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Brak reguł",
|
||||
"new_mail_only": "Tylko dla nowych wiadomości",
|
||||
"running": "Uruchamianie „{name}” w folderze {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Znajdź e-maile od {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Oznacz folder jako przeczytany",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Ainda não há regras",
|
||||
"new_mail_only": "Apenas para mensagens novas",
|
||||
"running": "A executar \"{name}\" em {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Encontrar e-mails de {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marcar pasta como lida",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Nicio regulă încă",
|
||||
"new_mail_only": "Doar pentru mesajele noi",
|
||||
"running": "Se rulează „{name}” în {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Găsește e-mailurile de la {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Marcați folderul ca citit",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Правил пока нет",
|
||||
"new_mail_only": "Только для новых писем",
|
||||
"running": "Выполняется «{name}» в папке {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Найти письма от {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Отметить папку как прочитанную",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Zatiaľ žiadne pravidlá",
|
||||
"new_mail_only": "Len pre nové správy",
|
||||
"running": "Spúšťa sa „{name}“ v priečinku {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Nájsť e-maily od {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Označiť priečinok ako prečítaný",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Henüz kural yok",
|
||||
"new_mail_only": "Yalnızca yeni iletiler için",
|
||||
"running": "\"{name}\" {folder} klasöründe çalıştırılıyor…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "{name} adlı kişiden gelen e-postaları bul"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Klasörü okundu olarak işaretle",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "Правил поки немає",
|
||||
"new_mail_only": "Лише для нових листів",
|
||||
"running": "Виконується «{name}» у теці {folder}…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "Знайти листи від {name}"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "Позначити папку як прочитану",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "尚無規則",
|
||||
"new_mail_only": "僅限新郵件",
|
||||
"running": "正在 {folder} 中執行「{name}」…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "尋找來自 {name} 的郵件"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "將信件匣標示為已讀",
|
||||
|
||||
@@ -2517,7 +2517,8 @@
|
||||
"none": "暂无规则",
|
||||
"new_mail_only": "仅限新邮件",
|
||||
"running": "正在 {folder} 中运行“{name}”…"
|
||||
}
|
||||
},
|
||||
"find_emails_from": "查找来自 {name} 的邮件"
|
||||
},
|
||||
"mailbox_context_menu": {
|
||||
"mark_folder_read": "将文件夹标记为已读",
|
||||
|
||||
Reference in New Issue
Block a user