import { useState, useRef, useEffect } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { searchNotes, searchUsers, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr"; import { getNip50Relays } from "../../lib/nostr/relayInfo"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; import { shortenPubkey } from "../../lib/utils"; import { NoteCard } from "../feed/NoteCard"; interface ParsedUser { pubkey: string; name: string; displayName: string; picture: string; nip05: string; about: string; } function parseUserEvent(event: NDKEvent): ParsedUser { let meta: Record = {}; try { meta = JSON.parse(event.content); } catch { /* ignore */ } return { pubkey: event.pubkey, name: meta.name || "", displayName: meta.display_name || meta.name || "", picture: meta.picture || "", nip05: meta.nip05 || "", about: meta.about || "", }; } function UserRow({ user }: { user: ParsedUser }) { const { loggedIn, pubkey: myPubkey, follows, follow, unfollow } = useUserStore(); const { openProfile: navToProfile } = useUIStore(); const isOwn = user.pubkey === myPubkey; const isFollowing = follows.includes(user.pubkey); const [pending, setPending] = useState(false); const displayName = user.displayName || user.name || shortenPubkey(user.pubkey); const handleFollowToggle = async () => { setPending(true); try { if (isFollowing) await unfollow(user.pubkey); else await follow(user.pubkey); } finally { setPending(false); } }; return (
navToProfile(user.pubkey)}> {user.picture ? ( { (e.target as HTMLImageElement).style.display = "none"; }} /> ) : (
{displayName.charAt(0).toUpperCase()}
)}
navToProfile(user.pubkey)}>
{displayName}
{user.nip05 &&
{user.nip05}
} {user.about &&
{user.about}
}
{loggedIn && !isOwn && ( )}
); } interface Suggestion { pubkey: string; mutualCount: number; profile: ParsedUser | null; } function SuggestionFollowButton({ pubkey }: { pubkey: string }) { const { loggedIn, follows, follow, unfollow } = useUserStore(); const isFollowing = follows.includes(pubkey); const [pending, setPending] = useState(false); if (!loggedIn) return null; const handleClick = async () => { setPending(true); try { if (isFollowing) await unfollow(pubkey); else await follow(pubkey); } finally { setPending(false); } }; return ( ); } export function SearchView() { const { pendingSearch } = useUIStore(); const [query, setQuery] = useState(pendingSearch ?? ""); const [noteResults, setNoteResults] = useState([]); const [userResults, setUserResults] = useState([]); const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(false); const [activeTab, setActiveTab] = useState<"notes" | "people">("notes"); const [nip50Relays, setNip50Relays] = useState(null); // null = not checked yet const inputRef = useRef(null); const [suggestions, setSuggestions] = useState([]); const [suggestionsLoading, setSuggestionsLoading] = useState(false); const [suggestionsLoaded, setSuggestionsLoaded] = useState(false); const isHashtag = query.trim().startsWith("#"); // Check relay NIP-50 support once on mount (background, non-blocking) useEffect(() => { const urls = getStoredRelayUrls(); getNip50Relays(urls).then(setNip50Relays); }, []); const { loggedIn, follows } = useUserStore(); // Load follow suggestions on mount (only for logged-in users with follows) useEffect(() => { if (!loggedIn || follows.length === 0 || suggestionsLoaded) return; setSuggestionsLoading(true); fetchFollowSuggestions(follows).then(async (results) => { // Load profiles for top suggestions const withProfiles: Suggestion[] = await Promise.all( results.slice(0, 20).map(async (s) => { try { const p = await fetchProfile(s.pubkey); return { ...s, profile: p ? { pubkey: s.pubkey, name: (p as Record).name || "", displayName: (p as Record).display_name || (p as Record).name || "", picture: (p as Record).picture || "", nip05: (p as Record).nip05 || "", about: (p as Record).about || "", } : null, }; } catch { return { ...s, profile: null }; } }) ); setSuggestions(withProfiles.filter((s) => s.profile !== null)); setSuggestionsLoading(false); setSuggestionsLoaded(true); }).catch(() => setSuggestionsLoading(false)); }, [loggedIn, follows.length]); // Run pending search from hashtag/mention click useEffect(() => { if (pendingSearch) { useUIStore.setState({ pendingSearch: null }); handleSearch(pendingSearch); } }, []); const handleSearch = async (overrideQuery?: string) => { const q = (overrideQuery ?? query).trim(); if (!q) return; if (overrideQuery) setQuery(overrideQuery); setLoading(true); setSearched(false); try { const isTag = q.startsWith("#"); const [notes, userEvents] = await Promise.all([ searchNotes(q), isTag ? Promise.resolve([]) : searchUsers(q), ]); setNoteResults(notes); setUserResults(userEvents.map(parseUserEvent)); setActiveTab(notes.length > 0 ? "notes" : "people"); } finally { setLoading(false); setSearched(true); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") handleSearch(); }; // Switch query to hashtag format and re-run const tryAsHashtag = () => { const raw = query.trim().replace(/^#+/, ""); const hashQuery = `#${raw}`; setQuery(hashQuery); handleSearch(hashQuery); }; const totalResults = noteResults.length + userResults.length; const allRelays = getStoredRelayUrls(); const nip50Count = nip50Relays?.length ?? null; const noNip50 = nip50Relays !== null && nip50Relays.length === 0; return (
{/* Search bar */}
setQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="search notes, #hashtags, or people…" autoFocus className="flex-1 bg-transparent text-text text-[13px] placeholder:text-text-dim focus:outline-none" />
{/* Tabs — shown once a search has been run (except for hashtag, which is notes-only) */} {searched && !isHashtag && (
{(["notes", "people"] as const).map((tab) => { const count = tab === "notes" ? noteResults.length : userResults.length; return ( ); })}
)} {/* Results area */}
{/* Idle / pre-search hint */} {!searched && !loading && (

Use #hashtag to browse topics, or type a keyword for full-text search.

{nip50Relays !== null && (

{nip50Count === 0 ? "None of your relays support full-text search — #hashtag search always works." : `${nip50Count} of ${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""} support full-text search.`}

)}
)} {/* Discover — follow suggestions */} {!searched && !loading && loggedIn && (

Discover people

Based on who your follows follow

{suggestionsLoading && (
Finding suggestions...
)} {suggestions.map((s) => s.profile && (
useUIStore.getState().openProfile(s.pubkey)}> {s.profile.picture ? ( { (e.target as HTMLImageElement).style.display = "none"; }} /> ) : (
{(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()}
)}
useUIStore.getState().openProfile(s.pubkey)}>
{s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)}
{s.mutualCount} mutual follow{s.mutualCount !== 1 ? "s" : ""} {s.profile.nip05 && {s.profile.nip05}}
{s.profile.about && (
{s.profile.about}
)}
))} {suggestionsLoaded && suggestions.length === 0 && (
Follow more people to see suggestions here.
)}
)} {/* Zero results for full-text search */} {searched && totalResults === 0 && !isHashtag && (

No results for {query}.

{/* Relay NIP-50 status */} {nip50Relays !== null && (

{noNip50 ? "None of your relays support full-text search." : `${nip50Count} of ${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""} support full-text search.`}

)} {/* Hashtag suggestion */} {!query.startsWith("#") && (

Try a hashtag search instead:

)}
)} {/* Zero results for hashtag search */} {searched && totalResults === 0 && isHashtag && (

No notes found for {query}.

Try a different hashtag or check your relay connections.

)} {/* People tab — zero results hint */} {searched && activeTab === "people" && userResults.length === 0 && totalResults > 0 && (

No people found for {query}.

{noNip50 && (

People search requires NIP-50 relay support.

)}
)} {/* People results */} {activeTab === "people" && userResults.map((user) => ( ))} {/* Notes results */} {(activeTab === "notes" || isHashtag) && noteResults.map((event) => ( ))}
); }