From d72dfe56d850ca989cb44f4b4cfeeca6b17deea6 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:21:22 +0100 Subject: [PATCH] =?UTF-8?q?Search=20improvements=20=E2=80=94=20NIP-50=20re?= =?UTF-8?q?lay=20detection=20+=20smarter=20zero-results=20(roadmap=20#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relayInfo.ts: checkNip50Support fetches NIP-11 relay info (Accept: application/nostr+json) and checks supported_nips for 50; results cached per session with 4s timeout; getNip50Relays checks all relays in parallel - SearchView: relay NIP-50 support checked on mount in the background and shown as context in the idle state ("N of M relays support full-text search") and in zero-results states - Zero results for full-text search now shows: - relay NIP-50 count (or "none of your relays support full-text") - prominent "Search #" one-click button to retry as hashtag - Tabs (notes / people) now always rendered after any search, not only when both result types are non-empty — makes the UI consistent - Per-tab zero-results explanations: people tab explains NIP-50 requirement when no relay supports it Co-Authored-By: Claude Sonnet 4.6 --- src/components/search/SearchView.tsx | 156 +++++++++++++++++++-------- src/lib/nostr/relayInfo.ts | 34 ++++++ 2 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 src/lib/nostr/relayInfo.ts diff --git a/src/components/search/SearchView.tsx b/src/components/search/SearchView.tsx index 0113280..9b12095 100644 --- a/src/components/search/SearchView.tsx +++ b/src/components/search/SearchView.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -import { searchNotes, searchUsers } from "../../lib/nostr"; +import { searchNotes, searchUsers, getStoredRelayUrls } from "../../lib/nostr"; +import { getNip50Relays } from "../../lib/nostr/relayInfo"; import { useUserStore } from "../../stores/user"; import { useUIStore } from "../../stores/ui"; import { shortenPubkey } from "../../lib/utils"; @@ -48,17 +49,10 @@ function UserRow({ user }: { user: ParsedUser }) { return (
-
navToProfile(user.pubkey)} - > +
navToProfile(user.pubkey)}> {user.picture ? ( - { (e.target as HTMLImageElement).style.display = "none"; }} - /> + { (e.target as HTMLImageElement).style.display = "none"; }} /> ) : (
{displayName.charAt(0).toUpperCase()} @@ -95,11 +89,18 @@ export function SearchView() { 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 isHashtag = query.trim().startsWith("#"); - // If opened with a pending search query (e.g. from a hashtag click), run it immediately + // Check relay NIP-50 support once on mount (background, non-blocking) + useEffect(() => { + const urls = getStoredRelayUrls(); + getNip50Relays(urls).then(setNip50Relays); + }, []); + + // Run pending search from hashtag/mention click useEffect(() => { if (pendingSearch) { useUIStore.setState({ pendingSearch: null }); @@ -115,9 +116,10 @@ export function SearchView() { setSearched(false); try { const isTag = q.startsWith("#"); - const notesPromise = searchNotes(q); - const usersPromise = isTag ? Promise.resolve([]) : searchUsers(q); - const [notes, userEvents] = await Promise.all([notesPromise, usersPromise]); + 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"); @@ -131,11 +133,22 @@ 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; + const allRelays = getStoredRelayUrls(); + const nip50Count = nip50Relays?.length ?? null; + const noNip50 = nip50Relays !== null && nip50Relays.length === 0; return (
- {/* Header */} + {/* Search bar */}
- {/* Tabs (only when we have both note and people results) */} - {searched && !isHashtag && noteResults.length > 0 && userResults.length > 0 && ( + {/* Tabs — shown once a search has been run (except for hashtag, which is notes-only) */} + {searched && !isHashtag && (
- {(["notes", "people"] as const).map((tab) => ( - - ))} + {(["notes", "people"] as const).map((tab) => { + const count = tab === "notes" ? noteResults.length : userResults.length; + return ( + + ); + })}
)} - {/* Results */} + {/* Results area */}
- {/* Empty / idle state */} + + {/* Idle / pre-search hint */} {!searched && !loading && ( -
-

Search notes with NIP-50 full-text, or use #hashtag to browse topics.

-

NIP-50 requires relay support — results vary by relay.

+
+

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

+ )}
)} - {searched && totalResults === 0 && ( -
- No results for {query}. - {!isHashtag &&

Your relays may not support NIP-50 full-text search.

} + {/* 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:

+ +
+ )}
)} - {/* People tab */} + {/* 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 tab (or all notes for hashtag search) */} + {/* Notes results */} {(activeTab === "notes" || isHashtag) && noteResults.map((event) => ( ))} - - {/* People inline when only people results */} - {searched && noteResults.length === 0 && userResults.length > 0 && activeTab === "notes" && ( - userResults.map((user) => ) - )}
); diff --git a/src/lib/nostr/relayInfo.ts b/src/lib/nostr/relayInfo.ts new file mode 100644 index 0000000..5b4768c --- /dev/null +++ b/src/lib/nostr/relayInfo.ts @@ -0,0 +1,34 @@ +/** Per-session cache of relay NIP-50 support. */ +const nip50Cache = new Map(); + +/** + * Fetch a relay's NIP-11 info and return whether it supports NIP-50 (full-text search). + * Results are cached for the session. Times out after 4 s. + */ +export async function checkNip50Support(relayWssUrl: string): Promise { + if (nip50Cache.has(relayWssUrl)) return nip50Cache.get(relayWssUrl)!; + + const httpUrl = relayWssUrl.replace(/^wss?:\/\//, "https://"); + try { + const resp = await fetch(httpUrl, { + headers: { Accept: "application/nostr+json" }, + signal: AbortSignal.timeout(4000), + }); + const info = await resp.json(); + const supported = + Array.isArray(info.supported_nips) && (info.supported_nips as number[]).includes(50); + nip50Cache.set(relayWssUrl, supported); + return supported; + } catch { + nip50Cache.set(relayWssUrl, false); + return false; + } +} + +/** Check all provided relay URLs in parallel; return those that support NIP-50. */ +export async function getNip50Relays(relayUrls: string[]): Promise { + const results = await Promise.all( + relayUrls.map(async (url) => ({ url, ok: await checkNip50Support(url) })) + ); + return results.filter((r) => r.ok).map((r) => r.url); +}