Browse Source

subscription sorting

master
silberengel 7 months ago
parent
commit
de0e1a8aed
  1. 4
      src/lib/utils/search_constants.ts
  2. 351
      src/lib/utils/subscription_search.ts

4
src/lib/utils/search_constants.ts

@ -17,7 +17,7 @@ export const TIMEOUTS = {
SUBSCRIPTION_SEARCH: 10000, SUBSCRIPTION_SEARCH: 10000,
/** Timeout for second-order search operations */ /** Timeout for second-order search operations */
SECOND_ORDER_SEARCH: 3000, // AI-NOTE: 2025-01-24 - Reduced timeout since we limit scope SECOND_ORDER_SEARCH: 30000, // AI-NOTE: 2025-01-24 - Increased timeout to allow more time for relay responses
/** Timeout for relay diagnostics */ /** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000, RELAY_DIAGNOSTICS: 5000,
@ -56,7 +56,7 @@ export const SEARCH_LIMITS = {
COMMUNITY_CHECK: 1, COMMUNITY_CHECK: 1,
/** Limit for second-order search results */ /** Limit for second-order search results */
SECOND_ORDER_RESULTS: 100, SECOND_ORDER_RESULTS: 50, // AI-NOTE: 2025-01-24 - Reduced to improve performance and reduce timeout issues
/** Maximum results for profile searches */ /** Maximum results for profile searches */
MAX_PROFILE_RESULTS: 20, MAX_PROFILE_RESULTS: 20,

351
src/lib/utils/subscription_search.ts

@ -25,6 +25,168 @@ const normalizeUrl = (url: string): string => {
return url.replace(/\/$/, ""); // Remove trailing slash return url.replace(/\/$/, ""); // Remove trailing slash
}; };
// AI-NOTE: 2025-01-24 - 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 prioritizeSubscriptionEvents(
events: NDKEvent[],
targetPubkey?: string,
maxResults: number = SEARCH_LIMITS.GENERAL_CONTENT,
ndk?: NDK
): Promise<NDKEvent[]> {
if (events.length === 0) {
return [];
}
// AI-NOTE: 2025-01-24 - 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: 2025-01-24 - 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 * Filter out unwanted events from search results
* @param events Array of NDKEvent to filter * @param events Array of NDKEvent to filter
@ -870,12 +1032,12 @@ function searchOtherRelaysInBackground(
let resolved = false; let resolved = false;
// Add timeout to prevent hanging // Add timeout to prevent hanging
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(async () => {
if (!resolved) { if (!resolved) {
console.log("subscription_search: Background search timeout, resolving with current results"); console.log("subscription_search: Background search timeout, resolving with current results");
resolved = true; resolved = true;
sub.stop(); sub.stop();
const result = processEoseResults( const result = await processEoseResults(
searchType, searchType,
searchState, searchState,
searchFilter, searchFilter,
@ -888,11 +1050,11 @@ function searchOtherRelaysInBackground(
} }
}, TIMEOUTS.SUBSCRIPTION_SEARCH); }, TIMEOUTS.SUBSCRIPTION_SEARCH);
sub.on("eose", () => { sub.on("eose", async () => {
if (!resolved) { if (!resolved) {
resolved = true; resolved = true;
clearTimeout(timeoutId); clearTimeout(timeoutId);
const result = processEoseResults( const result = await processEoseResults(
searchType, searchType,
searchState, searchState,
searchFilter, searchFilter,
@ -910,19 +1072,19 @@ function searchOtherRelaysInBackground(
/** /**
* Process EOSE results * Process EOSE results
*/ */
function processEoseResults( async function processEoseResults(
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
searchState: any, searchState: any,
searchFilter: SearchFilter, searchFilter: SearchFilter,
ndk: NDK, ndk: NDK,
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
): SearchResult { ): Promise<SearchResult> {
if (searchType === "n") { if (searchType === "n") {
return processProfileEoseResults(searchState, searchFilter, ndk, callbacks); return processProfileEoseResults(searchState, searchFilter, ndk, callbacks);
} else if (searchType === "d") { } else if (searchType === "d") {
return processContentEoseResults(searchState, searchType, ndk, callbacks); return await processContentEoseResults(searchState, searchType, ndk, callbacks);
} else if (searchType === "t") { } else if (searchType === "t") {
return processTTagEoseResults(searchState); return await processTTagEoseResults(searchState, ndk);
} }
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
@ -956,6 +1118,10 @@ function processProfileEoseResults(
.sort((a, b) => b.created_at - a.created_at) .sort((a, b) => b.created_at - a.created_at)
.map((x) => x.event); .map((x) => x.event);
// AI-NOTE: 2025-01-24 - 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 // Perform second-order search for npub searches
if ( if (
searchFilter.subscriptionType === "npub-specific" || searchFilter.subscriptionType === "npub-specific" ||
@ -1021,12 +1187,12 @@ function processProfileEoseResults(
/** /**
* Process content EOSE results * Process content EOSE results
*/ */
function processContentEoseResults( async function processContentEoseResults(
searchState: any, searchState: any,
searchType: SearchSubscriptionType, searchType: SearchSubscriptionType,
ndk: NDK, ndk: NDK,
callbacks?: SearchCallbacks, callbacks?: SearchCallbacks,
): SearchResult { ): Promise<SearchResult> {
if (searchState.firstOrderEvents.length === 0) { if (searchState.firstOrderEvents.length === 0) {
return createEmptySearchResult( return createEmptySearchResult(
searchType, searchType,
@ -1046,6 +1212,15 @@ function processContentEoseResults(
} }
const dedupedEvents = Object.values(deduped).map((x) => x.event); const dedupedEvents = Object.values(deduped).map((x) => x.event);
// AI-NOTE: 2025-01-24 - 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 prioritizeSubscriptionEvents(
dedupedEvents,
undefined, // No specific target pubkey for d-tag searches
SEARCH_LIMITS.GENERAL_CONTENT,
ndk
);
// Perform second-order search for d-tag searches // Perform second-order search for d-tag searches
if (dedupedEvents.length > 0) { if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground( performSecondOrderSearchInBackground(
@ -1060,7 +1235,7 @@ function processContentEoseResults(
} }
return { return {
events: dedupedEvents, events: prioritizedEvents,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: searchState.eventIds, eventIds: searchState.eventIds,
@ -1073,13 +1248,22 @@ function processContentEoseResults(
/** /**
* Process t-tag EOSE results * Process t-tag EOSE results
*/ */
function processTTagEoseResults(searchState: any): SearchResult { async function processTTagEoseResults(searchState: any, ndk?: NDK): Promise<SearchResult> {
if (searchState.tTagEvents.length === 0) { if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult("t", searchState.normalizedSearchTerm); return createEmptySearchResult("t", searchState.normalizedSearchTerm);
} }
// AI-NOTE: 2025-01-24 - 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 prioritizeSubscriptionEvents(
searchState.tTagEvents,
undefined, // No specific target pubkey for t-tag searches
SEARCH_LIMITS.GENERAL_CONTENT,
ndk
);
return { return {
events: searchState.tTagEvents, events: prioritizedEvents,
secondOrder: [], secondOrder: [],
tTagEvents: [], tTagEvents: [],
eventIds: new Set(), eventIds: new Set(),
@ -1128,15 +1312,15 @@ async function performSecondOrderSearchInBackground(
); );
let allSecondOrderEvents: NDKEvent[] = []; let allSecondOrderEvents: NDKEvent[] = [];
// Set a timeout for second-order search // Set a timeout for the initial event fetching only
const timeoutPromise = new Promise((_, reject) => { const fetchTimeoutPromise = new Promise((_, reject) => {
setTimeout( setTimeout(
() => reject(new Error("Second-order search timeout")), () => reject(new Error("Second-order search fetch timeout")),
TIMEOUTS.SECOND_ORDER_SEARCH, TIMEOUTS.SECOND_ORDER_SEARCH,
); );
}); });
const searchPromise = (async () => { const fetchPromise = (async () => {
if (searchType === "n" && targetPubkey) { if (searchType === "n" && targetPubkey) {
console.log( console.log(
"subscription_search: Searching for events mentioning pubkey:", "subscription_search: Searching for events mentioning pubkey:",
@ -1236,64 +1420,103 @@ async function performSecondOrderSearchInBackground(
...filteredATagEvents, ...filteredATagEvents,
]; ];
} }
})();
// Deduplicate by event ID // Race between fetch and timeout - only timeout the initial event fetching
const uniqueSecondOrder = new Map<string, NDKEvent>(); await Promise.race([fetchPromise, fetchTimeoutPromise]);
allSecondOrderEvents.forEach((event) => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
});
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); // Now do the prioritization without timeout
console.log("subscription_search: Event fetching completed, starting prioritization...");
// Remove any events already in first order // Deduplicate by event ID
const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id)); const uniqueSecondOrder = new Map<string, NDKEvent>();
deduplicatedSecondOrder = deduplicatedSecondOrder.filter( allSecondOrderEvents.forEach((event) => {
(e) => !firstOrderIds.has(e.id), if (event.id) {
); uniqueSecondOrder.set(event.id, event);
}
});
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values());
// Sort by creation date (newest first) and limit to newest results // Remove any events already in first order
const sortedSecondOrder = deduplicatedSecondOrder const firstOrderIds = new Set(firstOrderEvents.map((e) => e.id));
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) deduplicatedSecondOrder = deduplicatedSecondOrder.filter(
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); (e) => !firstOrderIds.has(e.id),
);
// AI-NOTE: 2025-01-24 - Apply prioritization to second-order search results with timeout
// Prioritize events from the target pubkey and specific event kinds
const prioritizationPromise = prioritizeSubscriptionEvents(
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( console.log(
"subscription_search: Second-order search completed with", "subscription_search: Second-order search completed with",
sortedSecondOrder.length, prioritizedSecondOrder.length,
"results", "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
if (targetPubkey) {
const aIsTarget = a.pubkey === targetPubkey;
const bIsTarget = b.pubkey === targetPubkey;
if (aIsTarget && !bIsTarget) return -1;
if (!aIsTarget && bIsTarget) 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);
// Update the search results with second-order events console.log(
const result: SearchResult = { "subscription_search: Using fallback sorting with",
events: firstOrderEvents, prioritizedSecondOrder.length,
secondOrder: sortedSecondOrder, "results",
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 // Update the search results with second-order events
if (callbacks?.onSecondOrderUpdate) { const result: SearchResult = {
console.log( events: firstOrderEvents,
"subscription_search: Calling onSecondOrderUpdate callback with", secondOrder: prioritizedSecondOrder,
sortedSecondOrder.length, tTagEvents: [],
"second-order events", eventIds: searchType === "n"
); ? new Set(firstOrderEvents.map((p) => p.id))
callbacks.onSecondOrderUpdate(result); : eventIds,
} else { addresses: searchType === "n" ? new Set() : addresses,
console.log( searchType: searchType,
"subscription_search: No onSecondOrderUpdate callback available", searchTerm: "", // This will be set by the caller
); };
}
})();
// Race between search and timeout // Notify UI of updated results
await Promise.race([searchPromise, timeoutPromise]); 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) { } catch (err) {
console.error( console.error(
`[Search] Error in second-order ${searchType}-tag search:`, `[Search] Error in second-order ${searchType}-tag search:`,

Loading…
Cancel
Save