clone of repo on github
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

// 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,
);
}
}