mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
fix: show the keys for reply, delete, star and the rest in the message menu
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user