You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1039 lines
32 KiB
1039 lines
32 KiB
// deno-lint-ignore-file no-explicit-any |
|
import { ndkInstance } from "../ndk.ts"; |
|
import { getMatchingTags, getNpubFromNip05 } from "./nostrUtils.ts"; |
|
import { nip19 } from "./nostrUtils.ts"; |
|
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; |
|
import { searchCache } from "./searchCache.ts"; |
|
import { communityRelays, searchRelays } from "../consts.ts"; |
|
import { get } from "svelte/store"; |
|
import type { |
|
SearchResult, |
|
SearchSubscriptionType, |
|
SearchFilter, |
|
SearchCallbacks, |
|
} from "./search_types.ts"; |
|
import { |
|
fieldMatches, |
|
nip05Matches, |
|
COMMON_DOMAINS, |
|
isEmojiReaction, |
|
} from "./search_utils.ts"; |
|
import { TIMEOUTS, SEARCH_LIMITS } from "./search_constants.ts"; |
|
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; |
|
|
|
// Helper function to normalize URLs for comparison |
|
const normalizeUrl = (url: string): string => { |
|
return url.replace(/\/$/, ''); // Remove trailing slash |
|
}; |
|
|
|
/** |
|
* Filter out unwanted events from search results |
|
* @param events Array of NDKEvent to filter |
|
* @returns Filtered array of NDKEvent |
|
*/ |
|
function filterUnwantedEvents(events: NDKEvent[]): NDKEvent[] { |
|
return events.filter( |
|
(event) => !isEmojiReaction(event) && event.kind !== 3 && event.kind !== 5, |
|
); |
|
} |
|
|
|
/** |
|
* Search for events by subscription type (d, t, n) |
|
*/ |
|
export async function searchBySubscription( |
|
searchType: SearchSubscriptionType, |
|
searchTerm: string, |
|
callbacks?: SearchCallbacks, |
|
abortSignal?: AbortSignal, |
|
): Promise<SearchResult> { |
|
const startTime = Date.now(); // AI-NOTE: 2025-01-08 - Track search performance |
|
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); |
|
|
|
console.log("subscription_search: Starting search:", { |
|
searchType, |
|
searchTerm, |
|
normalizedSearchTerm, |
|
}); |
|
|
|
// Check cache first |
|
const cachedResult = searchCache.get(searchType, normalizedSearchTerm); |
|
if (cachedResult) { |
|
console.log("subscription_search: Found cached result:", cachedResult); |
|
// AI-NOTE: 2025-01-24 - For profile searches, return cached results immediately |
|
// The EventSearch component now handles cache checking before calling this function |
|
if (searchType === "n") { |
|
console.log("subscription_search: Returning cached profile result immediately"); |
|
return cachedResult; |
|
} else { |
|
return cachedResult; |
|
} |
|
} |
|
|
|
const ndk = get(ndkInstance); |
|
if (!ndk) { |
|
console.error("subscription_search: NDK not initialized"); |
|
throw new Error("NDK not initialized"); |
|
} |
|
|
|
console.log("subscription_search: NDK initialized, creating search state"); |
|
const searchState = createSearchState(); |
|
const cleanup = createCleanupFunction(searchState); |
|
|
|
// Set a timeout to force completion after subscription search timeout |
|
searchState.timeoutId = setTimeout(() => { |
|
console.log("subscription_search: Search timeout reached"); |
|
cleanup(); |
|
}, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-24 - Use standard timeout since cache is checked first |
|
|
|
// Check for abort signal |
|
if (abortSignal?.aborted) { |
|
console.log("subscription_search: Search aborted"); |
|
cleanup(); |
|
throw new Error("Search cancelled"); |
|
} |
|
|
|
const searchFilter = await createSearchFilter( |
|
searchType, |
|
normalizedSearchTerm, |
|
); |
|
console.log("subscription_search: Created search filter:", searchFilter); |
|
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk); |
|
console.log( |
|
"subscription_search: Created primary relay set with", |
|
primaryRelaySet.relays.size, |
|
"relays", |
|
); |
|
|
|
// Phase 1: Search primary relay |
|
if (primaryRelaySet.relays.size > 0) { |
|
try { |
|
console.log( |
|
"subscription_search: Searching primary relay with filter:", |
|
searchFilter.filter, |
|
); |
|
const primaryEvents = await ndk.fetchEvents( |
|
searchFilter.filter, |
|
{ closeOnEose: true }, |
|
primaryRelaySet, |
|
); |
|
|
|
console.log( |
|
"subscription_search: Primary relay returned", |
|
primaryEvents.size, |
|
"events", |
|
); |
|
processPrimaryRelayResults( |
|
primaryEvents, |
|
searchType, |
|
searchFilter.subscriptionType, |
|
normalizedSearchTerm, |
|
searchState, |
|
abortSignal, |
|
cleanup, |
|
); |
|
|
|
// If we found results from primary relay, return them immediately |
|
if (hasResults(searchState, searchType)) { |
|
console.log( |
|
"subscription_search: Found results from primary relay, returning immediately", |
|
); |
|
const immediateResult = createSearchResult( |
|
searchState, |
|
searchType, |
|
normalizedSearchTerm, |
|
); |
|
searchCache.set(searchType, normalizedSearchTerm, immediateResult); |
|
|
|
// AI-NOTE: 2025-01-08 - For profile searches, return immediately when found |
|
// but still start background search for second-order results |
|
if (searchType === "n") { |
|
console.log("subscription_search: Profile found, returning immediately but starting background second-order search"); |
|
|
|
// Start Phase 2 in background for second-order results |
|
searchOtherRelaysInBackground( |
|
searchType, |
|
searchFilter, |
|
searchState, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
const elapsed = Date.now() - startTime; |
|
console.log(`subscription_search: Profile search completed in ${elapsed}ms`); |
|
return immediateResult; |
|
} |
|
|
|
// Start Phase 2 in background for additional results (only for non-profile searches) |
|
searchOtherRelaysInBackground( |
|
searchType, |
|
searchFilter, |
|
searchState, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
return immediateResult; |
|
} else { |
|
console.log( |
|
"subscription_search: No results from primary relay", |
|
); |
|
|
|
// AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays, |
|
// try all relays as fallback |
|
if (searchType === "n") { |
|
console.log( |
|
"subscription_search: No profile found in search relays, trying all relays", |
|
); |
|
// Try with all relays as fallback |
|
const allRelaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())) as any, ndk); |
|
try { |
|
const fallbackEvents = await ndk.fetchEvents( |
|
searchFilter.filter, |
|
{ closeOnEose: true }, |
|
allRelaySet, |
|
); |
|
|
|
console.log( |
|
"subscription_search: Fallback search returned", |
|
fallbackEvents.size, |
|
"events", |
|
); |
|
|
|
processPrimaryRelayResults( |
|
fallbackEvents, |
|
searchType, |
|
searchFilter.subscriptionType, |
|
normalizedSearchTerm, |
|
searchState, |
|
abortSignal, |
|
cleanup, |
|
); |
|
|
|
if (hasResults(searchState, searchType)) { |
|
console.log( |
|
"subscription_search: Found profile in fallback search, returning immediately", |
|
); |
|
const fallbackResult = createSearchResult( |
|
searchState, |
|
searchType, |
|
normalizedSearchTerm, |
|
); |
|
searchCache.set(searchType, normalizedSearchTerm, fallbackResult); |
|
const elapsed = Date.now() - startTime; |
|
console.log(`subscription_search: Profile search completed in ${elapsed}ms (fallback)`); |
|
return fallbackResult; |
|
} |
|
} catch (fallbackError) { |
|
console.error("subscription_search: Fallback search failed:", fallbackError); |
|
} |
|
|
|
console.log( |
|
"subscription_search: Profile not found in any relays, returning empty result", |
|
); |
|
const emptyResult = createEmptySearchResult(searchType, normalizedSearchTerm); |
|
// AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues |
|
// rather than the profile not existing |
|
const elapsed = Date.now() - startTime; |
|
console.log(`subscription_search: Profile search completed in ${elapsed}ms (not found)`); |
|
return emptyResult; |
|
} else { |
|
console.log( |
|
"subscription_search: No results from primary relay, continuing to Phase 2", |
|
); |
|
} |
|
} |
|
} catch (error) { |
|
console.error( |
|
`subscription_search: Error searching primary relay:`, |
|
error, |
|
); |
|
} |
|
} else { |
|
console.log( |
|
"subscription_search: No primary relays available, skipping Phase 1", |
|
); |
|
} |
|
|
|
// Always do Phase 2: Search all other relays in parallel |
|
const result = await searchOtherRelaysInBackground( |
|
searchType, |
|
searchFilter, |
|
searchState, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
// AI-NOTE: 2025-01-08 - Log performance for non-profile searches |
|
if (searchType !== "n") { |
|
const elapsed = Date.now() - startTime; |
|
console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
/** |
|
* Create search state object |
|
*/ |
|
function createSearchState() { |
|
return { |
|
timeoutId: null as ReturnType<typeof setTimeout> | null, |
|
firstOrderEvents: [] as NDKEvent[], |
|
secondOrderEvents: [] as NDKEvent[], |
|
tTagEvents: [] as NDKEvent[], |
|
eventIds: new Set<string>(), |
|
eventAddresses: new Set<string>(), |
|
foundProfiles: [] as NDKEvent[], |
|
isCompleted: false, |
|
currentSubscription: null as any, |
|
}; |
|
} |
|
|
|
/** |
|
* Create cleanup function |
|
*/ |
|
function createCleanupFunction(searchState: any) { |
|
return () => { |
|
if (searchState.timeoutId) { |
|
clearTimeout(searchState.timeoutId); |
|
searchState.timeoutId = null; |
|
} |
|
if (searchState.currentSubscription) { |
|
try { |
|
searchState.currentSubscription.stop(); |
|
} catch (e) { |
|
console.warn("Error stopping subscription:", e); |
|
} |
|
searchState.currentSubscription = null; |
|
} |
|
}; |
|
} |
|
|
|
/** |
|
* Create search filter based on search type |
|
*/ |
|
async function createSearchFilter( |
|
searchType: SearchSubscriptionType, |
|
normalizedSearchTerm: string, |
|
): Promise<SearchFilter> { |
|
console.log("subscription_search: Creating search filter for:", { |
|
searchType, |
|
normalizedSearchTerm, |
|
}); |
|
|
|
switch (searchType) { |
|
case "d": { |
|
const dFilter = { |
|
filter: { "#d": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT }, |
|
subscriptionType: "d-tag", |
|
}; |
|
console.log("subscription_search: Created d-tag filter:", dFilter); |
|
return dFilter; |
|
} |
|
case "t": { |
|
const tFilter = { |
|
filter: { "#t": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT }, |
|
subscriptionType: "t-tag", |
|
}; |
|
console.log("subscription_search: Created t-tag filter:", tFilter); |
|
return tFilter; |
|
} |
|
case "n": { |
|
const nFilter = await createProfileSearchFilter(normalizedSearchTerm); |
|
console.log("subscription_search: Created profile filter:", nFilter); |
|
return nFilter; |
|
} |
|
default: { |
|
throw new Error(`Unknown search type: ${searchType}`); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Create profile search filter |
|
*/ |
|
async function createProfileSearchFilter( |
|
normalizedSearchTerm: string, |
|
): Promise<SearchFilter> { |
|
// For npub searches, try to decode the search term first |
|
try { |
|
const decoded = nip19.decode(normalizedSearchTerm); |
|
if (decoded && decoded.type === "npub") { |
|
return { |
|
filter: { |
|
kinds: [0], |
|
authors: [decoded.data], |
|
limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search |
|
}, |
|
subscriptionType: "npub-specific", |
|
}; |
|
} |
|
} catch { |
|
// Not a valid npub, continue with other strategies |
|
} |
|
|
|
// Try NIP-05 lookup first |
|
try { |
|
for (const domain of COMMON_DOMAINS) { |
|
const nip05Address = `${normalizedSearchTerm}@${domain}`; |
|
try { |
|
const npub = await getNpubFromNip05(nip05Address); |
|
if (npub) { |
|
return { |
|
filter: { |
|
kinds: [0], |
|
authors: [npub], |
|
limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search |
|
}, |
|
subscriptionType: "nip05-found", |
|
}; |
|
} |
|
} catch { |
|
// Continue to next domain |
|
} |
|
} |
|
} catch { |
|
// Fallback to reasonable profile search |
|
} |
|
|
|
return { |
|
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE }, |
|
subscriptionType: "profile", |
|
}; |
|
} |
|
|
|
/** |
|
* Create primary relay set for search operations |
|
* AI-NOTE: 2025-01-24 - Updated to use all available relays to prevent search failures |
|
*/ |
|
function createPrimaryRelaySet( |
|
searchType: SearchSubscriptionType, |
|
ndk: any, |
|
): NDKRelaySet { |
|
// Debug: Log all relays in NDK pool |
|
const poolRelays = Array.from(ndk.pool.relays.values()); |
|
console.debug('subscription_search: NDK pool relays:', poolRelays.map((r: any) => r.url)); |
|
|
|
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage |
|
// This ensures searches don't fail due to missing relays and provides maximum event discovery |
|
|
|
if (searchType === "n") { |
|
// For profile searches, prioritize search relays for speed but include all relays |
|
const searchRelaySet = poolRelays.filter( |
|
(relay: any) => |
|
searchRelays.some( |
|
(searchRelay: string) => |
|
normalizeUrl(relay.url) === normalizeUrl(searchRelay), |
|
), |
|
); |
|
|
|
if (searchRelaySet.length > 0) { |
|
console.debug('subscription_search: Profile search - using search relays for speed:', searchRelaySet.map((r: any) => r.url)); |
|
// Still include all relays for comprehensive coverage |
|
console.debug('subscription_search: Profile search - also including all relays for comprehensive coverage'); |
|
return new NDKRelaySet(new Set(poolRelays) as any, ndk); |
|
} else { |
|
// Use all relays if search relays not available |
|
console.debug('subscription_search: Profile search - using all relays:', poolRelays.map((r: any) => r.url)); |
|
return new NDKRelaySet(new Set(poolRelays) as any, ndk); |
|
} |
|
} else { |
|
// For all other searches, use ALL available relays for maximum coverage |
|
const activeRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; |
|
console.debug('subscription_search: Active relay stores:', { |
|
inboxRelays: get(activeInboxRelays), |
|
outboxRelays: get(activeOutboxRelays), |
|
activeRelays |
|
}); |
|
|
|
// AI-NOTE: 2025-01-24 - Use all pool relays instead of filtering to active relays only |
|
// This ensures we don't miss events that might be on other relays |
|
console.debug('subscription_search: Using ALL pool relays for comprehensive search coverage:', poolRelays.map((r: any) => r.url)); |
|
return new NDKRelaySet(new Set(poolRelays) as any, ndk); |
|
} |
|
} |
|
|
|
/** |
|
* Process primary relay results |
|
*/ |
|
function processPrimaryRelayResults( |
|
events: Set<NDKEvent>, |
|
searchType: SearchSubscriptionType, |
|
subscriptionType: string, |
|
normalizedSearchTerm: string, |
|
searchState: any, |
|
abortSignal?: AbortSignal, |
|
cleanup?: () => void, |
|
) { |
|
console.log( |
|
"subscription_search: Processing", |
|
events.size, |
|
"events from primary relay", |
|
); |
|
|
|
for (const event of events) { |
|
// Check for abort signal |
|
if (abortSignal?.aborted) { |
|
cleanup?.(); |
|
throw new Error("Search cancelled"); |
|
} |
|
|
|
try { |
|
if (searchType === "n") { |
|
processProfileEvent( |
|
event, |
|
subscriptionType, |
|
normalizedSearchTerm, |
|
searchState, |
|
); |
|
} else { |
|
processContentEvent(event, searchType, searchState); |
|
} |
|
} catch (e) { |
|
console.warn("subscription_search: Error processing event:", e); |
|
// Invalid JSON or other error, skip |
|
} |
|
} |
|
|
|
console.log( |
|
"subscription_search: Processed events - firstOrder:", |
|
searchState.firstOrderEvents.length, |
|
"profiles:", |
|
searchState.foundProfiles.length, |
|
"tTag:", |
|
searchState.tTagEvents.length, |
|
); |
|
} |
|
|
|
/** |
|
* Process profile event |
|
*/ |
|
function processProfileEvent( |
|
event: NDKEvent, |
|
subscriptionType: string, |
|
normalizedSearchTerm: string, |
|
searchState: any, |
|
) { |
|
if (!event.content) return; |
|
|
|
// If this is a specific npub search or NIP-05 found search, include all matching events |
|
if ( |
|
subscriptionType === "npub-specific" || |
|
subscriptionType === "nip05-found" |
|
) { |
|
searchState.foundProfiles.push(event); |
|
return; |
|
} |
|
|
|
// For general profile searches, filter by content |
|
const profileData = JSON.parse(event.content); |
|
const displayName = profileData.display_name || profileData.displayName || ""; |
|
const name = profileData.name || ""; |
|
const nip05 = profileData.nip05 || ""; |
|
const username = profileData.username || ""; |
|
const about = profileData.about || ""; |
|
const bio = profileData.bio || ""; |
|
const description = profileData.description || ""; |
|
|
|
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm); |
|
const matchesName = fieldMatches(name, normalizedSearchTerm); |
|
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm); |
|
const matchesUsername = fieldMatches(username, normalizedSearchTerm); |
|
const matchesAbout = fieldMatches(about, normalizedSearchTerm); |
|
const matchesBio = fieldMatches(bio, normalizedSearchTerm); |
|
const matchesDescription = fieldMatches(description, normalizedSearchTerm); |
|
|
|
if ( |
|
matchesDisplayName || |
|
matchesName || |
|
matchesNip05 || |
|
matchesUsername || |
|
matchesAbout || |
|
matchesBio || |
|
matchesDescription |
|
) { |
|
searchState.foundProfiles.push(event); |
|
} |
|
} |
|
|
|
/** |
|
* Process content event |
|
*/ |
|
function processContentEvent( |
|
event: NDKEvent, |
|
searchType: SearchSubscriptionType, |
|
searchState: any, |
|
) { |
|
if (isEmojiReaction(event)) return; // Skip emoji reactions |
|
|
|
if (searchType === "d") { |
|
console.log("subscription_search: Processing d-tag event:", { |
|
id: event.id, |
|
kind: event.kind, |
|
pubkey: event.pubkey, |
|
}); |
|
searchState.firstOrderEvents.push(event); |
|
|
|
// Collect event IDs and addresses for second-order search |
|
if (event.id) { |
|
searchState.eventIds.add(event.id); |
|
} |
|
// Handle both "a" tags (NIP-62) and "e" tags (legacy) |
|
let tags = getMatchingTags(event, "a"); |
|
if (tags.length === 0) { |
|
tags = getMatchingTags(event, "e"); |
|
} |
|
|
|
tags.forEach((tag: string[]) => { |
|
if (tag[1]) { |
|
searchState.eventAddresses.add(tag[1]); |
|
} |
|
}); |
|
} else if (searchType === "t") { |
|
searchState.tTagEvents.push(event); |
|
} |
|
} |
|
|
|
/** |
|
* Check if search state has results |
|
*/ |
|
function hasResults( |
|
searchState: any, |
|
searchType: SearchSubscriptionType, |
|
): boolean { |
|
if (searchType === "n") { |
|
return searchState.foundProfiles.length > 0; |
|
} else if (searchType === "d") { |
|
return searchState.firstOrderEvents.length > 0; |
|
} else if (searchType === "t") { |
|
return searchState.tTagEvents.length > 0; |
|
} |
|
return false; |
|
} |
|
|
|
/** |
|
* Create search result from state |
|
*/ |
|
function createSearchResult( |
|
searchState: any, |
|
searchType: SearchSubscriptionType, |
|
normalizedSearchTerm: string, |
|
): SearchResult { |
|
return { |
|
events: |
|
searchType === "n" |
|
? searchState.foundProfiles |
|
: searchType === "t" |
|
? searchState.tTagEvents |
|
: searchState.firstOrderEvents, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: searchState.eventIds, |
|
addresses: searchState.eventAddresses, |
|
searchType: searchType, |
|
searchTerm: normalizedSearchTerm, |
|
}; |
|
} |
|
|
|
/** |
|
* Search other relays in background |
|
*/ |
|
function searchOtherRelaysInBackground( |
|
searchType: SearchSubscriptionType, |
|
searchFilter: SearchFilter, |
|
searchState: any, |
|
callbacks?: SearchCallbacks, |
|
cleanup?: () => void, |
|
): Promise<SearchResult> { |
|
const ndk = get(ndkInstance); |
|
|
|
// AI-NOTE: 2025-01-24 - Use ALL available relays for comprehensive search coverage |
|
// This ensures we don't miss events that might be on any available relay |
|
const otherRelays = new NDKRelaySet( |
|
new Set(Array.from(ndk.pool.relays.values())), |
|
ndk, |
|
); |
|
|
|
console.debug('subscription_search: Background search using ALL relays:', |
|
Array.from(ndk.pool.relays.values()).map((r: any) => r.url)); |
|
|
|
// Subscribe to events from other relays |
|
const sub = ndk.subscribe( |
|
searchFilter.filter, |
|
{ closeOnEose: true }, |
|
otherRelays, |
|
); |
|
|
|
// Store the subscription for cleanup |
|
searchState.currentSubscription = sub; |
|
|
|
// Notify the component about the subscription for cleanup |
|
if (callbacks?.onSubscriptionCreated) { |
|
callbacks.onSubscriptionCreated(sub); |
|
} |
|
|
|
sub.on("event", (event: NDKEvent) => { |
|
try { |
|
if (searchType === "n") { |
|
processProfileEvent( |
|
event, |
|
searchFilter.subscriptionType, |
|
searchState.normalizedSearchTerm, |
|
searchState, |
|
); |
|
} else { |
|
processContentEvent(event, searchType, searchState); |
|
} |
|
} catch { |
|
// Invalid JSON or other error, skip |
|
} |
|
}); |
|
|
|
return new Promise<SearchResult>((resolve) => { |
|
sub.on("eose", () => { |
|
const result = processEoseResults( |
|
searchType, |
|
searchState, |
|
searchFilter, |
|
callbacks, |
|
); |
|
searchCache.set(searchType, searchState.normalizedSearchTerm, result); |
|
cleanup?.(); |
|
resolve(result); |
|
}); |
|
}); |
|
} |
|
|
|
/** |
|
* Process EOSE results |
|
*/ |
|
function processEoseResults( |
|
searchType: SearchSubscriptionType, |
|
searchState: any, |
|
searchFilter: SearchFilter, |
|
callbacks?: SearchCallbacks, |
|
): SearchResult { |
|
if (searchType === "n") { |
|
return processProfileEoseResults(searchState, searchFilter, callbacks); |
|
} else if (searchType === "d") { |
|
return processContentEoseResults(searchState, searchType); |
|
} else if (searchType === "t") { |
|
return processTTagEoseResults(searchState); |
|
} |
|
|
|
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); |
|
} |
|
|
|
/** |
|
* Process profile EOSE results |
|
*/ |
|
function processProfileEoseResults( |
|
searchState: any, |
|
searchFilter: SearchFilter, |
|
callbacks?: SearchCallbacks, |
|
): SearchResult { |
|
if (searchState.foundProfiles.length === 0) { |
|
return createEmptySearchResult("n", searchState.normalizedSearchTerm); |
|
} |
|
|
|
// Deduplicate by pubkey, keep only newest |
|
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {}; |
|
for (const event of searchState.foundProfiles) { |
|
const pubkey = event.pubkey; |
|
const created_at = event.created_at || 0; |
|
if (!deduped[pubkey] || deduped[pubkey].created_at < created_at) { |
|
deduped[pubkey] = { event, created_at }; |
|
} |
|
} |
|
|
|
// Sort by creation time (newest first) and take only the most recent profiles |
|
const dedupedProfiles = Object.values(deduped) |
|
.sort((a, b) => b.created_at - a.created_at) |
|
.map((x) => x.event); |
|
|
|
// Perform second-order search for npub searches |
|
if ( |
|
searchFilter.subscriptionType === "npub-specific" || |
|
searchFilter.subscriptionType === "nip05-found" |
|
) { |
|
const targetPubkey = dedupedProfiles[0]?.pubkey; |
|
if (targetPubkey) { |
|
console.log("subscription_search: Triggering second-order search for npub-specific profile:", targetPubkey); |
|
performSecondOrderSearchInBackground( |
|
"n", |
|
dedupedProfiles, |
|
new Set(), |
|
new Set(), |
|
targetPubkey, |
|
callbacks, |
|
); |
|
} else { |
|
console.log("subscription_search: No targetPubkey found for second-order search"); |
|
} |
|
} else if (searchFilter.subscriptionType === "profile") { |
|
// For general profile searches, perform second-order search for each found profile |
|
for (const profile of dedupedProfiles) { |
|
if (profile.pubkey) { |
|
console.log("subscription_search: Triggering second-order search for general profile:", profile.pubkey); |
|
performSecondOrderSearchInBackground( |
|
"n", |
|
dedupedProfiles, |
|
new Set(), |
|
new Set(), |
|
profile.pubkey, |
|
callbacks, |
|
); |
|
} |
|
} |
|
} else { |
|
console.log("subscription_search: No second-order search triggered for subscription type:", searchFilter.subscriptionType); |
|
} |
|
|
|
return { |
|
events: dedupedProfiles, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: new Set(dedupedProfiles.map((p) => p.id)), |
|
addresses: new Set(), |
|
searchType: "n", |
|
searchTerm: searchState.normalizedSearchTerm, |
|
}; |
|
} |
|
|
|
/** |
|
* Process content EOSE results |
|
*/ |
|
function processContentEoseResults( |
|
searchState: any, |
|
searchType: SearchSubscriptionType, |
|
): SearchResult { |
|
if (searchState.firstOrderEvents.length === 0) { |
|
return createEmptySearchResult( |
|
searchType, |
|
searchState.normalizedSearchTerm, |
|
); |
|
} |
|
|
|
// Deduplicate by kind, pubkey, and d-tag, keep only newest event for each combination |
|
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {}; |
|
for (const event of searchState.firstOrderEvents) { |
|
const dTag = getMatchingTags(event, "d")[0]?.[1] || ""; |
|
const key = `${event.kind}:${event.pubkey}:${dTag}`; |
|
const created_at = event.created_at || 0; |
|
if (!deduped[key] || deduped[key].created_at < created_at) { |
|
deduped[key] = { event, created_at }; |
|
} |
|
} |
|
const dedupedEvents = Object.values(deduped).map((x) => x.event); |
|
|
|
// Perform second-order search for d-tag searches |
|
if (dedupedEvents.length > 0) { |
|
performSecondOrderSearchInBackground( |
|
"d", |
|
dedupedEvents, |
|
searchState.eventIds, |
|
searchState.eventAddresses, |
|
); |
|
} |
|
|
|
return { |
|
events: dedupedEvents, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: searchState.eventIds, |
|
addresses: searchState.eventAddresses, |
|
searchType: searchType, |
|
searchTerm: searchState.normalizedSearchTerm, |
|
}; |
|
} |
|
|
|
/** |
|
* Process t-tag EOSE results |
|
*/ |
|
function processTTagEoseResults(searchState: any): SearchResult { |
|
if (searchState.tTagEvents.length === 0) { |
|
return createEmptySearchResult("t", searchState.normalizedSearchTerm); |
|
} |
|
|
|
return { |
|
events: searchState.tTagEvents, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: new Set(), |
|
addresses: new Set(), |
|
searchType: "t", |
|
searchTerm: searchState.normalizedSearchTerm, |
|
}; |
|
} |
|
|
|
/** |
|
* Create empty search result |
|
*/ |
|
function createEmptySearchResult( |
|
searchType: SearchSubscriptionType, |
|
searchTerm: string, |
|
): SearchResult { |
|
return { |
|
events: [], |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: new Set(), |
|
addresses: new Set(), |
|
searchType: searchType, |
|
searchTerm: searchTerm, |
|
}; |
|
} |
|
|
|
/** |
|
* Perform second-order search in background |
|
*/ |
|
async function performSecondOrderSearchInBackground( |
|
searchType: "n" | "d", |
|
firstOrderEvents: NDKEvent[], |
|
eventIds: Set<string> = new Set(), |
|
addresses: Set<string> = new Set(), |
|
targetPubkey?: string, |
|
callbacks?: SearchCallbacks, |
|
) { |
|
try { |
|
console.log("subscription_search: Starting second-order search for", searchType, "with targetPubkey:", targetPubkey); |
|
const ndk = get(ndkInstance); |
|
let allSecondOrderEvents: NDKEvent[] = []; |
|
|
|
// Set a timeout for second-order search |
|
const timeoutPromise = new Promise((_, reject) => { |
|
setTimeout( |
|
() => reject(new Error("Second-order search timeout")), |
|
TIMEOUTS.SECOND_ORDER_SEARCH, |
|
); |
|
}); |
|
|
|
const searchPromise = (async () => { |
|
if (searchType === "n" && targetPubkey) { |
|
console.log("subscription_search: Searching for events mentioning pubkey:", targetPubkey); |
|
|
|
// AI-NOTE: 2025-01-24 - Use only active relays for second-order profile search to prevent hanging |
|
const activeRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)]; |
|
const availableRelays = activeRelays |
|
.map(url => ndk.pool.relays.get(url)) |
|
.filter((relay): relay is any => relay !== undefined); |
|
const relaySet = new NDKRelaySet( |
|
new Set(availableRelays), |
|
ndk |
|
); |
|
|
|
console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search"); |
|
|
|
// Search for events that mention this pubkey via p-tags |
|
const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging |
|
const pTagEvents = await ndk.fetchEvents( |
|
pTagFilter, |
|
{ closeOnEose: true }, |
|
relaySet, |
|
); |
|
console.log("subscription_search: Found", pTagEvents.size, "events with p-tag for", targetPubkey); |
|
|
|
// AI-NOTE: 2025-01-24 - Also search for events written by this pubkey with limit |
|
const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging |
|
const authorEvents = await ndk.fetchEvents( |
|
authorFilter, |
|
{ closeOnEose: true }, |
|
relaySet, |
|
); |
|
console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey); |
|
|
|
// Filter out unwanted events from both sets |
|
const filteredPTagEvents = filterUnwantedEvents(Array.from(pTagEvents)); |
|
const filteredAuthorEvents = filterUnwantedEvents(Array.from(authorEvents)); |
|
|
|
console.log("subscription_search: After filtering unwanted events:", filteredPTagEvents.length, "p-tag events,", filteredAuthorEvents.length, "author events"); |
|
|
|
// Combine both sets of events |
|
allSecondOrderEvents = [...filteredPTagEvents, ...filteredAuthorEvents]; |
|
} else if (searchType === "d") { |
|
// Parallel fetch for #e and #a tag events |
|
const relaySet = new NDKRelaySet( |
|
new Set(Array.from(ndk.pool.relays.values())), |
|
ndk, |
|
); |
|
const [eTagEvents, aTagEvents] = await Promise.all([ |
|
eventIds.size > 0 |
|
? ndk.fetchEvents( |
|
{ "#e": Array.from(eventIds), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS }, |
|
{ closeOnEose: true }, |
|
relaySet, |
|
) |
|
: Promise.resolve([]), |
|
addresses.size > 0 |
|
? ndk.fetchEvents( |
|
{ "#a": Array.from(addresses), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS }, |
|
{ closeOnEose: true }, |
|
relaySet, |
|
) |
|
: Promise.resolve([]), |
|
]); |
|
// Filter out unwanted events |
|
const filteredETagEvents = filterUnwantedEvents(Array.from(eTagEvents)); |
|
const filteredATagEvents = filterUnwantedEvents(Array.from(aTagEvents)); |
|
allSecondOrderEvents = [ |
|
...allSecondOrderEvents, |
|
...filteredETagEvents, |
|
...filteredATagEvents, |
|
]; |
|
} |
|
|
|
// Deduplicate by event ID |
|
const uniqueSecondOrder = new Map<string, NDKEvent>(); |
|
allSecondOrderEvents.forEach((event) => { |
|
if (event.id) { |
|
uniqueSecondOrder.set(event.id, event); |
|
} |
|
}); |
|
|
|
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); |
|
|
|
// Remove any events already in first order |
|
const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id)); |
|
deduplicatedSecondOrder = deduplicatedSecondOrder.filter( |
|
(e) => !firstOrderIds.has(e.id), |
|
); |
|
|
|
// Sort by creation date (newest first) and limit to newest results |
|
const sortedSecondOrder = deduplicatedSecondOrder |
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) |
|
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); |
|
|
|
console.log("subscription_search: Second-order search completed with", sortedSecondOrder.length, "results"); |
|
|
|
// Update the search results with second-order events |
|
const result: SearchResult = { |
|
events: firstOrderEvents, |
|
secondOrder: sortedSecondOrder, |
|
tTagEvents: [], |
|
eventIds: |
|
searchType === "n" |
|
? new Set(firstOrderEvents.map((p) => p.id)) |
|
: eventIds, |
|
addresses: searchType === "n" ? new Set() : addresses, |
|
searchType: searchType, |
|
searchTerm: "", // This will be set by the caller |
|
}; |
|
|
|
// Notify UI of updated results |
|
if (callbacks?.onSecondOrderUpdate) { |
|
console.log("subscription_search: Calling onSecondOrderUpdate callback with", sortedSecondOrder.length, "second-order events"); |
|
callbacks.onSecondOrderUpdate(result); |
|
} else { |
|
console.log("subscription_search: No onSecondOrderUpdate callback available"); |
|
} |
|
})(); |
|
|
|
// Race between search and timeout |
|
await Promise.race([searchPromise, timeoutPromise]); |
|
} catch (err) { |
|
console.error( |
|
`[Search] Error in second-order ${searchType}-tag search:`, |
|
err, |
|
); |
|
} |
|
}
|
|
|