Browse Source

add relay hints

imwald
Silberengel 1 month ago
parent
commit
0ab025d575
  1. 143
      src/components/Username/index.tsx
  2. 103
      src/hooks/useFetchProfile.tsx
  3. 171
      src/services/client-events.service.ts
  4. 193
      src/services/client-replaceable-events.service.ts

143
src/components/Username/index.tsx

@ -1,8 +1,10 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { formatPubkey, userIdToPubkey, pubkeyToNpub, formatNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSmartProfileNavigation } from '@/PageManager' import { useSmartProfileNavigation } from '@/PageManager'
import { useMemo } from 'react'
export default function Username({ export default function Username({
userId, userId,
@ -19,10 +21,17 @@ export default function Username({
withoutSkeleton?: boolean withoutSkeleton?: boolean
style?: React.CSSProperties style?: React.CSSProperties
}) { }) {
const { profile } = useFetchProfile(userId) const { profile, isFetching } = useFetchProfile(userId)
const { navigateToProfile } = useSmartProfileNavigation() const { navigateToProfile } = useSmartProfileNavigation()
if (!profile && !withoutSkeleton) { // Get pubkey from userId (works even if profile isn't loaded)
const pubkey = useMemo(() => {
if (profile?.pubkey) return profile.pubkey
return userIdToPubkey(userId) || ''
}, [userId, profile?.pubkey])
// Show skeleton while fetching (unless withoutSkeleton is true)
if (isFetching && !withoutSkeleton) {
return ( return (
<div className="py-1"> <div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} /> <Skeleton className={cn('w-16', skeletonClassName)} />
@ -30,26 +39,57 @@ export default function Username({
) )
} }
if (!profile) { // If we have a profile, show the username
return null if (profile) {
const { username, pubkey: profilePubkey } = profile
return (
<span
data-username
className={cn('truncate hover:underline cursor-pointer', className)}
style={{ verticalAlign: 'baseline', ...style }}
onClick={(e) => {
e.stopPropagation()
navigateToProfile(toProfile(profilePubkey))
}}
>
{showAt && '@'}
{username}
</span>
)
}
// Fallback: show formatted npub (bech32) if we have a pubkey (even if profile fetch failed)
if (pubkey) {
// Convert to npub (bech32) format for display
const npub = pubkeyToNpub(pubkey)
const displayName = npub ? formatNpub(npub) : formatPubkey(pubkey)
return (
<span
data-username
className={cn('truncate hover:underline cursor-pointer', className)}
style={{ verticalAlign: 'baseline', ...style }}
onClick={(e) => {
e.stopPropagation()
navigateToProfile(toProfile(pubkey))
}}
>
{showAt && '@'}
{displayName}
</span>
)
}
// No pubkey available - return null or skeleton based on withoutSkeleton
if (!withoutSkeleton) {
return (
<div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} />
</div>
)
} }
const { username, pubkey } = profile return null
return (
<span
data-username
className={cn('truncate hover:underline cursor-pointer', className)}
style={{ verticalAlign: 'baseline', ...style }}
onClick={(e) => {
e.stopPropagation()
navigateToProfile(toProfile(pubkey))
}}
>
{showAt && '@'}
{username}
</span>
)
} }
export function SimpleUsername({ export function SimpleUsername({
@ -67,9 +107,16 @@ export function SimpleUsername({
withoutSkeleton?: boolean withoutSkeleton?: boolean
style?: React.CSSProperties style?: React.CSSProperties
}) { }) {
const { profile } = useFetchProfile(userId) const { profile, isFetching } = useFetchProfile(userId)
if (!profile && !withoutSkeleton) { // Get pubkey from userId (works even if profile isn't loaded)
const pubkey = useMemo(() => {
if (profile?.pubkey) return profile.pubkey
return userIdToPubkey(userId) || ''
}, [userId, profile?.pubkey])
// Show skeleton while fetching (unless withoutSkeleton is true)
if (isFetching && !withoutSkeleton) {
return ( return (
<div className="py-1"> <div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} /> <Skeleton className={cn('w-16', skeletonClassName)} />
@ -77,19 +124,45 @@ export function SimpleUsername({
) )
} }
if (!profile) { // If we have a profile, show the username
return null if (profile) {
const { username } = profile
return (
<span
className={cn('truncate', className)}
style={{ verticalAlign: 'baseline', ...style }}
>
{showAt && '@'}
{username}
</span>
)
}
// Fallback: show formatted npub (bech32) if we have a pubkey (even if profile fetch failed)
if (pubkey) {
// Convert to npub (bech32) format for display
const npub = pubkeyToNpub(pubkey)
const displayName = npub ? formatNpub(npub) : formatPubkey(pubkey)
return (
<span
className={cn('truncate', className)}
style={{ verticalAlign: 'baseline', ...style }}
>
{showAt && '@'}
{displayName}
</span>
)
}
// No pubkey available - return null or skeleton based on withoutSkeleton
if (!withoutSkeleton) {
return (
<div className="py-1">
<Skeleton className={cn('w-16', skeletonClassName)} />
</div>
)
} }
const { username } = profile return null
return (
<span
className={cn('truncate', className)}
style={{ verticalAlign: 'baseline', ...style }}
>
{showAt && '@'}
{username}
</span>
)
} }

103
src/hooks/useFetchProfile.tsx

@ -4,7 +4,7 @@ import { useNostr } from '@/providers/NostrProvider'
import { replaceableEventService } from '@/services/client.service' import { replaceableEventService } from '@/services/client.service'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { useEffect, useState } from 'react' import { useEffect, useState, useRef } from 'react'
export function useFetchProfile(id?: string, skipCache = false) { export function useFetchProfile(id?: string, skipCache = false) {
const { profile: currentAccountProfile } = useNostr() const { profile: currentAccountProfile } = useNostr()
@ -12,6 +12,39 @@ export function useFetchProfile(id?: string, skipCache = false) {
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
const [profile, setProfile] = useState<TProfile | null>(null) const [profile, setProfile] = useState<TProfile | null>(null)
const [pubkey, setPubkey] = useState<string | null>(null) const [pubkey, setPubkey] = useState<string | null>(null)
const checkIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Function to check for profile updates
const checkProfile = async (pubkey: string, cancelled: { current: boolean }) => {
if (cancelled.current) return
try {
// Re-check cache (might have been updated by background fetch)
const profileEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata)
if (cancelled.current) return
if (profileEvent) {
const newProfile = getProfileFromEvent(profileEvent)
if (newProfile) {
setProfile(newProfile)
setIsFetching(false)
// Clear interval once we have a profile
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
return true
}
}
return false
} catch (err) {
if (!cancelled.current) {
setError(err as Error)
}
return false
}
}
useEffect(() => { useEffect(() => {
if (!id) { if (!id) {
@ -22,47 +55,71 @@ export function useFetchProfile(id?: string, skipCache = false) {
return return
} }
let cancelled = false const cancelled = { current: false }
const pubkey = userIdToPubkey(id) const pubkey = userIdToPubkey(id)
setPubkey(pubkey) setPubkey(pubkey)
const run = async () => { const run = async () => {
setIsFetching(true) setIsFetching(true)
try { setError(null)
// fetchReplaceableEvent now checks in-memory cache first (instant), then IndexedDB, then network
// This is optimized for speed - memory cache is synchronous // Initial fetch
const profileEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata) const found = await checkProfile(pubkey, cancelled)
if (cancelled) return if (cancelled.current) return
if (profileEvent) { if (found) {
const profile = getProfileFromEvent(profileEvent) // Profile found, we're done
if (profile) { return
setProfile(profile) }
setIsFetching(false)
return // Return immediately with cached/fetched profile // No profile found yet - set fetching to false but keep checking in background
setIsFetching(false)
// If no profile was found, periodically re-check (profiles might load asynchronously)
// Check every 2 seconds for up to 30 seconds (15 checks)
let checkCount = 0
const maxChecks = 15
checkIntervalRef.current = setInterval(async () => {
if (cancelled.current || checkCount >= maxChecks) {
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
} }
return
} }
// If we get here, no profile was found checkCount++
setIsFetching(false) const found = await checkProfile(pubkey, cancelled)
} catch (err) { if (found) {
if (!cancelled) { // Profile found, stop checking
setError(err as Error) if (checkIntervalRef.current) {
setIsFetching(false) clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
} }
} }, 2000) // Check every 2 seconds
} }
run() run()
return () => { return () => {
cancelled = true cancelled.current = true
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
} }
}, [id, skipCache]) }, [id, skipCache])
useEffect(() => { useEffect(() => {
if (currentAccountProfile && pubkey === currentAccountProfile.pubkey) { if (currentAccountProfile && pubkey === currentAccountProfile.pubkey) {
setProfile(currentAccountProfile) setProfile(currentAccountProfile)
// Clear interval if we got the profile from current account
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current)
checkIntervalRef.current = null
}
} }
}, [currentAccountProfile, pubkey]) }, [currentAccountProfile, pubkey])

171
src/services/client-events.service.ts

@ -1,11 +1,80 @@
import { FAST_READ_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import type { Event as NEvent, Filter } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
import DataLoader from 'dataloader' import DataLoader from 'dataloader'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import type { QueryService } from './client-query.service' import type { QueryService } from './client-query.service'
import client from './client.service'
/**
* Build comprehensive relay list: author's outboxes + user's inboxes + relay hints + defaults
*/
async function buildComprehensiveRelayList(
authorPubkey: string | undefined,
relayHints: string[] = [],
seenRelays: string[] = []
): Promise<string[]> {
const relayUrls = new Set<string>()
// 1. Add relay hints (highest priority - these are explicit hints)
relayHints.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// 2. Add relays where event was seen
seenRelays.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// 3. Add author's outboxes (write relays) - where they publish
if (authorPubkey) {
try {
const authorRelayList = await client.fetchRelayList(authorPubkey)
const authorOutboxes = (authorRelayList.write || []).slice(0, 10) // Limit to 10 to avoid too many
authorOutboxes.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
logger.debug('[EventService] Added author outboxes', {
author: authorPubkey.substring(0, 8),
count: authorOutboxes.length
})
} catch (error) {
logger.debug('[EventService] Failed to fetch author relay list', { error })
}
}
// 4. Add logged-in user's inboxes (read relays) - where they receive events
const userPubkey = client.pubkey
if (userPubkey) {
try {
const userRelayList = await client.fetchRelayList(userPubkey)
const userInboxes = (userRelayList.read || []).slice(0, 10) // Limit to 10
userInboxes.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
logger.debug('[EventService] Added user inboxes', {
count: userInboxes.length
})
} catch (error) {
logger.debug('[EventService] Failed to fetch user relay list', { error })
}
}
// 5. Add default fast read relays as fallback
FAST_READ_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
return Array.from(relayUrls)
}
export class EventService { export class EventService {
private queryService: QueryService private queryService: QueryService
@ -129,6 +198,39 @@ export class EventService {
return results return results
} }
/**
* Extract relay hints from event tags
* Relay hints are in the 3rd position (index 2) of e, a, q, etc. tags
* Also checks for a dedicated "relays" tag
*/
private extractRelayHintsFromEvent(event: NEvent | undefined): string[] {
if (!event) return []
const hints = new Set<string>()
// Extract from e, a, q tags (relay hint is in position 2, index 2)
const tagTypesWithRelayHints = ['e', 'a', 'q']
for (const tag of event.tags) {
if (tagTypesWithRelayHints.includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') {
const hint = tag[2]
if (hint.startsWith('wss://') || hint.startsWith('ws://')) {
hints.add(hint)
}
}
}
// Also check for dedicated "relays" tag
const relaysTag = event.tags.find(tag => tag[0] === 'relays')
if (relaysTag && relaysTag.length > 1) {
relaysTag.slice(1).forEach(url => {
if (typeof url === 'string' && (url.startsWith('wss://') || url.startsWith('ws://'))) {
hints.add(url)
}
})
}
return Array.from(hints)
}
/** /**
* Clear all in-memory event caches * Clear all in-memory event caches
*/ */
@ -180,32 +282,34 @@ export class EventService {
const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0]) const cached = await indexedDb.getEventFromPublicationStore(filter.ids[0])
if (cached) { if (cached) {
this.addEventToCache(cached) this.addEventToCache(cached)
// Extract relay hints from cached event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(cached)
if (eventRelayHints.length > 0) {
relays = [...new Set([...relays, ...eventRelayHints])]
}
return cached return cached
} }
} }
// Try big relays first // Try big relays first (uses user's inboxes + defaults)
if (filter.ids?.length) { if (filter.ids?.length) {
const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0]) const event = await this.fetchEventFromBigRelaysDataloader.load(filter.ids[0])
if (event) { if (event) {
this.addEventToCache(event) this.addEventToCache(event)
// Extract relay hints from found event's tags (e, a, q tags)
const eventRelayHints = this.extractRelayHintsFromEvent(event)
if (eventRelayHints.length > 0) {
relays = [...new Set([...relays, ...eventRelayHints])]
}
return event return event
} }
} }
// Try harder with specified relays or author relays // Always try comprehensive relay list (author's outboxes + user's inboxes + hints + seen + defaults)
if (filter.ids?.length && relays.length) { const event = await this.tryHarderToFetchEvent(relays, filter, true)
const event = await this.tryHarderToFetchEvent(relays, filter, true) if (event) {
if (event) { this.addEventToCache(event)
this.addEventToCache(event) return event
return event
}
} else if (filter.authors?.length) {
const event = await this.tryHarderToFetchEvent(relays, filter, false)
if (event) {
this.addEventToCache(event)
return event
}
} }
return undefined return undefined
@ -213,19 +317,36 @@ export class EventService {
/** /**
* Private: Try harder to fetch event from relays * Private: Try harder to fetch event from relays
* ALWAYS uses: author's outboxes + user's inboxes + relay hints + seen relays + defaults
*/ */
private async tryHarderToFetchEvent( private async tryHarderToFetchEvent(
relayUrls: string[], relayHints: string[],
filter: Filter, filter: Filter,
alreadyFetchedFromBigRelays = false alreadyFetchedFromBigRelays = false
): Promise<NEvent | undefined> { ): Promise<NEvent | undefined> {
if (!relayUrls.length && filter.authors?.length) { // Get seen relays if we have an event ID
// Would need relay list service - for now use big relays const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : []
relayUrls = FAST_READ_RELAY_URLS
} else if (!relayUrls.length && !alreadyFetchedFromBigRelays) { // Get author pubkey
relayUrls = FAST_READ_RELAY_URLS const authorPubkey = filter.authors?.length === 1 ? filter.authors[0] : undefined
// Build comprehensive relay list
const relayUrls = await buildComprehensiveRelayList(authorPubkey, relayHints, seenRelays)
if (!relayUrls.length) {
// Fallback to default relays if comprehensive list is empty
if (!alreadyFetchedFromBigRelays) {
return undefined
}
return undefined
} }
if (!relayUrls.length) return undefined
logger.debug('[EventService] Using comprehensive relay list', {
author: authorPubkey?.substring(0, 8),
relayCount: relayUrls.length,
hasHints: relayHints.length > 0,
hasSeen: seenRelays.length > 0
})
const isSingleEventById = filter.ids && filter.ids.length === 1 && filter.limit === 1 const isSingleEventById = filter.ids && filter.ids.length === 1 && filter.limit === 1
const events = await this.queryService.query(relayUrls, filter, undefined, { const events = await this.queryService.query(relayUrls, filter, undefined, {
@ -238,10 +359,12 @@ export class EventService {
/** /**
* Private: Fetch events from big relays (batch) * Private: Fetch events from big relays (batch)
* Uses comprehensive relay list: user's inboxes + defaults
*/ */
private async fetchEventsFromBigRelays(ids: readonly string[]): Promise<(NEvent | undefined)[]> { private async fetchEventsFromBigRelays(ids: readonly string[]): Promise<(NEvent | undefined)[]> {
const initialRelays = FAST_READ_RELAY_URLS // Build comprehensive relay list (user's inboxes + defaults)
const relayUrls = initialRelays.length > 0 ? initialRelays : FAST_READ_RELAY_URLS // Note: For batch fetches, we don't have author info, so we use user's inboxes + defaults
const relayUrls = await buildComprehensiveRelayList(undefined, [], [])
const isSingleEventFetch = ids.length === 1 const isSingleEventFetch = ids.length === 1
const events = await this.queryService.query(relayUrls, { const events = await this.queryService.query(relayUrls, {
@ -256,6 +379,8 @@ export class EventService {
const eventsMap = new Map<string, NEvent>() const eventsMap = new Map<string, NEvent>()
for (const event of events) { for (const event of events) {
eventsMap.set(event.id, event) eventsMap.set(event.id, event)
// Note: We can't track which relay returned which event in batch queries,
// but events are still cached and will be found in future queries
} }
return ids.map((id) => eventsMap.get(id)) return ids.map((id) => eventsMap.get(id))

193
src/services/client-replaceable-events.service.ts

@ -11,6 +11,8 @@ import { LRUCache } from 'lru-cache'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
import type { QueryService } from './client-query.service' import type { QueryService } from './client-query.service'
import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event' import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event'
import logger from '@/lib/logger'
import client from './client.service'
export class ReplaceableEventService { export class ReplaceableEventService {
private queryService: QueryService private queryService: QueryService
@ -69,9 +71,108 @@ export class ReplaceableEventService {
) )
} }
/**
* Extract relay hints from event tags (e, a, q tags - 3rd position)
*/
private extractRelayHintsFromEvent(event: NEvent | undefined): string[] {
if (!event) return []
const hints = new Set<string>()
// Extract from e, a, q tags (relay hint is in position 2, index 2)
const tagTypesWithRelayHints = ['e', 'a', 'q']
for (const tag of event.tags) {
if (tagTypesWithRelayHints.includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') {
const hint = tag[2]
if (hint.startsWith('wss://') || hint.startsWith('ws://')) {
hints.add(hint)
}
}
}
// Also check for dedicated "relays" tag
const relaysTag = event.tags.find(tag => tag[0] === 'relays')
if (relaysTag && relaysTag.length > 1) {
relaysTag.slice(1).forEach(url => {
if (typeof url === 'string' && (url.startsWith('wss://') || url.startsWith('ws://'))) {
hints.add(url)
}
})
}
return Array.from(hints)
}
/**
* Build comprehensive relay list: author's outboxes + user's inboxes + relay hints + defaults
*/
private async buildComprehensiveRelayListForAuthor(
authorPubkey: string,
kind: number,
relayHints: string[] = []
): Promise<string[]> {
const relayUrls = new Set<string>()
// 1. Add relay hints (highest priority - these are explicit hints)
relayHints.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// 2. Add author's outboxes (write relays) - where they publish
try {
const authorRelayList = await client.fetchRelayList(authorPubkey)
const authorOutboxes = (authorRelayList.write || []).slice(0, 10)
authorOutboxes.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
logger.debug('[ReplaceableEventService] Added author outboxes', {
author: authorPubkey.substring(0, 8),
count: authorOutboxes.length
})
} catch (error) {
logger.debug('[ReplaceableEventService] Failed to fetch author relay list', { error })
}
// 3. Add logged-in user's inboxes (read relays) - where they receive events
const userPubkey = client.pubkey
if (userPubkey) {
try {
const userRelayList = await client.fetchRelayList(userPubkey)
const userInboxes = (userRelayList.read || []).slice(0, 10)
userInboxes.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
logger.debug('[ReplaceableEventService] Added user inboxes', {
count: userInboxes.length
})
} catch (error) {
logger.debug('[ReplaceableEventService] Failed to fetch user relay list', { error })
}
}
// 4. Add default fast read relays as fallback
FAST_READ_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// 5. Add profile fetch relays for profiles
if (kind === kinds.Metadata) {
PROFILE_FETCH_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
return Array.from(relayUrls)
}
/** /**
* Fetch replaceable event (profile, relay list, etc.) * Fetch replaceable event (profile, relay list, etc.)
* Always checks in-memory cache FIRST (instant), then IndexedDB, then fetches from relays * Always checks in-memory cache FIRST (instant), then IndexedDB, then fetches from relays
* ALWAYS uses: author's outboxes + user's inboxes + relay hints + defaults
*/ */
async fetchReplaceableEvent(pubkey: string, kind: number, d?: string): Promise<NEvent | undefined> { async fetchReplaceableEvent(pubkey: string, kind: number, d?: string): Promise<NEvent | undefined> {
const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}` const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}`
@ -114,14 +215,48 @@ export class ReplaceableEventService {
} }
// 3. Not in cache, fetch from network // 3. Not in cache, fetch from network
const event = d // Note: DataLoader will use comprehensive relay list from batch load function
? await this.replaceableEventDataLoader.load({ pubkey, kind, d }) try {
: await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) const event = d
? await this.replaceableEventDataLoader.load({ pubkey, kind, d })
if (event) { : await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind })
// Add to memory cache for instant access next time
this.replaceableEventMemoryCache.set(cacheKey, event) if (event) {
return event // Extract relay hints from the found event (for future related fetches)
const eventRelayHints = this.extractRelayHintsFromEvent(event)
// Add to memory cache for instant access next time
this.replaceableEventMemoryCache.set(cacheKey, event)
if (kind === kinds.Metadata) {
this.profileMemoryCache.set(pubkey, event)
}
// If we found relay hints, log them (they're already used in the batch load function)
if (eventRelayHints.length > 0) {
logger.debug('[ReplaceableEventService] Found relay hints in event', {
pubkey: formatPubkey(pubkey),
hintCount: eventRelayHints.length
})
}
return event
}
// Log when no event is found (helps debug relay failures)
if (kind === kinds.Metadata) {
logger.debug('[ReplaceableEventService] No profile found for pubkey', {
pubkey: formatPubkey(pubkey),
cacheKey
})
}
} catch (error) {
// Log errors but don't throw - return undefined so UI can show fallback
if (kind === kinds.Metadata) {
logger.warn('[ReplaceableEventService] Error fetching profile', {
pubkey: formatPubkey(pubkey),
error: error instanceof Error ? error.message : String(error)
})
}
} }
return undefined return undefined
@ -284,18 +419,27 @@ export class ReplaceableEventService {
const eventsMap = new Map<string, NEvent>() const eventsMap = new Map<string, NEvent>()
await Promise.allSettled( await Promise.allSettled(
Array.from(groups.entries()).map(async ([kind, pubkeys]) => { Array.from(groups.entries()).map(async ([kind, pubkeys]) => {
// Use more relays in parallel for better performance // ALWAYS use comprehensive relay list: author's outboxes + user's inboxes + defaults
// Browsers can handle many concurrent subscriptions, so we use all available relays // For each pubkey, build comprehensive relay list
let relayUrls: string[] const relayUrlSets = await Promise.all(
if (kind === kinds.Metadata || kind === kinds.RelayList) { pubkeys.map(async (pubkey) => {
// Combine all available relays for profiles and relay lists // Build comprehensive relay list for this author
const base = Array.from(new Set([...FAST_READ_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS])) return await this.buildComprehensiveRelayListForAuthor(pubkey, kind, [])
// TODO: Inject relay list service to get user's relays })
relayUrls = base )
} else {
// Use all big relays for other replaceable events // Merge all relay sets
relayUrls = FAST_READ_RELAY_URLS const mergedRelays = new Set<string>()
} relayUrlSets.forEach(relayList => {
relayList.forEach(url => mergedRelays.add(url))
})
const relayUrls = Array.from(mergedRelays)
logger.debug('[ReplaceableEventService] Using comprehensive relay list', {
pubkeyCount: pubkeys.length,
totalRelayCount: relayUrls.length,
kind
})
// Use all relays in parallel - browsers can handle many concurrent subscriptions // Use all relays in parallel - browsers can handle many concurrent subscriptions
// The QueryService manages per-relay concurrency limits to avoid overloading individual relays // The QueryService manages per-relay concurrency limits to avoid overloading individual relays
@ -309,6 +453,15 @@ export class ReplaceableEventService {
globalTimeout: 3000 globalTimeout: 3000
}) })
// Log when no events are found (helps debug relay failures)
if (kind === kinds.Metadata && events.length === 0 && pubkeys.length > 0) {
logger.debug('[ReplaceableEventService] No profile events found from relays', {
pubkeyCount: pubkeys.length,
relayCount: relayUrls.length,
relays: relayUrls.slice(0, 3) // Show first 3 for brevity
})
}
for (const event of events) { for (const event of events) {
// Check tombstone in background (non-blocking) // Check tombstone in background (non-blocking)
const tombstoneKey = isReplaceableEvent(event.kind) const tombstoneKey = isReplaceableEvent(event.kind)

Loading…
Cancel
Save