mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 20:48:35 -07:00
Overhaul search with dedicated NIP-50 relays and hybrid results
Text search now queries relay.nostr.band and search.nos.today via a persistent NDK instance, while also running hashtag lookups on user relays. Results are merged and deduplicated. Hashtag searches now show all three tabs (notes, articles, people) instead of notes-only. Added loading spinner and clear-on-search for better UX.
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
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 { parseSearchQuery, describeSearch } from "../../lib/search";
|
||||||
import { getNip50Relays } from "../../lib/nostr/relayInfo";
|
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useMuteStore } from "../../stores/mute";
|
import { useMuteStore } from "../../stores/mute";
|
||||||
import { useDismissedSuggestionsStore } from "../../stores/dismissedSuggestions";
|
import { useDismissedSuggestionsStore } from "../../stores/dismissedSuggestions";
|
||||||
@@ -132,7 +131,6 @@ export function SearchView() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<"notes" | "people" | "articles">("notes");
|
const [activeTab, setActiveTab] = useState<"notes" | "people" | "articles">("notes");
|
||||||
const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||||
@@ -142,14 +140,6 @@ export function SearchView() {
|
|||||||
const [trending, setTrending] = useState<{ tag: string; count: number }[]>([]);
|
const [trending, setTrending] = useState<{ tag: string; count: number }[]>([]);
|
||||||
const [trendingLoading, setTrendingLoading] = useState(false);
|
const [trendingLoading, setTrendingLoading] = useState(false);
|
||||||
const [trendingLoaded, setTrendingLoaded] = 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
|
// Load trending hashtags on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trendingLoaded) return;
|
if (trendingLoaded) return;
|
||||||
@@ -215,6 +205,9 @@ export function SearchView() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
setSearchHint(null);
|
setSearchHint(null);
|
||||||
|
setNoteResults([]);
|
||||||
|
setArticleResults([]);
|
||||||
|
setUserResults([]);
|
||||||
try {
|
try {
|
||||||
const parsed = parseSearchQuery(q);
|
const parsed = parseSearchQuery(q);
|
||||||
const isAdvanced = parsed.authors.length > 0 || parsed.unresolvedNip05.length > 0 ||
|
const isAdvanced = parsed.authors.length > 0 || parsed.unresolvedNip05.length > 0 ||
|
||||||
@@ -241,18 +234,7 @@ export function SearchView() {
|
|||||||
if (e.key === "Enter") handleSearch();
|
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 totalResults = noteResults.length + userResults.length + articleResults.length;
|
||||||
const allRelays = getStoredRelayUrls();
|
|
||||||
const nip50Count = nip50Relays?.length ?? null;
|
|
||||||
const noNip50 = nip50Relays !== null && nip50Relays.length === 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
@@ -279,8 +261,8 @@ export function SearchView() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Tabs — shown once a search has been run (except for hashtag, which is notes-only) */}
|
{/* Tabs — shown once a search has been run */}
|
||||||
{searched && !isHashtag && (
|
{searched && (
|
||||||
<div className="border-b border-border flex shrink-0">
|
<div className="border-b border-border flex shrink-0">
|
||||||
{(["notes", "articles", "people"] as const).map((tab) => {
|
{(["notes", "articles", "people"] as const).map((tab) => {
|
||||||
const count = tab === "notes" ? noteResults.length : tab === "articles" ? articleResults.length : userResults.length;
|
const count = tab === "notes" ? noteResults.length : tab === "articles" ? articleResults.length : userResults.length;
|
||||||
@@ -311,6 +293,16 @@ export function SearchView() {
|
|||||||
{/* Results area */}
|
{/* Results area */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{loading && (
|
||||||
|
<div className="px-4 py-12 flex flex-col items-center gap-3">
|
||||||
|
<span className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-text-dim text-[12px]">
|
||||||
|
Searching relays for <span className="text-text font-medium">{query}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Idle / pre-search hint */}
|
{/* Idle / pre-search hint */}
|
||||||
{!searched && !loading && (
|
{!searched && !loading && (
|
||||||
<div className="px-4 py-6 space-y-4">
|
<div className="px-4 py-6 space-y-4">
|
||||||
@@ -318,23 +310,19 @@ export function SearchView() {
|
|||||||
<p className="text-text-dim text-[12px]">
|
<p className="text-text-dim text-[12px]">
|
||||||
Type a keyword, <span className="text-accent">#hashtag</span>, or use search modifiers.
|
Type a keyword, <span className="text-accent">#hashtag</span>, or use search modifiers.
|
||||||
</p>
|
</p>
|
||||||
{nip50Relays !== null && (
|
<p className="text-text-dim text-[11px] opacity-70">
|
||||||
<p className="text-text-dim text-[11px] opacity-70">
|
Full-text search uses dedicated search relays. Hashtag and keyword results are combined.
|
||||||
{nip50Count === 0
|
</p>
|
||||||
? "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>
|
</div>
|
||||||
|
|
||||||
{/* Search syntax help */}
|
{/* Search syntax help */}
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<h3 className="text-text-dim text-[10px] uppercase tracking-widest mb-2">Search modifiers</h3>
|
<h3 className="text-text-dim text-[10px] uppercase tracking-widest mb-2">Search modifiers</h3>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
||||||
<span className="text-accent font-mono">by:name</span>
|
|
||||||
<span className="text-text-dim">notes from author</span>
|
|
||||||
<span className="text-accent font-mono">by:user@domain</span>
|
<span className="text-accent font-mono">by:user@domain</span>
|
||||||
<span className="text-text-dim">NIP-05 author lookup</span>
|
<span className="text-text-dim">notes from NIP-05 author</span>
|
||||||
|
<span className="text-accent font-mono">by:npub1...</span>
|
||||||
|
<span className="text-text-dim">notes from pubkey</span>
|
||||||
<span className="text-accent font-mono">#bitcoin</span>
|
<span className="text-accent font-mono">#bitcoin</span>
|
||||||
<span className="text-text-dim">hashtag search</span>
|
<span className="text-text-dim">hashtag search</span>
|
||||||
<span className="text-accent font-mono">has:image</span>
|
<span className="text-accent font-mono">has:image</span>
|
||||||
@@ -434,42 +422,15 @@ export function SearchView() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Zero results for full-text search */}
|
{/* Zero results */}
|
||||||
{searched && totalResults === 0 && !isHashtag && (
|
{searched && totalResults === 0 && (
|
||||||
<div className="px-4 py-8 text-center space-y-3">
|
<div className="px-4 py-8 text-center space-y-3">
|
||||||
<p className="text-text-dim text-[12px]">
|
<p className="text-text-dim text-[12px]">
|
||||||
No results for <span className="text-text font-medium">{query}</span>.
|
No results for <span className="text-text font-medium">{query}</span>.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-text-dim text-[11px] opacity-70">
|
||||||
{/* Relay NIP-50 status */}
|
Try different keywords, a #hashtag, or check your relay connections.
|
||||||
{nip50Relays !== null && (
|
</p>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -477,9 +438,6 @@ export function SearchView() {
|
|||||||
{searched && activeTab === "people" && userResults.length === 0 && totalResults > 0 && (
|
{searched && activeTab === "people" && userResults.length === 0 && totalResults > 0 && (
|
||||||
<div className="px-4 py-6 text-center">
|
<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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -494,7 +452,7 @@ export function SearchView() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Notes results */}
|
{/* Notes results */}
|
||||||
{(activeTab === "notes" || isHashtag) && noteResults.map((event) => (
|
{activeTab === "notes" && noteResults.map((event) => (
|
||||||
<NoteCard key={event.id} event={event} />
|
<NoteCard key={event.id} event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { 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<void> | null = null;
|
||||||
|
|
||||||
|
async function getSearchNDK(): Promise<NDK> {
|
||||||
|
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<NDKEvent>();
|
||||||
|
|
||||||
|
/** Fetch events from the dedicated search relays with timeout. */
|
||||||
|
async function searchFetch(filter: NDKFilter, timeoutMs = FEED_TIMEOUT): Promise<Set<NDKEvent>> {
|
||||||
|
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<NDKEvent[]> {
|
export async function searchNotes(query: string, limit = 50): Promise<NDKEvent[]> {
|
||||||
const instance = getNDK();
|
const instance = getNDK();
|
||||||
const isHashtag = query.startsWith("#");
|
const isHashtag = query.startsWith("#");
|
||||||
const filter: NDKFilter & { search?: string } = isHashtag
|
|
||||||
? { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit }
|
if (isHashtag) {
|
||||||
: { kinds: [NDKKind.Text], search: query, limit };
|
const filter: NDKFilter = { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit };
|
||||||
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
|
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
|
||||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
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<string>();
|
||||||
|
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<NDKEvent[]> {
|
export async function searchUsers(query: string, limit = 20): Promise<NDKEvent[]> {
|
||||||
const instance = getNDK();
|
|
||||||
const filter: NDKFilter & { search?: string } = {
|
const filter: NDKFilter & { search?: string } = {
|
||||||
kinds: [NDKKind.Metadata],
|
kinds: [NDKKind.Metadata],
|
||||||
search: query,
|
search: query,
|
||||||
limit,
|
limit,
|
||||||
};
|
};
|
||||||
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
|
const events = await searchFetch(filter);
|
||||||
return Array.from(events);
|
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];
|
const resolvedAuthors = [...parsed.authors];
|
||||||
for (const nip05 of parsed.unresolvedNip05) {
|
for (const nip05 of parsed.unresolvedNip05) {
|
||||||
const resolved = await resolveNip05(nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`);
|
const identifier = nip05.includes("@") || nip05.includes(".") ? nip05 : `_@${nip05}`;
|
||||||
if (resolved) {
|
const resolved = await resolveNip05(identifier);
|
||||||
resolvedAuthors.push(resolved);
|
if (resolved) resolvedAuthors.push(resolved);
|
||||||
} else {
|
|
||||||
const nameResults = await searchUsers(nip05, 1);
|
|
||||||
if (nameResults.length > 0) {
|
|
||||||
resolvedAuthors.push(nameResults[0].pubkey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which kinds to search
|
// 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 noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null;
|
||||||
const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : 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([
|
const usesNip50 = hasSearch && !hasHashtags;
|
||||||
noteFilter ? fetchWithTimeout(instance, noteFilter, FEED_TIMEOUT) : Promise.resolve(new Set<NDKEvent>()),
|
|
||||||
articleFilter ? fetchWithTimeout(instance, articleFilter, FEED_TIMEOUT) : Promise.resolve(new Set<NDKEvent>()),
|
|
||||||
shouldSearchUsers ? fetchWithTimeout(instance, { kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, FEED_TIMEOUT) : Promise.resolve(new Set<NDKEvent>()),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let notes = Array.from(noteEvents);
|
// Build parallel fetch promises
|
||||||
let articles = Array.from(articleEvents);
|
const fetches: Promise<Set<NDKEvent>>[] = [];
|
||||||
|
|
||||||
|
// 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<NDKEvent>()));
|
||||||
|
// Articles: same logic
|
||||||
|
fetches.push(articleFilter ? (usesNip50 ? searchFetch(articleFilter) : fetchWithTimeout(instance, articleFilter, FEED_TIMEOUT)) : Promise.resolve(new Set<NDKEvent>()));
|
||||||
|
// 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<NDKEvent>()));
|
||||||
|
|
||||||
|
// 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<NDKEvent>()));
|
||||||
|
}
|
||||||
|
if (hybridTerms.length > 0 && articleKinds.length > 0) {
|
||||||
|
fetches.push(fetchWithTimeout(instance, buildHybridFilter(articleKinds), FEED_TIMEOUT));
|
||||||
|
} else {
|
||||||
|
fetches.push(Promise.resolve(new Set<NDKEvent>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [noteEvents, articleEvents, userEvents, hybridNoteEvents, hybridArticleEvents] = await Promise.all(fetches);
|
||||||
|
|
||||||
|
// Merge and deduplicate results from multiple sources
|
||||||
|
const dedup = (...sources: Set<NDKEvent>[]): NDKEvent[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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);
|
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.
|
// Client-side filters: has:image, has:video, has:code, etc.
|
||||||
if (parsed.hasFilters.length > 0) {
|
if (parsed.hasFilters.length > 0) {
|
||||||
const applyHas = (events: NDKEvent[]) =>
|
const applyHas = (events: NDKEvent[]) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user