diff --git a/src/components/search/SearchView.tsx b/src/components/search/SearchView.tsx index 3803ce2..5fd7bf5 100644 --- a/src/components/search/SearchView.tsx +++ b/src/components/search/SearchView.tsx @@ -1,8 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -import { getStoredRelayUrls, fetchFollowSuggestions, fetchProfile, advancedSearch, fetchTrendingHashtags } from "../../lib/nostr"; +import { fetchFollowSuggestions, fetchProfile, advancedSearch, fetchTrendingHashtags } from "../../lib/nostr"; import { parseSearchQuery, describeSearch } from "../../lib/search"; -import { getNip50Relays } from "../../lib/nostr/relayInfo"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { useDismissedSuggestionsStore } from "../../stores/dismissedSuggestions"; @@ -132,7 +131,6 @@ export function SearchView() { const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(false); const [activeTab, setActiveTab] = useState<"notes" | "people" | "articles">("notes"); - const [nip50Relays, setNip50Relays] = useState(null); // null = not checked yet const inputRef = useRef(null); const [suggestions, setSuggestions] = useState([]); const [suggestionsLoading, setSuggestionsLoading] = useState(false); @@ -142,14 +140,6 @@ export function SearchView() { const [trending, setTrending] = useState<{ tag: string; count: number }[]>([]); const [trendingLoading, setTrendingLoading] = useState(false); const [trendingLoaded, setTrendingLoaded] = useState(false); - const isHashtag = query.trim().startsWith("#") && !query.includes(":"); - - // Check relay NIP-50 support once on mount (background, non-blocking) - useEffect(() => { - const urls = getStoredRelayUrls(); - getNip50Relays(urls).then(setNip50Relays); - }, []); - // Load trending hashtags on mount useEffect(() => { if (trendingLoaded) return; @@ -215,6 +205,9 @@ export function SearchView() { setLoading(true); setSearched(false); setSearchHint(null); + setNoteResults([]); + setArticleResults([]); + setUserResults([]); try { const parsed = parseSearchQuery(q); const isAdvanced = parsed.authors.length > 0 || parsed.unresolvedNip05.length > 0 || @@ -241,18 +234,7 @@ export function SearchView() { 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 + articleResults.length; - const allRelays = getStoredRelayUrls(); - const nip50Count = nip50Relays?.length ?? null; - const noNip50 = nip50Relays !== null && nip50Relays.length === 0; return (
@@ -279,8 +261,8 @@ export function SearchView() {
- {/* Tabs — shown once a search has been run (except for hashtag, which is notes-only) */} - {searched && !isHashtag && ( + {/* Tabs — shown once a search has been run */} + {searched && (
{(["notes", "articles", "people"] as const).map((tab) => { const count = tab === "notes" ? noteResults.length : tab === "articles" ? articleResults.length : userResults.length; @@ -311,6 +293,16 @@ export function SearchView() { {/* Results area */}
+ {/* Loading indicator */} + {loading && ( +
+ +

+ Searching relays for {query} +

+
+ )} + {/* Idle / pre-search hint */} {!searched && !loading && (
@@ -318,23 +310,19 @@ export function SearchView() {

Type a keyword, #hashtag, or use search modifiers.

- {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.`} -

- )} +

+ Full-text search uses dedicated search relays. Hashtag and keyword results are combined. +

{/* Search syntax help */}

Search modifiers

- by:name - notes from author by:user@domain - NIP-05 author lookup + notes from NIP-05 author + by:npub1... + notes from pubkey #bitcoin hashtag search has:image @@ -434,42 +422,15 @@ export function SearchView() {
)} - {/* Zero results for full-text search */} - {searched && totalResults === 0 && !isHashtag && ( + {/* Zero results */} + {searched && totalResults === 0 && (

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.

+

+ Try different keywords, a #hashtag, or check your relay connections. +

)} @@ -477,9 +438,6 @@ export function SearchView() { {searched && activeTab === "people" && userResults.length === 0 && totalResults > 0 && (

No people found for {query}.

- {noNip50 && ( -

People search requires NIP-50 relay support.

- )}
)} @@ -494,7 +452,7 @@ export function SearchView() { ))} {/* Notes results */} - {(activeTab === "notes" || isHashtag) && noteResults.map((event) => ( + {activeTab === "notes" && noteResults.map((event) => ( ))}
diff --git a/src/lib/nostr/search.ts b/src/lib/nostr/search.ts index 97d19d6..83a6ded 100644 --- a/src/lib/nostr/search.ts +++ b/src/lib/nostr/search.ts @@ -1,25 +1,74 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKUser } from "@nostr-dev-kit/ndk"; +import NDK, { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage, NDKUser } from "@nostr-dev-kit/ndk"; import { type ParsedSearch, matchesHasFilter } from "../search"; -import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core"; +import { getNDK, fetchWithTimeout, withTimeout, FEED_TIMEOUT } from "./core"; + +// Dedicated NIP-50 search relays — queried for full-text search regardless of user's relay list +const SEARCH_RELAYS = [ + "wss://relay.nostr.band", + "wss://search.nos.today", +]; + +// Persistent NDK instance dedicated to search relays — stays connected +let searchNDK: NDK | null = null; +let searchNDKConnecting: Promise | null = null; + +async function getSearchNDK(): Promise { + if (searchNDK) return searchNDK; + searchNDK = new NDK({ explicitRelayUrls: SEARCH_RELAYS }); + searchNDKConnecting = searchNDK.connect().then(() => { + console.log("[Wrystr] Search relays connected"); + searchNDKConnecting = null; + }); + await withTimeout(searchNDKConnecting, 5000, undefined); + return searchNDK; +} + +const EMPTY_SET = new Set(); + +/** Fetch events from the dedicated search relays with timeout. */ +async function searchFetch(filter: NDKFilter, timeoutMs = FEED_TIMEOUT): Promise> { + const ndk = await getSearchNDK(); + const promise = ndk.fetchEvents(filter, { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + groupable: false, + }); + return withTimeout(promise, timeoutMs, EMPTY_SET); +} export async function searchNotes(query: string, limit = 50): Promise { const instance = getNDK(); const isHashtag = query.startsWith("#"); - const filter: NDKFilter & { search?: string } = isHashtag - ? { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit } - : { kinds: [NDKKind.Text], search: query, limit }; - const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); - return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); + + if (isHashtag) { + const filter: NDKFilter = { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit }; + const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); + return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); + } + + // Hybrid: NIP-50 full-text on search relays + hashtag on user relays + const searchFilter: NDKFilter & { search?: string } = { kinds: [NDKKind.Text], search: query, limit }; + const tagFilter: NDKFilter = { kinds: [NDKKind.Text], "#t": [query.toLowerCase()], limit }; + const [nip50Events, tagEvents] = await Promise.all([ + searchFetch(searchFilter), + fetchWithTimeout(instance, tagFilter, FEED_TIMEOUT), + ]); + + // Merge and deduplicate + const seen = new Set(); + const merged: NDKEvent[] = []; + for (const e of [...nip50Events, ...tagEvents]) { + if (e.id && !seen.has(e.id)) { seen.add(e.id); merged.push(e); } + } + return merged.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); } export async function searchUsers(query: string, limit = 20): Promise { - const instance = getNDK(); const filter: NDKFilter & { search?: string } = { kinds: [NDKKind.Metadata], search: query, limit, }; - const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); + const events = await searchFetch(filter); return Array.from(events); } @@ -70,18 +119,12 @@ export async function advancedSearch(parsed: ParsedSearch, limit = 50): Promise< }; } - // Resolve any NIP-05 or name-based author identifiers + // Resolve author identifiers (npub or NIP-05 only — display name resolution not yet supported) const resolvedAuthors = [...parsed.authors]; for (const nip05 of parsed.unresolvedNip05) { - const resolved = await resolveNip05(nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`); - if (resolved) { - resolvedAuthors.push(resolved); - } else { - const nameResults = await searchUsers(nip05, 1); - if (nameResults.length > 0) { - resolvedAuthors.push(nameResults[0].pubkey); - } - } + const identifier = nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`; + const resolved = await resolveNip05(identifier); + if (resolved) resolvedAuthors.push(resolved); } // Determine which kinds to search @@ -117,18 +160,69 @@ export async function advancedSearch(parsed: ParsedSearch, limit = 50): Promise< const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null; const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null; - const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags; + const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && (hasSearch || hasHashtags); - const [noteEvents, articleEvents, userEvents] = await Promise.all([ - noteFilter ? fetchWithTimeout(instance, noteFilter, FEED_TIMEOUT) : Promise.resolve(new Set()), - articleFilter ? fetchWithTimeout(instance, articleFilter, FEED_TIMEOUT) : Promise.resolve(new Set()), - shouldSearchUsers ? fetchWithTimeout(instance, { kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, FEED_TIMEOUT) : Promise.resolve(new Set()), - ]); + const usesNip50 = hasSearch && !hasHashtags; - let notes = Array.from(noteEvents); - let articles = Array.from(articleEvents); + // Build parallel fetch promises + const fetches: Promise>[] = []; + + // Notes: NIP-50 on search relays, or hashtag on user relays + fetches.push(noteFilter ? (usesNip50 ? searchFetch(noteFilter) : fetchWithTimeout(instance, noteFilter, FEED_TIMEOUT)) : Promise.resolve(new Set())); + // Articles: same logic + fetches.push(articleFilter ? (usesNip50 ? searchFetch(articleFilter) : fetchWithTimeout(instance, articleFilter, FEED_TIMEOUT)) : Promise.resolve(new Set())); + // Users: NIP-50 on search relays + fetches.push(shouldSearchUsers + ? searchFetch({ kinds: [NDKKind.Metadata], search: hasSearch ? searchText : parsed.hashtags.join(" "), limit: 20 } as NDKFilter & { search: string }) + : Promise.resolve(new Set())); + + // Hybrid: if text search, also do hashtag lookup on user relays and merge + // Carry over author/mention/time constraints so modifiers like by:jack still filter + const hybridTerms = hasSearch && !hasHashtags ? searchText.toLowerCase().split(/\s+/).filter(Boolean) : []; + const buildHybridFilter = (kinds: number[]): NDKFilter => { + const f: NDKFilter = { kinds: kinds.map((k) => k as NDKKind), "#t": hybridTerms, limit }; + if (resolvedAuthors.length > 0) f.authors = resolvedAuthors; + if (parsed.mentions.length > 0) f["#p"] = parsed.mentions; + if (parsed.since) f.since = parsed.since; + if (parsed.until) f.until = parsed.until; + return f; + }; + if (hybridTerms.length > 0 && noteKinds.length > 0) { + fetches.push(fetchWithTimeout(instance, buildHybridFilter(noteKinds), FEED_TIMEOUT)); + } else { + fetches.push(Promise.resolve(new Set())); + } + if (hybridTerms.length > 0 && articleKinds.length > 0) { + fetches.push(fetchWithTimeout(instance, buildHybridFilter(articleKinds), FEED_TIMEOUT)); + } else { + fetches.push(Promise.resolve(new Set())); + } + + const [noteEvents, articleEvents, userEvents, hybridNoteEvents, hybridArticleEvents] = await Promise.all(fetches); + + // Merge and deduplicate results from multiple sources + const dedup = (...sources: Set[]): NDKEvent[] => { + const seen = new Set(); + const result: NDKEvent[] = []; + for (const source of sources) { + for (const e of source) { + if (e.id && !seen.has(e.id)) { seen.add(e.id); result.push(e); } + } + } + return result; + }; + + let notes = dedup(noteEvents, hybridNoteEvents); + let articles = dedup(articleEvents, hybridArticleEvents); const users = Array.from(userEvents); + // Client-side author filter — search relays may not intersect authors with search properly + if (resolvedAuthors.length > 0) { + const authorSet = new Set(resolvedAuthors); + notes = notes.filter((e) => authorSet.has(e.pubkey)); + articles = articles.filter((e) => authorSet.has(e.pubkey)); + } + // Client-side filters: has:image, has:video, has:code, etc. if (parsed.hasFilters.length > 0) { const applyHas = (events: NDKEvent[]) =>