35 changed files with 3251 additions and 1381 deletions
File diff suppressed because it is too large
Load Diff
@ -1,12 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
export let showModal; |
|
||||||
export let event: NDKEvent; |
|
||||||
// let str: string = JSON.stringify(event); |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if showModal} |
|
||||||
<div class="backdrop"> |
|
||||||
<div class="Modal">{event.id}</div> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
@ -0,0 +1,80 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||||
|
import { getMatchingTags } from "$lib/utils/nostrUtils"; |
||||||
|
import { naddrEncode } from "$lib/utils"; |
||||||
|
import { getEventType } from "$lib/utils/mime"; |
||||||
|
import { standardRelays } from "$lib/consts"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
|
let { event, className = "" } = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
className?: string; |
||||||
|
}>(); |
||||||
|
|
||||||
|
function getDeferralNaddr(event: NDKEvent): string | undefined { |
||||||
|
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...'] |
||||||
|
return getMatchingTags(event, "deferral")[0]?.[1]; |
||||||
|
} |
||||||
|
|
||||||
|
function isAddressableEvent(event: NDKEvent): boolean { |
||||||
|
return getEventType(event.kind || 0) === "addressable"; |
||||||
|
} |
||||||
|
|
||||||
|
function getNaddrAddress(event: NDKEvent): string | null { |
||||||
|
if (!isAddressableEvent(event)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
try { |
||||||
|
return naddrEncode(event, standardRelays); |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getViewPublicationNaddr(event: NDKEvent): string | null { |
||||||
|
// First, check for a-tags with 'defer' - these indicate the event is deferring to someone else's version |
||||||
|
const aTags = getMatchingTags(event, "a"); |
||||||
|
for (const tag of aTags) { |
||||||
|
if (tag.length >= 2 && tag.includes("defer")) { |
||||||
|
// This is a deferral to someone else's addressable event |
||||||
|
return tag[1]; // Return the addressable event address |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr |
||||||
|
const deferralNaddr = getDeferralNaddr(event); |
||||||
|
if (deferralNaddr) { |
||||||
|
return deferralNaddr; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, use the event's own naddr if it's addressable |
||||||
|
return getNaddrAddress(event); |
||||||
|
} |
||||||
|
|
||||||
|
function navigateToPublication() { |
||||||
|
const naddrAddress = getViewPublicationNaddr(event); |
||||||
|
console.log("ViewPublicationLink: navigateToPublication called", { |
||||||
|
eventKind: event.kind, |
||||||
|
naddrAddress, |
||||||
|
isAddressable: isAddressableEvent(event) |
||||||
|
}); |
||||||
|
if (naddrAddress) { |
||||||
|
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress); |
||||||
|
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`); |
||||||
|
} else { |
||||||
|
console.log("ViewPublicationLink: No naddr address found for event"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let naddrAddress = $derived(getViewPublicationNaddr(event)); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if naddrAddress} |
||||||
|
<button |
||||||
|
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg transition-colors {className}" |
||||||
|
onclick={navigateToPublication} |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
View Publication |
||||||
|
</button> |
||||||
|
{/if} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
import { communityRelay } from '$lib/consts'; |
||||||
|
import { RELAY_CONSTANTS, SEARCH_LIMITS } from './search_constants'; |
||||||
|
|
||||||
|
// Cache for pubkeys with kind 1 events on communityRelay
|
||||||
|
const communityCache = new Map<string, boolean>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a pubkey has posted to the community relay |
||||||
|
*/ |
||||||
|
export async function checkCommunity(pubkey: string): Promise<boolean> { |
||||||
|
if (communityCache.has(pubkey)) { |
||||||
|
return communityCache.get(pubkey)!; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const relayUrl = communityRelay; |
||||||
|
const ws = new WebSocket(relayUrl); |
||||||
|
return await new Promise((resolve) => { |
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify([ |
||||||
|
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, {
|
||||||
|
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: SEARCH_LIMITS.COMMUNITY_CHECK
|
||||||
|
} |
||||||
|
])); |
||||||
|
}; |
||||||
|
ws.onmessage = (event) => { |
||||||
|
const data = JSON.parse(event.data); |
||||||
|
if (data[0] === 'EVENT' && data[2]?.kind === 1) { |
||||||
|
communityCache.set(pubkey, true); |
||||||
|
ws.close(); |
||||||
|
resolve(true); |
||||||
|
} else if (data[0] === 'EOSE') { |
||||||
|
communityCache.set(pubkey, false); |
||||||
|
ws.close(); |
||||||
|
resolve(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
ws.onerror = () => { |
||||||
|
communityCache.set(pubkey, false); |
||||||
|
ws.close(); |
||||||
|
resolve(false); |
||||||
|
}; |
||||||
|
}); |
||||||
|
} catch { |
||||||
|
communityCache.set(pubkey, false); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check community status for multiple profiles |
||||||
|
*/ |
||||||
|
export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>): Promise<Record<string, boolean>> { |
||||||
|
const communityStatus: Record<string, boolean> = {}; |
||||||
|
|
||||||
|
for (const profile of profiles) { |
||||||
|
if (profile.pubkey) { |
||||||
|
communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return communityStatus; |
||||||
|
}
|
||||||
@ -0,0 +1,143 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { fetchEventWithFallback } from '$lib/utils/nostrUtils'; |
||||||
|
import { nip19 } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { wellKnownUrl, isValidNip05Address } from './search_utils'; |
||||||
|
import { TIMEOUTS, VALIDATION } from './search_constants'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for a single event by ID or filter |
||||||
|
*/ |
||||||
|
export async function searchEvent(query: string): Promise<NDKEvent | null> { |
||||||
|
// Clean the query and normalize to lowercase
|
||||||
|
let cleanedQuery = query.replace(/^nostr:/, "").toLowerCase(); |
||||||
|
let filterOrId: any = cleanedQuery; |
||||||
|
|
||||||
|
// If it's a valid hex string, try as event id first, then as pubkey (profile)
|
||||||
|
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(cleanedQuery)) { |
||||||
|
// Try as event id
|
||||||
|
filterOrId = cleanedQuery; |
||||||
|
const eventResult = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
filterOrId, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
// Always try as pubkey (profile event) as well
|
||||||
|
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; |
||||||
|
const profileEvent = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
profileFilter, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
// Prefer profile if found and pubkey matches query
|
||||||
|
if ( |
||||||
|
profileEvent && |
||||||
|
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase() |
||||||
|
) { |
||||||
|
return profileEvent; |
||||||
|
} else if (eventResult) { |
||||||
|
return eventResult; |
||||||
|
} |
||||||
|
} else if ( |
||||||
|
new RegExp(`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`, 'i').test(cleanedQuery) |
||||||
|
) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(cleanedQuery); |
||||||
|
if (!decoded) throw new Error("Invalid identifier"); |
||||||
|
switch (decoded.type) { |
||||||
|
case "nevent": |
||||||
|
filterOrId = decoded.data.id; |
||||||
|
break; |
||||||
|
case "note": |
||||||
|
filterOrId = decoded.data; |
||||||
|
break; |
||||||
|
case "naddr": |
||||||
|
filterOrId = { |
||||||
|
kinds: [decoded.data.kind], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
"#d": [decoded.data.identifier], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case "nprofile": |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case "npub": |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data], |
||||||
|
}; |
||||||
|
break; |
||||||
|
default: |
||||||
|
filterOrId = cleanedQuery; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error("[Search] Invalid Nostr identifier:", cleanedQuery, e); |
||||||
|
throw new Error("Invalid Nostr identifier."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const event = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
filterOrId, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
console.warn("[Search] Event not found for filterOrId:", filterOrId); |
||||||
|
return null; |
||||||
|
} else { |
||||||
|
return event; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error("[Search] Error fetching event:", err, "Query:", query); |
||||||
|
throw new Error("Error fetching event. Please check the ID and try again."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for NIP-05 address |
||||||
|
*/ |
||||||
|
export async function searchNip05(nip05Address: string): Promise<NDKEvent | null> { |
||||||
|
// NIP-05 address pattern: user@domain
|
||||||
|
if (!isValidNip05Address(nip05Address)) { |
||||||
|
throw new Error("Invalid NIP-05 address format. Expected: user@domain"); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const [name, domain] = nip05Address.split("@"); |
||||||
|
|
||||||
|
const res = await fetch(wellKnownUrl(domain, name)); |
||||||
|
|
||||||
|
if (!res.ok) { |
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await res.json(); |
||||||
|
|
||||||
|
const pubkey = data.names?.[name]; |
||||||
|
if (pubkey) { |
||||||
|
const profileFilter = { kinds: [0], authors: [pubkey] }; |
||||||
|
const profileEvent = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
profileFilter, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
if (profileEvent) { |
||||||
|
return profileEvent; |
||||||
|
} else { |
||||||
|
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`); |
||||||
|
} |
||||||
|
} else { |
||||||
|
throw new Error(`NIP-05 address not found: ${name}@${domain}`); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error(`[Search] Error resolving NIP-05 address ${nip05Address}:`, e); |
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e); |
||||||
|
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`); |
||||||
|
} |
||||||
|
}
|
||||||
@ -0,0 +1,132 @@ |
|||||||
|
import type { NDKEvent } from "./nostrUtils"; |
||||||
|
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
export interface IndexEventCacheEntry { |
||||||
|
events: NDKEvent[]; |
||||||
|
timestamp: number; |
||||||
|
relayUrls: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
class IndexEventCache { |
||||||
|
private cache: Map<string, IndexEventCacheEntry> = new Map(); |
||||||
|
private readonly CACHE_DURATION = CACHE_DURATIONS.INDEX_EVENT_CACHE; |
||||||
|
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached relay combinations
|
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a cache key based on relay URLs |
||||||
|
*/ |
||||||
|
private generateKey(relayUrls: string[]): string { |
||||||
|
return relayUrls.sort().join('|'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a cached entry is still valid |
||||||
|
*/ |
||||||
|
private isExpired(entry: IndexEventCacheEntry): boolean { |
||||||
|
return Date.now() - entry.timestamp > this.CACHE_DURATION; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached index events for a set of relays |
||||||
|
*/ |
||||||
|
get(relayUrls: string[]): NDKEvent[] | null { |
||||||
|
const key = this.generateKey(relayUrls); |
||||||
|
const entry = this.cache.get(key); |
||||||
|
|
||||||
|
if (!entry || this.isExpired(entry)) { |
||||||
|
if (entry) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`); |
||||||
|
return entry.events; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store index events in cache |
||||||
|
*/ |
||||||
|
set(relayUrls: string[], events: NDKEvent[]): void { |
||||||
|
const key = this.generateKey(relayUrls); |
||||||
|
|
||||||
|
// Implement LRU eviction if cache is full
|
||||||
|
if (this.cache.size >= this.MAX_CACHE_SIZE) { |
||||||
|
const oldestKey = this.cache.keys().next().value; |
||||||
|
if (oldestKey) { |
||||||
|
this.cache.delete(oldestKey); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
this.cache.set(key, { |
||||||
|
events, |
||||||
|
timestamp: Date.now(), |
||||||
|
relayUrls: [...relayUrls] |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if index events are cached for a set of relays |
||||||
|
*/ |
||||||
|
has(relayUrls: string[]): boolean { |
||||||
|
const key = this.generateKey(relayUrls); |
||||||
|
const entry = this.cache.get(key); |
||||||
|
return entry !== undefined && !this.isExpired(entry); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear expired entries from cache |
||||||
|
*/ |
||||||
|
cleanup(): void { |
||||||
|
const now = Date.now(); |
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
if (this.isExpired(entry)) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cache entries |
||||||
|
*/ |
||||||
|
clear(): void { |
||||||
|
this.cache.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache size |
||||||
|
*/ |
||||||
|
size(): number { |
||||||
|
return this.cache.size; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache statistics |
||||||
|
*/ |
||||||
|
getStats(): { size: number; totalEvents: number; oldestEntry: number | null } { |
||||||
|
let totalEvents = 0; |
||||||
|
let oldestTimestamp: number | null = null; |
||||||
|
|
||||||
|
for (const entry of this.cache.values()) { |
||||||
|
totalEvents += entry.events.length; |
||||||
|
if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) { |
||||||
|
oldestTimestamp = entry.timestamp; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
size: this.cache.size, |
||||||
|
totalEvents, |
||||||
|
oldestEntry: oldestTimestamp |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const indexEventCache = new IndexEventCache(); |
||||||
|
|
||||||
|
// Clean up expired entries periodically
|
||||||
|
setInterval(() => { |
||||||
|
indexEventCache.cleanup(); |
||||||
|
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute
|
||||||
@ -0,0 +1,233 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { searchCache } from '$lib/utils/searchCache'; |
||||||
|
import { communityRelay, profileRelay } from '$lib/consts'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import type { NostrProfile, ProfileSearchResult } from './search_types'; |
||||||
|
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils'; |
||||||
|
import { checkCommunityStatus } from './community_checker'; |
||||||
|
import { TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for profiles by various criteria (display name, name, NIP-05, npub) |
||||||
|
*/ |
||||||
|
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> { |
||||||
|
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); |
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cachedResult = searchCache.get('profile', normalizedSearchTerm); |
||||||
|
if (cachedResult) { |
||||||
|
const profiles = cachedResult.events.map(event => { |
||||||
|
try { |
||||||
|
const profileData = JSON.parse(event.content); |
||||||
|
return createProfileFromEvent(event, profileData); |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
}).filter(Boolean) as NostrProfile[]; |
||||||
|
|
||||||
|
const communityStatus = await checkCommunityStatus(profiles); |
||||||
|
return { profiles, Status: communityStatus }; |
||||||
|
} |
||||||
|
|
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) { |
||||||
|
throw new Error('NDK not initialized'); |
||||||
|
} |
||||||
|
|
||||||
|
let foundProfiles: NostrProfile[] = []; |
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null; |
||||||
|
|
||||||
|
// Set a timeout to force completion after profile search timeout
|
||||||
|
timeoutId = setTimeout(() => { |
||||||
|
if (foundProfiles.length === 0) { |
||||||
|
// Timeout reached, but no need to log this
|
||||||
|
} |
||||||
|
}, TIMEOUTS.PROFILE_SEARCH); |
||||||
|
|
||||||
|
try { |
||||||
|
// Check if it's a valid npub/nprofile first
|
||||||
|
if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) { |
||||||
|
try { |
||||||
|
const metadata = await getUserMetadata(normalizedSearchTerm); |
||||||
|
if (metadata) { |
||||||
|
foundProfiles = [metadata]; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching metadata for npub:', error); |
||||||
|
} |
||||||
|
} else if (normalizedSearchTerm.includes('@')) { |
||||||
|
// Check if it's a NIP-05 address
|
||||||
|
try { |
||||||
|
const npub = await getNpubFromNip05(normalizedSearchTerm); |
||||||
|
if (npub) { |
||||||
|
const metadata = await getUserMetadata(npub); |
||||||
|
const profile: NostrProfile = { |
||||||
|
...metadata, |
||||||
|
pubkey: npub |
||||||
|
}; |
||||||
|
foundProfiles = [profile]; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error('[Search] NIP-05 lookup failed:', e); |
||||||
|
// If NIP-05 lookup fails, continue with regular search
|
||||||
|
} |
||||||
|
} else { |
||||||
|
// Try searching for NIP-05 addresses that match the search term
|
||||||
|
foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk); |
||||||
|
|
||||||
|
// If no NIP-05 results found, search for profiles across relays
|
||||||
|
if (foundProfiles.length === 0) { |
||||||
|
foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Wait for search to complete or timeout
|
||||||
|
await new Promise<void>((resolve) => { |
||||||
|
const checkComplete = () => { |
||||||
|
if (timeoutId === null || foundProfiles.length > 0) { |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
setTimeout(checkComplete, 100); |
||||||
|
} |
||||||
|
}; |
||||||
|
checkComplete(); |
||||||
|
}); |
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
if (foundProfiles.length > 0) { |
||||||
|
const events = foundProfiles.map(profile => { |
||||||
|
const event = new NDKEvent(ndk); |
||||||
|
event.content = JSON.stringify(profile); |
||||||
|
event.pubkey = profile.pubkey || ''; |
||||||
|
return event; |
||||||
|
}); |
||||||
|
|
||||||
|
const result = { |
||||||
|
events, |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: [], |
||||||
|
eventIds: new Set<string>(), |
||||||
|
addresses: new Set<string>(), |
||||||
|
searchType: 'profile', |
||||||
|
searchTerm: normalizedSearchTerm |
||||||
|
}; |
||||||
|
searchCache.set('profile', normalizedSearchTerm, result); |
||||||
|
} |
||||||
|
|
||||||
|
// Check community status for all profiles
|
||||||
|
const communityStatus = await checkCommunityStatus(foundProfiles); |
||||||
|
return { profiles: foundProfiles, Status: communityStatus }; |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Error searching profiles:', error); |
||||||
|
return { profiles: [], Status: {} }; |
||||||
|
} finally { |
||||||
|
if (timeoutId) { |
||||||
|
clearTimeout(timeoutId); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for NIP-05 addresses across common domains |
||||||
|
*/ |
||||||
|
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> { |
||||||
|
try { |
||||||
|
for (const domain of COMMON_DOMAINS) { |
||||||
|
const nip05Address = `${searchTerm}@${domain}`; |
||||||
|
try { |
||||||
|
const npub = await getNpubFromNip05(nip05Address); |
||||||
|
if (npub) { |
||||||
|
const metadata = await getUserMetadata(npub); |
||||||
|
const profile: NostrProfile = { |
||||||
|
...metadata, |
||||||
|
pubkey: npub |
||||||
|
}; |
||||||
|
return [profile]; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Continue to next domain
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error('[Search] NIP-05 domain search failed:', e); |
||||||
|
} |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for profiles across relays |
||||||
|
*/ |
||||||
|
async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise<NostrProfile[]> { |
||||||
|
const foundProfiles: NostrProfile[] = []; |
||||||
|
|
||||||
|
// Prioritize community relays for better search results
|
||||||
|
const allRelays = Array.from(ndk.pool.relays.values()) as any[]; |
||||||
|
const prioritizedRelays = new Set([ |
||||||
|
...allRelays.filter((relay: any) => relay.url === communityRelay), |
||||||
|
...allRelays.filter((relay: any) => relay.url !== communityRelay) |
||||||
|
]); |
||||||
|
const relaySet = new NDKRelaySet(prioritizedRelays as any, ndk); |
||||||
|
|
||||||
|
// Subscribe to profile events
|
||||||
|
const sub = ndk.subscribe( |
||||||
|
{ kinds: [0] }, |
||||||
|
{ closeOnEose: true }, |
||||||
|
relaySet |
||||||
|
); |
||||||
|
|
||||||
|
return new Promise((resolve) => { |
||||||
|
sub.on('event', (event: NDKEvent) => { |
||||||
|
try { |
||||||
|
if (!event.content) return; |
||||||
|
const profileData = JSON.parse(event.content); |
||||||
|
const displayName = profileData.displayName || profileData.display_name || ''; |
||||||
|
const display_name = profileData.display_name || ''; |
||||||
|
const name = profileData.name || ''; |
||||||
|
const nip05 = profileData.nip05 || ''; |
||||||
|
const about = profileData.about || ''; |
||||||
|
|
||||||
|
// Check if any field matches the search term
|
||||||
|
const matchesDisplayName = fieldMatches(displayName, searchTerm); |
||||||
|
const matchesDisplay_name = fieldMatches(display_name, searchTerm); |
||||||
|
const matchesName = fieldMatches(name, searchTerm); |
||||||
|
const matchesNip05 = nip05Matches(nip05, searchTerm); |
||||||
|
const matchesAbout = fieldMatches(about, searchTerm); |
||||||
|
|
||||||
|
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) { |
||||||
|
const profile = createProfileFromEvent(event, profileData); |
||||||
|
|
||||||
|
// Check if we already have this profile
|
||||||
|
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey); |
||||||
|
if (existingIndex === -1) { |
||||||
|
foundProfiles.push(profile); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Invalid JSON or other error, skip
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
sub.on('eose', () => { |
||||||
|
if (foundProfiles.length > 0) { |
||||||
|
// Deduplicate by pubkey, keep only newest
|
||||||
|
const deduped: Record<string, { profile: NostrProfile; created_at: number }> = {}; |
||||||
|
for (const profile of foundProfiles) { |
||||||
|
const pubkey = profile.pubkey; |
||||||
|
if (pubkey) { |
||||||
|
// We don't have created_at from getUserMetadata, so just keep the first one
|
||||||
|
if (!deduped[pubkey]) { |
||||||
|
deduped[pubkey] = { profile, created_at: 0 }; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
const dedupedProfiles = Object.values(deduped).map(x => x.profile); |
||||||
|
resolve(dedupedProfiles); |
||||||
|
} else { |
||||||
|
resolve([]); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
}
|
||||||
@ -0,0 +1,105 @@ |
|||||||
|
import type { NDKEvent } from "./nostrUtils"; |
||||||
|
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
export interface SearchResult { |
||||||
|
events: NDKEvent[]; |
||||||
|
secondOrder: NDKEvent[]; |
||||||
|
tTagEvents: NDKEvent[]; |
||||||
|
eventIds: Set<string>; |
||||||
|
addresses: Set<string>; |
||||||
|
searchType: string; |
||||||
|
searchTerm: string; |
||||||
|
timestamp: number; |
||||||
|
} |
||||||
|
|
||||||
|
class SearchCache { |
||||||
|
private cache: Map<string, SearchResult> = new Map(); |
||||||
|
private readonly CACHE_DURATION = CACHE_DURATIONS.SEARCH_CACHE; |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a cache key for a search |
||||||
|
*/ |
||||||
|
private generateKey(searchType: string, searchTerm: string): string { |
||||||
|
if (!searchTerm) { |
||||||
|
return `${searchType}:`; |
||||||
|
} |
||||||
|
return `${searchType}:${searchTerm.toLowerCase().trim()}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a cached result is still valid |
||||||
|
*/ |
||||||
|
private isExpired(result: SearchResult): boolean { |
||||||
|
return Date.now() - result.timestamp > this.CACHE_DURATION; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached search results |
||||||
|
*/ |
||||||
|
get(searchType: string, searchTerm: string): SearchResult | null { |
||||||
|
const key = this.generateKey(searchType, searchTerm); |
||||||
|
const result = this.cache.get(key); |
||||||
|
|
||||||
|
if (!result || this.isExpired(result)) { |
||||||
|
if (result) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store search results in cache |
||||||
|
*/ |
||||||
|
set(searchType: string, searchTerm: string, result: Omit<SearchResult, 'timestamp'>): void { |
||||||
|
const key = this.generateKey(searchType, searchTerm); |
||||||
|
this.cache.set(key, { |
||||||
|
...result, |
||||||
|
timestamp: Date.now() |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a search result is cached and valid |
||||||
|
*/ |
||||||
|
has(searchType: string, searchTerm: string): boolean { |
||||||
|
const key = this.generateKey(searchType, searchTerm); |
||||||
|
const result = this.cache.get(key); |
||||||
|
return result !== undefined && !this.isExpired(result); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear expired entries from cache |
||||||
|
*/ |
||||||
|
cleanup(): void { |
||||||
|
const now = Date.now(); |
||||||
|
for (const [key, result] of this.cache.entries()) { |
||||||
|
if (this.isExpired(result)) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cache entries |
||||||
|
*/ |
||||||
|
clear(): void { |
||||||
|
this.cache.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache size |
||||||
|
*/ |
||||||
|
size(): number { |
||||||
|
return this.cache.size; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const searchCache = new SearchCache(); |
||||||
|
|
||||||
|
// Clean up expired entries periodically
|
||||||
|
setInterval(() => { |
||||||
|
searchCache.cleanup(); |
||||||
|
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute
|
||||||
@ -0,0 +1,121 @@ |
|||||||
|
/** |
||||||
|
* Search and Event Utility Constants |
||||||
|
*
|
||||||
|
* This file centralizes all magic numbers used throughout the search and event utilities |
||||||
|
* to improve maintainability and reduce code duplication. |
||||||
|
*/ |
||||||
|
|
||||||
|
// Timeout constants (in milliseconds)
|
||||||
|
export const TIMEOUTS = { |
||||||
|
/** Default timeout for event fetching operations */ |
||||||
|
EVENT_FETCH: 10000, |
||||||
|
|
||||||
|
/** Timeout for profile search operations */ |
||||||
|
PROFILE_SEARCH: 15000, |
||||||
|
|
||||||
|
/** Timeout for subscription search operations */ |
||||||
|
SUBSCRIPTION_SEARCH: 30000, |
||||||
|
|
||||||
|
/** Timeout for relay diagnostics */ |
||||||
|
RELAY_DIAGNOSTICS: 5000, |
||||||
|
|
||||||
|
/** Timeout for general operations */ |
||||||
|
GENERAL: 5000, |
||||||
|
|
||||||
|
/** Cache cleanup interval */ |
||||||
|
CACHE_CLEANUP: 60000, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Cache duration constants (in milliseconds)
|
||||||
|
export const CACHE_DURATIONS = { |
||||||
|
/** Default cache duration for search results */ |
||||||
|
SEARCH_CACHE: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|
||||||
|
/** Cache duration for index events */ |
||||||
|
INDEX_EVENT_CACHE: 10 * 60 * 1000, // 10 minutes
|
||||||
|
} as const; |
||||||
|
|
||||||
|
// Search limits
|
||||||
|
export const SEARCH_LIMITS = { |
||||||
|
/** Limit for specific profile searches (npub, NIP-05) */ |
||||||
|
SPECIFIC_PROFILE: 10, |
||||||
|
|
||||||
|
/** Limit for general profile searches */ |
||||||
|
GENERAL_PROFILE: 500, |
||||||
|
|
||||||
|
/** Limit for community relay checks */ |
||||||
|
COMMUNITY_CHECK: 1, |
||||||
|
|
||||||
|
/** Limit for second-order search results */ |
||||||
|
SECOND_ORDER_RESULTS: 100, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Nostr event kind ranges
|
||||||
|
export const EVENT_KINDS = { |
||||||
|
/** Replaceable event kinds (0, 3, 10000-19999) */ |
||||||
|
REPLACEABLE: { |
||||||
|
MIN: 0, |
||||||
|
MAX: 19999, |
||||||
|
SPECIFIC: [0, 3], |
||||||
|
}, |
||||||
|
|
||||||
|
/** Parameterized replaceable event kinds (20000-29999) */ |
||||||
|
PARAMETERIZED_REPLACEABLE: { |
||||||
|
MIN: 20000, |
||||||
|
MAX: 29999, |
||||||
|
}, |
||||||
|
|
||||||
|
/** Addressable event kinds (30000-39999) */ |
||||||
|
ADDRESSABLE: { |
||||||
|
MIN: 30000, |
||||||
|
MAX: 39999, |
||||||
|
}, |
||||||
|
|
||||||
|
/** Comment event kind */ |
||||||
|
COMMENT: 1111, |
||||||
|
|
||||||
|
/** Text note event kind */ |
||||||
|
TEXT_NOTE: 1, |
||||||
|
|
||||||
|
/** Profile metadata event kind */ |
||||||
|
PROFILE_METADATA: 0, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Relay-specific constants
|
||||||
|
export const RELAY_CONSTANTS = { |
||||||
|
/** Request ID for community relay checks */ |
||||||
|
COMMUNITY_REQUEST_ID: 'alexandria-forest', |
||||||
|
|
||||||
|
/** Default relay request kinds for community checks */ |
||||||
|
COMMUNITY_REQUEST_KINDS: [1], |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Time constants
|
||||||
|
export const TIME_CONSTANTS = { |
||||||
|
/** Unix timestamp conversion factor (seconds to milliseconds) */ |
||||||
|
UNIX_TIMESTAMP_FACTOR: 1000, |
||||||
|
|
||||||
|
/** Current timestamp in seconds */ |
||||||
|
CURRENT_TIMESTAMP: Math.floor(Date.now() / 1000), |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Validation constants
|
||||||
|
export const VALIDATION = { |
||||||
|
/** Hex string length for event IDs and pubkeys */ |
||||||
|
HEX_LENGTH: 64, |
||||||
|
|
||||||
|
/** Minimum length for Nostr identifiers */ |
||||||
|
MIN_NOSTR_IDENTIFIER_LENGTH: 4, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// HTTP status codes
|
||||||
|
export const HTTP_STATUS = { |
||||||
|
/** OK status code */ |
||||||
|
OK: 200, |
||||||
|
|
||||||
|
/** Not found status code */ |
||||||
|
NOT_FOUND: 404, |
||||||
|
|
||||||
|
/** Internal server error status code */ |
||||||
|
INTERNAL_SERVER_ERROR: 500, |
||||||
|
} as const;
|
||||||
@ -0,0 +1,69 @@ |
|||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Extended NostrProfile interface for search results |
||||||
|
*/ |
||||||
|
export interface NostrProfile { |
||||||
|
name?: string; |
||||||
|
displayName?: string; |
||||||
|
nip05?: string; |
||||||
|
picture?: string; |
||||||
|
about?: string; |
||||||
|
banner?: string; |
||||||
|
website?: string; |
||||||
|
lud16?: string; |
||||||
|
pubkey?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search result interface for subscription-based searches |
||||||
|
*/ |
||||||
|
export interface SearchResult { |
||||||
|
events: NDKEvent[]; |
||||||
|
secondOrder: NDKEvent[]; |
||||||
|
tTagEvents: NDKEvent[]; |
||||||
|
eventIds: Set<string>; |
||||||
|
addresses: Set<string>; |
||||||
|
searchType: string; |
||||||
|
searchTerm: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Profile search result interface |
||||||
|
*/ |
||||||
|
export interface ProfileSearchResult { |
||||||
|
profiles: NostrProfile[]; |
||||||
|
Status: Record<string, boolean>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search subscription type |
||||||
|
*/ |
||||||
|
export type SearchSubscriptionType = 'd' | 't' | 'n'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search filter configuration |
||||||
|
*/ |
||||||
|
export interface SearchFilter { |
||||||
|
filter: any; |
||||||
|
subscriptionType: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Second-order search parameters |
||||||
|
*/ |
||||||
|
export interface SecondOrderSearchParams { |
||||||
|
searchType: 'n' | 'd'; |
||||||
|
firstOrderEvents: NDKEvent[]; |
||||||
|
eventIds?: Set<string>; |
||||||
|
addresses?: Set<string>; |
||||||
|
targetPubkey?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search callback functions |
||||||
|
*/ |
||||||
|
export interface SearchCallbacks { |
||||||
|
onSecondOrderUpdate?: (result: SearchResult) => void; |
||||||
|
onSubscriptionCreated?: (sub: any) => void; |
||||||
|
}
|
||||||
@ -0,0 +1,25 @@ |
|||||||
|
// Re-export all search functionality from modular files
|
||||||
|
export * from './search_types'; |
||||||
|
export * from './search_utils'; |
||||||
|
export * from './community_checker'; |
||||||
|
export * from './profile_search'; |
||||||
|
export * from './event_search'; |
||||||
|
export * from './subscription_search'; |
||||||
|
export * from './search_constants'; |
||||||
|
|
||||||
|
// Legacy exports for backward compatibility
|
||||||
|
export { searchProfiles } from './profile_search'; |
||||||
|
export { searchBySubscription } from './subscription_search'; |
||||||
|
export { searchEvent, searchNip05 } from './event_search'; |
||||||
|
export { checkCommunity } from './community_checker'; |
||||||
|
export {
|
||||||
|
wellKnownUrl,
|
||||||
|
lnurlpWellKnownUrl,
|
||||||
|
isValidNip05Address, |
||||||
|
normalizeSearchTerm, |
||||||
|
fieldMatches, |
||||||
|
nip05Matches, |
||||||
|
COMMON_DOMAINS, |
||||||
|
isEmojiReaction, |
||||||
|
createProfileFromEvent |
||||||
|
} from './search_utils';
|
||||||
@ -0,0 +1,104 @@ |
|||||||
|
/** |
||||||
|
* Generate well-known NIP-05 URL |
||||||
|
*/ |
||||||
|
export function wellKnownUrl(domain: string, name: string): string { |
||||||
|
return `https://${domain}/.well-known/nostr.json?name=${name}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate well-known LNURLp URL for Lightning Network addresses |
||||||
|
*/ |
||||||
|
export function lnurlpWellKnownUrl(domain: string, name: string): string { |
||||||
|
return `https://${domain}/.well-known/lnurlp/${name}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate NIP-05 address format |
||||||
|
*/ |
||||||
|
export function isValidNip05Address(address: string): boolean { |
||||||
|
return /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(address); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to normalize search terms |
||||||
|
*/ |
||||||
|
export function normalizeSearchTerm(term: string): string { |
||||||
|
return term.toLowerCase().replace(/\s+/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to check if a profile field matches the search term |
||||||
|
*/ |
||||||
|
export function fieldMatches(field: string, searchTerm: string): boolean { |
||||||
|
if (!field) return false; |
||||||
|
const fieldLower = field.toLowerCase(); |
||||||
|
const fieldNormalized = fieldLower.replace(/\s+/g, ''); |
||||||
|
const searchTermLower = searchTerm.toLowerCase(); |
||||||
|
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); |
||||||
|
|
||||||
|
// Check exact match
|
||||||
|
if (fieldLower === searchTermLower) return true; |
||||||
|
if (fieldNormalized === normalizedSearchTerm) return true; |
||||||
|
|
||||||
|
// Check if field contains the search term
|
||||||
|
if (fieldLower.includes(searchTermLower)) return true; |
||||||
|
if (fieldNormalized.includes(normalizedSearchTerm)) return true; |
||||||
|
|
||||||
|
// Check individual words (handle spaces in display names)
|
||||||
|
const words = fieldLower.split(/\s+/); |
||||||
|
return words.some(word => word.includes(searchTermLower)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to check if NIP-05 address matches the search term |
||||||
|
*/ |
||||||
|
export function nip05Matches(nip05: string, searchTerm: string): boolean { |
||||||
|
if (!nip05) return false; |
||||||
|
const nip05Lower = nip05.toLowerCase(); |
||||||
|
const searchTermLower = searchTerm.toLowerCase(); |
||||||
|
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); |
||||||
|
|
||||||
|
// Check if the part before @ contains the search term
|
||||||
|
const atIndex = nip05Lower.indexOf('@'); |
||||||
|
if (atIndex !== -1) { |
||||||
|
const localPart = nip05Lower.substring(0, atIndex); |
||||||
|
const localPartNormalized = localPart.replace(/\s+/g, ''); |
||||||
|
return localPart.includes(searchTermLower) || localPartNormalized.includes(normalizedSearchTerm); |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Common domains for NIP-05 lookups |
||||||
|
*/ |
||||||
|
export const COMMON_DOMAINS = [ |
||||||
|
'gitcitadel.com', |
||||||
|
'theforest.nostr1.com', |
||||||
|
'nostr1.com', |
||||||
|
'nostr.land', |
||||||
|
'sovbit.host' |
||||||
|
] as const; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if an event is an emoji reaction (kind 7) |
||||||
|
*/ |
||||||
|
export function isEmojiReaction(event: any): boolean { |
||||||
|
return event.kind === 7; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a profile object from event data |
||||||
|
*/ |
||||||
|
export function createProfileFromEvent(event: any, profileData: any): any { |
||||||
|
return { |
||||||
|
name: profileData.name, |
||||||
|
displayName: profileData.displayName || profileData.display_name, |
||||||
|
nip05: profileData.nip05, |
||||||
|
picture: profileData.picture, |
||||||
|
about: profileData.about, |
||||||
|
banner: profileData.banner, |
||||||
|
website: profileData.website, |
||||||
|
lud16: profileData.lud16, |
||||||
|
pubkey: event.pubkey |
||||||
|
}; |
||||||
|
}
|
||||||
@ -0,0 +1,651 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils'; |
||||||
|
import { nip19 } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { searchCache } from '$lib/utils/searchCache'; |
||||||
|
import { communityRelay, profileRelay } from '$lib/consts'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types'; |
||||||
|
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils'; |
||||||
|
import { TIMEOUTS, SEARCH_LIMITS } from './search_constants'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for events by subscription type (d, t, n) |
||||||
|
*/ |
||||||
|
export async function searchBySubscription( |
||||||
|
searchType: SearchSubscriptionType, |
||||||
|
searchTerm: string, |
||||||
|
callbacks?: SearchCallbacks, |
||||||
|
abortSignal?: AbortSignal |
||||||
|
): Promise<SearchResult> { |
||||||
|
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); |
||||||
|
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); |
||||||
|
|
||||||
|
// 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); |
||||||
|
|
||||||
|
// Start Phase 2 in background for additional results
|
||||||
|
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup); |
||||||
|
|
||||||
|
return immediateResult; |
||||||
|
} 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
|
||||||
|
return searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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] }, |
||||||
|
subscriptionType: 'd-tag' |
||||||
|
}; |
||||||
|
console.log("subscription_search: Created d-tag filter:", dFilter); |
||||||
|
return dFilter; |
||||||
|
case 't': |
||||||
|
const tFilter = { |
||||||
|
filter: { "#t": [normalizedSearchTerm] }, |
||||||
|
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: SEARCH_LIMITS.SPECIFIC_PROFILE }, |
||||||
|
subscriptionType: 'npub-specific' |
||||||
|
}; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// 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: SEARCH_LIMITS.SPECIFIC_PROFILE }, |
||||||
|
subscriptionType: 'nip05-found' |
||||||
|
}; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Continue to next domain
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Fallback to reasonable profile search
|
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE }, |
||||||
|
subscriptionType: 'profile' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create primary relay set based on search type |
||||||
|
*/ |
||||||
|
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet { |
||||||
|
if (searchType === 'n') { |
||||||
|
// For profile searches, use profile relay first
|
||||||
|
const profileRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
|
||||||
|
relay.url === profileRelay || relay.url === profileRelay + '/' |
||||||
|
); |
||||||
|
return new NDKRelaySet(new Set(profileRelays) as any, ndk); |
||||||
|
} else { |
||||||
|
// For other searches, use community relay first
|
||||||
|
const communityRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
|
||||||
|
relay.url === communityRelay || relay.url === communityRelay + '/' |
||||||
|
); |
||||||
|
return new NDKRelaySet(new Set(communityRelays) 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); |
||||||
|
} |
||||||
|
const aTags = getMatchingTags(event, "a"); |
||||||
|
aTags.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 : searchState.firstOrderEvents, |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: searchType === 't' ? searchState.tTagEvents : [], |
||||||
|
eventIds: searchState.eventIds, |
||||||
|
addresses: searchState.eventAddresses, |
||||||
|
searchType: searchType, |
||||||
|
searchTerm: normalizedSearchTerm |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search other relays in background |
||||||
|
*/ |
||||||
|
async function searchOtherRelaysInBackground( |
||||||
|
searchType: SearchSubscriptionType, |
||||||
|
searchFilter: SearchFilter, |
||||||
|
searchState: any, |
||||||
|
callbacks?: SearchCallbacks, |
||||||
|
abortSignal?: AbortSignal, |
||||||
|
cleanup?: () => void |
||||||
|
): Promise<SearchResult> { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
|
||||||
|
const otherRelays = new NDKRelaySet( |
||||||
|
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => { |
||||||
|
if (searchType === 'n') { |
||||||
|
// For profile searches, exclude profile relay from fallback search
|
||||||
|
return relay.url !== profileRelay && relay.url !== profileRelay + '/'; |
||||||
|
} else { |
||||||
|
// For other searches, exclude community relay from fallback search
|
||||||
|
return relay.url !== communityRelay && relay.url !== communityRelay + '/'; |
||||||
|
} |
||||||
|
})), |
||||||
|
ndk |
||||||
|
); |
||||||
|
|
||||||
|
// 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 (e) { |
||||||
|
// 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) { |
||||||
|
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), targetPubkey, callbacks); |
||||||
|
} |
||||||
|
} 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) { |
||||||
|
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), profile.pubkey, callbacks); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
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: [], |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: searchState.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 { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
let allSecondOrderEvents: NDKEvent[] = []; |
||||||
|
|
||||||
|
if (searchType === 'n' && targetPubkey) { |
||||||
|
// Search for events that mention this pubkey via p-tags
|
||||||
|
const pTagFilter = { "#p": [targetPubkey] }; |
||||||
|
const pTagEvents = await ndk.fetchEvents( |
||||||
|
pTagFilter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), |
||||||
|
); |
||||||
|
|
||||||
|
// Filter out emoji reactions
|
||||||
|
const filteredEvents = Array.from(pTagEvents).filter(event => !isEmojiReaction(event)); |
||||||
|
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents]; |
||||||
|
|
||||||
|
} else if (searchType === 'd') { |
||||||
|
// Search for events that reference the original events via e-tags and a-tags
|
||||||
|
|
||||||
|
// Search for events that reference the original events via e-tags
|
||||||
|
if (eventIds.size > 0) { |
||||||
|
const eTagFilter = { "#e": Array.from(eventIds) }; |
||||||
|
const eTagEvents = await ndk.fetchEvents( |
||||||
|
eTagFilter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), |
||||||
|
); |
||||||
|
|
||||||
|
// Filter out emoji reactions
|
||||||
|
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event)); |
||||||
|
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents]; |
||||||
|
} |
||||||
|
|
||||||
|
// Search for events that reference the original events via a-tags
|
||||||
|
if (addresses.size > 0) { |
||||||
|
const aTagFilter = { "#a": Array.from(addresses) }; |
||||||
|
const aTagEvents = await ndk.fetchEvents( |
||||||
|
aTagFilter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), |
||||||
|
); |
||||||
|
|
||||||
|
// Filter out emoji reactions
|
||||||
|
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event)); |
||||||
|
allSecondOrderEvents = [...allSecondOrderEvents, ...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); |
||||||
|
|
||||||
|
// 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) { |
||||||
|
callbacks.onSecondOrderUpdate(result); |
||||||
|
} |
||||||
|
|
||||||
|
} catch (err) { |
||||||
|
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err); |
||||||
|
} |
||||||
|
}
|
||||||
Loading…
Reference in new issue