From f44be446bb29ac334e13987a7085a812e35fe220 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:06 +0100 Subject: [PATCH] fix: show the keys for reply, delete, star and the rest in the message menu --- .../email-context-menu-shortcuts.test.tsx | 89 +++++++++++++++++++ components/email/email-context-menu.tsx | 7 ++ components/email/message-action-shortcuts.ts | 39 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 components/email/__tests__/email-context-menu-shortcuts.test.tsx create mode 100644 components/email/message-action-shortcuts.ts diff --git a/components/email/__tests__/email-context-menu-shortcuts.test.tsx b/components/email/__tests__/email-context-menu-shortcuts.test.tsx new file mode 100644 index 000000000..062e60278 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shortcuts.test.tsx @@ -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> = {}) { + render( + , + ); +} + +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(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..deaf6e91e 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -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({ 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)} /> ); diff --git a/components/email/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts new file mode 100644 index 000000000..ce9efe28c --- /dev/null +++ b/components/email/message-action-shortcuts.ts @@ -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> = { + 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]); +}