diff --git a/src/components/AccountManager/PrivateKeyLogin.tsx b/src/components/AccountManager/PrivateKeyLogin.tsx index c8b54978..177e6bbb 100644 --- a/src/components/AccountManager/PrivateKeyLogin.tsx +++ b/src/components/AccountManager/PrivateKeyLogin.tsx @@ -1,78 +1,12 @@ +import QrScannerModal from '@/components/QrScannerModal' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useNostr } from '@/providers/NostrProvider' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { ScanLine, X } from 'lucide-react' -import QrScanner from 'qr-scanner' - -function QrScannerModal({ - onScan, - onClose -}: { - onScan: (result: string) => void - onClose: () => void -}) { - const { t } = useTranslation() - const videoRef = useRef(null) - const scannerRef = useRef(null) - const [error, setError] = useState(null) - - const handleScan = useCallback( - (result: QrScanner.ScanResult) => { - onScan(result.data) - onClose() - }, - [onScan, onClose] - ) - - useEffect(() => { - if (!videoRef.current) return - - const scanner = new QrScanner(videoRef.current, handleScan, { - preferredCamera: 'environment', - highlightScanRegion: true, - highlightCodeOutline: true - }) - - scannerRef.current = scanner - - scanner.start().catch(() => { - setError(t('Failed to access camera')) - }) - - return () => { - scanner.destroy() - } - }, [handleScan, t]) - - return ( -
-
- -
- {error ? ( -
{error}
- ) : ( -
-

- {t('Point camera at QR code')} -

-
-
- ) -} +import { ScanLine } from 'lucide-react' export default function PrivateKeyLogin({ back, diff --git a/src/components/Help/index.tsx b/src/components/Help/index.tsx index 6c4fbcaa..f22aab49 100644 --- a/src/components/Help/index.tsx +++ b/src/components/Help/index.tsx @@ -23,15 +23,33 @@ export default function Help() {

{t('Navigate the app entirely with your keyboard:')}

+

{t('Toggle Keyboard Mode:')}

- - - - - - - + +
+

{t('You can also click the keyboard button in the sidebar to toggle.')}

+

{t('Movement:')}

+
+ + + +
+

{t('Actions:')}

+
+ + + +
+

{t('Note Actions (when a note is selected):')}

+
+ + + + + +
+

{t('Selected items are centered on screen for easy viewing.')}

@@ -156,18 +174,33 @@ export default function Help() { ) } -function KeyBinding({ keys, description }: { keys: string[]; description: string }) { +function KeyBinding({ + keys, + altKeys, + description +}: { + keys: string[] + altKeys?: string[] + description: string +}) { return (
-
+
{keys.map((key) => ( - + {key} ))} + {altKeys && ( + <> + / + {altKeys.map((key) => ( + + {key} + + ))} + + )}
{description}
diff --git a/src/components/Inbox/MessageView.tsx b/src/components/Inbox/MessageView.tsx index ef60ea9b..a0179358 100644 --- a/src/components/Inbox/MessageView.tsx +++ b/src/components/Inbox/MessageView.tsx @@ -26,9 +26,10 @@ import { useFollowList } from '@/providers/FollowListProvider' interface MessageViewProps { onBack?: () => void + hideHeader?: boolean } -export default function MessageView({ onBack }: MessageViewProps) { +export default function MessageView({ onBack, hideHeader }: MessageViewProps) { const { t } = useTranslation() const { pubkey } = useNostr() const { @@ -184,6 +185,20 @@ export default function MessageView({ onBack }: MessageViewProps) { lastMessageCountRef.current = 0 }, [currentConversation]) + // Scroll to bottom when conversation opens and messages are loaded + const hasMessages = messages.length > 0 + useEffect(() => { + if (currentConversation && hasMessages && scrollRef.current) { + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + lastMessageCountRef.current = messages.length + } + }) + } + }, [currentConversation, hasMessages]) + if (!currentConversation || !pubkey) { return null } @@ -192,114 +207,116 @@ export default function MessageView({ onBack }: MessageViewProps) { return (
- {/* Header */} -
- {isSelectionMode ? ( - // Selection mode header - <> - -
- - {t('Delete')} -
-
- - - - ) : ( - // Normal header - <> - -
-
- {displayName} - {isFollowing && ( - - - + {/* Header - show when not hidden, or when in selection mode */} + {(!hideHeader || isSelectionMode) && ( +
+ {isSelectionMode ? ( + // Selection mode header + <> + +
+ + {t('Delete')} +
+
+ + + + ) : ( + // Normal header + <> + +
+
+ {displayName} + {isFollowing && ( + + + + )} +
+ {profile?.nip05 && ( + {profile.nip05} )}
- {profile?.nip05 && ( - {profile.nip05} - )} -
- - - - - - - - - - {t('Delete All')} - - - - {t('Undelete All')} - - - - {onBack && ( - )} - - )} -
+ + + + + + + + + {t('Delete All')} + + + + {t('Undelete All')} + + + + {onBack && ( + + )} + + )} +
+ )} {/* Messages */}
diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 1c7fae4d..d33575ab 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -80,7 +80,7 @@ const NoteList = forwardRef< const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() - const { offsetSelection } = useKeyboardNavigation() + const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation() const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) const [initialLoading, setInitialLoading] = useState(false) @@ -370,6 +370,12 @@ const NoteList = forwardRef< initialLoading }) + // Register load more callback for keyboard navigation + useEffect(() => { + registerLoadMore(navColumn, handleLoadMore) + return () => unregisterLoadMore(navColumn) + }, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore]) + const showNewEvents = useCallback(() => { if (filteredNewEvents.length === 0) return // Offset the selection by the number of new items being added at the top diff --git a/src/components/QrScannerModal/index.tsx b/src/components/QrScannerModal/index.tsx new file mode 100644 index 00000000..a57d1346 --- /dev/null +++ b/src/components/QrScannerModal/index.tsx @@ -0,0 +1,71 @@ +import { Button } from '@/components/ui/button' +import { X } from 'lucide-react' +import QrScanner from 'qr-scanner' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function QrScannerModal({ + onScan, + onClose +}: { + onScan: (result: string) => void + onClose: () => void +}) { + const { t } = useTranslation() + const videoRef = useRef(null) + const scannerRef = useRef(null) + const [error, setError] = useState(null) + + const handleScan = useCallback( + (result: QrScanner.ScanResult) => { + onScan(result.data) + onClose() + }, + [onScan, onClose] + ) + + useEffect(() => { + if (!videoRef.current) return + + const scanner = new QrScanner(videoRef.current, handleScan, { + preferredCamera: 'environment', + highlightScanRegion: true, + highlightCodeOutline: true + }) + + scannerRef.current = scanner + + scanner.start().catch(() => { + setError(t('Failed to access camera')) + }) + + return () => { + scanner.destroy() + } + }, [handleScan, t]) + + return ( +
+
+ +
+ {error ? ( +
{error}
+ ) : ( +
+

+ {t('Point camera at QR code')} +

+
+
+ ) +} diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx index e90942a8..ab2f5fe4 100644 --- a/src/components/Settings/index.tsx +++ b/src/components/Settings/index.tsx @@ -1,4 +1,5 @@ import AboutInfoDialog from '@/components/AboutInfoDialog' +import QrScannerModal from '@/components/QrScannerModal' import Donation from '@/components/Donation' import Emoji from '@/components/Emoji' import EmojiPackList from '@/components/EmojiPackList' @@ -54,7 +55,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider' import { useZap } from '@/providers/ZapProvider' import storage, { dispatchSettingsChanged } from '@/services/local-storage.service' import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types' -import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' +import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { Check, Cog, @@ -71,6 +72,7 @@ import { PanelLeft, PencilLine, RotateCcw, + ScanLine, Server, Settings2, Smile, @@ -80,7 +82,8 @@ import { import { kinds } from 'nostr-tools' import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider' +import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider' +import { usePrimaryPage } from '@/PageManager' type TEmojiTab = 'my-packs' | 'explore' @@ -114,57 +117,77 @@ export default function Settings() { const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1) const accordionRefs = useRef<(HTMLDivElement | null)[]>([]) - const { activeColumn, registerSettingsHandlers, unregisterSettingsHandlers } = useKeyboardNavigation() + const { activeColumn, scrollToCenter } = useKeyboardNavigation() + const { current: currentPage } = usePrimaryPage() // Get the visible accordion items based on pubkey availability const visibleAccordionItems = pubkey ? ACCORDION_ITEMS : ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item)) - // Register keyboard handlers for settings page navigation + // Register as a navigation region - Settings decides what "up/down" means + const handleSettingsIntent = useCallback( + (intent: NavigationIntent): boolean => { + switch (intent) { + case 'up': + setSelectedAccordionIndex((prev) => { + const newIndex = prev <= 0 ? 0 : prev - 1 + setTimeout(() => { + const el = accordionRefs.current[newIndex] + if (el) scrollToCenter(el) + }, 0) + return newIndex + }) + return true + + case 'down': + setSelectedAccordionIndex((prev) => { + const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1) + setTimeout(() => { + const el = accordionRefs.current[newIndex] + if (el) scrollToCenter(el) + }, 0) + return newIndex + }) + return true + + case 'activate': + if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) { + const value = visibleAccordionItems[selectedAccordionIndex] + setOpenSection((prev) => (prev === value ? '' : value)) + return true + } + return false + + case 'cancel': + if (openSection) { + setOpenSection('') + return true + } + return false + + default: + return false + } + }, + [selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter] + ) + + // Register this component as a navigation region when it's active + useNavigationRegion( + 'settings-accordion', + 100, // High priority - handle intents before default handlers + () => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed + handleSettingsIntent, + [handleSettingsIntent, activeColumn, currentPage] + ) + + // Reset selection when column changes useEffect(() => { if (activeColumn !== 1) { setSelectedAccordionIndex(-1) - return } - - const handlers = { - onUp: () => { - setSelectedAccordionIndex((prev) => { - const newIndex = prev <= 0 ? 0 : prev - 1 - setTimeout(() => { - accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - }, 0) - return newIndex - }) - }, - onDown: () => { - setSelectedAccordionIndex((prev) => { - const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1) - setTimeout(() => { - accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - }, 0) - return newIndex - }) - }, - onEnter: () => { - if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) { - const value = visibleAccordionItems[selectedAccordionIndex] - setOpenSection((prev) => (prev === value ? '' : value)) - } - }, - onEscape: () => { - if (openSection) { - setOpenSection('') - return true - } - return false - } - } - - registerSettingsHandlers(handlers) - return () => unregisterSettingsHandlers() - }, [activeColumn, selectedAccordionIndex, openSection, visibleAccordionItems]) + }, [activeColumn]) // Helper to get accordion index and check selection const getAccordionIndex = useCallback( @@ -235,6 +258,16 @@ export default function Settings() { // Messaging settings const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44()) + // Wallet QR scanner + const [showWalletScanner, setShowWalletScanner] = useState(false) + + const handleWalletScan = useCallback((result: string) => { + // Check if it's a valid NWC URI + if (result.startsWith('nostr+walletconnect://')) { + connectNWC(result) + } + }, []) + const handleLanguageChange = (value: TLanguage) => { i18n.changeLanguage(value) setLanguage(value) @@ -559,11 +592,27 @@ export default function Settings() { ) : ( -
- -
+ <> + {showWalletScanner && ( + setShowWalletScanner(false)} + /> + )} +
+ + +
+ )} diff --git a/src/components/Sidebar/KeyboardModeButton.tsx b/src/components/Sidebar/KeyboardModeButton.tsx new file mode 100644 index 00000000..88e8a320 --- /dev/null +++ b/src/components/Sidebar/KeyboardModeButton.tsx @@ -0,0 +1,36 @@ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider' +import { Keyboard } from 'lucide-react' +import { useTranslation } from 'react-i18next' + +export default function KeyboardModeButton({ collapse }: { collapse: boolean }) { + const { t } = useTranslation() + const { isEnabled, toggleKeyboardMode } = useKeyboardNavigation() + + return ( + + ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index d48c5b50..7b075cc8 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -12,6 +12,7 @@ import BookmarkButton from './BookmarkButton' import HelpButton from './HelpButton' import HomeButton from './HomeButton' import InboxButton from './InboxButton' +import KeyboardModeButton from './KeyboardModeButton' import LayoutSwitcher from './LayoutSwitcher' import NotificationsButton from './NotificationButton' import PostButton from './PostButton' @@ -67,6 +68,7 @@ export default function PrimaryPageSidebar() {
+
diff --git a/src/components/StuffStats/LikeButton.tsx b/src/components/StuffStats/LikeButton.tsx index de884e4d..567b5425 100644 --- a/src/components/StuffStats/LikeButton.tsx +++ b/src/components/StuffStats/LikeButton.tsx @@ -111,8 +111,8 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) { const trigger = ( diff --git a/src/components/StuffStats/RepostButton.tsx b/src/components/StuffStats/RepostButton.tsx index ee9fa63c..2cb544a6 100644 --- a/src/components/StuffStats/RepostButton.tsx +++ b/src/components/StuffStats/RepostButton.tsx @@ -77,11 +77,11 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) { const trigger = ( ) @@ -108,10 +111,25 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) { /> ) + // Hidden button for keyboard shortcut (q for quote) + const quoteButton = ( + {event && ( diff --git a/src/pages/secondary/DMConversationPage/index.tsx b/src/pages/secondary/DMConversationPage/index.tsx index 7b2287e5..847c589a 100644 --- a/src/pages/secondary/DMConversationPage/index.tsx +++ b/src/pages/secondary/DMConversationPage/index.tsx @@ -1,22 +1,51 @@ import MessageView from '@/components/Inbox/MessageView' -import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' +import UserAvatar from '@/components/UserAvatar' +import { Button } from '@/components/ui/button' +import { Titlebar } from '@/components/Titlebar' import { useSecondaryPage } from '@/PageManager' import { useDM } from '@/providers/DMProvider' -import { TPageRef } from '@/types' +import { useFollowList } from '@/providers/FollowListProvider' +import client from '@/services/client.service' +import { TPageRef, TProfile } from '@/types' +import { ChevronLeft, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react' import { nip19 } from 'nostr-tools' -import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import ConversationSettingsModal from '@/components/Inbox/ConversationSettingsModal' +import indexedDb from '@/services/indexed-db.service' +import { useNostr } from '@/providers/NostrProvider' interface DMConversationPageProps { pubkey?: string - index?: number } -const DMConversationPage = forwardRef(({ pubkey, index }, ref) => { +const DMConversationPage = forwardRef(({ pubkey }, ref) => { const { t } = useTranslation() const layoutRef = useRef(null) - const { selectConversation, currentConversation } = useDM() + const { pubkey: userPubkey } = useNostr() + const { + selectConversation, + currentConversation, + isLoadingConversation, + isNewConversation, + clearNewConversationFlag, + reloadConversation, + deleteAllInConversation, + undeleteAllInConversation + } = useDM() const { pop } = useSecondaryPage() + const { followingSet } = useFollowList() + const [profile, setProfile] = useState(null) + const [settingsOpen, setSettingsOpen] = useState(false) + const [selectedRelays, setSelectedRelays] = useState([]) + const [showPulse, setShowPulse] = useState(false) // Decode npub to hex if needed const hexPubkey = useMemo(() => { @@ -32,6 +61,8 @@ const DMConversationPage = forwardRef(({ pubk return pubkey }, [pubkey]) + const isFollowing = hexPubkey ? followingSet.has(hexPubkey) : false + useImperativeHandle(ref, () => layoutRef.current as TPageRef) // Select the conversation when this page mounts @@ -48,17 +79,161 @@ const DMConversationPage = forwardRef(({ pubk } }, []) + // Fetch profile + useEffect(() => { + if (!hexPubkey) return + + const fetchProfileData = async () => { + try { + const profileData = await client.fetchProfile(hexPubkey) + if (profileData) { + setProfile(profileData) + } + } catch (error) { + console.error('Failed to fetch profile:', error) + } + } + fetchProfileData() + }, [hexPubkey]) + + // Handle pulsing animation for new conversations + useEffect(() => { + if (isNewConversation) { + setShowPulse(true) + const timer = setTimeout(() => { + setShowPulse(false) + clearNewConversationFlag() + }, 10000) + return () => clearTimeout(timer) + } + }, [isNewConversation, clearNewConversationFlag]) + + // Load saved relay settings when conversation changes + useEffect(() => { + if (!hexPubkey || !userPubkey) return + + const loadRelaySettings = async () => { + const saved = await indexedDb.getConversationRelaySettings(userPubkey, hexPubkey) + setSelectedRelays(saved || []) + } + loadRelaySettings() + }, [hexPubkey, userPubkey]) + + // Save relay settings when they change + const handleRelaysChange = async (relays: string[]) => { + setSelectedRelays(relays) + if (userPubkey && hexPubkey) { + await indexedDb.putConversationRelaySettings(userPubkey, hexPubkey, relays) + } + } + const handleBack = () => { selectConversation(null) pop() } + const displayName = profile?.username || (hexPubkey ? hexPubkey.slice(0, 8) + '...' : '') + + // Custom titlebar with user info + const titlebar = ( +
+ + {hexPubkey && ( + <> + +
+
+ {displayName} + {isFollowing && ( + + + + )} +
+ {profile?.nip05 && ( + {profile.nip05} + )} +
+ + + + + + + + + + {t('Delete All')} + + + + {t('Undelete All')} + + + + + + )} +
+ ) + return ( - -
- + <> + + {titlebar} + +
+
- + {hexPubkey && ( + + )} + ) }) diff --git a/src/providers/KeyboardNavigationProvider.tsx b/src/providers/KeyboardNavigationProvider.tsx index 0f4605f1..aef4591a 100644 --- a/src/providers/KeyboardNavigationProvider.tsx +++ b/src/providers/KeyboardNavigationProvider.tsx @@ -15,6 +15,42 @@ import modalManager from '@/services/modal-manager.service' import { useScreenSize } from './ScreenSizeProvider' import { useUserPreferences } from './UserPreferencesProvider' +// ============================================================================ +// Abstract Navigation Intent System +// ============================================================================ + +/** + * Navigation intents are abstract actions that the control plane emits. + * Components decide what each intent means in their context. + */ +export type NavigationIntent = + | 'up' + | 'down' + | 'left' + | 'right' + | 'activate' + | 'back' + | 'cancel' + | 'pageUp' + | 'pageDown' + | 'nextAction' + | 'prevAction' + +/** + * Navigation regions handle intents for a specific area of the UI. + * Higher priority regions handle intents first. + */ +export interface NavigationRegion { + id: string + priority: number + isActive: () => boolean + handleIntent: (intent: NavigationIntent) => boolean // returns true if handled +} + +// ============================================================================ +// Legacy Types (for backward compatibility during migration) +// ============================================================================ + export type TNavigationColumn = 0 | 1 | 2 // 0=sidebar, 1=primary, 2=secondary export type TActionType = 'reply' | 'repost' | 'quote' | 'react' | 'zap' @@ -36,15 +72,17 @@ type TRegisteredItem = { meta?: TItemMeta } -type TSettingsHandlers = { - onUp: () => void - onDown: () => void - onEnter: () => void - onEscape: () => boolean // return true if handled -} +// ============================================================================ +// Context Types +// ============================================================================ type TKeyboardNavigationContext = { - // Column focus + // Intent system + emitIntent: (intent: NavigationIntent) => void + registerRegion: (region: NavigationRegion) => void + unregisterRegion: (id: string) => void + + // Column focus (legacy, components should migrate to regions) activeColumn: TNavigationColumn setActiveColumn: (column: TNavigationColumn) => void @@ -65,6 +103,10 @@ type TKeyboardNavigationContext = { unregisterItem: (column: TNavigationColumn, index: number) => void getItemCount: (column: TNavigationColumn) => number + // Load more callback per column (for infinite scroll) + registerLoadMore: (column: TNavigationColumn, callback: () => void) => void + unregisterLoadMore: (column: TNavigationColumn) => void + // Action mode actionMode: TActionMode enterActionMode: (noteEvent: Event) => void @@ -78,12 +120,12 @@ type TKeyboardNavigationContext = { openAccordionItem: string | null setOpenAccordionItem: (value: string | null) => void - // Settings page handlers - registerSettingsHandlers: (handlers: TSettingsHandlers) => void - unregisterSettingsHandlers: () => void - // Keyboard nav enabled isEnabled: boolean + toggleKeyboardMode: () => void + + // Scroll utilities + scrollToCenter: (element: HTMLElement) => void } const ACTIONS: TActionType[] = ['reply', 'repost', 'quote', 'react', 'zap'] @@ -98,6 +140,32 @@ export function useKeyboardNavigation() { return context } +/** + * Hook for components to register as a navigation region. + * The region handles intents and decides what they mean. + */ +export function useNavigationRegion( + id: string, + priority: number, + isActive: () => boolean, + handleIntent: (intent: NavigationIntent) => boolean, + deps: React.DependencyList = [] +) { + const { registerRegion, unregisterRegion } = useKeyboardNavigation() + + useEffect(() => { + const region: NavigationRegion = { + id, + priority, + isActive, + handleIntent + } + registerRegion(region) + return () => unregisterRegion(id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, priority, registerRegion, unregisterRegion, ...deps]) +} + // Helper to check if an input element is focused function isInputFocused(): boolean { const activeElement = document.activeElement @@ -126,7 +194,7 @@ export function KeyboardNavigationProvider({ const { isSmallScreen } = useScreenSize() const { enableSingleColumnLayout } = useUserPreferences() - const [activeColumn, setActiveColumn] = useState(1) + const [activeColumn, setActiveColumnState] = useState(1) const [selectedIndex, setSelectedIndexState] = useState>({ 0: 0, 1: 0, @@ -140,6 +208,47 @@ export function KeyboardNavigationProvider({ const [openAccordionItem, setOpenAccordionItem] = useState(null) const [isEnabled, setIsEnabled] = useState(false) + // Track Escape presses for triple-Escape to disable keyboard mode + const escapeTimestampsRef = useRef([]) + const TRIPLE_ESCAPE_WINDOW = 800 // ms within which 3 escapes must occur + + // Refs to always have latest values (avoid stale closures) + const activeColumnRef = useRef(activeColumn) + const selectedIndexRef = useRef>(selectedIndex) + + // Wrapper to update both state and ref + const setActiveColumn = useCallback((column: TNavigationColumn) => { + activeColumnRef.current = column + setActiveColumnState(column) + }, []) + + // Toggle keyboard navigation mode + const toggleKeyboardMode = useCallback(() => { + setIsEnabled((prev) => { + const newEnabled = !prev + if (newEnabled) { + // When enabling, initialize selection to first item in active column + const items = itemsRef.current[activeColumnRef.current] + if (items.size > 0) { + const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0] + if (firstIndex !== undefined) { + setSelectedIndexState((prevState) => ({ + ...prevState, + [activeColumnRef.current]: firstIndex + })) + } + } + } + return newEnabled + }) + }, []) + + // Navigation regions registry + const regionsRef = useRef>(new Map()) + + // Ref to hold the latest handleDefaultIntent function + const handleDefaultIntentRef = useRef<(intent: NavigationIntent) => void>(() => {}) + // Item registration per column const itemsRef = useRef>>({ 0: new Map(), @@ -147,18 +256,124 @@ export function KeyboardNavigationProvider({ 2: new Map() }) - // Settings page handlers - const settingsHandlersRef = useRef(null) + // Load more callbacks per column (for infinite scroll) + const loadMoreRef = useRef void) | null>>({ + 0: null, + 1: null, + 2: null + }) - const registerSettingsHandlers = useCallback((handlers: TSettingsHandlers) => { - settingsHandlersRef.current = handlers + // ============================================================================ + // Intent System + // ============================================================================ + + const registerRegion = useCallback((region: NavigationRegion) => { + regionsRef.current.set(region.id, region) }, []) - const unregisterSettingsHandlers = useCallback(() => { - settingsHandlersRef.current = null + const unregisterRegion = useCallback((id: string) => { + regionsRef.current.delete(id) }, []) + const emitIntent = useCallback((intent: NavigationIntent) => { + // Sort regions by priority (highest first) + const regions = Array.from(regionsRef.current.values()) + .filter((r) => r.isActive()) + .sort((a, b) => b.priority - a.priority) + + // Let regions handle the intent in priority order + for (const region of regions) { + if (region.handleIntent(intent)) { + return // Intent was handled + } + } + + // Fallback to default handling if no region handled it + handleDefaultIntentRef.current(intent) + }, []) + + // ============================================================================ + // Scroll to Center (or top if near edge) + // ============================================================================ + + const scrollToCenter = useCallback((element: HTMLElement) => { + // Find the scrollable container (look for overflow-y: auto/scroll) + let scrollContainer: HTMLElement | null = element.parentElement + while (scrollContainer) { + const style = window.getComputedStyle(scrollContainer) + const overflowY = style.overflowY + if (overflowY === 'auto' || overflowY === 'scroll') { + break + } + scrollContainer = scrollContainer.parentElement + } + + const headerOffset = 100 // Account for sticky headers + + if (scrollContainer) { + // Scroll within container + const containerRect = scrollContainer.getBoundingClientRect() + const elementRect = element.getBoundingClientRect() + + // Position relative to container + const elementTopInContainer = elementRect.top - containerRect.top + const elementBottomInContainer = elementRect.bottom - containerRect.top + const containerHeight = containerRect.height + const visibleHeight = containerHeight - headerOffset + + // If element is taller than visible area, scroll to show its top + if (elementRect.height > visibleHeight) { + const targetScrollTop = scrollContainer.scrollTop + elementTopInContainer - headerOffset + scrollContainer.scrollTo({ + top: Math.max(0, targetScrollTop), + behavior: 'smooth' + }) + return + } + + // Check if already visible with margin + const isVisible = elementTopInContainer >= headerOffset && + elementBottomInContainer <= containerHeight - 50 + + if (!isVisible) { + // Calculate target scroll to center the element + const elementMiddle = elementTopInContainer + elementRect.height / 2 + const containerMiddle = containerHeight / 2 + const scrollAdjustment = elementMiddle - containerMiddle + const newScrollTop = scrollContainer.scrollTop + scrollAdjustment + + // Don't scroll past the top + scrollContainer.scrollTo({ + top: Math.max(0, newScrollTop), + behavior: 'smooth' + }) + } + } else { + // Fallback to window scrolling + const rect = element.getBoundingClientRect() + const viewportHeight = window.innerHeight + const visibleHeight = viewportHeight - headerOffset + + // If element is taller than visible area, scroll to show its top + if (rect.height > visibleHeight) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + return + } + + const isVisible = rect.top >= headerOffset && rect.bottom <= viewportHeight - 50 + + if (!isVisible) { + element.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + } + }, []) + + // ============================================================================ + // Legacy Item Management + // ============================================================================ + const setSelectedIndex = useCallback((column: TNavigationColumn, index: number) => { + selectedIndexRef.current = { ...selectedIndexRef.current, [column]: index } setSelectedIndexState((prev) => ({ ...prev, [column]: index @@ -167,16 +382,16 @@ export function KeyboardNavigationProvider({ const resetPrimarySelection = useCallback(() => { setSelectedIndex(1, 0) - // Also switch focus to primary column setActiveColumn(1) - }, [setSelectedIndex]) + }, [setSelectedIndex, setActiveColumn]) const offsetSelection = useCallback( (column: TNavigationColumn, offset: number) => { - setSelectedIndexState((prev) => ({ - ...prev, - [column]: Math.max(0, prev[column] + offset) - })) + setSelectedIndexState((prev) => { + const newState = { ...prev, [column]: Math.max(0, prev[column] + offset) } + selectedIndexRef.current = newState + return newState + }) }, [] ) @@ -204,6 +419,14 @@ export function KeyboardNavigationProvider({ return itemsRef.current[column].size }, []) + const registerLoadMore = useCallback((column: TNavigationColumn, callback: () => void) => { + loadMoreRef.current[column] = callback + }, []) + + const unregisterLoadMore = useCallback((column: TNavigationColumn) => { + loadMoreRef.current[column] = null + }, []) + const isItemSelected = useCallback( (column: TNavigationColumn, index: number) => { return isEnabled && activeColumn === column && selectedIndex[column] === index @@ -211,14 +434,16 @@ export function KeyboardNavigationProvider({ [isEnabled, activeColumn, selectedIndex] ) + // ============================================================================ + // Column Navigation + // ============================================================================ + const getAvailableColumns = useCallback((): TNavigationColumn[] => { if (isSmallScreen || enableSingleColumnLayout) { - // Single column mode if (sidebarDrawerOpen) return [0] if (secondaryStackLength > 0) return [2] return [1] } - // Desktop 2-column mode if (secondaryStackLength > 0) return [0, 1, 2] return [0, 1] }, [isSmallScreen, enableSingleColumnLayout, sidebarDrawerOpen, secondaryStackLength]) @@ -226,45 +451,57 @@ export function KeyboardNavigationProvider({ const moveColumn = useCallback( (direction: 1 | -1) => { const available = getAvailableColumns() - const currentIdx = available.indexOf(activeColumn) + const currentColumn = activeColumnRef.current + const currentIdx = available.indexOf(currentColumn) if (currentIdx === -1) { setActiveColumn(available[0]) return } const newIdx = Math.max(0, Math.min(available.length - 1, currentIdx + direction)) - setActiveColumn(available[newIdx]) + const newColumn = available[newIdx] + if (newColumn === currentColumn) return // Already at edge + + setActiveColumn(newColumn) + + // Scroll to currently selected item in the new column + const items = itemsRef.current[newColumn] + const currentSelectedIdx = selectedIndexRef.current[newColumn] + const item = items.get(currentSelectedIdx) + if (item?.ref.current) { + scrollToCenter(item.ref.current) + } else if (items.size > 0) { + // If no item at current index, select the first one + const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0] + if (firstIndex !== undefined) { + setSelectedIndex(newColumn, firstIndex) + const firstItem = items.get(firstIndex) + if (firstItem?.ref.current) { + scrollToCenter(firstItem.ref.current) + } + } + } }, - [activeColumn, getAvailableColumns] + [getAvailableColumns, scrollToCenter, setSelectedIndex, setActiveColumn] ) - const scrollItemIntoView = useCallback( - (ref: HTMLElement, direction: 'up' | 'down', isAtEdge = false) => { - // At edges, use start/end to ensure item is fully visible - // Otherwise use 'nearest' to minimize scrolling - ref.scrollIntoView({ - behavior: 'smooth', - block: isAtEdge ? (direction === 'up' ? 'start' : 'end') : 'nearest' - }) - }, - [] - ) + // ============================================================================ + // Item Navigation with Centered Scrolling + // ============================================================================ const moveItem = useCallback( (direction: 1 | -1) => { - const items = itemsRef.current[activeColumn] + const currentColumn = activeColumnRef.current + const items = itemsRef.current[currentColumn] if (items.size === 0) return - // Get sorted indices const indices = Array.from(items.keys()).sort((a, b) => a - b) if (indices.length === 0) return - const currentSelected = selectedIndex[activeColumn] + const currentSelected = selectedIndexRef.current[currentColumn] let currentIdx = indices.indexOf(currentSelected) - let newIdx: number if (currentIdx === -1) { - // Selection not found - find the nearest valid index - // This can happen when items are filtered/hidden or list changes + // Find nearest valid index let nearestIdx = 0 let minDistance = Infinity for (let i = 0; i < indices.length; i++) { @@ -274,51 +511,45 @@ export function KeyboardNavigationProvider({ nearestIdx = i } } - // Adjust based on direction: if going up, prefer index below target; if going down, prefer index above - if (direction === -1 && indices[nearestIdx] > currentSelected && nearestIdx > 0) { - nearestIdx-- - } else if (direction === 1 && indices[nearestIdx] < currentSelected && nearestIdx < indices.length - 1) { - nearestIdx++ - } currentIdx = nearestIdx - // Set selection to nearest valid index immediately - const nearestItemIndex = indices[currentIdx] - if (nearestItemIndex !== undefined) { - setSelectedIndex(activeColumn, nearestItemIndex) - const item = items.get(nearestItemIndex) - if (item?.ref.current) { - scrollItemIntoView(item.ref.current, direction === -1 ? 'up' : 'down', false) - } - } - return } - // Clamp to valid range (no wrap-around) - newIdx = Math.max(0, Math.min(indices.length - 1, currentIdx + direction)) + // Calculate new index with wrap-around + let newIdx = currentIdx + direction + + // Wrap around behavior + if (newIdx < 0) { + // At top, going up -> wrap to bottom + newIdx = indices.length - 1 + } else if (newIdx >= indices.length) { + // At bottom, going down -> trigger load more and wrap to top + const loadMore = loadMoreRef.current[currentColumn] + if (loadMore) { + loadMore() + } + newIdx = 0 + } const newItemIndex = indices[newIdx] if (newItemIndex === undefined) return - setSelectedIndex(activeColumn, newItemIndex) + setSelectedIndex(currentColumn, newItemIndex) - // Check if at edge - const isAtEdge = newIdx === 0 || newIdx === indices.length - 1 - - // Scroll into view + // Scroll to center const item = items.get(newItemIndex) if (item?.ref.current) { - scrollItemIntoView(item.ref.current, direction === -1 ? 'up' : 'down', isAtEdge) + scrollToCenter(item.ref.current) } }, - [activeColumn, selectedIndex, setSelectedIndex, scrollItemIntoView] + [setSelectedIndex, scrollToCenter] ) const jumpToEdge = useCallback( (edge: 'top' | 'bottom') => { - const items = itemsRef.current[activeColumn] + const currentColumn = activeColumnRef.current + const items = itemsRef.current[currentColumn] if (items.size === 0) return - // Get sorted indices const indices = Array.from(items.keys()).sort((a, b) => a - b) if (indices.length === 0) return @@ -326,17 +557,24 @@ export function KeyboardNavigationProvider({ const newItemIndex = indices[newIdx] if (newItemIndex === undefined) return - setSelectedIndex(activeColumn, newItemIndex) + setSelectedIndex(currentColumn, newItemIndex) - // Scroll into view (always at edge for jumpToEdge) const item = items.get(newItemIndex) if (item?.ref.current) { - scrollItemIntoView(item.ref.current, edge === 'top' ? 'up' : 'down', true) + // For edges, use start/end positioning + item.ref.current.scrollIntoView({ + behavior: 'smooth', + block: edge === 'top' ? 'start' : 'end' + }) } }, - [activeColumn, setSelectedIndex, scrollItemIntoView] + [setSelectedIndex] ) + // ============================================================================ + // Action Mode + // ============================================================================ + const enterActionMode = useCallback((noteEvent: Event) => { setActionMode({ active: true, @@ -357,8 +595,9 @@ export function KeyboardNavigationProvider({ (direction: 1 | -1 = 1) => { setActionMode((prev) => { if (!prev.active) { - // Enter action mode - const item = itemsRef.current[activeColumn].get(selectedIndex[activeColumn]) + const currentColumn = activeColumnRef.current + const currentSelected = selectedIndexRef.current[currentColumn] + const item = itemsRef.current[currentColumn].get(currentSelected) if (item?.meta?.type === 'note' && item.meta.event) { return { active: true, @@ -377,13 +616,19 @@ export function KeyboardNavigationProvider({ } }) }, - [activeColumn, selectedIndex] + [] ) + // ============================================================================ + // Intent Handlers + // ============================================================================ + const handleEnter = useCallback(() => { + const currentColumn = activeColumnRef.current + const currentSelected = selectedIndexRef.current[currentColumn] + if (actionMode.active) { - // Execute the selected action - const item = itemsRef.current[activeColumn].get(selectedIndex[activeColumn]) + const item = itemsRef.current[currentColumn].get(currentSelected) if (item?.ref.current && actionMode.selectedAction) { const stuffStats = item.ref.current.querySelector('[data-stuff-stats]') const actionButton = stuffStats?.querySelector( @@ -395,12 +640,10 @@ export function KeyboardNavigationProvider({ return } - // Activate the current item - const item = itemsRef.current[activeColumn].get(selectedIndex[activeColumn]) + const item = itemsRef.current[currentColumn].get(currentSelected) if (!item) return - // If activating a sidebar item, reset column 1 selection and switch focus - if (activeColumn === 0 && item.meta?.type === 'sidebar') { + if (currentColumn === 0 && item.meta?.type === 'sidebar') { setSelectedIndex(1, 0) setActiveColumn(1) } @@ -408,40 +651,49 @@ export function KeyboardNavigationProvider({ if (item.meta?.onActivate) { item.meta.onActivate() } else if (item.ref.current) { - // Click the element item.ref.current.click() } - }, [activeColumn, selectedIndex, actionMode, exitActionMode, setSelectedIndex]) + }, [actionMode, exitActionMode, setSelectedIndex, setActiveColumn]) const handleEscape = useCallback(() => { + // Track Escape press for triple-Escape detection + const now = Date.now() + escapeTimestampsRef.current.push(now) + // Keep only presses within the time window + escapeTimestampsRef.current = escapeTimestampsRef.current.filter( + (t) => now - t < TRIPLE_ESCAPE_WINDOW + ) + // Check for triple-Escape to disable keyboard mode + if (escapeTimestampsRef.current.length >= 3 && isEnabled) { + setIsEnabled(false) + escapeTimestampsRef.current = [] + return + } + if (actionMode.active) { exitActionMode() return } - // Settings: close accordion if (openAccordionItem) { setOpenAccordionItem(null) return } - // Single column/mobile: go back if ((isSmallScreen || enableSingleColumnLayout) && secondaryStackLength > 0) { onBack?.() return } - // Third column: close all secondary pages and return to primary column - if (activeColumn === 2 && secondaryStackLength > 0) { + const currentColumn = activeColumnRef.current + if (currentColumn === 2 && secondaryStackLength > 0) { onCloseSecondary?.() setActiveColumn(1) return } - // Go to sidebar in all column views - if (activeColumn !== 0) { + if (currentColumn !== 0) { setActiveColumn(0) - // Reset sidebar selection to ensure valid item is selected setSelectedIndex(0, 0) } }, [ @@ -453,134 +705,241 @@ export function KeyboardNavigationProvider({ secondaryStackLength, onBack, onCloseSecondary, - activeColumn, - setSelectedIndex + setSelectedIndex, + setActiveColumn, + isEnabled, + TRIPLE_ESCAPE_WINDOW ]) - // Enable keyboard nav on first arrow key press (disabled on touch devices) + // Handle back action - move left through columns or close secondary panel + const handleBack = useCallback(() => { + const currentColumn = activeColumnRef.current + + // If focused on secondary column (2), close it and move to primary + if (currentColumn === 2) { + if (secondaryStackLength > 0) { + if (isSmallScreen || enableSingleColumnLayout) { + // On mobile/single column, use onBack to pop the stack + onBack?.() + } else { + // On desktop with columns, close secondary and focus primary + onCloseSecondary?.() + setActiveColumn(1) + } + } else { + // No secondary stack, just move to primary + setActiveColumn(1) + } + return + } + + // If focused on primary column (1), move to sidebar + if (currentColumn === 1) { + setActiveColumn(0) + return + } + + // If focused on sidebar (0), do nothing (already at leftmost) + }, [secondaryStackLength, isSmallScreen, enableSingleColumnLayout, onBack, onCloseSecondary, setActiveColumn]) + + // Default intent handler (fallback when no region handles) + const handleDefaultIntent = useCallback( + (intent: NavigationIntent) => { + switch (intent) { + case 'up': + moveItem(-1) + break + case 'down': + moveItem(1) + break + case 'left': + moveColumn(-1) + break + case 'right': + moveColumn(1) + break + case 'pageUp': + jumpToEdge('top') + break + case 'pageDown': + jumpToEdge('bottom') + break + case 'activate': + handleEnter() + break + case 'back': + handleBack() + break + case 'cancel': + handleEscape() + break + case 'nextAction': + cycleAction(1) + break + case 'prevAction': + cycleAction(-1) + break + } + }, + [moveItem, moveColumn, jumpToEdge, handleEnter, handleBack, handleEscape, cycleAction] + ) + + // Keep the ref updated with the latest handleDefaultIntent + useEffect(() => { + handleDefaultIntentRef.current = handleDefaultIntent + }, [handleDefaultIntent]) + + // ============================================================================ + // Keyboard Event Handler + // ============================================================================ + + // Helper to trigger an action on the currently selected note + const triggerNoteAction = useCallback((action: TActionType) => { + const currentColumn = activeColumnRef.current + const currentSelected = selectedIndexRef.current[currentColumn] + const item = itemsRef.current[currentColumn].get(currentSelected) + if (item?.meta?.type === 'note' && item.ref.current) { + const stuffStats = item.ref.current.querySelector('[data-stuff-stats]') + const actionButton = stuffStats?.querySelector(`[data-action="${action}"]`) as HTMLButtonElement | null + actionButton?.click() + } + }, []) + + // Main keyboard handler - translates keys to intents + // Also handles enabling keyboard nav on first navigation key press useEffect(() => { - // Don't enable keyboard navigation on touch devices if (isTouchDevice()) return - const handleFirstKeyPress = (e: KeyboardEvent) => { - if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { - setIsEnabled(true) - } - } - - if (!isEnabled) { - window.addEventListener('keydown', handleFirstKeyPress) - return () => window.removeEventListener('keydown', handleFirstKeyPress) - } - }, [isEnabled]) - - // Main keyboard handler - useEffect(() => { - if (!isEnabled) return - const handleKeyDown = (e: KeyboardEvent) => { - // Skip if in input or modal open if (isInputFocused()) return if (modalManager.hasOpenModal?.()) return - // Check for settings handlers first - const settingsHandlers = settingsHandlersRef.current + // Map keys to intents + let intent: NavigationIntent | null = null + const isNavKey = ['ArrowUp', 'ArrowDown', 'j', 'k', 'Tab'].includes(e.key) switch (e.key) { - case 'ArrowLeft': - e.preventDefault() - // Left arrow: column 2 -> column 1, column 1 -> column 0 - if (activeColumn === 2) { - setActiveColumn(1) - } else if (activeColumn === 1) { - setActiveColumn(0) - } - break - case 'ArrowRight': - e.preventDefault() - moveColumn(1) - break case 'ArrowUp': - e.preventDefault() - // Only use settings handlers when on column 1 (primary) - if (settingsHandlers && activeColumn === 1) { - settingsHandlers.onUp() - } else { - moveItem(-1) - } + case 'k': // Vim-style + intent = 'up' break case 'ArrowDown': - e.preventDefault() - // Only use settings handlers when on column 1 (primary) - if (settingsHandlers && activeColumn === 1) { - settingsHandlers.onDown() - } else { - moveItem(1) - } + case 'j': // Vim-style + intent = 'down' break - case 'PageUp': - e.preventDefault() - jumpToEdge('top') + case 'ArrowLeft': + case 'h': // Vim-style + intent = 'back' break - case 'PageDown': - e.preventDefault() - jumpToEdge('bottom') - break - case 'Tab': - // Only intercept Tab for action mode on notes - { - const item = itemsRef.current[activeColumn].get(selectedIndex[activeColumn]) - if (item?.meta?.type === 'note') { - e.preventDefault() - cycleAction(e.shiftKey ? -1 : 1) - } - } + case 'ArrowRight': + case 'l': // Vim-style + intent = 'right' break case 'Enter': - e.preventDefault() - // Only use settings handlers when on column 1 (primary) - if (settingsHandlers && activeColumn === 1) { - settingsHandlers.onEnter() - } else { - handleEnter() - } + intent = 'activate' + break + case 'PageUp': + intent = 'pageUp' + break + case 'PageDown': + intent = 'pageDown' break case 'Escape': - e.preventDefault() - // Only use settings handlers when on column 1 (primary) - if (settingsHandlers && activeColumn === 1) { - const handled = settingsHandlers.onEscape() - if (!handled) { - handleEscape() - } - } else { - handleEscape() - } + intent = 'cancel' break case 'Backspace': - e.preventDefault() - // Navigate back (like browser back button) - onBack?.() + intent = 'back' break + case 'Tab': + // Tab switches between columns + e.preventDefault() + intent = e.shiftKey ? 'left' : 'right' + break + // Direct note actions + case 'r': + if (isEnabled) { + e.preventDefault() + triggerNoteAction('reply') + return + } + break + case 'R': + if (isEnabled) { + e.preventDefault() + triggerNoteAction('react') + return + } + break + case 'p': + if (isEnabled) { + e.preventDefault() + triggerNoteAction('repost') + return + } + break + case 'q': + if (isEnabled) { + e.preventDefault() + triggerNoteAction('quote') + return + } + break + case 'z': + if (isEnabled) { + e.preventDefault() + triggerNoteAction('zap') + return + } + break + case 'K': + // Shift+K toggles keyboard mode + if (e.shiftKey) { + e.preventDefault() + toggleKeyboardMode() + return + } + break + } + + // Enable keyboard nav on first navigation key press + if (!isEnabled && isNavKey) { + setIsEnabled(true) + + // Initialize selection to first item in active column + const available = getAvailableColumns() + const currentColumn = activeColumnRef.current + const column = available.includes(currentColumn) ? currentColumn : available[0] + const items = itemsRef.current[column] + if (items.size > 0) { + const firstIndex = Array.from(items.keys()).sort((a, b) => a - b)[0] + if (firstIndex !== undefined) { + setSelectedIndex(column, firstIndex) + const item = items.get(firstIndex) + if (item?.ref.current) { + scrollToCenter(item.ref.current) + } + } + } + } + + if (intent && isEnabled) { + e.preventDefault() + emitIntent(intent) + } else if (intent && isNavKey) { + // First keypress enables and processes the intent + e.preventDefault() + emitIntent(intent) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [ - isEnabled, - moveColumn, - moveItem, - jumpToEdge, - cycleAction, - handleEnter, - handleEscape, - activeColumn, - selectedIndex, - onBack - ]) + }, [isEnabled, emitIntent, getAvailableColumns, setSelectedIndex, scrollToCenter, triggerNoteAction, toggleKeyboardMode]) + + // ============================================================================ + // Layout Effects + // ============================================================================ - // Update active column when layout changes useEffect(() => { const available = getAvailableColumns() if (!available.includes(activeColumn)) { @@ -588,22 +947,48 @@ export function KeyboardNavigationProvider({ } }, [getAvailableColumns, activeColumn]) - // Auto-switch columns when secondary stack changes + // Track secondary panel changes to switch focus const prevSecondaryStackLength = useRef(secondaryStackLength) useEffect(() => { if (secondaryStackLength > prevSecondaryStackLength.current && isEnabled) { - // Secondary stack grew, switch to column 2 + // Secondary opened - switch to column 2 immediately + // This ensures the user can navigate back with left/Escape even if + // the secondary panel doesn't have keyboard-navigable items setActiveColumn(2) setSelectedIndex(2, 0) + + // If there are items in column 2, scroll to the first one + const items = itemsRef.current[2] + if (items.size > 0) { + const indices = Array.from(items.keys()).sort((a, b) => a - b) + const firstIndex = indices[0] + if (firstIndex !== undefined) { + setSelectedIndex(2, firstIndex) + const item = items.get(firstIndex) + if (item?.ref.current) { + scrollToCenter(item.ref.current) + } + } + } } else if (secondaryStackLength < prevSecondaryStackLength.current && isEnabled) { - // Secondary stack shrank, switch back to column 1 + // Secondary closed - return to primary setActiveColumn(1) } prevSecondaryStackLength.current = secondaryStackLength - }, [secondaryStackLength, isEnabled, setSelectedIndex]) + }, [secondaryStackLength, isEnabled, setSelectedIndex, scrollToCenter, setActiveColumn]) + + // ============================================================================ + // Context Value + // ============================================================================ const value = useMemo( () => ({ + // Intent system + emitIntent, + registerRegion, + unregisterRegion, + + // Legacy activeColumn, setActiveColumn, selectedIndex, @@ -614,6 +999,8 @@ export function KeyboardNavigationProvider({ registerItem, unregisterItem, getItemCount, + registerLoadMore, + unregisterLoadMore, actionMode, enterActionMode, exitActionMode, @@ -621,11 +1008,14 @@ export function KeyboardNavigationProvider({ isItemSelected, openAccordionItem, setOpenAccordionItem, - registerSettingsHandlers, - unregisterSettingsHandlers, - isEnabled + isEnabled, + toggleKeyboardMode, + scrollToCenter }), [ + emitIntent, + registerRegion, + unregisterRegion, activeColumn, selectedIndex, setSelectedIndex, @@ -635,15 +1025,17 @@ export function KeyboardNavigationProvider({ registerItem, unregisterItem, getItemCount, + registerLoadMore, + unregisterLoadMore, actionMode, enterActionMode, exitActionMode, cycleAction, isItemSelected, openAccordionItem, - registerSettingsHandlers, - unregisterSettingsHandlers, - isEnabled + isEnabled, + toggleKeyboardMode, + scrollToCenter ] ) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 0953fd7f..fdf06598 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -127,18 +127,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const init = async () => { if (hasNostrLoginHash()) { - return await loginByNostrLoginHash() + await loginByNostrLoginHash() + setIsInitialized(true) + return } const accounts = storage.getAccounts() const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account - if (!act) return + if (!act) { + setIsInitialized(true) + return + } + // Set account immediately so feed can load based on pubkey + // while signer initializes in the background + setAccount({ pubkey: act.pubkey, signerType: act.signerType }) + setIsInitialized(true) + + // Initialize signer in background - feed doesn't need it to load await loginWithAccountPointer(act) } - init().then(() => { - setIsInitialized(true) - }) + init() const handleHashChange = () => { if (hasNostrLoginHash()) { diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index da9e9aee..4f004290 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -1,7 +1,6 @@ import { ALLOWED_FILTER_KINDS, DEFAULT_FAVICON_URL_TEMPLATE, - DEFAULT_NIP_96_SERVICE, ExtendedKind, MEDIA_AUTO_LOAD_POLICY, NOTIFICATION_LIST_STYLE, @@ -40,7 +39,6 @@ class LocalStorageService { private defaultZapComment: string = 'Zap!' private quickZap: boolean = false private accountFeedInfoMap: Record = {} - private mediaUploadService: string = DEFAULT_NIP_96_SERVICE private autoplay: boolean = true private hideUntrustedInteractions: boolean = false private hideUntrustedNotifications: boolean = false @@ -124,10 +122,6 @@ class LocalStorageService { window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}' this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr) - // deprecated - this.mediaUploadService = - window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE - this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false' const hideUntrustedEvents = @@ -439,7 +433,7 @@ class LocalStorageService { } getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig { - const defaultConfig = { type: 'nip96', service: this.mediaUploadService } as const + const defaultConfig = { type: 'blossom' } as const if (!pubkey) { return defaultConfig }