From d64a75f7ae725deabc2fa41b1da3164c4b459925 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 22 Mar 2026 14:26:18 -0500 Subject: [PATCH] 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. --- src/components/MobileSearchSheet.tsx | 369 ++++++++++++++++++++-- src/components/ProfileSearchDropdown.tsx | 383 +++++++++++++++++++++-- src/lib/nostrIdentifier.ts | 110 ++++++- src/pages/SearchPage.tsx | 15 +- 4 files changed, 821 insertions(+), 56 deletions(-) diff --git a/src/components/MobileSearchSheet.tsx b/src/components/MobileSearchSheet.tsx index 0431d4af..398d5c9e 100644 --- a/src/components/MobileSearchSheet.tsx +++ b/src/components/MobileSearchSheet.tsx @@ -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(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(['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 && ( -
+
{hasCountry && countryAtTop && ( )} + {hasIdentifier && ( + + )}
)} @@ -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 ; + case 'npub': + case 'nprofile': + return ; + case 'note': + return ; + case 'nevent': + return ; + case 'naddr': + return ; + case 'hex': + return ; + } +} + +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 ( +
+
+
+
+
+
+
+ ); + } + + if (!pubkey) return null; + + return ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +function MobileHexItem({ + hex, + isSelected, + onNavigate, +}: { + hex: string; + isSelected: boolean; + onNavigate: (path: string) => void; +}) { + return ( + + ); +} + function SearchCountryItem({ country, isSelected, diff --git a/src/components/ProfileSearchDropdown.tsx b/src/components/ProfileSearchDropdown.tsx index 77e7a72d..b5eabae3 100644 --- a/src/components/ProfileSearchDropdown.tsx +++ b/src/components/ProfileSearchDropdown.tsx @@ -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(null); const listRef = useRef(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({
{/* Dropdown results — only when text search is not enabled */} - {!enableTextSearch && open && (hasCountry || (profiles && profiles.length > 0)) && ( + {!enableTextSearch && open && (hasIdentifier || hasCountry || (profiles && profiles.length > 0)) && (
+ {hasIdentifier && ( + + )} {hasCountry && countryAtTop && ( + {/* Identifier suggestion — NIP-05, NIP-19, hex */} + {hasIdentifier && ( + + )} + {/* Comment on URL option — shown when query is a full URL */} {hasUrlComment && ( 0 && !isFetching && !hasCountry && profiles && profiles.length === 0 && ( + {!enableTextSearch && open && query.trim().length > 0 && !isFetching && !hasIdentifier && !hasCountry && profiles && profiles.length === 0 && (
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 ; + case 'npub': + case 'nprofile': + return ; + case 'note': + return ; + case 'nevent': + return ; + case 'naddr': + return ; + case 'hex': + return ; + } +} + +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 ( +
+
+
+
+
+
+
+ ); + } + + if (!pubkey) return null; // NIP-05 didn't resolve — don't show + + return ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +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 ( + + ); +} + +function HexIdentifierItem({ + hex, + isSelected, + onNavigate, +}: { + hex: string; + isSelected: boolean; + onNavigate: (path: string) => void; +}) { + return ( + + ); +} + function CountryItem({ country, isSelected, diff --git a/src/lib/nostrIdentifier.ts b/src/lib/nostrIdentifier.ts index f4e83abf..70289e42 100644 --- a/src/lib/nostrIdentifier.ts +++ b/src/lib/nostrIdentifier.ts @@ -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. diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 92c64df5..2dd44e7a 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -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]);