Move Nostr identifiers from submit-redirect to autocomplete suggestions

Enter/submit now always performs a text search instead of redirecting
when the query looks like a NIP-05, npub, nevent, etc.

Identifiers are detected in real-time and shown as autocomplete items:
- NIP-05 (user@domain.com, domain.com): resolves to profile via .well-known
- npub/nprofile: shows profile with avatar and metadata
- note/nevent: shows event content preview with author
- naddr: shows addressable event with title/content preview
- hex: shows navigable link to the identifier

Resolved profiles are deduplicated from regular search results.
Works in both desktop sidebar and mobile bottom sheet search.
This commit is contained in:
Alex Gleason
2026-03-22 14:26:18 -05:00
parent 10972064a6
commit d64a75f7ae
4 changed files with 821 additions and 56 deletions
+348 -21
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, UserRoundCheck, X, MessageSquare } from 'lucide-react';
import { Search, UserRoundCheck, X, MessageSquare, FileText, Hash } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
@@ -8,12 +8,15 @@ import { EmojifiedText } from '@/components/CustomEmoji';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import { useNip05Verify } from '@/hooks/useNip05Verify';
import { getNostrIdentifierPath, isFullUrl } from '@/lib/nostrIdentifier';
import { isFullUrl, detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier';
import { getProfileUrl } from '@/lib/profileUrl';
import { searchCountry, type CountryEntry } from '@/lib/countries';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useQueryClient } from '@tanstack/react-query';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { useAuthor } from '@/hooks/useAuthor';
import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { cn } from '@/lib/utils';
interface MobileSearchSheetProps {
@@ -28,23 +31,53 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Country suggestion (local, synchronous)
const countryMatch = useMemo(() => searchCountry(query), [query]);
const profileCount = profiles?.length ?? 0;
const hasCountry = !!countryMatch;
// Show country at top only for exact matches; otherwise at bottom (after profiles)
const countryAtTop = hasCountry && (countryMatch.exact || profileCount === 0);
// URL detection — show "Comment on" option when query is a full URL
const queryIsUrl = useMemo(() => isFullUrl(query), [query]);
const hasUrlComment = queryIsUrl;
const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0);
const urlCommentIndex = 0;
const countryIndex = countryAtTop ? (hasUrlComment ? 1 : 0) : profileCount + (hasUrlComment ? 1 : 0);
const profileStartIndex = (countryAtTop && hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0);
// Identifier detection — NIP-05, NIP-19, hex
const identifierMatch = useMemo(() => detectIdentifier(query), [query]);
// Resolve NIP-05 identifier pubkey for deduplication
const nip05Identifier = identifierMatch?.type === 'nip05' ? identifierMatch.identifier : undefined;
const { data: nip05Pubkey } = useNip05Resolve(nip05Identifier);
// The pubkey that the identifier item will show (for deduplication)
const identifierPubkey = useMemo(() => {
if (!identifierMatch) return undefined;
if (identifierMatch.type === 'npub' || identifierMatch.type === 'nprofile') return identifierMatch.pubkey;
if (identifierMatch.type === 'nip05' && nip05Pubkey) return nip05Pubkey;
return undefined;
}, [identifierMatch, nip05Pubkey]);
// Filter out the identifier-resolved profile from search results
const profiles = useMemo(() => {
if (!rawProfiles || !identifierPubkey) return rawProfiles;
return rawProfiles.filter((p) => p.pubkey !== identifierPubkey);
}, [rawProfiles, identifierPubkey]);
const profileCount = profiles?.length ?? 0;
const hasCountry = !!countryMatch;
// Show country at top only for exact matches; otherwise at bottom (after profiles)
const countryAtTop = hasCountry && (countryMatch.exact || profileCount === 0);
const hasIdentifier = !!identifierMatch;
const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0);
// Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom)]
let nextMobileIdx = 0;
const identifierIndex = hasIdentifier ? nextMobileIdx++ : -1;
const urlCommentIndex = hasUrlComment ? nextMobileIdx++ : -1;
const countryTopIndex = (hasCountry && countryAtTop) ? nextMobileIdx++ : -1;
const profileStartIndex = nextMobileIdx;
nextMobileIdx += profileCount;
const countryBottomIndex = (hasCountry && !countryAtTop) ? nextMobileIdx++ : -1;
const countryIndex = countryAtTop ? countryTopIndex : countryBottomIndex;
// Focus input when opened
useEffect(() => {
@@ -79,6 +112,11 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
navigate(`/i/iso3166:${country.code}`);
}, [navigate, handleClose]);
const handleSelectIdentifier = useCallback((path: string) => {
handleClose();
navigate(path);
}, [navigate, handleClose]);
const handleSelect = useCallback((profile: SearchProfile) => {
const nip05 = profile.metadata.nip05;
const nip05Verified = !!nip05 && queryClient.getQueryData<boolean>(['nip05-verify', nip05, profile.pubkey]) === true;
@@ -90,13 +128,6 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const handleTextSearch = useCallback(() => {
if (!query.trim()) return;
const identifierPath = getNostrIdentifierPath(query);
if (identifierPath) {
handleClose();
navigate(identifierPath);
return;
}
handleClose();
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}, [query, navigate, handleClose]);
@@ -110,7 +141,13 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < totalItems) {
if (hasUrlComment && selectedIndex === urlCommentIndex) {
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]');
const items = sheet?.querySelectorAll('[data-search-item]');
(items?.[selectedIndex] as HTMLElement)?.click();
} else if (hasUrlComment && selectedIndex === urlCommentIndex) {
handleCommentOnUrl();
} else if (hasCountry && selectedIndex === countryIndex) {
handleSelectCountry(countryMatch!.country);
@@ -132,7 +169,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
}
};
const hasResults = query.trim().length > 0 && (hasUrlComment || hasCountry || (profiles && profiles.length > 0));
const hasResults = query.trim().length > 0 && (hasIdentifier || hasUrlComment || hasCountry || (profiles && profiles.length > 0));
if (!open) return null;
@@ -149,7 +186,7 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
<div 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">
<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">
{hasCountry && countryAtTop && (
<SearchCountryItem
country={countryMatch!.country}
@@ -180,6 +217,13 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
onClick={handleCommentOnUrl}
/>
)}
{hasIdentifier && (
<MobileIdentifierItem
match={identifierMatch!}
isSelected={selectedIndex === identifierIndex}
onNavigate={handleSelectIdentifier}
/>
)}
</div>
)}
@@ -227,6 +271,289 @@ export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
);
}
/**
* Mobile autocomplete item for a detected Nostr identifier.
*/
function MobileIdentifierItem({
match,
isSelected,
onNavigate,
}: {
match: IdentifierMatch;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
switch (match.type) {
case 'nip05':
return <MobileNip05Item identifier={match.identifier} isSelected={isSelected} onNavigate={onNavigate} />;
case 'npub':
case 'nprofile':
return <MobilePubkeyItem pubkey={match.pubkey} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'note':
return <MobileEventItem eventId={match.eventId} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'nevent':
return <MobileEventItem eventId={match.eventId} relays={match.relays} authorHint={match.authorHint} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'naddr':
return <MobileAddrItem addr={match.addr} relays={match.relays} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'hex':
return <MobileHexItem hex={match.hex} isSelected={isSelected} onNavigate={onNavigate} />;
}
}
function MobileNip05Item({
identifier,
isSelected,
onNavigate,
}: {
identifier: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: pubkey, isLoading } = useNip05Resolve(identifier);
const author = useAuthor(pubkey ?? undefined);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || (pubkey ? genUserName(pubkey) : identifier);
const tags = author.data?.event?.tags ?? [];
if (isLoading) {
return (
<div data-search-item className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : '',
)}>
<div className="size-9 shrink-0 rounded-full bg-secondary animate-pulse" />
<div className="flex-1 min-w-0 space-y-1">
<div className="h-4 w-24 bg-secondary animate-pulse rounded" />
<div className="h-3 w-32 bg-secondary animate-pulse rounded" />
</div>
</div>
);
}
if (!pubkey) return null;
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={() => onNavigate(`/${identifier}`)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-9 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">
<EmojifiedText tags={tags}>{displayName}</EmojifiedText>
</span>
<span className="text-xs text-muted-foreground truncate block">{identifier}</span>
</div>
</button>
);
}
function MobilePubkeyItem({
pubkey,
raw,
isSelected,
onNavigate,
}: {
pubkey: string;
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
const tags = author.data?.event?.tags ?? [];
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={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-9 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">
{author.isLoading ? (
<span className="text-muted-foreground">Loading profile...</span>
) : (
<EmojifiedText tags={tags}>{displayName}</EmojifiedText>
)}
</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{raw.slice(0, 8)}...{raw.slice(-4)}
</span>
</div>
</button>
);
}
function MobileEventItem({
eventId,
relays,
authorHint,
raw,
isSelected,
onNavigate,
}: {
eventId: string;
relays?: string[];
authorHint?: string;
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: event, isLoading } = useEvent(eventId, relays, authorHint);
const author = useAuthor(event?.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || (event ? genUserName(event.pubkey) : undefined);
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={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<FileText className="size-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
{isLoading ? (
<span className="text-sm text-muted-foreground">Loading event...</span>
) : event ? (
<>
<span className="text-sm truncate block">{event.content.slice(0, 80) || `Kind ${event.kind} event`}</span>
<span className="text-xs text-muted-foreground truncate block">
{displayName ? `by ${displayName}` : raw.slice(0, 8) + '...' + raw.slice(-4)}
</span>
</>
) : (
<>
<span className="text-sm font-medium truncate block">Go to event</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{raw.slice(0, 8)}...{raw.slice(-4)}
</span>
</>
)}
</div>
</button>
);
}
function MobileAddrItem({
addr,
relays,
raw,
isSelected,
onNavigate,
}: {
addr: AddrCoords;
relays?: string[];
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: event, isLoading } = useAddrEvent(addr, relays);
const author = useAuthor(event?.pubkey ?? addr.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(addr.pubkey);
const title = event?.tags.find(([t]) => t === 'title')?.[1];
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={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<FileText className="size-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
{isLoading ? (
<span className="text-sm text-muted-foreground">Loading...</span>
) : (
<>
<span className="text-sm truncate block">
{title || event?.content.slice(0, 80) || `Kind ${addr.kind} event`}
</span>
<span className="text-xs text-muted-foreground truncate block">
by {displayName}
</span>
</>
)}
</div>
</button>
);
}
function MobileHexItem({
hex,
isSelected,
onNavigate,
}: {
hex: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
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={() => onNavigate(`/${hex}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<Hash className="size-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">Go to identifier</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{hex.slice(0, 8)}...{hex.slice(-4)}
</span>
</div>
</button>
);
}
function SearchCountryItem({
country,
isSelected,
+360 -23
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, UserRoundCheck, MessageSquare } from 'lucide-react';
import { Search, UserRoundCheck, MessageSquare, FileText, Hash } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -9,13 +9,16 @@ import { EmojifiedText } from '@/components/CustomEmoji';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import { useNip05Verify } from '@/hooks/useNip05Verify';
import { getNostrIdentifierPath, isFullUrl } from '@/lib/nostrIdentifier';
import { isFullUrl, detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getProfileUrl } from '@/lib/profileUrl';
import { searchCountry, type CountryEntry } from '@/lib/countries';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useQueryClient } from '@tanstack/react-query';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { useAuthor } from '@/hooks/useAuthor';
import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { cn } from '@/lib/utils';
interface ProfileSearchDropdownProps {
@@ -48,18 +51,40 @@ export function ProfileSearchDropdown({
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Country suggestion (local, synchronous) — suppressed when hideCountry is true
const countryMatchRaw = useMemo(() => searchCountry(query), [query]);
const countryMatch = hideCountry ? null : countryMatchRaw;
const profileCount = profiles?.length ?? 0;
// Show country at top only for exact matches; otherwise at bottom (after profiles)
const countryAtTop = !!countryMatch && (countryMatch.exact || profileCount === 0);
// URL detection — show "Comment on" option when query is a full URL
const queryIsUrl = useMemo(() => isFullUrl(query), [query]);
// Identifier detection — NIP-05, NIP-19, hex
const identifierMatch = useMemo(() => detectIdentifier(query), [query]);
// Resolve NIP-05 identifier pubkey at the parent so we can deduplicate
const nip05Identifier = identifierMatch?.type === 'nip05' ? identifierMatch.identifier : undefined;
const { data: nip05Pubkey } = useNip05Resolve(nip05Identifier);
// The pubkey that the identifier item will show (for deduplication)
const identifierPubkey = useMemo(() => {
if (!identifierMatch) return undefined;
if (identifierMatch.type === 'npub' || identifierMatch.type === 'nprofile') return identifierMatch.pubkey;
if (identifierMatch.type === 'nip05' && nip05Pubkey) return nip05Pubkey;
return undefined;
}, [identifierMatch, nip05Pubkey]);
// Filter out the identifier-resolved profile from search results to avoid duplication
const profiles = useMemo(() => {
if (!rawProfiles || !identifierPubkey) return rawProfiles;
return rawProfiles.filter((p) => p.pubkey !== identifierPubkey);
}, [rawProfiles, identifierPubkey]);
const profileCount = profiles?.length ?? 0;
// Show country at top only for exact matches; otherwise at bottom (after profiles)
const countryAtTop = !!countryMatch && (countryMatch.exact || profileCount === 0);
// Show dropdown when we have results, or when text search is enabled and there's a query
useEffect(() => {
if (query.trim().length > 0) {
@@ -101,28 +126,26 @@ export function ProfileSearchDropdown({
setQuery('');
inputRef.current?.blur();
// If the input is a Nostr identifier (NIP-19 or NIP-05), navigate directly
const identifierPath = getNostrIdentifierPath(query);
if (identifierPath) {
navigate(identifierPath);
return;
}
if (!enableTextSearch) return;
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}, [enableTextSearch, query, navigate]);
// Total selectable items: profiles + optional country + optional URL comment
// Total selectable items: identifier? + URL comment? + country?(top) + profiles + country?(bottom)
const hasCountry = !!countryMatch;
const hasUrlComment = queryIsUrl && enableTextSearch;
const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0);
const hasIdentifier = !!identifierMatch;
const totalItems = profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0);
// Map selectedIndex to what it refers to.
// When enableTextSearch with URL: [commentUrl, country?(top), ...profiles, country?(bottom)]
// The "Comment on" item sits at index 0 (right after the always-highlighted "Search for")
const urlCommentIndex = 0;
const countryIndex = countryAtTop ? (hasUrlComment ? 1 : 0) : profileCount + (hasUrlComment ? 1 : 0);
const profileStartIndex = (countryAtTop && hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0);
// Order: [identifier?, commentUrl?, country?(top), ...profiles, country?(bottom)]
let nextIdx = 0;
const identifierIndex = hasIdentifier ? nextIdx++ : -1;
const urlCommentIndex = hasUrlComment ? nextIdx++ : -1;
const countryTopIndex = (hasCountry && countryAtTop) ? nextIdx++ : -1;
const profileStartIndex = nextIdx;
nextIdx += profileCount;
const countryBottomIndex = (hasCountry && !countryAtTop) ? nextIdx++ : -1;
const countryIndex = countryAtTop ? countryTopIndex : countryBottomIndex;
const handleCommentOnUrl = useCallback(() => {
if (!queryIsUrl) return;
@@ -138,6 +161,13 @@ export function ProfileSearchDropdown({
navigate(`/i/iso3166:${country.code}`);
}, [navigate]);
const handleSelectIdentifier = useCallback((path: string) => {
setOpen(false);
setQuery('');
inputRef.current?.blur();
navigate(path);
}, [navigate]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
@@ -149,7 +179,12 @@ export function ProfileSearchDropdown({
if (e.key === 'Enter') {
e.preventDefault();
if (open && selectedIndex >= 0 && selectedIndex < totalItems) {
if (hasUrlComment && selectedIndex === urlCommentIndex) {
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]');
(items?.[selectedIndex] as HTMLElement)?.click();
} else if (hasUrlComment && selectedIndex === urlCommentIndex) {
handleCommentOnUrl();
} else if (hasCountry && selectedIndex === countryIndex) {
handleSelectCountry(countryMatch!.country);
@@ -235,13 +270,20 @@ export function ProfileSearchDropdown({
</div>
{/* Dropdown results — only when text search is not enabled */}
{!enableTextSearch && open && (hasCountry || (profiles && profiles.length > 0)) && (
{!enableTextSearch && open && (hasIdentifier || hasCountry || (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">
{hasIdentifier && (
<IdentifierItem
match={identifierMatch!}
isSelected={selectedIndex === identifierIndex}
onNavigate={handleSelectIdentifier}
/>
)}
{hasCountry && countryAtTop && (
<CountryItem
country={countryMatch!.country}
@@ -290,6 +332,15 @@ export function ProfileSearchDropdown({
</span>
</button>
{/* Identifier suggestion — NIP-05, NIP-19, hex */}
{hasIdentifier && (
<IdentifierItem
match={identifierMatch!}
isSelected={selectedIndex === identifierIndex}
onNavigate={handleSelectIdentifier}
/>
)}
{/* Comment on URL option — shown when query is a full URL */}
{hasUrlComment && (
<CommentOnUrlItem
@@ -332,7 +383,7 @@ export function ProfileSearchDropdown({
)}
{/* Empty state — only when text search is not enabled */}
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasCountry && profiles && profiles.length === 0 && (
{!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && 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
@@ -343,6 +394,292 @@ export function ProfileSearchDropdown({
);
}
/**
* 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.
*/
function IdentifierItem({
match,
isSelected,
onNavigate,
}: {
match: IdentifierMatch;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
switch (match.type) {
case 'nip05':
return <Nip05IdentifierItem identifier={match.identifier} isSelected={isSelected} onNavigate={onNavigate} />;
case 'npub':
case 'nprofile':
return <PubkeyIdentifierItem pubkey={match.pubkey} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'note':
return <EventIdentifierItem eventId={match.eventId} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'nevent':
return <EventIdentifierItem eventId={match.eventId} relays={match.relays} authorHint={match.authorHint} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'naddr':
return <AddrIdentifierItem addr={match.addr} relays={match.relays} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'hex':
return <HexIdentifierItem hex={match.hex} isSelected={isSelected} onNavigate={onNavigate} />;
}
}
function Nip05IdentifierItem({
identifier,
isSelected,
onNavigate,
}: {
identifier: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: pubkey, isLoading } = useNip05Resolve(identifier);
const author = useAuthor(pubkey ?? undefined);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || (pubkey ? genUserName(pubkey) : identifier);
const tags = author.data?.event?.tags ?? [];
if (isLoading) {
return (
<div data-search-item className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : '',
)}>
<div className="size-10 shrink-0 rounded-full bg-secondary animate-pulse" />
<div className="flex-1 min-w-0 space-y-1">
<div className="h-4 w-24 bg-secondary animate-pulse rounded" />
<div className="h-3 w-32 bg-secondary animate-pulse rounded" />
</div>
</div>
);
}
if (!pubkey) return null; // NIP-05 didn't resolve — don't show
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={() => onNavigate(`/${identifier}`)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">
<EmojifiedText tags={tags}>{displayName}</EmojifiedText>
</span>
<span className="text-xs text-muted-foreground truncate block">{identifier}</span>
</div>
</button>
);
}
function PubkeyIdentifierItem({
pubkey,
raw,
isSelected,
onNavigate,
}: {
pubkey: string;
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
const tags = author.data?.event?.tags ?? [];
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={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar shape={getAvatarShape(metadata)} className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">
{author.isLoading ? (
<span className="text-muted-foreground">Loading profile...</span>
) : (
<EmojifiedText tags={tags}>{displayName}</EmojifiedText>
)}
</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{raw.slice(0, 8)}...{raw.slice(-4)}
</span>
</div>
</button>
);
}
function EventIdentifierItem({
eventId,
relays,
authorHint,
raw,
isSelected,
onNavigate,
}: {
eventId: string;
relays?: string[];
authorHint?: string;
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: event, isLoading } = useEvent(eventId, relays, authorHint);
const author = useAuthor(event?.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || (event ? genUserName(event.pubkey) : undefined);
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={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-10 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<FileText className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
{isLoading ? (
<span className="text-sm text-muted-foreground">Loading event...</span>
) : event ? (
<>
<span className="text-sm truncate block">{event.content.slice(0, 80) || `Kind ${event.kind} event`}</span>
<span className="text-xs text-muted-foreground truncate block">
{displayName ? `by ${displayName}` : raw.slice(0, 8) + '...' + raw.slice(-4)}
</span>
</>
) : (
<>
<span className="text-sm font-medium truncate block">Go to event</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{raw.slice(0, 8)}...{raw.slice(-4)}
</span>
</>
)}
</div>
</button>
);
}
function AddrIdentifierItem({
addr,
relays,
raw,
isSelected,
onNavigate,
}: {
addr: AddrCoords;
relays?: string[];
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: event, isLoading } = useAddrEvent(addr, relays);
const author = useAuthor(event?.pubkey ?? addr.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(addr.pubkey);
// Try to get a title from tags
const title = event?.tags.find(([t]) => t === 'title')?.[1];
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={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-10 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<FileText className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
{isLoading ? (
<span className="text-sm text-muted-foreground">Loading...</span>
) : (
<>
<span className="text-sm truncate block">
{title || event?.content.slice(0, 80) || `Kind ${addr.kind} event`}
</span>
<span className="text-xs text-muted-foreground truncate block">
by {displayName}
</span>
</>
)}
</div>
</button>
);
}
function HexIdentifierItem({
hex,
isSelected,
onNavigate,
}: {
hex: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
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={() => onNavigate(`/${hex}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-10 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<Hash className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">Go to identifier</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{hex.slice(0, 8)}...{hex.slice(-4)}
</span>
</div>
</button>
);
}
function CountryItem({
country,
isSelected,
+108 -2
View File
@@ -1,4 +1,5 @@
import { nip19 } from 'nostr-tools';
import type { DecodedResult } from 'nostr-tools/nip19';
/**
* Known NIP-19 bech32 prefixes that the app can route to.
@@ -14,7 +15,7 @@ const HEX_64_RE = /^[0-9a-f]{64}$/;
* Checks whether a string looks like a NIP-05 identifier (user@domain.com)
* or a bare domain (e.g. fiatjaf.com). Strips a leading `@` if present.
*/
function looksLikeNip05(value: string): boolean {
export function looksLikeNip05(value: string): boolean {
const cleaned = value.startsWith('@') ? value.slice(1) : value;
// user@domain.com — must have text on both sides of @
if (cleaned.includes('@')) {
@@ -48,7 +49,112 @@ export function isFullUrl(value: string): boolean {
}
/**
* If `input` is a Nostr identifier (NIP-19 bech32 or NIP-05 address),
* Try to decode the input as a NIP-19 bech32 identifier.
* Strips the `nostr:` URI prefix if present.
* Returns the decoded result or `null` if it isn't a valid NIP-19 string.
*/
export function tryDecodeNip19(input: string): { decoded: DecodedResult; raw: string } | null {
const trimmed = input.trim();
if (!trimmed) return null;
const value = trimmed.startsWith('nostr:') ? trimmed.slice(6) : trimmed;
if (NIP19_PREFIXES.some((p) => value.startsWith(p))) {
try {
const decoded = nip19.decode(value);
return { decoded, raw: value };
} catch {
// Not a valid NIP-19
}
}
return null;
}
/**
* Check if the input is a raw 64-char hex string (event ID or pubkey).
*/
export function isHex64(input: string): boolean {
return HEX_64_RE.test(input.trim());
}
/** Coordinates for an addressable event (naddr). */
interface AddrCoords {
kind: number;
pubkey: string;
identifier: string;
}
/**
* Structured result describing a detected Nostr identifier in user input.
*/
export type IdentifierMatch =
| { type: 'nip05'; identifier: string }
| { type: 'npub'; pubkey: string; raw: string }
| { type: 'nprofile'; pubkey: string; raw: string }
| { type: 'note'; eventId: string; raw: string }
| { type: 'nevent'; eventId: string; relays?: string[]; authorHint?: string; raw: string }
| { type: 'naddr'; addr: AddrCoords; relays?: string[]; raw: string }
| { type: 'hex'; hex: string };
/**
* Detect whether a search query is a Nostr identifier.
* Returns a structured result, or null if it's a regular search query.
* Does NOT match full URLs (those are handled separately).
*/
export function detectIdentifier(query: string): IdentifierMatch | null {
const trimmed = query.trim();
if (!trimmed) return null;
// Don't detect identifiers if it's a URL
if (isFullUrl(trimmed)) return null;
const value = trimmed.startsWith('nostr:') ? trimmed.slice(6) : trimmed;
// Try NIP-19
const nip19Result = tryDecodeNip19(value);
if (nip19Result) {
const { decoded, raw } = nip19Result;
switch (decoded.type) {
case 'npub':
return { type: 'npub', pubkey: decoded.data, raw };
case 'nprofile':
return { type: 'nprofile', pubkey: decoded.data.pubkey, raw };
case 'note':
return { type: 'note', eventId: decoded.data, raw };
case 'nevent':
return {
type: 'nevent',
eventId: decoded.data.id,
relays: decoded.data.relays,
authorHint: decoded.data.author,
raw,
};
case 'naddr':
return {
type: 'naddr',
addr: { kind: decoded.data.kind, pubkey: decoded.data.pubkey, identifier: decoded.data.identifier },
relays: decoded.data.relays,
raw,
};
}
}
// Try hex
if (isHex64(value)) {
return { type: 'hex', hex: value };
}
// Try NIP-05
if (looksLikeNip05(value)) {
const cleaned = value.startsWith('@') ? value.slice(1) : value;
return { type: 'nip05', identifier: cleaned };
}
return null;
}
/**
* If `input` is a Nostr identifier (NIP-19 bech32, hex, or NIP-05 address),
* returns the path the app should navigate to (e.g. `/npub1...` or `/user@domain.com`).
*
* Returns `null` if the input is a regular search query.
+5 -10
View File
@@ -14,7 +14,7 @@ import {
} from 'lucide-react';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useInView } from 'react-intersection-observer';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { NoteCard } from '@/components/NoteCard';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
@@ -46,7 +46,7 @@ import { ListPackPicker } from '@/components/SavedFeedFiltersEditor';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { TabButton } from '@/components/TabButton';
import { getNostrIdentifierPath } from '@/lib/nostrIdentifier';
// getNostrIdentifierPath removed — identifiers are now handled as autocomplete suggestions
import { cn, STICKY_HEADER_CLASS, parseKindFilter } from '@/lib/utils';
import type { TabFilter } from '@/contexts/AppContext';
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
@@ -91,7 +91,6 @@ export function SearchPage() {
description: 'Search Nostr',
});
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Derive tab directly from URL — single source of truth
@@ -244,13 +243,9 @@ export function SearchPage() {
}
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
// If the search query is a Nostr identifier, redirect immediately
useEffect(() => {
const path = getNostrIdentifierPath(debouncedSearchQuery);
if (path) {
navigate(path, { replace: true });
}
}, [debouncedSearchQuery, navigate]);
// NOTE: Previously this redirected NIP-19/NIP-05 identifiers away from the
// search page. Now identifiers are handled as autocomplete suggestions in the
// search dropdowns, and submitting always performs a text search.
const protocols = useMemo(() => [platform], [platform]);