mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Search improvements — NIP-50 relay detection + smarter zero-results (roadmap #12)
- 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 #<query>" 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors">
|
||||
<div
|
||||
className="shrink-0 cursor-pointer"
|
||||
onClick={() => navToProfile(user.pubkey)}
|
||||
>
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => navToProfile(user.pubkey)}>
|
||||
{user.picture ? (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<img src={user.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{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<string[] | null>(null); // null = not checked yet
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
{/* Search bar */}
|
||||
<header className="border-b border-border px-4 py-2.5 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -157,56 +170,105 @@ export function SearchView() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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 && (
|
||||
<div className="border-b border-border flex shrink-0">
|
||||
{(["notes", "people"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-[11px] border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{tab === "notes" ? `notes (${noteResults.length})` : `people (${userResults.length})`}
|
||||
</button>
|
||||
))}
|
||||
{(["notes", "people"] as const).map((tab) => {
|
||||
const count = tab === "notes" ? noteResults.length : userResults.length;
|
||||
return (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-[11px] border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? "border-accent text-accent"
|
||||
: "border-transparent text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{tab} {count > 0 ? `(${count})` : ""}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{/* Results area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Empty / idle state */}
|
||||
|
||||
{/* Idle / pre-search hint */}
|
||||
{!searched && !loading && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
<p>Search notes with NIP-50 full-text, or use <span className="text-accent">#hashtag</span> to browse topics.</p>
|
||||
<p className="mt-1 text-[11px] opacity-60">NIP-50 requires relay support — results vary by relay.</p>
|
||||
<div className="px-4 py-8 text-center space-y-2">
|
||||
<p className="text-text-dim text-[12px]">
|
||||
Use <span className="text-accent">#hashtag</span> to browse topics, or type a keyword for full-text search.
|
||||
</p>
|
||||
{nip50Relays !== null && (
|
||||
<p className="text-text-dim text-[11px] opacity-70">
|
||||
{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.`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searched && totalResults === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
No results for <span className="text-text">{query}</span>.
|
||||
{!isHashtag && <p className="mt-1 text-[11px] opacity-60">Your relays may not support NIP-50 full-text search.</p>}
|
||||
{/* Zero results for full-text search */}
|
||||
{searched && totalResults === 0 && !isHashtag && (
|
||||
<div className="px-4 py-8 text-center space-y-3">
|
||||
<p className="text-text-dim text-[12px]">
|
||||
No results for <span className="text-text font-medium">{query}</span>.
|
||||
</p>
|
||||
|
||||
{/* Relay NIP-50 status */}
|
||||
{nip50Relays !== null && (
|
||||
<p className="text-text-dim text-[11px]">
|
||||
{noNip50
|
||||
? "None of your relays support full-text search."
|
||||
: `${nip50Count} of ${allRelays.length} relay${allRelays.length !== 1 ? "s" : ""} support full-text search.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hashtag suggestion */}
|
||||
{!query.startsWith("#") && (
|
||||
<div>
|
||||
<p className="text-text-dim text-[11px] mb-2">Try a hashtag search instead:</p>
|
||||
<button
|
||||
onClick={tryAsHashtag}
|
||||
className="px-3 py-1.5 text-[12px] border border-accent/50 text-accent hover:bg-accent hover:text-white transition-colors"
|
||||
>
|
||||
Search #{query.replace(/^#+/, "")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* People tab */}
|
||||
{/* Zero results for hashtag search */}
|
||||
{searched && totalResults === 0 && isHashtag && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-text-dim text-[12px]">No notes found for <span className="text-text">{query}</span>.</p>
|
||||
<p className="text-text-dim text-[11px] mt-1 opacity-70">Try a different hashtag or check your relay connections.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* People tab — zero results hint */}
|
||||
{searched && activeTab === "people" && userResults.length === 0 && totalResults > 0 && (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<p className="text-text-dim text-[12px]">No people found for <span className="text-text">{query}</span>.</p>
|
||||
{noNip50 && (
|
||||
<p className="text-text-dim text-[11px] mt-1 opacity-70">People search requires NIP-50 relay support.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* People results */}
|
||||
{activeTab === "people" && userResults.map((user) => (
|
||||
<UserRow key={user.pubkey} user={user} />
|
||||
))}
|
||||
|
||||
{/* Notes tab (or all notes for hashtag search) */}
|
||||
{/* Notes results */}
|
||||
{(activeTab === "notes" || isHashtag) && noteResults.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
|
||||
{/* People inline when only people results */}
|
||||
{searched && noteResults.length === 0 && userResults.length > 0 && activeTab === "notes" && (
|
||||
userResults.map((user) => <UserRow key={user.pubkey} user={user} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
34
src/lib/nostr/relayInfo.ts
Normal file
34
src/lib/nostr/relayInfo.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/** Per-session cache of relay NIP-50 support. */
|
||||
const nip50Cache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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<string[]> {
|
||||
const results = await Promise.all(
|
||||
relayUrls.map(async (url) => ({ url, ok: await checkNip50Support(url) }))
|
||||
);
|
||||
return results.filter((r) => r.ok).map((r) => r.url);
|
||||
}
|
||||
Reference in New Issue
Block a user