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.
This commit is contained in:
@@ -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<HTMLInputElement>(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 && (
|
||||
<div data-mobile-search-results className="flex flex-col-reverse bg-popover/95 rounded-2xl mx-6 mb-0.5 overflow-hidden max-h-[55vh] overflow-y-auto shadow-lg">
|
||||
{navItems.map((item, index) => (
|
||||
<MobileNavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={index + navItemStartIndex === selectedIndex}
|
||||
onClick={handleSelectNavItem}
|
||||
/>
|
||||
))}
|
||||
{hasIdentifier && (
|
||||
<MobileIdentifierItem
|
||||
match={identifierMatch!}
|
||||
@@ -314,6 +338,40 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavItem({
|
||||
item,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
item: SidebarItemDef;
|
||||
isSelected: boolean;
|
||||
onClick: (item: SidebarItemDef) => void;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
data-search-item
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
|
||||
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
|
||||
)}
|
||||
onClick={() => onClick(item)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="size-9 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-semibold text-sm truncate block">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">{item.path}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile autocomplete item for a detected Nostr identifier.
|
||||
*/
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(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({
|
||||
</div>
|
||||
|
||||
{/* 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)) && (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150"
|
||||
>
|
||||
<div className="max-h-[320px] overflow-y-auto py-1">
|
||||
{navItems.map((item, index) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={index + navItemStartIndex === selectedIndex}
|
||||
onClick={handleSelectNavItem}
|
||||
/>
|
||||
))}
|
||||
{hasIdentifier && (
|
||||
<IdentifierItem
|
||||
match={identifierMatch!}
|
||||
@@ -379,6 +405,16 @@ export function ProfileSearchDropdown({
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Nav item results — sidebar pages matching query */}
|
||||
{navItems.map((item, index) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isSelected={index + navItemStartIndex === selectedIndex}
|
||||
onClick={handleSelectNavItem}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Identifier suggestion — NIP-05, NIP-19, hex */}
|
||||
{hasIdentifier && (
|
||||
<IdentifierItem
|
||||
@@ -448,7 +484,7 @@ export function ProfileSearchDropdown({
|
||||
)}
|
||||
|
||||
{/* Empty state — only when text search is not enabled */}
|
||||
{!enableTextSearch && open && query.trim().length > 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 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 rounded-xl border border-border bg-popover shadow-lg overflow-hidden animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-150">
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
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 (
|
||||
<button
|
||||
data-search-item
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors cursor-pointer',
|
||||
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
|
||||
)}
|
||||
onClick={() => onClick(item)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="size-10 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-semibold text-sm truncate block">{item.label}</span>
|
||||
<span className="text-xs text-muted-foreground truncate block">{item.path}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user