From 348ec1d399ded74882754c424123239daf8988be Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 03:44:09 +0200 Subject: [PATCH] feat: let plugins stop app shortcuts and hear when one ran --- ...se-keyboard-shortcuts-plugin-hooks.test.ts | 50 +++++++++++++++ hooks/use-keyboard-shortcuts.ts | 63 ++++++++++++------- lib/__tests__/plugin-hook-catalog.test.ts | 2 +- 3 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 hooks/__tests__/use-keyboard-shortcuts-plugin-hooks.test.ts diff --git a/hooks/__tests__/use-keyboard-shortcuts-plugin-hooks.test.ts b/hooks/__tests__/use-keyboard-shortcuts-plugin-hooks.test.ts new file mode 100644 index 000000000..44ff9faa2 --- /dev/null +++ b/hooks/__tests__/use-keyboard-shortcuts-plugin-hooks.test.ts @@ -0,0 +1,50 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { clearAllHooks, keyboardHooks } from '@/lib/plugin-hooks'; +import { useKeyboardShortcuts } from '../use-keyboard-shortcuts'; + +function press(key: string, code: string, init: KeyboardEventInit = {}) { + document.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, ...init })); +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => clearAllHooks()); + +describe('shortcut hooks', () => { + it('runs the shortcut and tells plugins after it ran', () => { + const onArchive = vi.fn(); + const after = vi.fn(); + keyboardHooks.onAfterShortcut.register('p', after); + renderHook(() => useKeyboardShortcuts({ emails: [], selectedEmailId: 'e1', handlers: { onArchive } })); + + press('e', 'KeyE'); + + expect(onArchive).toHaveBeenCalledTimes(1); + expect(after).toHaveBeenCalledWith({ key: 'e', action: 'archive' }); + }); + + it('lets a plugin stop a shortcut', async () => { + const onReplyAll = vi.fn(); + const before = vi.fn().mockReturnValue(false); + keyboardHooks.onBeforeShortcut.register('p', before); + renderHook(() => useKeyboardShortcuts({ emails: [], selectedEmailId: 'e1', handlers: { onReplyAll } })); + + press('R', 'KeyR', { shiftKey: true }); + await flush(); + + expect(before).toHaveBeenCalledWith({ key: 'shift+r', action: 'reply-all' }); + expect(onReplyAll).not.toHaveBeenCalled(); + }); + + it('runs the shortcut when the plugin lets it through', async () => { + const onCompose = vi.fn(); + keyboardHooks.onBeforeShortcut.register('p', () => true); + renderHook(() => useKeyboardShortcuts({ emails: [], handlers: { onCompose } })); + + press('c', 'KeyC'); + await flush(); + + expect(onCompose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 21e9231cc..9e9c007c9 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -3,6 +3,7 @@ import { useEffect, useCallback, useRef } from "react"; import { Email } from "@/lib/jmap/types"; import { isEditableEventTarget } from "@/lib/keyboard"; +import { keyboardHooks } from "@/lib/plugin-hooks"; import type { InterfaceLayout } from "@/stores/settings-store"; export interface KeyboardShortcutHandlers { @@ -120,6 +121,22 @@ export function useKeyboardShortcuts({ const h = handlersRef.current; const key = physicalShortcutKey(event); + const keyName = `${event.ctrlKey || event.metaKey ? "mod+" : ""}${event.shiftKey && key.length === 1 && /[a-z]/.test(key) ? "shift+" : ""}${key}`; + // Runs a shortcut's action. Plugins may stop it first (onBeforeShortcut) + // and hear about it after; with no plugin listening it runs at once. + const run = (action: string, fn: (() => void) | undefined) => { + if (!fn) return; + const payload = { key: keyName, action }; + const done = () => { + fn(); + void keyboardHooks.onAfterShortcut.emit(payload); + }; + if (keyboardHooks.onBeforeShortcut.size === 0) { + done(); + return; + } + void keyboardHooks.onBeforeShortcut.intercept(payload).then((ok) => { if (ok) done(); }); + }; const hasModifier = event.ctrlKey || event.metaKey || event.altKey; // Shortcuts that work with modifiers @@ -128,7 +145,7 @@ export function useKeyboardShortcuts({ case "a": // Ctrl/Cmd + A: Select all event.preventDefault(); - h.onSelectAll?.(); + run("select-all", h.onSelectAll); return; } } @@ -146,7 +163,8 @@ export function useKeyboardShortcuts({ const target = GO_TO_KEYS[key]; if (target && h.onGoToMailbox) { event.preventDefault(); - h.onGoToMailbox(target); + const goTo = h.onGoToMailbox; + run(`go-to-${target}`, () => goTo(target)); return; } } @@ -156,27 +174,26 @@ export function useKeyboardShortcuts({ case "j": case "arrowdown": event.preventDefault(); - h.onNextEmail?.(); + run("next-email", h.onNextEmail); break; case "k": case "arrowup": event.preventDefault(); - h.onPreviousEmail?.(); + run("previous-email", h.onPreviousEmail); break; case "enter": case "o": if (selectedEmailId) { event.preventDefault(); - h.onOpenEmail?.(); + run("open-email", h.onOpenEmail); } break; case "escape": event.preventDefault(); - h.onCloseEmail?.(); - h.onDeselectAll?.(); + run("close", h.onCloseEmail || h.onDeselectAll ? () => { h.onCloseEmail?.(); h.onDeselectAll?.(); } : undefined); break; // Email actions (only when email is selected) @@ -184,9 +201,9 @@ export function useKeyboardShortcuts({ if (selectedEmailId) { event.preventDefault(); if (event.shiftKey) { - h.onReplyAll?.(); + run("reply-all", h.onReplyAll); } else { - h.onReply?.(); + run("reply", h.onReply); } } break; @@ -194,28 +211,28 @@ export function useKeyboardShortcuts({ case "a": if (selectedEmailId) { event.preventDefault(); - h.onReplyAll?.(); + run("reply-all", h.onReplyAll); } break; case "f": if (selectedEmailId) { event.preventDefault(); - h.onForward?.(); + run("forward", h.onForward); } break; case "s": if (selectedEmailId) { event.preventDefault(); - h.onToggleStar?.(); + run("toggle-star", h.onToggleStar); } break; case "e": if (hasBatchTarget) { event.preventDefault(); - h.onArchive?.(); + run("archive", h.onArchive); } break; @@ -224,51 +241,51 @@ export function useKeyboardShortcuts({ case "backspace": if (hasBatchTarget) { event.preventDefault(); - h.onDelete?.(); + run("delete", h.onDelete); } break; case "u": if (hasBatchTarget) { event.preventDefault(); - h.onMarkAsUnread?.(); + run("mark-as-unread", h.onMarkAsUnread); } break; case "i": if (hasBatchTarget && event.shiftKey) { event.preventDefault(); - h.onMarkAsRead?.(); + run("mark-as-read", h.onMarkAsRead); } break; case "!": if (hasBatchTarget) { event.preventDefault(); - h.onToggleSpam?.(); + run("toggle-spam", h.onToggleSpam); } break; // Global actions case "c": event.preventDefault(); - h.onCompose?.(); + run("compose", h.onCompose); break; case "/": event.preventDefault(); - h.onFocusSearch?.(); + run("focus-search", h.onFocusSearch); break; case "?": event.preventDefault(); - h.onShowHelp?.(); + run("show-help", h.onShowHelp); break; case "g": if (event.shiftKey) { event.preventDefault(); - h.onRefresh?.(); + run("refresh", h.onRefresh); } else if (mountainViewKeys && h.onGoToMailbox) { // Arm the sequence; the next key either completes it or lapses. event.preventDefault(); @@ -283,11 +300,11 @@ export function useKeyboardShortcuts({ if (mountainViewKeys) { if (selectedEmailId) { event.preventDefault(); - h.onToggleSelection?.(); + run("toggle-selection", h.onToggleSelection); } } else if (selectedEmailId) { event.preventDefault(); - h.onToggleThreadExpansion?.(); + run("toggle-thread-expansion", h.onToggleThreadExpansion); } break; } diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index c62f67ddb..f6d726450 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean { const NOT_YET_WIRED = new Set([ 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', 'mail-sync', 'calendar', 'tasks', 'settings', 'identities', - 'filters', 'vacation', 'templates', 'security', 'keyboard', + 'filters', 'vacation', 'templates', 'security' ]); describe('hook catalogue', () => {