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.
1758 lines
53 KiB
1758 lines
53 KiB
// deno-lint-ignore-file no-explicit-any |
|
import { getMatchingTags, getNpubFromNip05 } from "./nostrUtils.ts"; |
|
import { nip19 } from "./nostrUtils.ts"; |
|
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; |
|
import { searchCache } from "./searchCache.ts"; |
|
import { searchRelays } from "../consts.ts"; |
|
import { get } from "svelte/store"; |
|
import type { |
|
SearchCallbacks, |
|
SearchFilter, |
|
SearchResult, |
|
SearchSubscriptionType, |
|
} from "./search_types.ts"; |
|
import { |
|
COMMON_DOMAINS, |
|
fieldMatches, |
|
isEmojiReaction, |
|
nip05Matches, |
|
} from "./search_utils.ts"; |
|
import { SEARCH_LIMITS, TIMEOUTS } 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 |
|
}; |
|
|
|
// AI-NOTE: Define prioritized event kinds for subscription search |
|
const PRIORITIZED_EVENT_KINDS = new Set([ |
|
1, // Text notes |
|
1111, // Comments |
|
9802, // Highlights |
|
20, // Article |
|
21, // Article |
|
22, // Article |
|
1222, // Long-form content |
|
1244, // Long-form content |
|
30023, // Long-form content |
|
30040, // Long-form content |
|
30041, // Long-form content |
|
]); |
|
|
|
/** |
|
* Prioritize events for subscription search results |
|
* @param events Array of events to prioritize |
|
* @param targetPubkey The pubkey being searched for (for n: searches only - events from this pubkey get highest priority) |
|
* @param maxResults Maximum number of results to return |
|
* @param ndk NDK instance for user list and community checks |
|
* @returns Prioritized array of events |
|
* |
|
* Priority tiers: |
|
* 1. Prioritized event kinds (1, 1111, 9802, 20, 21, 22, 1222, 1244, 30023, 30040, 30041) + target pubkey events (n: searches only) |
|
* 2. Events from user's follows (if logged in) |
|
* 3. Events from community members |
|
* 4. All other events |
|
*/ |
|
async function prioritizeSearchEvents( |
|
events: NDKEvent[], |
|
targetPubkey?: string, |
|
maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT, |
|
ndk?: NDK, |
|
): Promise<NDKEvent[]> { |
|
if (events.length === 0) { |
|
return []; |
|
} |
|
|
|
// AI-NOTE: Get user lists and community status for prioritization |
|
let userFollowPubkeys = new Set<string>(); |
|
let communityMemberPubkeys = new Set<string>(); |
|
|
|
// Only attempt user list and community checks if NDK is provided |
|
if (ndk) { |
|
try { |
|
// Import user list functions dynamically to avoid circular dependencies |
|
const { fetchCurrentUserLists, getPubkeysFromListKind } = await import( |
|
"./user_lists.ts" |
|
); |
|
const { checkCommunity } = await import("./community_checker.ts"); |
|
|
|
// Get current user's follow lists (if logged in) |
|
const userLists = await fetchCurrentUserLists(undefined, ndk); |
|
userFollowPubkeys = getPubkeysFromListKind(userLists, 3); // Kind 3 = follow list |
|
|
|
// Check community status for unique pubkeys in events (limit to prevent hanging) |
|
const uniquePubkeys = new Set( |
|
events.map((e) => e.pubkey).filter(Boolean), |
|
); |
|
const pubkeysToCheck = Array.from(uniquePubkeys).slice(0, 20); // Limit to first 20 pubkeys |
|
|
|
console.log( |
|
`subscription_search: Checking community status for ${pubkeysToCheck.length} pubkeys out of ${uniquePubkeys.size} total`, |
|
); |
|
|
|
const communityChecks = await Promise.allSettled( |
|
pubkeysToCheck.map(async (pubkey) => { |
|
try { |
|
const isCommunityMember = await Promise.race([ |
|
checkCommunity(pubkey), |
|
new Promise((_, reject) => |
|
setTimeout( |
|
() => reject(new Error("Community check timeout")), |
|
2000, |
|
) |
|
), |
|
]); |
|
return { pubkey, isCommunityMember }; |
|
} catch (error) { |
|
console.warn( |
|
`subscription_search: Community check failed for ${pubkey}:`, |
|
error, |
|
); |
|
return { pubkey, isCommunityMember: false }; |
|
} |
|
}), |
|
); |
|
|
|
// Build set of community member pubkeys |
|
communityChecks.forEach((result) => { |
|
if (result.status === "fulfilled" && result.value.isCommunityMember) { |
|
communityMemberPubkeys.add(result.value.pubkey); |
|
} |
|
}); |
|
|
|
console.log("subscription_search: Prioritization data loaded:", { |
|
userFollows: userFollowPubkeys.size, |
|
communityMembers: communityMemberPubkeys.size, |
|
totalEvents: events.length, |
|
}); |
|
} catch (error) { |
|
console.warn( |
|
"subscription_search: Failed to load prioritization data:", |
|
error, |
|
); |
|
} |
|
} else { |
|
console.log( |
|
"subscription_search: No NDK provided, skipping user list and community checks", |
|
); |
|
} |
|
|
|
// Separate events into priority tiers |
|
const tier1: NDKEvent[] = []; // Events from target pubkey (n: searches only) + prioritized kinds |
|
const tier2: NDKEvent[] = []; // Events from user's follows |
|
const tier3: NDKEvent[] = []; // Events from community members |
|
const tier4: NDKEvent[] = []; // All other events |
|
|
|
for (const event of events) { |
|
const isFromTarget = targetPubkey && event.pubkey === targetPubkey; |
|
const isPrioritizedKind = PRIORITIZED_EVENT_KINDS.has(event.kind || 0); |
|
const isFromFollow = userFollowPubkeys.has(event.pubkey || ""); |
|
const isFromCommunityMember = communityMemberPubkeys.has( |
|
event.pubkey || "", |
|
); |
|
|
|
// AI-NOTE: Prioritized kinds are always in tier 1 |
|
// Target pubkey priority only applies to n: searches (when targetPubkey is provided) |
|
if (isPrioritizedKind || isFromTarget) { |
|
tier1.push(event); |
|
} else if (isFromFollow) { |
|
tier2.push(event); |
|
} else if (isFromCommunityMember) { |
|
tier3.push(event); |
|
} else { |
|
tier4.push(event); |
|
} |
|
} |
|
|
|
// Sort each tier by creation time (newest first) |
|
tier1.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
|
tier2.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
|
tier3.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
|
tier4.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
|
|
|
// Combine tiers in priority order, respecting the limit |
|
const result: NDKEvent[] = []; |
|
|
|
// Add tier 1 events (highest priority) |
|
result.push(...tier1); |
|
|
|
// Add tier 2 events (follows) if we haven't reached the limit |
|
const remainingAfterTier1 = maxResults - result.length; |
|
if (remainingAfterTier1 > 0) { |
|
result.push(...tier2.slice(0, remainingAfterTier1)); |
|
} |
|
|
|
// Add tier 3 events (community members) if we haven't reached the limit |
|
const remainingAfterTier2 = maxResults - result.length; |
|
if (remainingAfterTier2 > 0) { |
|
result.push(...tier3.slice(0, remainingAfterTier2)); |
|
} |
|
|
|
// Add tier 4 events (others) if we haven't reached the limit |
|
const remainingAfterTier3 = maxResults - result.length; |
|
if (remainingAfterTier3 > 0) { |
|
result.push(...tier4.slice(0, remainingAfterTier3)); |
|
} |
|
|
|
console.log("subscription_search: Event prioritization complete:", { |
|
tier1: tier1.length, // Prioritized kinds + target pubkey (n: searches only) |
|
tier2: tier2.length, // User follows |
|
tier3: tier3.length, // Community members |
|
tier4: tier4.length, // Others |
|
total: result.length, |
|
}); |
|
|
|
return result; |
|
} |
|
|
|
/** |
|
* 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, |
|
ndk: NDK, |
|
callbacks?: SearchCallbacks, |
|
abortSignal?: AbortSignal, |
|
): Promise<SearchResult> { |
|
const startTime = Date.now(); // AI-NOTE: 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: Ensure cached events have created_at property preserved |
|
// This fixes the "Unknown date" issue when events are retrieved from cache |
|
const eventsWithCreatedAt = cachedResult.events.map((event) => { |
|
if (event && typeof event === "object" && !event.created_at) { |
|
console.warn( |
|
"subscription_search: Event missing created_at, setting to 0:", |
|
event.id, |
|
); |
|
(event as any).created_at = 0; |
|
} |
|
return event; |
|
}); |
|
|
|
const secondOrderWithCreatedAt = cachedResult.secondOrder.map((event) => { |
|
if (event && typeof event === "object" && !event.created_at) { |
|
console.warn( |
|
"subscription_search: Second order event missing created_at, setting to 0:", |
|
event.id, |
|
); |
|
(event as any).created_at = 0; |
|
} |
|
return event; |
|
}); |
|
|
|
const tTagEventsWithCreatedAt = cachedResult.tTagEvents.map((event) => { |
|
if (event && typeof event === "object" && !event.created_at) { |
|
console.warn( |
|
"subscription_search: T-tag event missing created_at, setting to 0:", |
|
event.id, |
|
); |
|
(event as any).created_at = 0; |
|
} |
|
return event; |
|
}); |
|
|
|
const resultWithCreatedAt = { |
|
...cachedResult, |
|
events: eventsWithCreatedAt, |
|
secondOrder: secondOrderWithCreatedAt, |
|
tTagEvents: tTagEventsWithCreatedAt, |
|
}; |
|
|
|
// AI-NOTE: Return cached results immediately but trigger second-order search in background |
|
// This ensures we get fast results while still updating second-order data |
|
console.log( |
|
"subscription_search: Returning cached result immediately, triggering background second-order search", |
|
); |
|
|
|
// Trigger second-order search in background for all search types |
|
if (ndk) { |
|
// Start second-order search in background for n and d searches only |
|
if (searchType === "n" || searchType === "d") { |
|
console.log( |
|
"subscription_search: Triggering background second-order search for cached result", |
|
); |
|
performSecondOrderSearchInBackground( |
|
searchType as "n" | "d", |
|
eventsWithCreatedAt, |
|
cachedResult.eventIds || new Set(), |
|
cachedResult.addresses || new Set(), |
|
ndk, |
|
searchType === "n" ? eventsWithCreatedAt[0]?.pubkey : undefined, |
|
callbacks, |
|
); |
|
} |
|
} |
|
|
|
return resultWithCreatedAt; |
|
} |
|
|
|
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: 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, |
|
ndk, |
|
); |
|
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", |
|
); |
|
|
|
// AI-NOTE: Check for preloaded events first (for profile searches) |
|
if (searchFilter.preloadedEvents && searchFilter.preloadedEvents.length > 0) { |
|
console.log( |
|
"subscription_search: Using preloaded events:", |
|
searchFilter.preloadedEvents.length, |
|
); |
|
processPrimaryRelayResults( |
|
new Set(searchFilter.preloadedEvents), |
|
searchType, |
|
searchFilter.subscriptionType, |
|
normalizedSearchTerm, |
|
searchState, |
|
abortSignal, |
|
cleanup, |
|
); |
|
|
|
if (hasResults(searchState, searchType)) { |
|
console.log( |
|
"subscription_search: Found results from preloaded events, returning immediately", |
|
); |
|
const immediateResult = createSearchResult( |
|
searchState, |
|
searchType, |
|
normalizedSearchTerm, |
|
); |
|
searchCache.set(searchType, normalizedSearchTerm, immediateResult); |
|
|
|
// AI-NOTE: For profile searches, start background second-order search even for preloaded events |
|
if (searchType === "n") { |
|
console.log( |
|
"subscription_search: Profile found from preloaded events, starting background second-order search", |
|
); |
|
|
|
// Start Phase 2 in background for second-order results |
|
searchOtherRelaysInBackground( |
|
searchType, |
|
searchFilter, |
|
searchState, |
|
ndk, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
// Clear the main timeout since we're returning early |
|
cleanup(); |
|
} |
|
|
|
return immediateResult; |
|
} |
|
} |
|
|
|
// Phase 1: Search primary relay |
|
if (primaryRelaySet.relays.size > 0) { |
|
try { |
|
console.log( |
|
"subscription_search: Searching primary relay with filter:", |
|
searchFilter.filter, |
|
); |
|
|
|
// Add timeout to primary relay search |
|
const primaryEventsPromise = ndk.fetchEvents( |
|
searchFilter.filter, |
|
{ closeOnEose: true }, |
|
primaryRelaySet, |
|
); |
|
|
|
const timeoutPromise = new Promise((_, reject) => { |
|
setTimeout( |
|
() => reject(new Error("Primary relay search timeout")), |
|
TIMEOUTS.SUBSCRIPTION_SEARCH, |
|
); |
|
}); |
|
|
|
const primaryEvents = await Promise.race([ |
|
primaryEventsPromise, |
|
timeoutPromise, |
|
]) as any; |
|
|
|
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: 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, |
|
ndk, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
const elapsed = Date.now() - startTime; |
|
console.log( |
|
`subscription_search: Profile search completed in ${elapsed}ms`, |
|
); |
|
|
|
// Clear the main timeout since we're returning early |
|
cleanup(); |
|
return immediateResult; |
|
} |
|
|
|
// Start Phase 2 in background for additional results (only for non-profile searches) |
|
searchOtherRelaysInBackground( |
|
searchType, |
|
searchFilter, |
|
searchState, |
|
ndk, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
// Clear the main timeout since we're returning early |
|
cleanup(); |
|
return immediateResult; |
|
} else { |
|
console.log( |
|
"subscription_search: No results from primary relay", |
|
); |
|
|
|
// AI-NOTE: 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 { |
|
// Add timeout to fallback search |
|
const fallbackEventsPromise = ndk.fetchEvents( |
|
searchFilter.filter, |
|
{ closeOnEose: true }, |
|
allRelaySet, |
|
); |
|
|
|
const fallbackTimeoutPromise = new Promise((_, reject) => { |
|
setTimeout( |
|
() => reject(new Error("Fallback search timeout")), |
|
TIMEOUTS.SUBSCRIPTION_SEARCH, |
|
); |
|
}); |
|
|
|
const fallbackEvents = await Promise.race([ |
|
fallbackEventsPromise, |
|
fallbackTimeoutPromise, |
|
]) as any; |
|
|
|
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)`, |
|
); |
|
|
|
// Clear the main timeout since we're returning early |
|
cleanup(); |
|
return fallbackResult; |
|
} |
|
} catch (fallbackError) { |
|
console.error( |
|
"subscription_search: Fallback search failed:", |
|
fallbackError, |
|
); |
|
|
|
// If it's a timeout error, continue to return empty result |
|
if ( |
|
fallbackError instanceof Error && |
|
fallbackError.message.includes("timeout") |
|
) { |
|
console.log( |
|
"subscription_search: Fallback search timed out, returning empty result", |
|
); |
|
} |
|
} |
|
|
|
console.log( |
|
"subscription_search: Profile not found in any relays, returning empty result", |
|
); |
|
const emptyResult = createEmptySearchResult( |
|
searchType, |
|
normalizedSearchTerm, |
|
); |
|
// AI-NOTE: 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)`, |
|
); |
|
|
|
// Clear the main timeout since we're returning early |
|
cleanup(); |
|
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, |
|
); |
|
|
|
// If it's a timeout error, continue to Phase 2 instead of failing |
|
if (error instanceof Error && error.message.includes("timeout")) { |
|
console.log( |
|
"subscription_search: Primary relay search timed out, continuing to Phase 2", |
|
); |
|
} else { |
|
// For other errors, we might want to fail the search |
|
throw 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, |
|
ndk, |
|
callbacks, |
|
cleanup, |
|
); |
|
|
|
// AI-NOTE: Log performance for non-profile searches |
|
if (searchType !== "n") { |
|
const elapsed = Date.now() - startTime; |
|
console.log( |
|
`subscription_search: ${searchType} search completed in ${elapsed}ms`, |
|
); |
|
} |
|
|
|
// Clear the main timeout since we're completing normally |
|
cleanup(); |
|
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, |
|
ndk: NDK, |
|
): 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": { |
|
// AI-NOTE: Use the existing profile search functionality |
|
// This properly handles NIP-05 lookups and name searches |
|
const { searchProfiles } = await import("./profile_search.ts"); |
|
const profileResult = await searchProfiles(normalizedSearchTerm, ndk); |
|
|
|
// Convert profile results to events for compatibility |
|
const events = profileResult.profiles.map((profile) => { |
|
const event = new NDKEvent(ndk); |
|
event.content = JSON.stringify(profile); |
|
|
|
// AI-NOTE: Convert npub to hex public key for compatibility with nprofileEncode |
|
// The profile.pubkey is an npub (bech32-encoded), but nprofileEncode expects hex-encoded public key |
|
let hexPubkey = profile.pubkey || ""; |
|
if (profile.pubkey && profile.pubkey.startsWith("npub")) { |
|
try { |
|
const decoded = nip19.decode(profile.pubkey); |
|
if (decoded.type === "npub") { |
|
hexPubkey = decoded.data as string; |
|
} |
|
} catch (e) { |
|
console.warn( |
|
"subscription_search: Failed to decode npub:", |
|
profile.pubkey, |
|
e, |
|
); |
|
} |
|
} |
|
event.pubkey = hexPubkey; |
|
event.kind = 0; |
|
|
|
// AI-NOTE: Use the preserved created_at timestamp from the profile |
|
// This ensures the profile cards show the actual creation date instead of "Unknown date" |
|
if ((profile as any).created_at) { |
|
event.created_at = (profile as any).created_at; |
|
console.log( |
|
"subscription_search: Using preserved timestamp:", |
|
event.created_at, |
|
); |
|
} else { |
|
// Fallback to current timestamp if no preserved timestamp |
|
event.created_at = Math.floor(Date.now() / 1000); |
|
console.log( |
|
"subscription_search: Using fallback timestamp:", |
|
event.created_at, |
|
); |
|
} |
|
|
|
return event; |
|
}); |
|
|
|
// Return a mock filter since we're using the profile search directly |
|
const nFilter = { |
|
filter: { kinds: [0], limit: 1 }, // Dummy filter |
|
subscriptionType: "profile-search", |
|
searchTerm: normalizedSearchTerm, |
|
preloadedEvents: events, // AI-NOTE: Pass preloaded events |
|
}; |
|
console.log( |
|
"subscription_search: Created profile filter with preloaded events:", |
|
nFilter, |
|
); |
|
return nFilter; |
|
} |
|
default: { |
|
throw new Error(`Unknown search type: ${searchType}`); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Create primary relay set for search operations |
|
* AI-NOTE: Updated to use all available relays to prevent search failures |
|
*/ |
|
function createPrimaryRelaySet( |
|
searchType: SearchSubscriptionType, |
|
ndk: NDK, |
|
): 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: 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: 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", |
|
); |
|
|
|
// AI-NOTE: Apply subscription fetch limit to primary relay results |
|
const maxEvents = SEARCH_LIMITS.SUBSCRIPTION_FETCH_LIMIT; |
|
let processedCount = 0; |
|
|
|
for (const event of events) { |
|
// Check if we've reached the event limit |
|
if (processedCount >= maxEvents) { |
|
console.log( |
|
`subscription_search: Reached event limit of ${maxEvents} in primary relay processing`, |
|
); |
|
break; |
|
} |
|
|
|
// 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); |
|
} |
|
processedCount++; |
|
} catch (e) { |
|
console.warn("subscription_search: Error processing event:", e); |
|
// Invalid JSON or other error, skip |
|
} |
|
} |
|
|
|
console.log( |
|
`subscription_search: Processed ${processedCount} events (limit: ${maxEvents}) - 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, NIP-05 found search, or profile-search, include all matching events |
|
if ( |
|
subscriptionType === "npub-specific" || |
|
subscriptionType === "nip05-found" || |
|
subscriptionType === "profile-search" |
|
) { |
|
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, |
|
ndk: NDK, |
|
callbacks?: SearchCallbacks, |
|
cleanup?: () => void, |
|
): Promise<SearchResult> { |
|
// AI-NOTE: 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); |
|
} |
|
|
|
// AI-NOTE: Track event count to enforce subscription fetch limit |
|
let eventCount = 0; |
|
const maxEvents = SEARCH_LIMITS.SUBSCRIPTION_FETCH_LIMIT; |
|
|
|
sub.on("event", (event: NDKEvent) => { |
|
// Check if we've reached the event limit |
|
if (eventCount >= maxEvents) { |
|
console.log( |
|
`subscription_search: Reached event limit of ${maxEvents}, stopping event processing`, |
|
); |
|
sub.stop(); |
|
return; |
|
} |
|
|
|
eventCount++; |
|
|
|
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) => { |
|
let resolved = false; |
|
|
|
// Add timeout to prevent hanging |
|
const timeoutId = setTimeout(async () => { |
|
if (!resolved) { |
|
console.log( |
|
"subscription_search: Background search timeout, resolving with current results", |
|
); |
|
resolved = true; |
|
sub.stop(); |
|
const result = await processEoseResults( |
|
searchType, |
|
searchState, |
|
searchFilter, |
|
ndk, |
|
callbacks, |
|
); |
|
searchCache.set(searchType, searchState.normalizedSearchTerm, result); |
|
cleanup?.(); |
|
resolve(result); |
|
} |
|
}, TIMEOUTS.SUBSCRIPTION_SEARCH); |
|
|
|
sub.on("eose", async () => { |
|
if (!resolved) { |
|
resolved = true; |
|
clearTimeout(timeoutId); |
|
const result = await processEoseResults( |
|
searchType, |
|
searchState, |
|
searchFilter, |
|
ndk, |
|
callbacks, |
|
); |
|
searchCache.set(searchType, searchState.normalizedSearchTerm, result); |
|
cleanup?.(); |
|
resolve(result); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
/** |
|
* Process EOSE results |
|
*/ |
|
async function processEoseResults( |
|
searchType: SearchSubscriptionType, |
|
searchState: any, |
|
searchFilter: SearchFilter, |
|
ndk: NDK, |
|
callbacks?: SearchCallbacks, |
|
): Promise<SearchResult> { |
|
if (searchType === "n") { |
|
return processProfileEoseResults(searchState, searchFilter, ndk, callbacks); |
|
} else if (searchType === "d") { |
|
return await processContentEoseResults( |
|
searchState, |
|
searchType, |
|
ndk, |
|
callbacks, |
|
); |
|
} else if (searchType === "t") { |
|
return await processTTagEoseResults(searchState, ndk); |
|
} |
|
|
|
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); |
|
} |
|
|
|
/** |
|
* Process profile EOSE results |
|
*/ |
|
function processProfileEoseResults( |
|
searchState: any, |
|
searchFilter: SearchFilter, |
|
ndk: NDK, |
|
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); |
|
|
|
// AI-NOTE: For profile searches, we don't apply prioritization to the profiles themselves |
|
// since they are all kind 0 events and should be shown in chronological order |
|
// However, we do pass the target pubkey to the second-order search for prioritization |
|
|
|
// 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(), |
|
ndk, |
|
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(), |
|
ndk, |
|
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 |
|
*/ |
|
async function processContentEoseResults( |
|
searchState: any, |
|
searchType: SearchSubscriptionType, |
|
ndk: NDK, |
|
callbacks?: SearchCallbacks, |
|
): Promise<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); |
|
|
|
// AI-NOTE: Apply prioritization to first-order events for d-tag searches |
|
// For d-tag searches, we don't have a specific target pubkey, so we only prioritize by event kind |
|
const prioritizedEvents = await prioritizeSearchEvents( |
|
dedupedEvents, |
|
undefined, // No specific target pubkey for d-tag searches |
|
SEARCH_LIMITS.GENERAL_CONTENT, |
|
ndk, |
|
); |
|
|
|
// AI-NOTE: Attach profile data to first-order events for display |
|
// This ensures profile pictures and other metadata are available in the UI |
|
await attachProfileDataToEvents(prioritizedEvents, ndk); |
|
|
|
// Perform second-order search for d-tag searches |
|
if (dedupedEvents.length > 0) { |
|
performSecondOrderSearchInBackground( |
|
"d", |
|
dedupedEvents, |
|
searchState.eventIds, |
|
searchState.eventAddresses, |
|
ndk, |
|
undefined, // targetPubkey not needed for d-tag searches |
|
callbacks, |
|
); |
|
} |
|
|
|
return { |
|
events: prioritizedEvents, |
|
secondOrder: [], |
|
tTagEvents: [], |
|
eventIds: searchState.eventIds, |
|
addresses: searchState.eventAddresses, |
|
searchType: searchType, |
|
searchTerm: searchState.normalizedSearchTerm, |
|
}; |
|
} |
|
|
|
/** |
|
* Process t-tag EOSE results |
|
*/ |
|
async function processTTagEoseResults( |
|
searchState: any, |
|
ndk?: NDK, |
|
): Promise<SearchResult> { |
|
if (searchState.tTagEvents.length === 0) { |
|
return createEmptySearchResult("t", searchState.normalizedSearchTerm); |
|
} |
|
|
|
// AI-NOTE: Apply prioritization to t-tag search results |
|
// For t-tag searches, we don't have a specific target pubkey, so we only prioritize by event kind |
|
const prioritizedEvents = await prioritizeSearchEvents( |
|
searchState.tTagEvents, |
|
undefined, // No specific target pubkey for t-tag searches |
|
SEARCH_LIMITS.GENERAL_CONTENT, |
|
ndk, |
|
); |
|
|
|
// AI-NOTE: Attach profile data to t-tag events for display |
|
// This ensures profile pictures and other metadata are available in the UI |
|
if (ndk) { |
|
await attachProfileDataToEvents(prioritizedEvents, ndk); |
|
} |
|
|
|
return { |
|
events: prioritizedEvents, |
|
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(), |
|
ndk: NDK, |
|
targetPubkey?: string, |
|
callbacks?: SearchCallbacks, |
|
) { |
|
try { |
|
console.log( |
|
"subscription_search: Starting second-order search for", |
|
searchType, |
|
"with targetPubkey:", |
|
targetPubkey, |
|
); |
|
let allSecondOrderEvents: NDKEvent[] = []; |
|
|
|
// Set a timeout for the initial event fetching only |
|
const fetchTimeoutPromise = new Promise((_, reject) => { |
|
setTimeout( |
|
() => reject(new Error("Second-order search fetch timeout")), |
|
TIMEOUTS.SECOND_ORDER_SEARCH, |
|
); |
|
}); |
|
|
|
const fetchPromise = (async () => { |
|
if (searchType === "n" && targetPubkey) { |
|
console.log( |
|
"subscription_search: Searching for events mentioning pubkey:", |
|
targetPubkey, |
|
); |
|
|
|
// AI-NOTE: Use all available relays for second-order search to maximize results |
|
const relaySet = new NDKRelaySet( |
|
new Set(Array.from(ndk.pool.relays.values())), |
|
ndk, |
|
); |
|
|
|
console.log( |
|
"subscription_search: Using", |
|
ndk.pool.relays.size, |
|
"relays for second-order search", |
|
); |
|
|
|
// Search for events that mention this pubkey via p-tags |
|
const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 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: Also search for events written by this pubkey with limit |
|
const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 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, |
|
]; |
|
} |
|
})(); |
|
|
|
// Race between fetch and timeout - only timeout the initial event fetching |
|
await Promise.race([fetchPromise, fetchTimeoutPromise]); |
|
|
|
// Now do the prioritization without timeout |
|
console.log( |
|
"subscription_search: Event fetching completed, starting prioritization...", |
|
); |
|
|
|
// 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), |
|
); |
|
|
|
// AI-NOTE: Apply prioritization to second-order search results with timeout |
|
// Prioritize events from the target pubkey and specific event kinds |
|
const prioritizationPromise = prioritizeSearchEvents( |
|
deduplicatedSecondOrder, |
|
targetPubkey, |
|
SEARCH_LIMITS.SECOND_ORDER_RESULTS, |
|
ndk, |
|
); |
|
|
|
const prioritizationTimeoutPromise = new Promise((_, reject) => { |
|
setTimeout(() => reject(new Error("Prioritization timeout")), 15000); // 15 second timeout |
|
}); |
|
|
|
let prioritizedSecondOrder: NDKEvent[]; |
|
try { |
|
prioritizedSecondOrder = await Promise.race([ |
|
prioritizationPromise, |
|
prioritizationTimeoutPromise, |
|
]) as NDKEvent[]; |
|
|
|
console.log( |
|
"subscription_search: Second-order search completed with", |
|
prioritizedSecondOrder.length, |
|
"prioritized results", |
|
); |
|
} catch (error) { |
|
console.warn( |
|
"subscription_search: Prioritization failed, using simple sorting:", |
|
error, |
|
); |
|
// Fallback to simple sorting if prioritization fails |
|
prioritizedSecondOrder = deduplicatedSecondOrder.sort((a, b) => { |
|
// Prioritize events from target pubkey first (for n: searches) |
|
if (targetPubkey) { |
|
const aIsTarget = a.pubkey === targetPubkey; |
|
const bIsTarget = b.pubkey === targetPubkey; |
|
if (aIsTarget && !bIsTarget) return -1; |
|
if (!aIsTarget && bIsTarget) return 1; |
|
} |
|
|
|
// Prioritize by event kind (for t: searches and general prioritization) |
|
const aIsPrioritized = PRIORITIZED_EVENT_KINDS.has(a.kind || 0); |
|
const bIsPrioritized = PRIORITIZED_EVENT_KINDS.has(b.kind || 0); |
|
if (aIsPrioritized && !bIsPrioritized) return -1; |
|
if (!aIsPrioritized && bIsPrioritized) return 1; |
|
|
|
// Then sort by creation time (newest first) |
|
return (b.created_at || 0) - (a.created_at || 0); |
|
}).slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); |
|
|
|
console.log( |
|
"subscription_search: Using fallback sorting with", |
|
prioritizedSecondOrder.length, |
|
"results", |
|
); |
|
} |
|
|
|
// AI-NOTE: Attach profile data to second-order events for display |
|
// This ensures profile pictures and other metadata are available in the UI |
|
await attachProfileDataToEvents(prioritizedSecondOrder, ndk); |
|
|
|
// Update the search results with second-order events |
|
const result: SearchResult = { |
|
events: firstOrderEvents, |
|
secondOrder: prioritizedSecondOrder, |
|
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", |
|
prioritizedSecondOrder.length, |
|
"second-order events", |
|
); |
|
callbacks.onSecondOrderUpdate(result); |
|
} else { |
|
console.log( |
|
"subscription_search: No onSecondOrderUpdate callback available", |
|
); |
|
} |
|
} catch (err) { |
|
console.error( |
|
`[Search] Error in second-order ${searchType}-tag search:`, |
|
err, |
|
); |
|
} |
|
} |
|
|
|
/** |
|
* Attach profile data to events for display purposes |
|
* This function fetches and attaches profile information to events so they can display profile pictures and other metadata |
|
* @param events Array of events to attach profile data to |
|
* @param ndk NDK instance for fetching profile data |
|
* @returns Promise that resolves when profile data is attached |
|
*/ |
|
async function attachProfileDataToEvents( |
|
events: NDKEvent[], |
|
ndk: NDK, |
|
): Promise<void> { |
|
if (events.length === 0) { |
|
return; |
|
} |
|
|
|
console.log( |
|
`subscription_search: Attaching profile data to ${events.length} events`, |
|
); |
|
|
|
try { |
|
// Import user list functions dynamically to avoid circular dependencies |
|
const { fetchCurrentUserLists, isPubkeyInUserLists } = await import( |
|
"./user_lists.ts" |
|
); |
|
|
|
// Get current user's lists for user list status |
|
const userLists = await fetchCurrentUserLists(undefined, ndk); |
|
|
|
// Get unique pubkeys from events |
|
const uniquePubkeys = new Set<string>(); |
|
events.forEach((event) => { |
|
if (event.pubkey) { |
|
uniquePubkeys.add(event.pubkey); |
|
} |
|
}); |
|
|
|
console.log( |
|
`subscription_search: Found ${uniquePubkeys.size} unique pubkeys to fetch profiles for`, |
|
); |
|
|
|
// Fetch profile data for each unique pubkey |
|
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { |
|
try { |
|
// Import getUserMetadata dynamically to avoid circular dependencies |
|
const { getUserMetadata } = await import("./nostrUtils.ts"); |
|
const npub = await import("./nostrUtils.ts").then((m) => |
|
m.toNpub(pubkey) |
|
); |
|
|
|
if (npub) { |
|
const profileData = await getUserMetadata(npub, ndk, true); |
|
if (profileData) { |
|
// Check if this pubkey is in user's lists |
|
const isInLists = isPubkeyInUserLists(pubkey, userLists); |
|
|
|
// Return profile data with user list status |
|
return { |
|
pubkey, |
|
profileData: { |
|
...profileData, |
|
isInUserLists: isInLists, |
|
}, |
|
}; |
|
} |
|
} |
|
} catch (error) { |
|
console.warn( |
|
`subscription_search: Failed to fetch profile for ${pubkey}:`, |
|
error, |
|
); |
|
} |
|
return null; |
|
}); |
|
|
|
const profileResults = await Promise.allSettled(profilePromises); |
|
|
|
// Create a map of pubkey to profile data |
|
const profileMap = new Map<string, any>(); |
|
profileResults.forEach((result) => { |
|
if (result.status === "fulfilled" && result.value) { |
|
profileMap.set(result.value.pubkey, result.value.profileData); |
|
} |
|
}); |
|
|
|
console.log( |
|
`subscription_search: Successfully fetched ${profileMap.size} profiles`, |
|
); |
|
|
|
// Attach profile data to each event |
|
events.forEach((event) => { |
|
if (event.pubkey && profileMap.has(event.pubkey)) { |
|
(event as any).profileData = profileMap.get(event.pubkey); |
|
} |
|
}); |
|
|
|
console.log(`subscription_search: Profile data attachment complete`); |
|
} catch (error) { |
|
console.error("subscription_search: Error attaching profile data:", error); |
|
} |
|
}
|
|
|