"use client" import { useEffect, useRef } from "react" import { useQueryClient } from "@tanstack/react-query" import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" import { useToggleEntryReadState, useToggleEntrySavedState, } from "@/lib/queries/use-entry-state-mutations" import { useMarkAllAsRead } from "@/lib/queries/use-mark-all-as-read" import { queryKeys } from "@/lib/queries/query-keys" import type { TimelineEntry } from "@/lib/types/timeline" import type { InfiniteData } from "@tanstack/react-query" function findEntryInCache( queryClient: ReturnType, entryIdentifier: string ): TimelineEntry | undefined { const allQueries = [ ...queryClient.getQueriesData>({ queryKey: queryKeys.timeline.all, }), ...queryClient.getQueriesData>({ queryKey: ["custom-feed-timeline"], }), ...queryClient.getQueriesData>({ queryKey: queryKeys.savedEntries.all, }), ] for (const [, data] of allQueries) { if (!data) continue for (const page of data.pages) { const match = page.find( (entry) => entry.entryIdentifier === entryIdentifier ) if (match) return match } } return undefined } const PANEL_ORDER = ["sidebar", "entryList", "detailPanel"] as const export function useKeyboardNavigation() { const queryClient = useQueryClient() const selectedEntryIdentifier = useUserInterfaceStore( (state) => state.selectedEntryIdentifier ) const setSelectedEntryIdentifier = useUserInterfaceStore( (state) => state.setSelectedEntryIdentifier ) const focusedEntryIdentifier = useUserInterfaceStore( (state) => state.focusedEntryIdentifier ) const setFocusedEntryIdentifier = useUserInterfaceStore( (state) => state.setFocusedEntryIdentifier ) const focusedPanel = useUserInterfaceStore((state) => state.focusedPanel) const setFocusedPanel = useUserInterfaceStore( (state) => state.setFocusedPanel ) const focusedSidebarIndex = useUserInterfaceStore( (state) => state.focusedSidebarIndex ) const setFocusedSidebarIndex = useUserInterfaceStore( (state) => state.setFocusedSidebarIndex ) const toggleSidebar = useUserInterfaceStore((state) => state.toggleSidebar) const setCommandPaletteOpen = useUserInterfaceStore( (state) => state.setCommandPaletteOpen ) const isCommandPaletteOpen = useUserInterfaceStore( (state) => state.isCommandPaletteOpen ) const setEntryListViewMode = useUserInterfaceStore( (state) => state.setEntryListViewMode ) const isSearchOpen = useUserInterfaceStore((state) => state.isSearchOpen) const setSearchOpen = useUserInterfaceStore((state) => state.setSearchOpen) const setSidebarCollapsed = useUserInterfaceStore( (state) => state.setSidebarCollapsed ) const isSidebarCollapsed = useUserInterfaceStore( (state) => state.isSidebarCollapsed ) const navigableEntryIdentifiers = useUserInterfaceStore( (state) => state.navigableEntryIdentifiers ) const isAddFeedDialogOpen = useUserInterfaceStore( (state) => state.isAddFeedDialogOpen ) const isShortcutsDialogOpen = useUserInterfaceStore( (state) => state.isShortcutsDialogOpen ) const toggleShortcutsDialog = useUserInterfaceStore( (state) => state.toggleShortcutsDialog ) const toggleReadState = useToggleEntryReadState() const toggleSavedState = useToggleEntrySavedState() const markAllAsRead = useMarkAllAsRead() const pendingGKeyTimestamp = useRef(0) useEffect(() => { function handleKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement if ( event.key !== "Escape" && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) ) { return } if (isShortcutsDialogOpen || isAddFeedDialogOpen) return if ((isCommandPaletteOpen || isSearchOpen) && event.key !== "Escape") return if (event.ctrlKey) { switch (event.key) { case "h": { event.preventDefault() const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) if (currentPanelIndex > 0) { const targetPanel = PANEL_ORDER[currentPanelIndex - 1] setFocusedPanel(targetPanel) if (targetPanel === "sidebar") { setSidebarCollapsed(false) } } else { setSidebarCollapsed(false) setFocusedPanel("sidebar") } return } case "l": { event.preventDefault() const currentPanelIndex = PANEL_ORDER.indexOf(focusedPanel) if (currentPanelIndex < PANEL_ORDER.length - 1) { const targetPanel = PANEL_ORDER[currentPanelIndex + 1] if (targetPanel === "detailPanel" && !selectedEntryIdentifier) { return } setFocusedPanel(targetPanel) } return } } return } if (event.key === " ") { const scrollSelectors: Record = { entryList: "[data-entry-list-scroll]", detailPanel: "[data-detail-article]", sidebar: "[data-sidebar-nav]", } const selector = scrollSelectors[focusedPanel] const scrollableElement = selector ? document.querySelector(selector) : null if (scrollableElement) { event.preventDefault() const pageScrollAmount = scrollableElement.clientHeight * 0.8 if (event.shiftKey) { scrollableElement.scrollTop -= pageScrollAmount } else { scrollableElement.scrollTop += pageScrollAmount } } return } if (focusedPanel === "sidebar") { handleSidebarKeyDown(event) return } if (focusedPanel === "detailPanel") { handleDetailPanelKeyDown(event) return } const activeIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier const currentIndex = activeIdentifier ? navigableEntryIdentifiers.indexOf(activeIdentifier) : -1 switch (event.key) { case "j": case "ArrowDown": { event.preventDefault() if (navigableEntryIdentifiers.length === 0) break const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, navigableEntryIdentifiers.length - 1) setFocusedEntryIdentifier(navigableEntryIdentifiers[nextIndex]) break } case "k": case "ArrowUp": { event.preventDefault() if (navigableEntryIdentifiers.length === 0) break if (currentIndex === -1) { setFocusedEntryIdentifier(navigableEntryIdentifiers[0]) } else { const previousIndex = Math.max(currentIndex - 1, 0) setFocusedEntryIdentifier(navigableEntryIdentifiers[previousIndex]) } break } case "Enter": { if (focusedEntryIdentifier) { event.preventDefault() setSelectedEntryIdentifier(focusedEntryIdentifier) } break } case "Escape": { if (isCommandPaletteOpen) { setCommandPaletteOpen(false) } else if (isSearchOpen) { setSearchOpen(false) } else if (selectedEntryIdentifier) { setSelectedEntryIdentifier(null) } else if (focusedEntryIdentifier) { setFocusedEntryIdentifier(null) } break } case "r": { const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier if (targetIdentifier) { const entry = findEntryInCache(queryClient, targetIdentifier) if (entry) { toggleReadState.mutate({ entryIdentifier: entry.entryIdentifier, isRead: !entry.isRead, }) } } break } case "s": { const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier if (targetIdentifier) { const entry = findEntryInCache(queryClient, targetIdentifier) if (entry) { toggleSavedState.mutate({ entryIdentifier: entry.entryIdentifier, isSaved: !entry.isSaved, }) } } break } case "o": { const targetIdentifier = focusedEntryIdentifier ?? selectedEntryIdentifier if (targetIdentifier) { const entry = findEntryInCache(queryClient, targetIdentifier) if (entry?.entryUrl) { window.open(entry.entryUrl, "_blank", "noopener,noreferrer") } } break } case "A": { if (event.shiftKey) { event.preventDefault() const currentFeedIdentifier = useUserInterfaceStore.getState().currentFeedIdentifier const currentFolderIdentifier = useUserInterfaceStore.getState().currentFolderIdentifier markAllAsRead.mutate({ feedIdentifier: currentFeedIdentifier, folderIdentifier: currentFolderIdentifier, }) } break } case "/": { event.preventDefault() setSearchOpen(true) break } case "b": { toggleSidebar() break } case "1": { setEntryListViewMode("compact") break } case "2": { setEntryListViewMode("comfortable") break } case "3": { setEntryListViewMode("expanded") break } case "?": { event.preventDefault() toggleShortcutsDialog() break } case "g": { const now = Date.now() if (now - pendingGKeyTimestamp.current < 500) { pendingGKeyTimestamp.current = 0 if (navigableEntryIdentifiers.length > 0) { setFocusedEntryIdentifier(navigableEntryIdentifiers[0]) } } else { pendingGKeyTimestamp.current = now } break } case "G": { if (event.shiftKey && navigableEntryIdentifiers.length > 0) { event.preventDefault() setFocusedEntryIdentifier( navigableEntryIdentifiers[navigableEntryIdentifiers.length - 1] ) } break } case "n": { if (navigableEntryIdentifiers.length === 0) break const startIndex = currentIndex === -1 ? 0 : currentIndex + 1 for ( let i = startIndex; i < navigableEntryIdentifiers.length; i++ ) { const entry = findEntryInCache( queryClient, navigableEntryIdentifiers[i] ) if (entry && !entry.isRead) { setFocusedEntryIdentifier(navigableEntryIdentifiers[i]) break } } break } case "N": { if (event.shiftKey && navigableEntryIdentifiers.length > 0) { const startIndex = currentIndex === -1 ? navigableEntryIdentifiers.length - 1 : currentIndex - 1 for (let i = startIndex; i >= 0; i--) { const entry = findEntryInCache( queryClient, navigableEntryIdentifiers[i] ) if (entry && !entry.isRead) { setFocusedEntryIdentifier(navigableEntryIdentifiers[i]) break } } } break } } } function handleDetailPanelKeyDown(event: KeyboardEvent) { const SCROLL_AMOUNT = 100 const detailArticle = document.querySelector( "[data-detail-article]" ) switch (event.key) { case "j": case "ArrowDown": { event.preventDefault() if (detailArticle) detailArticle.scrollTop += SCROLL_AMOUNT break } case "k": case "ArrowUp": { event.preventDefault() if (detailArticle) detailArticle.scrollTop -= SCROLL_AMOUNT break } case "?": { event.preventDefault() toggleShortcutsDialog() break } case "Escape": { setFocusedPanel("entryList") break } } } function handleSidebarKeyDown(event: KeyboardEvent) { const sidebarLinks = document.querySelectorAll( "[data-sidebar-nav-item]" ) const itemCount = sidebarLinks.length if (itemCount === 0) return switch (event.key) { case "j": case "ArrowDown": { event.preventDefault() const nextIndex = Math.min(focusedSidebarIndex + 1, itemCount - 1) setFocusedSidebarIndex(nextIndex) sidebarLinks[nextIndex]?.scrollIntoView({ block: "nearest" }) break } case "k": case "ArrowUp": { event.preventDefault() const previousIndex = Math.max(focusedSidebarIndex - 1, 0) setFocusedSidebarIndex(previousIndex) sidebarLinks[previousIndex]?.scrollIntoView({ block: "nearest" }) break } case "g": { const now = Date.now() if (now - pendingGKeyTimestamp.current < 500) { pendingGKeyTimestamp.current = 0 setFocusedSidebarIndex(0) sidebarLinks[0]?.scrollIntoView({ block: "nearest" }) } else { pendingGKeyTimestamp.current = now } break } case "G": { if (event.shiftKey) { event.preventDefault() const lastIndex = itemCount - 1 setFocusedSidebarIndex(lastIndex) sidebarLinks[lastIndex]?.scrollIntoView({ block: "nearest" }) } break } case "n": { const unreadItems = document.querySelectorAll( "[data-sidebar-nav-item][data-has-unreads]" ) if (unreadItems.length === 0) break const allItems = document.querySelectorAll( "[data-sidebar-nav-item]" ) const allIndexes = Array.from(allItems) const unreadIndexes = Array.from(unreadItems).map((element) => allIndexes.indexOf(element) ) const nextUnread = unreadIndexes.find( (index) => index > focusedSidebarIndex ) if (nextUnread !== undefined) { setFocusedSidebarIndex(nextUnread) allItems[nextUnread]?.scrollIntoView({ block: "nearest" }) } break } case "N": { if (!event.shiftKey) break const unreadItems = document.querySelectorAll( "[data-sidebar-nav-item][data-has-unreads]" ) if (unreadItems.length === 0) break const allItems = document.querySelectorAll( "[data-sidebar-nav-item]" ) const allIndexes = Array.from(allItems) const unreadIndexes = Array.from(unreadItems).map((element) => allIndexes.indexOf(element) ) const previousUnread = unreadIndexes .filter((index) => index < focusedSidebarIndex) .pop() if (previousUnread !== undefined) { setFocusedSidebarIndex(previousUnread) allItems[previousUnread]?.scrollIntoView({ block: "nearest" }) } break } case "?": { event.preventDefault() toggleShortcutsDialog() break } case "Enter": { event.preventDefault() sidebarLinks[focusedSidebarIndex]?.click() setFocusedPanel("entryList") break } case "Escape": { setFocusedPanel("entryList") break } } } document.addEventListener("keydown", handleKeyDown, true) return () => document.removeEventListener("keydown", handleKeyDown, true) }, [ selectedEntryIdentifier, focusedEntryIdentifier, focusedPanel, focusedSidebarIndex, isAddFeedDialogOpen, isCommandPaletteOpen, isSearchOpen, isShortcutsDialogOpen, isSidebarCollapsed, navigableEntryIdentifiers, queryClient, setSelectedEntryIdentifier, setFocusedEntryIdentifier, setFocusedPanel, setFocusedSidebarIndex, setCommandPaletteOpen, setSidebarCollapsed, toggleSidebar, setEntryListViewMode, setSearchOpen, toggleReadState, toggleSavedState, markAllAsRead, toggleShortcutsDialog, ]) }