From 13d697ddfb3c480453519dfe987559fa54431096 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:27:01 +0100 Subject: [PATCH] feat: turn off every single-key shortcut when the setting is off --- .../keyboard-shortcuts-sections.test.tsx | 24 ++++++++++ components/calendar/calendar-app.tsx | 2 +- components/calendar/event-detail-popover.tsx | 2 + .../__tests__/command-palette.test.tsx | 31 +++++++++++++ .../command-palette/command-palette.tsx | 4 ++ components/contacts/contact-list.tsx | 3 +- components/email/email-composer.tsx | 2 + components/files/file-browser.tsx | 3 +- components/keyboard-shortcuts-modal.tsx | 8 +++- components/mail/mail-app.tsx | 2 + .../use-keyboard-shortcuts-guards.test.ts | 46 +++++++++++++++++++ hooks/use-keyboard-shortcuts.ts | 18 +++++++- hooks/use-time-grid-focus.ts | 3 +- lib/__tests__/calendar-shortcuts.test.ts | 18 ++++++++ lib/calendar-shortcuts.ts | 8 +++- lib/single-key-shortcuts.ts | 12 +++++ locales/ar/common.json | 4 +- locales/ca/common.json | 4 +- locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/et/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/he/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/mn/common.json | 4 +- locales/nb/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/sk/common.json | 4 +- locales/th/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/zh-TW/common.json | 4 +- locales/zh/common.json | 4 +- 45 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 lib/single-key-shortcuts.ts diff --git a/components/__tests__/keyboard-shortcuts-sections.test.tsx b/components/__tests__/keyboard-shortcuts-sections.test.tsx index 2fabee777..635511557 100644 --- a/components/__tests__/keyboard-shortcuts-sections.test.tsx +++ b/components/__tests__/keyboard-shortcuts-sections.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/lib/keyboard-shortcuts/contacts', () => ({ CONTACTS_SECTIONS: [] })); vi.mock('@/lib/keyboard-shortcuts/files', () => ({ FILES_SECTIONS: [] })); const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal'); +const { useSettingsStore } = await import('@/stores/settings-store'); describe('keyboard shortcuts help per app', () => { it("opens on the current app's tab and lists the general keys there too", () => { @@ -39,3 +40,26 @@ describe('keyboard shortcuts help per app', () => { expect(screen.getAllByText('Ctrl + K').length).toBe(1); }); }); + +// MO-04: the help says when single-key shortcuts are off. +describe('keyboard shortcuts help with single-key shortcuts off', () => { + it('says so and drops the "press ?" tip', () => { + nav.pathname = '/'; + useSettingsStore.setState({ singleKeyShortcuts: false }); + try { + render( {}} />); + expect(screen.getByText('shortcuts.single_keys_off')).toBeInTheDocument(); + expect(screen.getByText('shortcuts.tip_single_keys_off')).toBeInTheDocument(); + expect(screen.queryByText('shortcuts.tip')).toBeNull(); + } finally { + useSettingsStore.setState({ singleKeyShortcuts: true }); + } + }); + + it('says nothing with them on', () => { + nav.pathname = '/'; + render( {}} />); + expect(screen.queryByText('shortcuts.single_keys_off')).toBeNull(); + expect(screen.getByText('shortcuts.tip')).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 20aaaf355..2f0159efe 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -1586,7 +1586,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (isContextMenuOpen()) return; if (showEventModal || showTaskModal || detailPinned) return; - const action = calendarShortcutAction(e); + const action = calendarShortcutAction(e, useSettingsStore.getState().singleKeyShortcuts !== false); if (!action) return; // A slider, tabs or a menu keeps its own arrows. if ((action === "prev" || action === "next") && isArrowKeyWidgetTarget(e)) return; diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index ee09a58fa..ed44ea0ed 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -31,6 +31,7 @@ import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons"; import { isEditableEventTarget } from "@/lib/keyboard"; +import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -300,6 +301,7 @@ export function EventDetailPopover({ if (e.key === "Escape") onClose(); if (isEditableEventTarget(e)) return; if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; + if (isSingleKeyShortcutOff(e)) return; if (e.code === "KeyE" && !noteExpanded && canEditBody) { e.preventDefault(); onEdit(); diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 4a185b44b..c19a790e9 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -242,3 +242,34 @@ describe('command palette and plugin pages', () => { pages.resetPages(); }); }); + +// MO-04 / MO-05: "?" opens the help, except with single-key shortcuts off or +// with a menu or dialog open. +describe('the ? key', () => { + const pressQuestion = () => + act(() => { fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true }); }); + + it('opens the shortcuts help', () => { + render(); + pressQuestion(); + expect(screen.getByText('shortcuts help')).toBeInTheDocument(); + }); + + it('does nothing with single-key shortcuts off', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ singleKeyShortcuts: false }); + try { + render(); + pressQuestion(); + expect(screen.queryByText('shortcuts help')).toBeNull(); + } finally { + useSettingsStore.setState({ singleKeyShortcuts: true }); + } + }); + + it('does nothing while a menu is open', () => { + render(<>
); + pressQuestion(); + expect(screen.queryByText('shortcuts help')).toBeNull(); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index e3924ea81..003a1689f 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -38,6 +38,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist import { shortcutAlternatives } from "@/lib/shortcut-keys"; import { toUnicodeEmail } from "@/lib/idn"; import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard"; +import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useMediaQuery } from "@/hooks/use-media-query"; import { IconButton } from "@/components/ui/icon-button"; @@ -110,6 +111,9 @@ export function CommandPaletteHost() { if (!question || isEditableEventTarget(event) || isContextMenuOpen()) return; // Another dialog, a menu or a popup is open: the key belongs to it. if (event.defaultPrevented || isKeyboardLayerOpen()) return; + // Turned off with the other single-key shortcuts; the menu and the + // command palette still open the help. + if (isSingleKeyShortcutOff(event)) return; event.preventDefault(); event.stopImmediatePropagation(); setShowShortcuts(true); diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 480433cc2..e954259aa 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -20,6 +20,7 @@ import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types"; import { getContactDisplayName, getContactSortName, hasContactPhoto } from "@/stores/contact-store"; import { useSettingsStore } from "@/stores/settings-store"; import { isAppShortcutBlocked } from "@/lib/keyboard"; +import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; type TriState = boolean | null; @@ -376,7 +377,7 @@ export function ContactList({ useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return; - if (isAppShortcutBlocked(e)) return; + if (isAppShortcutBlocked(e) || isSingleKeyShortcutOff(e)) return; if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return; e.preventDefault(); searchRef.current?.focus(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index afd7bdbb9..551ff4883 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -57,6 +57,7 @@ import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHt import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { isEditableEventTarget } from "@/lib/keyboard"; +import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { markReplyQuoteHeader } from "@/components/email/reply-quote-collapse"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; import { containsEmbeddedSignature } from "@/components/email/signature-block"; @@ -2077,6 +2078,7 @@ export function EmailComposer({ // island doesn't trigger the picker (#654). if (isEditableEventTarget(e)) return; if (!templatesEnabled) return; + if (isSingleKeyShortcutOff(e)) return; if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setShowTemplatePicker(true); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 549cb9032..c068054c5 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -39,6 +39,7 @@ import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNodeRights } from "@/lib/jmap/types"; import type { FileResourceView } from "@/lib/plugin-types"; import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard"; +import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; const NO_FORMATS: string[] = []; @@ -726,7 +727,7 @@ export function FileBrowser({ const selected = selectedList.map((r) => r.name); // "/" as in Mail and Contacts; Ctrl/⌘+F as in a file manager. - if ((mod && e.key.toLowerCase() === "f") || (e.key === "/" && !mod && !e.altKey)) { + if ((mod && e.key.toLowerCase() === "f") || (e.key === "/" && !mod && !e.altKey && !isSingleKeyShortcutOff(e))) { e.preventDefault(); searchInputRef.current?.focus(); return; diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 28a438e38..9f0af0c87 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -42,6 +42,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu // scroll to the tour link at the bottom when there are no tabs. const contentRef = useRef(null); const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); + const singleKeyShortcuts = useSettingsStore((state) => state.singleKeyShortcuts); const shell = useProInterfaceActive() ? "pro" : "standard"; const features = usePolicyStore((s) => s.policy.features); // Plugin buttons in the composer that have a shortcut (composer-toolbar-actions). @@ -91,6 +92,11 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu initialFocus={contentRef} >
+ {!singleKeyShortcuts && ( +

+ {t("shortcuts.single_keys_off")} +

+ )} {apps.length > 1 && (
{apps.map((a) => ( @@ -150,7 +156,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu {/* Footer tip */}

- {t("shortcuts.tip")} + {singleKeyShortcuts ? t("shortcuts.tip") : t("shortcuts.tip_single_keys_off")}