fix: show the keys for reply, delete, star and the rest in the message menu

This commit is contained in:
Linus Rath
2026-11-04 05:09:06 +01:00
parent 0e400dbcf4
commit f44be446bb
3 changed files with 135 additions and 0 deletions
@@ -0,0 +1,89 @@
import { render, screen, within } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: () => null,
}));
import { EmailContextMenu } from '../email-context-menu';
import { messageActionShortcut } from '../message-action-shortcuts';
const email = {
id: 'e1',
blobId: 'b1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
from: [{ name: 'Alice', email: 'alice@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
hasAttachment: false,
size: 100,
} as unknown as Email;
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
function renderMenu(props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) {
render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
onReply={vi.fn()}
onReplyAll={vi.fn()}
onForward={vi.fn()}
onDelete={vi.fn()}
onToggleStar={vi.fn()}
onMarkAsRead={vi.fn()}
onMarkAsSpam={vi.fn()}
{...props}
/>,
);
}
const row = (name: string) => screen.getByText(name).closest('button') as HTMLElement;
describe('message menu key hints', () => {
afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true }));
it('shows the keys the list runs each action with', () => {
renderMenu();
expect(within(row('reply')).getByText('R')).toBeInTheDocument();
expect(within(row('reply_all')).getByText('Shift+R')).toBeInTheDocument();
expect(within(row('forward')).getByText('F')).toBeInTheDocument();
expect(within(row('star')).getByText('S')).toBeInTheDocument();
expect(within(row('mark_read')).getByText('Shift+I')).toBeInTheDocument();
expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
});
it('leaves single keys out when they are turned off, and keeps Del', () => {
useSettingsStore.setState({ singleKeyShortcuts: false });
renderMenu();
expect(within(row('reply')).queryByText('R')).toBeNull();
expect(within(row('star')).queryByText('S')).toBeNull();
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
});
it('shows them on a selection too', () => {
renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchDelete: vi.fn(), onBatchMarkAsRead: vi.fn(), onBatchMarkAsSpam: vi.fn() });
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-mark-read')).getByText('Shift+I')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-spam')).getByText('!')).toBeInTheDocument();
});
it('matches the read state: U marks a read message unread', () => {
expect(messageActionShortcut('read', { unread: false, singleKeys: true })).toBe('U');
expect(messageActionShortcut('read', { unread: true, singleKeys: true })).toBe('Shift+I');
expect(messageActionShortcut('print', { singleKeys: true })).toBeUndefined();
});
});
+7
View File
@@ -30,6 +30,7 @@ import {
Fish,
} from "@/components/icons";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
import { useMessageActionShortcut } from "./message-action-shortcuts";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { localizeMailboxName } from "@/lib/mailbox-label";
@@ -198,6 +199,7 @@ export function EmailContextMenu({
label,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const shortcutOf = useMessageActionShortcut();
const archiveActionEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
const tSidebar = useTranslations("sidebar");
@@ -356,6 +358,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Archive}
label={t("archive")}
shortcut={shortcutOf("archive")}
onClick={() => handleAction(onBatchArchive!)}
disabled={!onBatchArchive}
/>
@@ -368,6 +371,7 @@ export function EmailContextMenu({
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
shortcut={shortcutOf("delete")}
onClick={() => handleAction(onBatchDelete!)}
disabled={!onBatchDelete}
destructive={!mountainView}
@@ -381,6 +385,7 @@ export function EmailContextMenu({
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
shortcut={shortcutOf("read", isUnread)}
onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))}
/>
</>
@@ -457,6 +462,7 @@ export function EmailContextMenu({
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
shortcut={shortcutOf("spam")}
onClick={() => handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)}
disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam}
destructive={!isInJunkFolder && !mountainView}
@@ -511,6 +517,7 @@ export function EmailContextMenu({
testId={entry.testId}
disabled={entry.disabled}
destructive={entry.destructive}
shortcut={shortcutOf(entry.key, isUnread)}
onClick={() => handleAction(entry.run)}
/>
);
@@ -0,0 +1,39 @@
"use client";
import { useCallback } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import type { MessageActionKey } from "./message-actions";
/**
* The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts),
* as the message menus show them next to the entries. The native Message menu shows the
* same ones. A single key goes when the user turned single-key shortcuts off, since it
* does nothing then.
*/
const KEYS: Partial<Record<MessageActionKey, { key: string; single: boolean }>> = {
reply: { key: "R", single: true },
reply_all: { key: "Shift+R", single: true },
forward: { key: "F", single: true },
archive: { key: "E", single: true },
delete: { key: "Del", single: false },
star: { key: "S", single: true },
spam: { key: "!", single: true },
};
export function messageActionShortcut(
key: MessageActionKey,
options: { unread?: boolean; singleKeys: boolean },
): string | undefined {
const entry = key === "read"
? { key: options.unread ? "Shift+I" : "U", single: true }
: KEYS[key];
if (!entry) return undefined;
if (entry.single && !options.singleKeys) return undefined;
return entry.key;
}
/** `messageActionShortcut` with the user's single-key setting. */
export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined {
const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false);
return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]);
}