diff --git a/ui/src/lib/keyboardShortcuts.test.ts b/ui/src/lib/keyboardShortcuts.test.ts index 1495fe40..f284a53c 100644 --- a/ui/src/lib/keyboardShortcuts.test.ts +++ b/ui/src/lib/keyboardShortcuts.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { hasBlockingShortcutDialog, isKeyboardShortcutTextInputTarget, + resolveGoToInboxKeyAction, resolveInboxQuickArchiveKeyAction, } from "./keyboardShortcuts"; @@ -109,4 +110,49 @@ describe("keyboardShortcuts helpers", () => { hasOpenDialog: false, })).toBe("disarm"); }); + + it("arms go-to-inbox on a clean g press", () => { + const button = document.createElement("button"); + + expect(resolveGoToInboxKeyAction({ + armed: false, + defaultPrevented: false, + key: "g", + metaKey: false, + ctrlKey: false, + altKey: false, + target: button, + hasOpenDialog: false, + })).toBe("arm"); + }); + + it("navigates to inbox on i after g", () => { + const button = document.createElement("button"); + + expect(resolveGoToInboxKeyAction({ + armed: true, + defaultPrevented: false, + key: "i", + metaKey: false, + ctrlKey: false, + altKey: false, + target: button, + hasOpenDialog: false, + })).toBe("navigate"); + }); + + it("disarms go-to-inbox instead of firing from an editor", () => { + const input = document.createElement("textarea"); + + expect(resolveGoToInboxKeyAction({ + armed: true, + defaultPrevented: false, + key: "i", + metaKey: false, + ctrlKey: false, + altKey: false, + target: input, + hasOpenDialog: false, + })).toBe("disarm"); + }); }); diff --git a/ui/src/lib/keyboardShortcuts.ts b/ui/src/lib/keyboardShortcuts.ts index 7007ea69..ab4cbd73 100644 --- a/ui/src/lib/keyboardShortcuts.ts +++ b/ui/src/lib/keyboardShortcuts.ts @@ -11,6 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [ const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]); export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm"; +export type GoToInboxKeyAction = "ignore" | "arm" | "navigate" | "disarm"; export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; @@ -52,3 +53,35 @@ export function resolveInboxQuickArchiveKeyAction({ if (key === "y") return "archive"; return "disarm"; } + +export function resolveGoToInboxKeyAction({ + armed, + defaultPrevented, + key, + metaKey, + ctrlKey, + altKey, + target, + hasOpenDialog, +}: { + armed: boolean; + defaultPrevented: boolean; + key: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + target: EventTarget | null; + hasOpenDialog: boolean; +}): GoToInboxKeyAction { + if (defaultPrevented) return armed ? "disarm" : "ignore"; + if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore"; + if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) { + return armed ? "disarm" : "ignore"; + } + + const normalizedKey = key.toLowerCase(); + if (!armed) return normalizedKey === "g" ? "arm" : "ignore"; + if (normalizedKey === "i") return "navigate"; + if (normalizedKey === "g") return "arm"; + return "disarm"; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 9c3a417b..3719b3a9 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -26,7 +26,11 @@ import { rememberIssueDetailLocationState, shouldArmIssueDetailInboxQuickArchive, } from "../lib/issueDetailBreadcrumb"; -import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts"; +import { + hasBlockingShortcutDialog, + resolveGoToInboxKeyAction, + resolveInboxQuickArchiveKeyAction, +} from "../lib/keyboardShortcuts"; import { applyOptimisticIssueFieldUpdate, applyOptimisticIssueFieldUpdateToCollection, @@ -1232,6 +1236,8 @@ export function IssueDetail() { }, [closePanel, handleIssuePropertiesUpdate, issuePanelKey, openNewSubIssue, openPanel]); const inboxQuickArchiveArmedRef = useRef(false); + const goToInboxShortcutArmedRef = useRef(false); + const goToInboxShortcutTimeoutRef = useRef(null); const canQuickArchiveFromInbox = keyboardShortcutsEnabled && !issue?.hiddenAt && @@ -1301,6 +1307,83 @@ export function IssueDetail() { }; }, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]); + useEffect(() => { + if (!keyboardShortcutsEnabled) { + goToInboxShortcutArmedRef.current = false; + if (goToInboxShortcutTimeoutRef.current !== null) { + window.clearTimeout(goToInboxShortcutTimeoutRef.current); + goToInboxShortcutTimeoutRef.current = null; + } + return; + } + + const clearArmTimeout = () => { + if (goToInboxShortcutTimeoutRef.current !== null) { + window.clearTimeout(goToInboxShortcutTimeoutRef.current); + goToInboxShortcutTimeoutRef.current = null; + } + }; + + const disarm = () => { + goToInboxShortcutArmedRef.current = false; + clearArmTimeout(); + }; + + const arm = () => { + goToInboxShortcutArmedRef.current = true; + clearArmTimeout(); + goToInboxShortcutTimeoutRef.current = window.setTimeout(() => { + goToInboxShortcutArmedRef.current = false; + goToInboxShortcutTimeoutRef.current = null; + }, 1200); + }; + + const handlePointerDown = () => { + disarm(); + }; + + const handleFocusIn = (event: FocusEvent) => { + if (event.target instanceof HTMLElement && event.target !== document.body) { + disarm(); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const action = resolveGoToInboxKeyAction({ + armed: goToInboxShortcutArmedRef.current, + defaultPrevented: event.defaultPrevented, + key: event.key, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + target: event.target, + hasOpenDialog: hasBlockingShortcutDialog(document), + }); + + if (action === "ignore") return; + if (action === "arm") { + arm(); + return; + } + + disarm(); + if (action !== "navigate") return; + + event.preventDefault(); + navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox"); + }; + + document.addEventListener("pointerdown", handlePointerDown, true); + document.addEventListener("focusin", handleFocusIn, true); + document.addEventListener("keydown", handleKeyDown, true); + return () => { + disarm(); + document.removeEventListener("pointerdown", handlePointerDown, true); + document.removeEventListener("focusin", handleFocusIn, true); + document.removeEventListener("keydown", handleKeyDown, true); + }; + }, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]); + const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/"); const attachmentList = attachments ?? []; const imageAttachments = attachmentList.filter(isImageAttachment);