From 8280f0ee2dad87bc897159a8e9abeb70538acea3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 1 Mar 2026 22:31:24 -0600 Subject: [PATCH] Add country suggestions to sidebar search autocomplete Typing a country name in the search bar now shows matching countries with their flag emoji, navigating to /i/iso3166: on click. Works on both desktop (ProfileSearchDropdown) and mobile (MobileSearchSheet) with full keyboard navigation support. --- src/components/MobileSearchSheet.tsx | 77 ++++++++++++--- src/components/ProfileSearchDropdown.tsx | 113 ++++++++++++++++++----- src/lib/countries.ts | 21 +++++ 3 files changed, 179 insertions(+), 32 deletions(-) diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 8c3819d0..79ee517f 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Search, UserRoundCheck, X } from 'lucide-react'; import { nip19 } from 'nostr-tools'; @@ -9,6 +9,7 @@ import { genUserName } from '@/lib/genUserName'; import { useNip05Verify } from '@/hooks/useNip05Verify'; import { getNostrIdentifierPath } from '@/lib/nostrIdentifier'; import { getProfileUrl } from '@/lib/profileUrl'; +import { searchCountries, type CountryEntry } from '@/lib/countries'; import { useQueryClient } from '@tanstack/react-query'; import { cn } from '@/lib/utils'; @@ -26,6 +27,12 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query); + // Country suggestions (local, synchronous) + const countries = useMemo(() => searchCountries(query, 3), [query]); + + // Total selectable items: countries + profiles + const totalItems = countries.length + (profiles?.length ?? 0); + // Focus input when opened useEffect(() => { if (open) { @@ -48,6 +55,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { onClose(); }, [onClose]); + const handleSelectCountry = useCallback((country: CountryEntry) => { + handleClose(); + navigate(`/i/iso3166:${country.code}`); + }, [navigate, handleClose]); + const handleSelect = useCallback((profile: SearchProfile) => { const nip05 = profile.metadata.nip05; const nip05Verified = !!nip05 && queryClient.getQueryData(['nip05-verify', nip05, profile.pubkey]) === true; @@ -78,29 +90,31 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { } if (e.key === 'Enter') { e.preventDefault(); - if (profiles && selectedIndex >= 0 && selectedIndex < profiles.length) { - handleSelect(profiles[selectedIndex]); + if (selectedIndex >= 0 && selectedIndex < totalItems) { + if (selectedIndex < countries.length) { + handleSelectCountry(countries[selectedIndex]); + } else { + handleSelect(profiles![selectedIndex - countries.length]); + } } else { handleTextSearch(); } return; } - if (!profiles || profiles.length === 0) return; + if (totalItems === 0) return; if (e.key === 'ArrowUp') { e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : profiles.length - 1)); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); } else if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedIndex((prev) => (prev < profiles.length - 1 ? prev + 1 : 0)); + setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); } }; - const hasResults = query.trim().length > 0 && (profiles && profiles.length > 0); + const hasResults = query.trim().length > 0 && (countries.length > 0 || (profiles && profiles.length > 0)); if (!open) return null; - const resultItems = hasResults ? [...profiles!] : []; - return ( <> {/* Backdrop β€” doesn't cover the bottom nav (z-30) */} @@ -116,11 +130,19 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { {/* Results list β€” reversed so closest to input = most relevant */} {hasResults && (
- {resultItems.map((profile, index) => ( + {countries.map((country, index) => ( + + ))} + {profiles && profiles.map((profile, index) => ( @@ -172,6 +194,39 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) { ); } +function SearchCountryItem({ + country, + isSelected, + onClick, +}: { + country: CountryEntry; + isSelected: boolean; + onClick: (country: CountryEntry) => void; +}) { + return ( + + ); +} + function SearchProfileItem({ profile, isSelected, diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 35d9b1b4..d7921661 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Search, UserRoundCheck } from 'lucide-react'; import { nip19 } from 'nostr-tools'; @@ -11,6 +11,7 @@ import { useNip05Verify } from '@/hooks/useNip05Verify'; import { getNostrIdentifierPath } from '@/lib/nostrIdentifier'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { getProfileUrl } from '@/lib/profileUrl'; +import { searchCountries, type CountryEntry } from '@/lib/countries'; import { useQueryClient } from '@tanstack/react-query'; import { cn } from '@/lib/utils'; @@ -43,14 +44,17 @@ export function ProfileSearchDropdown({ const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query); + // Country suggestions (local, synchronous) + const countries = useMemo(() => searchCountries(query, 3), [query]); + // 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)) { + if (enableTextSearch || (profiles && profiles.length > 0) || countries.length > 0) { setOpen(true); } } - }, [profiles, query, enableTextSearch]); + }, [profiles, query, enableTextSearch, countries.length]); // Reset selected index when results change useEffect(() => { @@ -95,6 +99,15 @@ export function ProfileSearchDropdown({ navigate(`/search?q=${encodeURIComponent(query.trim())}`); }, [enableTextSearch, query, navigate]); + // Total selectable items: countries + profiles + const totalItems = countries.length + (profiles?.length ?? 0); + + const handleSelectCountry = useCallback((country: CountryEntry) => { + setOpen(false); + setQuery(''); + navigate(`/i/iso3166:${country.code}`); + }, [navigate]); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -105,12 +118,18 @@ export function ProfileSearchDropdown({ if (e.key === 'Enter') { e.preventDefault(); - // If a profile is highlighted, select it - if (open && profiles && selectedIndex >= 0 && selectedIndex < profiles.length) { - const profile = profiles[selectedIndex]; - const nip05 = profile.metadata.nip05; - const nip05Verified = !!nip05 && queryClient.getQueryData(['nip05-verify', nip05, profile.pubkey]) === true; - handleSelect(profile, getProfileUrl(profile.pubkey, profile.metadata, nip05Verified)); + if (open && selectedIndex >= 0 && selectedIndex < totalItems) { + if (selectedIndex < countries.length) { + // Country item + handleSelectCountry(countries[selectedIndex]); + } else { + // Profile item + const profileIndex = selectedIndex - countries.length; + const profile = profiles![profileIndex]; + const nip05 = profile.metadata.nip05; + const nip05Verified = !!nip05 && queryClient.getQueryData(['nip05-verify', nip05, profile.pubkey]) === true; + handleSelect(profile, getProfileUrl(profile.pubkey, profile.metadata, nip05Verified)); + } } else { // Otherwise do a text search handleTextSearch(); @@ -118,16 +137,16 @@ export function ProfileSearchDropdown({ return; } - if (!open || !profiles || profiles.length === 0) return; + if (!open || totalItems === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); - setSelectedIndex((prev) => (prev < profiles.length - 1 ? prev + 1 : 0)); + setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0)); break; case 'ArrowUp': e.preventDefault(); - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : profiles.length - 1)); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1)); break; } }; @@ -135,7 +154,7 @@ export function ProfileSearchDropdown({ // Scroll selected item into view useEffect(() => { if (selectedIndex >= 0 && listRef.current) { - const items = listRef.current.querySelectorAll('[data-profile-item]'); + const items = listRef.current.querySelectorAll('[data-search-item]'); items[selectedIndex]?.scrollIntoView({ block: 'nearest' }); } }, [selectedIndex]); @@ -187,18 +206,26 @@ export function ProfileSearchDropdown({
{/* Dropdown results β€” only when text search is not enabled */} - {!enableTextSearch && open && profiles && profiles.length > 0 && ( + {!enableTextSearch && open && (countries.length > 0 || (profiles && profiles.length > 0)) && (
- {profiles.map((profile, index) => ( + {countries.map((country, index) => ( + + ))} + {profiles && profiles.map((profile, index) => ( @@ -210,12 +237,12 @@ export function ProfileSearchDropdown({ {/* Text search option */} {enableTextSearch && open && query.trim().length > 0 && (
-
+
{/* Search text option */} + {/* Country results */} + {countries.map((country, index) => ( + + ))} + {/* Profile results */} {profiles && profiles.length > 0 && profiles.map((profile, index) => ( @@ -243,7 +280,7 @@ export function ProfileSearchDropdown({ )} {/* Empty state β€” only when text search is not enabled */} - {!enableTextSearch && open && query.trim().length > 0 && !isFetching && profiles && profiles.length === 0 && ( + {!enableTextSearch && open && query.trim().length > 0 && !isFetching && countries.length === 0 && profiles && profiles.length === 0 && (
No profiles found @@ -254,6 +291,40 @@ export function ProfileSearchDropdown({ ); } +function CountryItem({ + country, + isSelected, + onClick, +}: { + country: CountryEntry; + isSelected: boolean; + onClick: (country: CountryEntry) => void; +}) { + return ( + + ); +} + function ProfileItem({ profile, isSelected, @@ -279,7 +350,7 @@ function ProfileItem({ return (