"use client"; import { useEffect, useCallback, useRef } from "react"; import { Email } from "@/lib/jmap/types"; export interface KeyboardShortcutHandlers { // Navigation onNextEmail?: () => void; onPreviousEmail?: () => void; onOpenEmail?: () => void; onCloseEmail?: () => void; // Email actions onReply?: () => void; onReplyAll?: () => void; onForward?: () => void; onToggleStar?: () => void; onArchive?: () => void; onDelete?: () => void; onMarkAsUnread?: () => void; onMarkAsRead?: () => void; // Global actions onCompose?: () => void; onFocusSearch?: () => void; onShowHelp?: () => void; onRefresh?: () => void; // Selection onSelectAll?: () => void; onDeselectAll?: () => void; // Thread actions onToggleThreadExpansion?: () => void; } export interface UseKeyboardShortcutsOptions { enabled?: boolean; emails: Email[]; selectedEmailId?: string; handlers: KeyboardShortcutHandlers; } // Check if user is typing in an input field function isInputFocused(): boolean { const activeElement = document.activeElement; if (!activeElement) return false; const tagName = activeElement.tagName.toLowerCase(); const isInput = tagName === "input" || tagName === "textarea" || tagName === "select"; const isContentEditable = activeElement.getAttribute("contenteditable") === "true"; return isInput || isContentEditable; } export function useKeyboardShortcuts({ enabled = true, emails, selectedEmailId, handlers, }: UseKeyboardShortcutsOptions) { const handlersRef = useRef(handlers); // Keep handlers ref updated useEffect(() => { handlersRef.current = handlers; }, [handlers]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { // Don't trigger shortcuts when typing in inputs if (isInputFocused()) return; const h = handlersRef.current; const key = event.key.toLowerCase(); const hasModifier = event.ctrlKey || event.metaKey || event.altKey; // Shortcuts that work with modifiers if (event.ctrlKey || event.metaKey) { switch (key) { case "a": // Ctrl/Cmd + A: Select all event.preventDefault(); h.onSelectAll?.(); return; } } // Shortcuts that should NOT work with modifiers if (hasModifier) return; switch (key) { // Navigation case "j": case "arrowdown": event.preventDefault(); h.onNextEmail?.(); break; case "k": case "arrowup": event.preventDefault(); h.onPreviousEmail?.(); break; case "enter": case "o": if (selectedEmailId) { event.preventDefault(); h.onOpenEmail?.(); } break; case "escape": event.preventDefault(); h.onCloseEmail?.(); h.onDeselectAll?.(); break; // Email actions (only when email is selected) case "r": if (selectedEmailId) { event.preventDefault(); if (event.shiftKey) { h.onReplyAll?.(); } else { h.onReply?.(); } } break; case "a": if (selectedEmailId) { event.preventDefault(); h.onReplyAll?.(); } break; case "f": if (selectedEmailId) { event.preventDefault(); h.onForward?.(); } break; case "s": if (selectedEmailId) { event.preventDefault(); h.onToggleStar?.(); } break; case "e": if (selectedEmailId) { event.preventDefault(); h.onArchive?.(); } break; case "#": case "delete": case "backspace": if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) { event.preventDefault(); h.onDelete?.(); } break; case "u": if (selectedEmailId) { event.preventDefault(); h.onMarkAsUnread?.(); } break; case "i": if (selectedEmailId && event.shiftKey) { event.preventDefault(); h.onMarkAsRead?.(); } break; // Global actions case "c": event.preventDefault(); h.onCompose?.(); break; case "/": event.preventDefault(); h.onFocusSearch?.(); break; case "?": event.preventDefault(); h.onShowHelp?.(); break; case "g": if (event.shiftKey) { event.preventDefault(); h.onRefresh?.(); } break; // Thread actions case "x": if (selectedEmailId) { event.preventDefault(); h.onToggleThreadExpansion?.(); } break; } }, [selectedEmailId] ); useEffect(() => { if (!enabled) return; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [enabled, handleKeyDown]); // Helper to get next/previous email const getAdjacentEmailIndex = useCallback( (direction: "next" | "previous"): number => { if (emails.length === 0) return -1; if (!selectedEmailId) { // If no email selected, select first (for next) or last (for previous) return direction === "next" ? 0 : emails.length - 1; } const currentIndex = emails.findIndex((e) => e.id === selectedEmailId); if (currentIndex === -1) return direction === "next" ? 0 : emails.length - 1; if (direction === "next") { return currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex; } else { return currentIndex > 0 ? currentIndex - 1 : currentIndex; } }, [emails, selectedEmailId] ); return { getAdjacentEmailIndex }; } // Shortcut definitions for the help modal export const KEYBOARD_SHORTCUTS = { navigation: [ { key: "j / ↓", description: "shortcuts.navigation.next_email" }, { key: "k / ↑", description: "shortcuts.navigation.previous_email" }, { key: "Enter / o", description: "shortcuts.navigation.open_email" }, { key: "Esc", description: "shortcuts.navigation.close_email" }, ], actions: [ { key: "r", description: "shortcuts.actions.reply" }, { key: "R / a", description: "shortcuts.actions.reply_all" }, { key: "f", description: "shortcuts.actions.forward" }, { key: "s", description: "shortcuts.actions.star" }, { key: "e", description: "shortcuts.actions.archive" }, { key: "# / Del", description: "shortcuts.actions.delete" }, { key: "u", description: "shortcuts.actions.mark_unread" }, { key: "Shift + I", description: "shortcuts.actions.mark_read" }, ], global: [ { key: "c", description: "shortcuts.global.compose" }, { key: "/", description: "shortcuts.global.search" }, { key: "?", description: "shortcuts.global.help" }, { key: "Shift + G", description: "shortcuts.global.refresh" }, { key: "Ctrl + A", description: "shortcuts.global.select_all" }, ], threads: [ { key: "x", description: "shortcuts.threads.expand_collapse" }, ], } as const;