From 292fe289f73ffe66c71eef6367751d5c27bfc4c7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 24 Mar 2026 19:47:21 -0500 Subject: [PATCH] Add sidebar nav items to autocomplete search results Allow users to search for sidebar navigation pages (e.g. Notifications, Settings, Bookmarks) in both the desktop and mobile search autocomplete. Results appear as instant local matches above profile/identifier results. --- src/components/MobileSearchSheet.tsx | 66 ++++++++++++++++-- src/components/ProfileSearchDropdown.tsx | 86 +++++++++++++++++++++--- src/lib/sidebarItems.tsx | 28 ++++++++ 3 files changed, 168 insertions(+), 12 deletions(-) diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 912efe7b..cb9fddec 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -20,6 +20,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface MobileSearchSheetProps { @@ -30,6 +32,7 @@ interface MobileSearchSheetProps { export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { user } = useCurrentUser(); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(-1); const inputRef = useRef(null); @@ -47,6 +50,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { // Country suggestion (local, synchronous) const countryMatch = useMemo(() => searchCountry(query), [query]); + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); const hasUrlComment = queryIsUrl; @@ -79,11 +85,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; + const navItemCount = navItems.length; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextMobileIdx = 0; + const navItemStartIndex = nextMobileIdx; + nextMobileIdx += navItemCount; const identifierIndex = hasIdentifier ? nextMobileIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextMobileIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextMobileIdx++ : -1; @@ -132,6 +141,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { navigate(path); }, [navigate, handleClose]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + handleClose(); + navigate(item.path); + }, [navigate, handleClose]); + const handleSelectWikipedia = useCallback((result: WikipediaSearchResult) => { handleClose(); navigate(`/i/${encodeURIComponent(result.url)}`); @@ -166,7 +180,9 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Identifier item navigation path is determined by the component // Trigger via its onClick handler const sheet = document.querySelector('[data-mobile-search-results]'); @@ -198,7 +214,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { } }; - const hasResults = query.trim().length > 0 && (hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); + const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)); if (!open) return null; @@ -216,6 +232,14 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { {/* Results list — reversed so closest to input = most relevant */} {hasResults && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Mobile autocomplete item for a detected Nostr identifier. */ diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 60923d04..7e85bef4 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -22,6 +22,8 @@ import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useWikipediaSearch, type WikipediaSearchResult } from '@/hooks/useWikipediaSearch'; import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch'; import { WikipediaIcon } from '@/components/icons/WikipediaIcon'; +import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; import { cn } from '@/lib/utils'; interface ProfileSearchDropdownProps { @@ -54,6 +56,7 @@ export function ProfileSearchDropdown({ const inputRef = useRef(null); const listRef = useRef(null); + const { user } = useCurrentUser(); const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query); // Wikipedia & Archive search (async, debounced by their hooks at >=2 chars) @@ -68,6 +71,9 @@ export function ProfileSearchDropdown({ const countryMatchRaw = useMemo(() => searchCountry(query), [query]); const countryMatch = hideCountry ? null : countryMatchRaw; + // Nav item suggestions (local, synchronous) + const navItems = useMemo(() => searchSidebarItems(query, !!user), [query, user]); + // URL detection — show "Comment on" option when query is a full URL const queryIsUrl = useMemo(() => isFullUrl(query), [query]); @@ -99,11 +105,11 @@ export function ProfileSearchDropdown({ // Show dropdown when we have results, or when text search is enabled and there's a query useEffect(() => { if (query.trim().length > 0) { - if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || wikipediaResult || archiveResult) { + if (enableTextSearch || (profiles && profiles.length > 0) || countryMatch || navItems.length > 0 || wikipediaResult || archiveResult) { setOpen(true); } } - }, [profiles, query, enableTextSearch, countryMatch, wikipediaResult, archiveResult]); + }, [profiles, query, enableTextSearch, countryMatch, navItems, wikipediaResult, archiveResult]); // Reset selected index when results change useEffect(() => { @@ -141,17 +147,20 @@ export function ProfileSearchDropdown({ navigate(`/search?q=${encodeURIComponent(query.trim())}`); }, [enableTextSearch, query, navigate]); - // Total selectable items: identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive? + // Total selectable items: navItems + identifier? + URL comment? + country?(top) + profiles + country?(bottom) + wikipedia? + archive? const hasCountry = !!countryMatch; const hasUrlComment = queryIsUrl && enableTextSearch; const hasIdentifier = !!identifierMatch; const hasWikipedia = !!wikipediaResult; const hasArchive = !!archiveResult; - const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); + const navItemCount = navItems.length; + const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasWikipedia ? 1 : 0) + (hasArchive ? 1 : 0); // Map selectedIndex to what it refers to. - // Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] + // Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), wikipedia?, archive?] let nextIdx = 0; + const navItemStartIndex = nextIdx; + nextIdx += navItemCount; const identifierIndex = hasIdentifier ? nextIdx++ : -1; const urlCommentIndex = hasUrlComment ? nextIdx++ : -1; const countryTopIndex = (hasCountry && countryAtTop) ? nextIdx++ : -1; @@ -197,6 +206,13 @@ export function ProfileSearchDropdown({ navigate(path); }, [navigate]); + const handleSelectNavItem = useCallback((item: SidebarItemDef) => { + setOpen(false); + setQuery(''); + inputRef.current?.blur(); + navigate(item.path); + }, [navigate]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -208,7 +224,9 @@ export function ProfileSearchDropdown({ if (e.key === 'Enter') { e.preventDefault(); if (open && selectedIndex >= 0 && selectedIndex < totalItems) { - if (hasIdentifier && selectedIndex === identifierIndex) { + if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) { + handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]); + } else if (hasIdentifier && selectedIndex === identifierIndex) { // Handled by the IdentifierItem component via its onClick // which calls handleSelectIdentifier — trigger via DOM click const items = listRef.current?.querySelectorAll('[data-search-item]'); @@ -303,13 +321,21 @@ export function ProfileSearchDropdown({
{/* Dropdown results — only when text search is not enabled */} - {!enableTextSearch && open && (hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && ( + {!enableTextSearch && open && (navItemCount > 0 || hasIdentifier || hasCountry || hasWikipedia || hasArchive || (profiles && profiles.length > 0)) && (
+ {navItems.map((item, index) => ( + + ))} {hasIdentifier && ( + {/* Nav item results — sidebar pages matching query */} + {navItems.map((item, index) => ( + + ))} + {/* Identifier suggestion — NIP-05, NIP-19, hex */} {hasIdentifier && ( 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && profiles && profiles.length === 0 && ( + {!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && !hasWikipedia && !hasArchive && navItemCount === 0 && profiles && profiles.length === 0 && (
No profiles found @@ -459,6 +495,40 @@ export function ProfileSearchDropdown({ ); } +function NavItem({ + item, + isSelected, + onClick, +}: { + item: SidebarItemDef; + isSelected: boolean; + onClick: (item: SidebarItemDef) => void; +}) { + const Icon = item.icon; + + return ( + + ); +} + /** * Autocomplete item for a detected Nostr identifier (NIP-05, NIP-19, hex). * Resolves the identifier in the background and renders a profile or event preview. diff --git a/src/lib/sidebarItems.tsx b/src/lib/sidebarItems.tsx index 22332866..f19f39dd 100644 --- a/src/lib/sidebarItems.tsx +++ b/src/lib/sidebarItems.tsx @@ -227,6 +227,34 @@ export function itemPath( return SIDEBAR_ITEM_MAP.get(id)?.path ?? `/${id}`; } +/** + * Search sidebar items by label. Returns items whose label starts with or + * contains the query (case-insensitive). Prefix matches are sorted first. + * Auth-requiring items are excluded when the user is not logged in. + */ +export function searchSidebarItems( + query: string, + isLoggedIn: boolean, +): SidebarItemDef[] { + const q = query.trim().toLowerCase(); + if (q.length === 0) return []; + + const prefixMatches: SidebarItemDef[] = []; + const substringMatches: SidebarItemDef[] = []; + + for (const item of SIDEBAR_ITEMS) { + if (item.requiresAuth && !isLoggedIn) continue; + const label = item.label.toLowerCase(); + if (label.startsWith(q)) { + prefixMatches.push(item); + } else if (label.includes(q)) { + substringMatches.push(item); + } + } + + return [...prefixMatches, ...substringMatches]; +} + /** Check if a sidebar item is active given the current location. */ export function isItemActive( id: string,