mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins stop app shortcuts and hear when one ran
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ function isFired(hookName: string, groupName: string): boolean {
|
||||
const NOT_YET_WIRED = new Set<HookGroup>([
|
||||
'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', () => {
|
||||
|
||||
Reference in New Issue
Block a user