KeyboardNavigationProvider.tsx raw

   1  import { isTouchDevice } from '@/lib/utils'
   2  import { Event } from 'nostr-tools'
   3  import {
   4    createContext,
   5    ReactNode,
   6    RefObject,
   7    useCallback,
   8    useContext,
   9    useEffect,
  10    useMemo,
  11    useRef,
  12    useState
  13  } from 'react'
  14  import modalManager from '@/services/modal-manager.service'
  15  import { useScreenSize } from './ScreenSizeProvider'
  16  import { useUserPreferences } from './UserPreferencesProvider'
  17  
  18  // ============================================================================
  19  // Abstract Navigation Intent System
  20  // ============================================================================
  21  
  22  /**
  23   * Navigation intents are abstract actions that the control plane emits.
  24   * Components decide what each intent means in their context.
  25   */
  26  export type NavigationIntent =
  27    | 'up'
  28    | 'down'
  29    | 'left'
  30    | 'right'
  31    | 'activate'
  32    | 'back'
  33    | 'cancel'
  34    | 'pageUp'
  35    | 'nextAction'
  36    | 'prevAction'
  37  
  38  /**
  39   * Navigation regions handle intents for a specific area of the UI.
  40   * Higher priority regions handle intents first.
  41   */
  42  export interface NavigationRegion {
  43    id: string
  44    priority: number
  45    isActive: () => boolean
  46    handleIntent: (intent: NavigationIntent) => boolean // returns true if handled
  47  }
  48  
  49  // ============================================================================
  50  // Legacy Types (for backward compatibility during migration)
  51  // ============================================================================
  52  
  53  export type TNavigationColumn = 0 | 1 | 2 // 0=sidebar, 1=primary, 2=secondary
  54  
  55  export type TActionType = 'reply' | 'repost' | 'quote' | 'react' | 'zap'
  56  
  57  export type TActionMode = {
  58    active: boolean
  59    selectedAction: TActionType | null
  60    noteEvent: Event | null
  61  }
  62  
  63  export type TItemMeta = {
  64    type: 'sidebar' | 'note' | 'settings'
  65    event?: Event
  66    onActivate?: () => void
  67  }
  68  
  69  type TRegisteredItem = {
  70    ref: RefObject<HTMLElement>
  71    meta?: TItemMeta
  72  }
  73  
  74  // ============================================================================
  75  // Context Types
  76  // ============================================================================
  77  
  78  type TKeyboardNavigationContext = {
  79    // Intent system
  80    emitIntent: (intent: NavigationIntent) => void
  81    registerRegion: (region: NavigationRegion) => void
  82    unregisterRegion: (id: string) => void
  83  
  84    // Column focus (legacy, components should migrate to regions)
  85    activeColumn: TNavigationColumn
  86    setActiveColumn: (column: TNavigationColumn) => void
  87  
  88    // Item selection per column
  89    selectedIndex: Record<TNavigationColumn, number>
  90    setSelectedIndex: (column: TNavigationColumn, index: number) => void
  91    resetPrimarySelection: () => void
  92    offsetSelection: (column: TNavigationColumn, offset: number) => void
  93    clearColumn: (column: TNavigationColumn) => void
  94  
  95    // Registered items per column
  96    registerItem: (
  97      column: TNavigationColumn,
  98      index: number,
  99      ref: RefObject<HTMLElement>,
 100      meta?: TItemMeta
 101    ) => void
 102    unregisterItem: (column: TNavigationColumn, index: number) => void
 103    getItemCount: (column: TNavigationColumn) => number
 104  
 105    // Load more callback per column (for infinite scroll)
 106    registerLoadMore: (column: TNavigationColumn, callback: () => void) => void
 107    unregisterLoadMore: (column: TNavigationColumn) => void
 108  
 109    // Action mode
 110    actionMode: TActionMode
 111    enterActionMode: (noteEvent: Event) => void
 112    exitActionMode: () => void
 113    cycleAction: (direction?: 1 | -1) => void
 114  
 115    // Visual state
 116    isItemSelected: (column: TNavigationColumn, index: number) => boolean
 117  
 118    // Settings accordion
 119    openAccordionItem: string | null
 120    setOpenAccordionItem: (value: string | null) => void
 121  
 122    // Keyboard nav enabled
 123    isEnabled: boolean
 124    toggleKeyboardMode: () => void
 125  
 126    // Scroll utilities
 127    scrollToCenter: (element: HTMLElement) => void
 128  }
 129  
 130  const ACTIONS: TActionType[] = ['reply', 'repost', 'quote', 'react', 'zap']
 131  
 132  const KeyboardNavigationContext = createContext<TKeyboardNavigationContext | undefined>(undefined)
 133  
 134  export function useKeyboardNavigation() {
 135    const context = useContext(KeyboardNavigationContext)
 136    if (!context) {
 137      throw new Error('useKeyboardNavigation must be used within KeyboardNavigationProvider')
 138    }
 139    return context
 140  }
 141  
 142  /**
 143   * Hook for components to register as a navigation region.
 144   * The region handles intents and decides what they mean.
 145   */
 146  export function useNavigationRegion(
 147    id: string,
 148    priority: number,
 149    isActive: () => boolean,
 150    handleIntent: (intent: NavigationIntent) => boolean,
 151    deps: React.DependencyList = []
 152  ) {
 153    const { registerRegion, unregisterRegion } = useKeyboardNavigation()
 154  
 155    useEffect(() => {
 156      const region: NavigationRegion = {
 157        id,
 158        priority,
 159        isActive,
 160        handleIntent
 161      }
 162      registerRegion(region)
 163      return () => unregisterRegion(id)
 164      // eslint-disable-next-line react-hooks/exhaustive-deps
 165    }, [id, priority, registerRegion, unregisterRegion, ...deps])
 166  }
 167  
 168  // Helper to check if an input element is focused
 169  function isInputFocused(): boolean {
 170    const activeElement = document.activeElement
 171    if (!activeElement) return false
 172    const tagName = activeElement.tagName.toLowerCase()
 173    return (
 174      tagName === 'input' ||
 175      tagName === 'textarea' ||
 176      activeElement.getAttribute('contenteditable') === 'true'
 177    )
 178  }
 179  
 180  export function KeyboardNavigationProvider({
 181    children,
 182    secondaryStackLength,
 183    sidebarDrawerOpen,
 184    onBack,
 185    onCloseSecondary
 186  }: {
 187    children: ReactNode
 188    secondaryStackLength: number
 189    sidebarDrawerOpen: boolean
 190    onBack?: () => void
 191    onCloseSecondary?: () => void
 192  }) {
 193    const { isSmallScreen } = useScreenSize()
 194    const { enableSingleColumnLayout } = useUserPreferences()
 195  
 196    const [activeColumn, setActiveColumnState] = useState<TNavigationColumn>(1)
 197    const [selectedIndex, setSelectedIndexState] = useState<Record<TNavigationColumn, number>>({
 198      0: 0,
 199      1: 0,
 200      2: 0
 201    })
 202    const [actionMode, setActionMode] = useState<TActionMode>({
 203      active: false,
 204      selectedAction: null,
 205      noteEvent: null
 206    })
 207    const [openAccordionItem, setOpenAccordionItem] = useState<string | null>(null)
 208    const [isEnabled, setIsEnabled] = useState(false)
 209  
 210    // Track Escape presses for triple-Escape to disable keyboard mode
 211    const escapeTimestampsRef = useRef<number[]>([])
 212    const TRIPLE_ESCAPE_WINDOW = 800 // ms within which 3 escapes must occur
 213  
 214    // Refs to always have latest values (avoid stale closures)
 215    const activeColumnRef = useRef<TNavigationColumn>(activeColumn)
 216    const selectedIndexRef = useRef<Record<TNavigationColumn, number>>(selectedIndex)
 217  
 218    // Wrapper to update both state and ref
 219    const setActiveColumn = useCallback((column: TNavigationColumn) => {
 220      activeColumnRef.current = column
 221      setActiveColumnState(column)
 222    }, [])
 223  
 224    // Toggle keyboard navigation mode
 225    const toggleKeyboardMode = useCallback(() => {
 226      setIsEnabled((prev) => {
 227        const newEnabled = !prev
 228        if (newEnabled) {
 229          // When enabling, initialize selection to first item in active column
 230          const items = itemsRef.current[activeColumnRef.current]
 231          if (items.size > 0) {
 232            const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0]
 233            if (firstIndex !== undefined) {
 234              setSelectedIndexState((prevState) => ({
 235                ...prevState,
 236                [activeColumnRef.current]: firstIndex
 237              }))
 238            }
 239          }
 240        }
 241        return newEnabled
 242      })
 243    }, [])
 244  
 245    // Navigation regions registry
 246    const regionsRef = useRef<Map<string, NavigationRegion>>(new Map())
 247  
 248    // Ref to hold the latest handleDefaultIntent function
 249    const handleDefaultIntentRef = useRef<(intent: NavigationIntent) => void>(() => {})
 250  
 251    // Item registration per column
 252    const itemsRef = useRef<Record<TNavigationColumn, Map<number, TRegisteredItem>>>({
 253      0: new Map(),
 254      1: new Map(),
 255      2: new Map()
 256    })
 257  
 258    // Load more callbacks per column (for infinite scroll)
 259    const loadMoreRef = useRef<Record<TNavigationColumn, (() => void) | null>>({
 260      0: null,
 261      1: null,
 262      2: null
 263    })
 264  
 265    // ============================================================================
 266    // Intent System
 267    // ============================================================================
 268  
 269    const registerRegion = useCallback((region: NavigationRegion) => {
 270      regionsRef.current.set(region.id, region)
 271    }, [])
 272  
 273    const unregisterRegion = useCallback((id: string) => {
 274      regionsRef.current.delete(id)
 275    }, [])
 276  
 277    const emitIntent = useCallback((intent: NavigationIntent) => {
 278      // Sort regions by priority (highest first)
 279      const regions = Array.from(regionsRef.current.values())
 280        .filter((r) => r.isActive())
 281        .sort((a, b) => b.priority - a.priority)
 282  
 283      // Let regions handle the intent in priority order
 284      for (const region of regions) {
 285        if (region.handleIntent(intent)) {
 286          return // Intent was handled
 287        }
 288      }
 289  
 290      // Fallback to default handling if no region handled it
 291      handleDefaultIntentRef.current(intent)
 292    }, [])
 293  
 294    // ============================================================================
 295    // Scroll to Center (or top if near edge)
 296    // ============================================================================
 297  
 298    const scrollToCenter = useCallback((element: HTMLElement) => {
 299      // Find the scrollable container (look for overflow-y: auto/scroll)
 300      // Stop at document.body - if we reach it, use window scrolling instead
 301      let scrollContainer: HTMLElement | null = element.parentElement
 302      while (scrollContainer && scrollContainer !== document.body && scrollContainer !== document.documentElement) {
 303        const style = window.getComputedStyle(scrollContainer)
 304        const overflowY = style.overflowY
 305        if (overflowY === 'auto' || overflowY === 'scroll') {
 306          // Verify this container is actually scrollable (has scrollable content)
 307          if (scrollContainer.scrollHeight > scrollContainer.clientHeight) {
 308            break
 309          }
 310        }
 311        scrollContainer = scrollContainer.parentElement
 312      }
 313  
 314      // If we reached body/documentElement, use window scrolling
 315      if (!scrollContainer || scrollContainer === document.body || scrollContainer === document.documentElement) {
 316        scrollContainer = null
 317      }
 318  
 319      const headerOffset = 100 // Account for sticky headers
 320  
 321      if (scrollContainer) {
 322        // Scroll within container
 323        const containerRect = scrollContainer.getBoundingClientRect()
 324        const elementRect = element.getBoundingClientRect()
 325  
 326        // Position relative to container
 327        const elementTopInContainer = elementRect.top - containerRect.top
 328        const elementBottomInContainer = elementRect.bottom - containerRect.top
 329        const containerHeight = containerRect.height
 330        const visibleHeight = containerHeight - headerOffset
 331  
 332        // If element is taller than visible area, scroll to show its top
 333        if (elementRect.height > visibleHeight) {
 334          const targetScrollTop = scrollContainer.scrollTop + elementTopInContainer - headerOffset
 335          scrollContainer.scrollTo({
 336            top: Math.max(0, targetScrollTop),
 337            behavior: 'instant'
 338          })
 339          return
 340        }
 341  
 342        // Check if already visible with margin
 343        const isVisible = elementTopInContainer >= headerOffset &&
 344                         elementBottomInContainer <= containerHeight - 50
 345  
 346        if (!isVisible) {
 347          // Calculate target scroll to center the element
 348          const elementMiddle = elementTopInContainer + elementRect.height / 2
 349          const containerMiddle = containerHeight / 2
 350          const scrollAdjustment = elementMiddle - containerMiddle
 351          const newScrollTop = scrollContainer.scrollTop + scrollAdjustment
 352  
 353          // Don't scroll past the top
 354          scrollContainer.scrollTo({
 355            top: Math.max(0, newScrollTop),
 356            behavior: 'instant'
 357          })
 358        }
 359      } else {
 360        // Window scrolling (mobile, single column mode)
 361        const rect = element.getBoundingClientRect()
 362        const viewportHeight = window.innerHeight
 363        const visibleHeight = viewportHeight - headerOffset
 364  
 365        // If element is taller than visible area, scroll to show its top
 366        if (rect.height > visibleHeight) {
 367          const targetScrollTop = window.scrollY + rect.top - headerOffset
 368          window.scrollTo({
 369            top: Math.max(0, targetScrollTop),
 370            behavior: 'instant'
 371          })
 372          return
 373        }
 374  
 375        // Check if already visible
 376        const isVisible = rect.top >= headerOffset && rect.bottom <= viewportHeight - 50
 377  
 378        if (!isVisible) {
 379          // Calculate target scroll to center the element
 380          const elementMiddle = rect.top + rect.height / 2
 381          const viewportMiddle = viewportHeight / 2
 382          const scrollAdjustment = elementMiddle - viewportMiddle
 383          const newScrollTop = window.scrollY + scrollAdjustment
 384  
 385          window.scrollTo({
 386            top: Math.max(0, newScrollTop),
 387            behavior: 'instant'
 388          })
 389        }
 390      }
 391    }, [])
 392  
 393    // ============================================================================
 394    // Legacy Item Management
 395    // ============================================================================
 396  
 397    const setSelectedIndex = useCallback((column: TNavigationColumn, index: number) => {
 398      selectedIndexRef.current = { ...selectedIndexRef.current, [column]: index }
 399      setSelectedIndexState((prev) => ({
 400        ...prev,
 401        [column]: index
 402      }))
 403    }, [])
 404  
 405    const resetPrimarySelection = useCallback(() => {
 406      setSelectedIndex(1, 0)
 407      setActiveColumn(1)
 408    }, [setSelectedIndex, setActiveColumn])
 409  
 410    const offsetSelection = useCallback(
 411      (column: TNavigationColumn, offset: number) => {
 412        setSelectedIndexState((prev) => {
 413          const newState = { ...prev, [column]: Math.max(0, prev[column] + offset) }
 414          selectedIndexRef.current = newState
 415          return newState
 416        })
 417      },
 418      []
 419    )
 420  
 421    const clearColumn = useCallback((column: TNavigationColumn) => {
 422      itemsRef.current[column].clear()
 423      setSelectedIndexState((prev) => ({
 424        ...prev,
 425        [column]: 0
 426      }))
 427    }, [])
 428  
 429    const registerItem = useCallback(
 430      (column: TNavigationColumn, index: number, ref: RefObject<HTMLElement>, meta?: TItemMeta) => {
 431        itemsRef.current[column].set(index, { ref, meta })
 432      },
 433      []
 434    )
 435  
 436    const unregisterItem = useCallback((column: TNavigationColumn, index: number) => {
 437      itemsRef.current[column].delete(index)
 438    }, [])
 439  
 440    const getItemCount = useCallback((column: TNavigationColumn) => {
 441      return itemsRef.current[column].size
 442    }, [])
 443  
 444    const registerLoadMore = useCallback((column: TNavigationColumn, callback: () => void) => {
 445      loadMoreRef.current[column] = callback
 446    }, [])
 447  
 448    const unregisterLoadMore = useCallback((column: TNavigationColumn) => {
 449      loadMoreRef.current[column] = null
 450    }, [])
 451  
 452    const isItemSelected = useCallback(
 453      (column: TNavigationColumn, index: number) => {
 454        return isEnabled && activeColumn === column && selectedIndex[column] === index
 455      },
 456      [isEnabled, activeColumn, selectedIndex]
 457    )
 458  
 459    // ============================================================================
 460    // Column Navigation
 461    // ============================================================================
 462  
 463    const getAvailableColumns = useCallback((): TNavigationColumn[] => {
 464      if (isSmallScreen) {
 465        // Mobile: sidebar is in a drawer, only one column visible at a time
 466        if (sidebarDrawerOpen) return [0]
 467        if (secondaryStackLength > 0) return [2]
 468        return [1]
 469      }
 470      if (enableSingleColumnLayout) {
 471        // Single column desktop: sidebar is always visible alongside main content
 472        if (secondaryStackLength > 0) return [0, 2]
 473        return [0, 1]
 474      }
 475      // Two column desktop
 476      if (secondaryStackLength > 0) return [0, 1, 2]
 477      return [0, 1]
 478    }, [isSmallScreen, enableSingleColumnLayout, sidebarDrawerOpen, secondaryStackLength])
 479  
 480    const moveColumn = useCallback(
 481      (direction: 1 | -1) => {
 482        const available = getAvailableColumns()
 483        const currentColumn = activeColumnRef.current
 484        const currentIdx = available.indexOf(currentColumn)
 485        if (currentIdx === -1) {
 486          setActiveColumn(available[0])
 487          return
 488        }
 489        const newIdx = Math.max(0, Math.min(available.length - 1, currentIdx + direction))
 490        const newColumn = available[newIdx]
 491        if (newColumn === currentColumn) return // Already at edge
 492  
 493        setActiveColumn(newColumn)
 494  
 495        // Scroll to currently selected item in the new column
 496        const items = itemsRef.current[newColumn]
 497        const currentSelectedIdx = selectedIndexRef.current[newColumn]
 498        const item = items.get(currentSelectedIdx)
 499        if (item?.ref.current) {
 500          scrollToCenter(item.ref.current)
 501        } else if (items.size > 0) {
 502          // If no item at current index, select the first one
 503          const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0]
 504          if (firstIndex !== undefined) {
 505            setSelectedIndex(newColumn, firstIndex)
 506            const firstItem = items.get(firstIndex)
 507            if (firstItem?.ref.current) {
 508              scrollToCenter(firstItem.ref.current)
 509            }
 510          }
 511        }
 512      },
 513      [getAvailableColumns, scrollToCenter, setSelectedIndex, setActiveColumn]
 514    )
 515  
 516    // ============================================================================
 517    // Item Navigation with Centered Scrolling
 518    // ============================================================================
 519  
 520    const moveItem = useCallback(
 521      (direction: 1 | -1) => {
 522        const currentColumn = activeColumnRef.current
 523        const items = itemsRef.current[currentColumn]
 524        if (items.size === 0) return
 525  
 526        const indices = Array.from(items.keys()).sort((a, b) => a - b)
 527        if (indices.length === 0) return
 528  
 529        const currentSelected = selectedIndexRef.current[currentColumn]
 530        let currentIdx = indices.indexOf(currentSelected)
 531  
 532        if (currentIdx === -1) {
 533          // Find nearest valid index
 534          let nearestIdx = 0
 535          let minDistance = Infinity
 536          for (let i = 0; i < indices.length; i++) {
 537            const distance = Math.abs(indices[i] - currentSelected)
 538            if (distance < minDistance) {
 539              minDistance = distance
 540              nearestIdx = i
 541            }
 542          }
 543          currentIdx = nearestIdx
 544        }
 545  
 546        // Calculate new index with wrap-around
 547        let newIdx = currentIdx + direction
 548  
 549        // Wrap around behavior
 550        if (newIdx < 0) {
 551          // At top, going up -> wrap to bottom
 552          newIdx = indices.length - 1
 553        } else if (newIdx >= indices.length) {
 554          // At bottom, going down -> trigger load more and wrap to top
 555          const loadMore = loadMoreRef.current[currentColumn]
 556          if (loadMore) {
 557            loadMore()
 558          }
 559          newIdx = 0
 560        }
 561  
 562        const newItemIndex = indices[newIdx]
 563        if (newItemIndex === undefined) return
 564  
 565        setSelectedIndex(currentColumn, newItemIndex)
 566  
 567        // Scroll to center
 568        const item = items.get(newItemIndex)
 569        if (item?.ref.current) {
 570          scrollToCenter(item.ref.current)
 571        }
 572      },
 573      [setSelectedIndex, scrollToCenter]
 574    )
 575  
 576    const jumpToTop = useCallback(() => {
 577        // For primary feed, use column 1; for secondary, use column 2
 578        // Fall back to current column if it has items
 579        let targetColumn = activeColumnRef.current
 580  
 581        // Check if current column has items, if not try column 1 (primary)
 582        if (itemsRef.current[targetColumn].size === 0) {
 583          if (itemsRef.current[1].size > 0) {
 584            targetColumn = 1
 585          } else if (itemsRef.current[2].size > 0) {
 586            targetColumn = 2
 587          }
 588        }
 589  
 590        const items = itemsRef.current[targetColumn]
 591        if (items.size === 0) return
 592  
 593        const indices = Array.from(items.keys()).sort((a, b) => a - b)
 594        if (indices.length === 0) return
 595  
 596        const newItemIndex = indices[0]
 597        if (newItemIndex === undefined) return
 598  
 599        // Set active column and selection immediately
 600        setActiveColumn(targetColumn)
 601        setSelectedIndex(targetColumn, newItemIndex)
 602  
 603        // Scroll to top of the page/container
 604        window.scrollTo({ top: 0, behavior: 'smooth' })
 605  
 606        // Also scroll any parent containers to top
 607        const item = items.get(newItemIndex)
 608        if (item?.ref.current) {
 609          let scrollContainer: HTMLElement | null = item.ref.current.parentElement
 610          while (scrollContainer && scrollContainer !== document.body) {
 611            const style = window.getComputedStyle(scrollContainer)
 612            if ((style.overflowY === 'auto' || style.overflowY === 'scroll') &&
 613                scrollContainer.scrollHeight > scrollContainer.clientHeight) {
 614              scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })
 615              break
 616            }
 617            scrollContainer = scrollContainer.parentElement
 618          }
 619        }
 620      },
 621      [setSelectedIndex, setActiveColumn]
 622    )
 623  
 624    // ============================================================================
 625    // Action Mode
 626    // ============================================================================
 627  
 628    const enterActionMode = useCallback((noteEvent: Event) => {
 629      setActionMode({
 630        active: true,
 631        selectedAction: 'reply',
 632        noteEvent
 633      })
 634    }, [])
 635  
 636    const exitActionMode = useCallback(() => {
 637      setActionMode({
 638        active: false,
 639        selectedAction: null,
 640        noteEvent: null
 641      })
 642    }, [])
 643  
 644    const cycleAction = useCallback(
 645      (direction: 1 | -1 = 1) => {
 646        setActionMode((prev) => {
 647          if (!prev.active) {
 648            const currentColumn = activeColumnRef.current
 649            const currentSelected = selectedIndexRef.current[currentColumn]
 650            const item = itemsRef.current[currentColumn].get(currentSelected)
 651            if (item?.meta?.type === 'note' && item.meta.event) {
 652              return {
 653                active: true,
 654                selectedAction: 'reply',
 655                noteEvent: item.meta.event
 656              }
 657            }
 658            return prev
 659          }
 660  
 661          const currentIdx = prev.selectedAction ? ACTIONS.indexOf(prev.selectedAction) : 0
 662          const newIdx = (currentIdx + direction + ACTIONS.length) % ACTIONS.length
 663          return {
 664            ...prev,
 665            selectedAction: ACTIONS[newIdx]
 666          }
 667        })
 668      },
 669      []
 670    )
 671  
 672    // ============================================================================
 673    // Intent Handlers
 674    // ============================================================================
 675  
 676    const handleEnter = useCallback(() => {
 677      const currentColumn = activeColumnRef.current
 678      const currentSelected = selectedIndexRef.current[currentColumn]
 679  
 680      if (actionMode.active) {
 681        const item = itemsRef.current[currentColumn].get(currentSelected)
 682        if (item?.ref.current && actionMode.selectedAction) {
 683          const stuffStats = item.ref.current.querySelector('[data-stuff-stats]')
 684          const actionButton = stuffStats?.querySelector(
 685            `[data-action="${actionMode.selectedAction}"]`
 686          ) as HTMLButtonElement | null
 687          actionButton?.click()
 688          exitActionMode()
 689        }
 690        return
 691      }
 692  
 693      const item = itemsRef.current[currentColumn].get(currentSelected)
 694      if (!item) return
 695  
 696      if (currentColumn === 0 && item.meta?.type === 'sidebar') {
 697        setSelectedIndex(1, 0)
 698        setActiveColumn(1)
 699      }
 700  
 701      if (item.meta?.onActivate) {
 702        item.meta.onActivate()
 703      } else if (item.ref.current) {
 704        item.ref.current.click()
 705      }
 706    }, [actionMode, exitActionMode, setSelectedIndex, setActiveColumn])
 707  
 708    const handleEscape = useCallback(() => {
 709      // Track Escape press for triple-Escape detection
 710      const now = Date.now()
 711      escapeTimestampsRef.current.push(now)
 712      // Keep only presses within the time window
 713      escapeTimestampsRef.current = escapeTimestampsRef.current.filter(
 714        (t) => now - t < TRIPLE_ESCAPE_WINDOW
 715      )
 716      // Check for triple-Escape to disable keyboard mode
 717      if (escapeTimestampsRef.current.length >= 3 && isEnabled) {
 718        setIsEnabled(false)
 719        escapeTimestampsRef.current = []
 720        return
 721      }
 722  
 723      if (actionMode.active) {
 724        exitActionMode()
 725        return
 726      }
 727  
 728      if (openAccordionItem) {
 729        setOpenAccordionItem(null)
 730        return
 731      }
 732  
 733      if ((isSmallScreen || enableSingleColumnLayout) && secondaryStackLength > 0) {
 734        onBack?.()
 735        return
 736      }
 737  
 738      const currentColumn = activeColumnRef.current
 739      if (currentColumn === 2 && secondaryStackLength > 0) {
 740        onCloseSecondary?.()
 741        setActiveColumn(1)
 742        return
 743      }
 744  
 745      if (currentColumn !== 0) {
 746        setActiveColumn(0)
 747        setSelectedIndex(0, 0)
 748      }
 749    }, [
 750      actionMode.active,
 751      exitActionMode,
 752      openAccordionItem,
 753      isSmallScreen,
 754      enableSingleColumnLayout,
 755      secondaryStackLength,
 756      onBack,
 757      onCloseSecondary,
 758      setSelectedIndex,
 759      setActiveColumn,
 760      isEnabled,
 761      TRIPLE_ESCAPE_WINDOW
 762    ])
 763  
 764    // Handle back action - move left through columns or close secondary panel
 765    const handleBack = useCallback(() => {
 766      const currentColumn = activeColumnRef.current
 767  
 768      // If focused on secondary column (2), close it and move to primary
 769      if (currentColumn === 2) {
 770        if (secondaryStackLength > 0) {
 771          if (isSmallScreen || enableSingleColumnLayout) {
 772            // On mobile/single column, use onBack to pop the stack
 773            onBack?.()
 774          } else {
 775            // On desktop with columns, close secondary and focus primary
 776            onCloseSecondary?.()
 777            setActiveColumn(1)
 778          }
 779        } else {
 780          // No secondary stack, just move to primary
 781          setActiveColumn(1)
 782        }
 783        return
 784      }
 785  
 786      // If focused on primary column (1), move to sidebar
 787      if (currentColumn === 1) {
 788        setActiveColumn(0)
 789        return
 790      }
 791  
 792      // If focused on sidebar (0), do nothing (already at leftmost)
 793    }, [secondaryStackLength, isSmallScreen, enableSingleColumnLayout, onBack, onCloseSecondary, setActiveColumn])
 794  
 795    // Default intent handler (fallback when no region handles)
 796    const handleDefaultIntent = useCallback(
 797      (intent: NavigationIntent) => {
 798        switch (intent) {
 799          case 'up':
 800            moveItem(-1)
 801            break
 802          case 'down':
 803            moveItem(1)
 804            break
 805          case 'left':
 806            moveColumn(-1)
 807            break
 808          case 'right':
 809            moveColumn(1)
 810            break
 811          case 'pageUp':
 812            jumpToTop()
 813            break
 814          case 'activate':
 815            handleEnter()
 816            break
 817          case 'back':
 818            handleBack()
 819            break
 820          case 'cancel':
 821            handleEscape()
 822            break
 823          case 'nextAction':
 824            cycleAction(1)
 825            break
 826          case 'prevAction':
 827            cycleAction(-1)
 828            break
 829        }
 830      },
 831      [moveItem, moveColumn, jumpToTop, handleEnter, handleBack, handleEscape, cycleAction]
 832    )
 833  
 834    // Keep the ref updated with the latest handleDefaultIntent
 835    useEffect(() => {
 836      handleDefaultIntentRef.current = handleDefaultIntent
 837    }, [handleDefaultIntent])
 838  
 839    // ============================================================================
 840    // Keyboard Event Handler
 841    // ============================================================================
 842  
 843    // Helper to trigger an action on the currently selected note
 844    const triggerNoteAction = useCallback((action: TActionType) => {
 845      const currentColumn = activeColumnRef.current
 846      const currentSelected = selectedIndexRef.current[currentColumn]
 847      const item = itemsRef.current[currentColumn].get(currentSelected)
 848      if (item?.meta?.type === 'note' && item.ref.current) {
 849        const stuffStats = item.ref.current.querySelector('[data-stuff-stats]')
 850        const actionButton = stuffStats?.querySelector(`[data-action="${action}"]`) as HTMLButtonElement | null
 851        actionButton?.click()
 852      }
 853    }, [])
 854  
 855    // Main keyboard handler - translates keys to intents
 856    // Also handles enabling keyboard nav on first navigation key press
 857    useEffect(() => {
 858      if (isTouchDevice()) return
 859  
 860      const handleKeyDown = (e: KeyboardEvent) => {
 861        if (isInputFocused()) return
 862        if (modalManager.hasOpenModal?.()) return
 863  
 864        // Map keys to intents
 865        let intent: NavigationIntent | null = null
 866        const isNavKey = ['ArrowUp', 'ArrowDown', 'j', 'k', 'Tab'].includes(e.key)
 867  
 868        switch (e.key) {
 869          case 'ArrowUp':
 870          case 'k': // Vim-style
 871            intent = 'up'
 872            break
 873          case 'ArrowDown':
 874          case 'j': // Vim-style
 875            intent = 'down'
 876            break
 877          case 'ArrowLeft':
 878          case 'h': // Vim-style
 879            intent = 'back'
 880            break
 881          case 'ArrowRight':
 882          case 'l': // Vim-style
 883            intent = 'right'
 884            break
 885          case 'Enter':
 886            intent = 'activate'
 887            break
 888          case 'PageUp':
 889            intent = 'pageUp'
 890            break
 891          case 'Escape':
 892            intent = 'cancel'
 893            break
 894          case 'Backspace':
 895            intent = 'back'
 896            break
 897          case 'Tab':
 898            // Tab switches between columns
 899            e.preventDefault()
 900            intent = e.shiftKey ? 'left' : 'right'
 901            break
 902          // Direct note actions
 903          case 'r':
 904            if (isEnabled) {
 905              e.preventDefault()
 906              triggerNoteAction('reply')
 907              return
 908            }
 909            break
 910          case 'R':
 911            if (isEnabled) {
 912              e.preventDefault()
 913              triggerNoteAction('react')
 914              return
 915            }
 916            break
 917          case 'p':
 918            if (isEnabled) {
 919              e.preventDefault()
 920              triggerNoteAction('repost')
 921              return
 922            }
 923            break
 924          case 'q':
 925            if (isEnabled) {
 926              e.preventDefault()
 927              triggerNoteAction('quote')
 928              return
 929            }
 930            break
 931          case 'z':
 932            if (isEnabled) {
 933              e.preventDefault()
 934              triggerNoteAction('zap')
 935              return
 936            }
 937            break
 938          case 'K':
 939            // Shift+K toggles keyboard mode
 940            if (e.shiftKey) {
 941              e.preventDefault()
 942              toggleKeyboardMode()
 943              return
 944            }
 945            break
 946          case 'M':
 947            // Shift+M expands/collapses the currently selected note
 948            if (e.shiftKey && isEnabled) {
 949              e.preventDefault()
 950              const col = activeColumnRef.current
 951              const sel = selectedIndexRef.current[col]
 952              const selectedItem = itemsRef.current[col].get(sel)
 953              if (selectedItem?.ref.current) {
 954                const expandBtn = selectedItem.ref.current.querySelector('[data-collapsible-expand]') as HTMLButtonElement | null
 955                if (expandBtn) {
 956                  expandBtn.click()
 957                  // Re-center after expand animation
 958                  setTimeout(() => {
 959                    if (selectedItem.ref.current) scrollToCenter(selectedItem.ref.current)
 960                  }, 50)
 961                }
 962              }
 963              return
 964            }
 965            break
 966        }
 967  
 968        // Enable keyboard nav on first navigation key press
 969        if (!isEnabled && isNavKey) {
 970          setIsEnabled(true)
 971  
 972          // Initialize selection to first item in active column
 973          const available = getAvailableColumns()
 974          const currentColumn = activeColumnRef.current
 975          const column = available.includes(currentColumn) ? currentColumn : available[0]
 976          const items = itemsRef.current[column]
 977          if (items.size > 0) {
 978            const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0]
 979            if (firstIndex !== undefined) {
 980              setSelectedIndex(column, firstIndex)
 981              const item = items.get(firstIndex)
 982              if (item?.ref.current) {
 983                scrollToCenter(item.ref.current)
 984              }
 985            }
 986          }
 987        }
 988  
 989        if (intent && isEnabled) {
 990          e.preventDefault()
 991          emitIntent(intent)
 992        } else if (intent && isNavKey) {
 993          // First keypress enables and processes the intent
 994          e.preventDefault()
 995          emitIntent(intent)
 996        }
 997      }
 998  
 999      window.addEventListener('keydown', handleKeyDown)
1000      return () => window.removeEventListener('keydown', handleKeyDown)
1001    }, [isEnabled, emitIntent, getAvailableColumns, setSelectedIndex, scrollToCenter, triggerNoteAction, toggleKeyboardMode])
1002  
1003    // ============================================================================
1004    // Layout Effects
1005    // ============================================================================
1006  
1007    useEffect(() => {
1008      const available = getAvailableColumns()
1009      if (!available.includes(activeColumn)) {
1010        // When current column becomes unavailable, find the best fallback:
1011        // - If coming from column 2 (secondary), prefer column 1 (primary) over column 0 (sidebar)
1012        // - Otherwise use the first available column
1013        if (activeColumn === 2 && available.includes(1)) {
1014          setActiveColumn(1)
1015        } else {
1016          setActiveColumn(available[0])
1017        }
1018      }
1019    }, [getAvailableColumns, activeColumn, setActiveColumn])
1020  
1021    // Track secondary panel changes to switch focus
1022    const prevSecondaryStackLength = useRef(secondaryStackLength)
1023    useEffect(() => {
1024      if (secondaryStackLength > prevSecondaryStackLength.current && isEnabled) {
1025        // Secondary opened - switch to column 2 immediately
1026        // This ensures the user can navigate back with left/Escape even if
1027        // the secondary panel doesn't have keyboard-navigable items
1028        setActiveColumn(2)
1029        setSelectedIndex(2, 0)
1030  
1031        // If there are items in column 2, scroll to the first one
1032        const items = itemsRef.current[2]
1033        if (items.size > 0) {
1034          const indices = Array.from(items.keys()).sort((a, b) => a - b)
1035          const firstIndex = indices[0]
1036          if (firstIndex !== undefined) {
1037            setSelectedIndex(2, firstIndex)
1038            const item = items.get(firstIndex)
1039            if (item?.ref.current) {
1040              scrollToCenter(item.ref.current)
1041            }
1042          }
1043        }
1044      } else if (secondaryStackLength < prevSecondaryStackLength.current && isEnabled) {
1045        // Secondary closed - return to primary only if we were in the secondary column
1046        // Don't move focus if user is already in sidebar or primary
1047        if (activeColumnRef.current === 2) {
1048          setActiveColumn(1)
1049        }
1050      }
1051      prevSecondaryStackLength.current = secondaryStackLength
1052    }, [secondaryStackLength, isEnabled, setSelectedIndex, scrollToCenter, setActiveColumn])
1053  
1054    // ============================================================================
1055    // Context Value
1056    // ============================================================================
1057  
1058    const value = useMemo(
1059      () => ({
1060        // Intent system
1061        emitIntent,
1062        registerRegion,
1063        unregisterRegion,
1064  
1065        // Legacy
1066        activeColumn,
1067        setActiveColumn,
1068        selectedIndex,
1069        setSelectedIndex,
1070        resetPrimarySelection,
1071        offsetSelection,
1072        clearColumn,
1073        registerItem,
1074        unregisterItem,
1075        getItemCount,
1076        registerLoadMore,
1077        unregisterLoadMore,
1078        actionMode,
1079        enterActionMode,
1080        exitActionMode,
1081        cycleAction,
1082        isItemSelected,
1083        openAccordionItem,
1084        setOpenAccordionItem,
1085        isEnabled,
1086        toggleKeyboardMode,
1087        scrollToCenter
1088      }),
1089      [
1090        emitIntent,
1091        registerRegion,
1092        unregisterRegion,
1093        activeColumn,
1094        selectedIndex,
1095        setSelectedIndex,
1096        resetPrimarySelection,
1097        offsetSelection,
1098        clearColumn,
1099        registerItem,
1100        unregisterItem,
1101        getItemCount,
1102        registerLoadMore,
1103        unregisterLoadMore,
1104        actionMode,
1105        enterActionMode,
1106        exitActionMode,
1107        cycleAction,
1108        isItemSelected,
1109        openAccordionItem,
1110        isEnabled,
1111        toggleKeyboardMode,
1112        scrollToCenter
1113      ]
1114    )
1115  
1116    return (
1117      <KeyboardNavigationContext.Provider value={value}>
1118        {children}
1119      </KeyboardNavigationContext.Provider>
1120    )
1121  }
1122