Browse Source

update relay selection

imwald
Silberengel 1 month ago
parent
commit
ab25b17191
  1. 2
      src/components/Content/index.tsx
  2. 51
      src/components/Embedded/EmbeddedNote.tsx
  3. 23
      src/components/ReplyNoteList/index.tsx
  4. 39
      src/components/SearchResult/index.tsx
  5. 265
      src/lib/relay-list-builder.ts
  6. 101
      src/services/client-events.service.ts
  7. 3
      src/services/client-query.service.ts
  8. 140
      src/services/client-replaceable-events.service.ts

2
src/components/Content/index.tsx

@ -451,7 +451,7 @@ export default function Content({ @@ -451,7 +451,7 @@ export default function Content({
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
return <EmbeddedNote key={index} noteId={id} className="mt-2" containingEvent={event} />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />

51
src/components/Embedded/EmbeddedNote.tsx

@ -18,7 +18,15 @@ import { contentParserService } from '@/services/content-parser.service' @@ -18,7 +18,15 @@ import { contentParserService } from '@/services/content-parser.service'
import { useSmartNoteNavigation } from '@/PageManager'
import { toNote } from '@/lib/link'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
export function EmbeddedNote({
noteId,
className,
containingEvent
}: {
noteId: string
className?: string
containingEvent?: Event // Event that contains this embedded note - use its author's relays and relay hints
}) {
const { event, isFetching } = useFetchEvent(noteId)
const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined)
const [isRetrying, setIsRetrying] = useState(false)
@ -59,7 +67,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? @@ -59,7 +67,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
}
if (!finalEvent) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} />
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} containingEvent={containingEvent} />
}
// Check if this event has bookstr tags (at least "book" tag)
@ -119,11 +127,13 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) { @@ -119,11 +127,13 @@ function EmbeddedNoteSkeleton({ className }: { className?: string }) {
function EmbeddedNoteNotFound({
noteId,
className,
onEventFound
onEventFound,
containingEvent
}: {
noteId: string
className?: string
onEventFound?: (event: Event) => void
containingEvent?: Event // Event that contains this embedded note - use its author's relays and relay hints
}) {
const { t } = useTranslation()
const [isSearchingExternal, setIsSearchingExternal] = useState(false)
@ -132,8 +142,12 @@ function EmbeddedNoteNotFound({ @@ -132,8 +142,12 @@ function EmbeddedNoteNotFound({
const [hexEventId, setHexEventId] = useState<string | null>(null)
// Calculate which external relays would be tried when user clicks "Try external relays".
// The client's initial fetch now uses: (1) user's relays or BIG, (2) bech32 hints + author read+write, (3) SEARCHABLE.
// We treat BIG + FAST_READ as "already tried"; external = (hints + author read+write + seenOn + SEARCHABLE) minus those.
// IMPORTANT: For embedded events, we should search:
// 1. Containing event author's relays (outboxes + inboxes)
// 2. Relay hints from containing event (e, a, q tags - 3rd position)
// 3. Bech32 hints + embedded event author's relays
// 4. Relays where embedded event was seen
// 5. SEARCHABLE_RELAY_URLS
useEffect(() => {
const getExternalRelays = async () => {
const alreadyTriedRelaysSet = new Set<string>()
@ -145,6 +159,27 @@ function EmbeddedNoteNotFound({ @@ -145,6 +159,27 @@ function EmbeddedNoteNotFound({
let hintRelays: string[] = []
let extractedHexEventId: string | null = null
// 1. Extract relay hints from containing event (e, a, q tags - 3rd position)
if (containingEvent) {
for (const tag of containingEvent.tags) {
if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') {
const hint = tag[2]
if (hint.startsWith('wss://') || hint.startsWith('ws://')) {
hintRelays.push(hint)
}
}
}
// Also get containing event author's relays
try {
const containingAuthorRelayList = await client.fetchRelayList(containingEvent.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...(containingAuthorRelayList.read ?? []).slice(0, 10), ...(containingAuthorRelayList.write ?? []).slice(0, 10))
} catch (err) {
logger.debug('Failed to fetch containing event author relays', { error: err })
}
}
// 2. Extract hints from bech32 ID and embedded event author
if (!/^[0-9a-f]{64}$/.test(noteId)) {
try {
const { type, data } = nip19.decode(noteId)
@ -154,12 +189,12 @@ function EmbeddedNoteNotFound({ @@ -154,12 +189,12 @@ function EmbeddedNoteNotFound({
if (data.relays) hintRelays.push(...data.relays)
if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4))
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 10), ...(authorRelayList.write ?? []).slice(0, 10))
}
} else if (type === 'naddr') {
if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 4), ...(authorRelayList.write ?? []).slice(0, 4))
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 10), ...(authorRelayList.write ?? []).slice(0, 10))
} else if (type === 'note') {
extractedHexEventId = data
}
@ -172,7 +207,7 @@ function EmbeddedNoteNotFound({ @@ -172,7 +207,7 @@ function EmbeddedNoteNotFound({
setHexEventId(extractedHexEventId)
// Get relays where this event was seen
// 3. Get relays where this embedded event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
hintRelays.push(...seenOn)

23
src/components/ReplyNoteList/index.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants'
import { ExtendedKind } from '@/constants'
import {
getParentETag,
getReplaceableCoordinateFromEvent,
@ -12,17 +12,18 @@ import { @@ -12,17 +12,18 @@ import {
import logger from '@/lib/logger'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import { eventService, queryService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { buildReplyReadRelayList } from '@/lib/relay-list-builder'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -44,7 +45,8 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even @@ -44,7 +45,8 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { relayList: userRelayList } = useNostr()
const { relayList: userRelayList, pubkey: userPubkey } = useNostr()
const { blockedRelays } = useFavoriteRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply()
@ -298,14 +300,13 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even @@ -298,14 +300,13 @@ function ReplyNoteList({ index, event, sort = 'oldest' }: { index?: number; even
if (!rootInfo) return // Type guard
try {
// Privacy: Only use user's own relays + defaults, never connect to other users' relays
const userReadRelays = userRelayList?.read || []
const userWriteRelays = userRelayList?.write || []
const finalRelayUrls = Array.from(new Set([
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), // Fast, well-connected relays
...userReadRelays.map(url => normalizeUrl(url) || url), // User's read relays
...userWriteRelays.map(url => normalizeUrl(url) || url) // User's write relays
]))
// READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined
const finalRelayUrls = await buildReplyReadRelayList(
opAuthorPubkey,
userPubkey || undefined,
blockedRelays || []
)
const filters: Filter[] = []
if (rootInfo.type === 'E') {

39
src/components/SearchResult/index.tsx

@ -1,12 +1,45 @@ @@ -1,12 +1,45 @@
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed'
import Profile from '../Profile'
import { ProfileListBySearch } from '../ProfileListBySearch'
import Relay from '../Relay'
import TrendingNotes from '../TrendingNotes'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { normalizeUrl } from '@/lib/url'
import { useMemo } from 'react'
export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) {
const { pubkey, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
// Build comprehensive relay list for search (all available relays)
const searchRelays = useMemo(() => {
let relays: string[] = []
// User's relays
if (relayList) {
relays.push(...(relayList.read || []), ...(relayList.write || []))
}
// User's favorite relays
relays.push(...(favoriteRelays || []))
// All default relays
relays.push(...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS, ...SEARCHABLE_RELAY_URLS)
// Normalize and deduplicate
const normalized = Array.from(new Set(
relays.map(url => normalizeUrl(url) || url).filter((url): url is string => !!url)
))
// Filter blocked
return normalized.filter(relay =>
!blockedRelays.some(blocked => relay.includes(blocked))
)
}, [pubkey, relayList, favoriteRelays, blockedRelays])
if (!searchParams) {
return <TrendingNotes />
}
@ -19,7 +52,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -19,7 +52,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
if (searchParams.type === 'notes') {
return (
<NormalFeed
subRequests={[{ urls: SEARCHABLE_RELAY_URLS, filter: { search: searchParams.search } }]}
subRequests={[{ urls: searchRelays, filter: { search: searchParams.search } }]}
showRelayCloseReason
/>
)
@ -27,7 +60,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa @@ -27,7 +60,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa
if (searchParams.type === 'hashtag') {
return (
<NormalFeed
subRequests={[{ urls: FAST_READ_RELAY_URLS, filter: { '#t': [searchParams.search] } }]}
subRequests={[{ urls: searchRelays, filter: { '#t': [searchParams.search] } }]}
showRelayCloseReason
/>
)

265
src/lib/relay-list-builder.ts

@ -0,0 +1,265 @@ @@ -0,0 +1,265 @@
/**
* Comprehensive relay list builder utility
* Handles all relay selection requirements:
* - Filters blocked relays
* - Includes local relays from kind 10432
* - Handles author's outboxes/inboxes
* - Handles user's outboxes/inboxes
* - Includes relay hints
* - Includes seen relays
*/
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { getCacheRelayUrls } from './private-relays'
import client from '@/services/client.service'
import logger from '@/lib/logger'
export interface RelayListBuilderOptions {
/** Author's pubkey - will include their outboxes (write relays) */
authorPubkey?: string
/** Logged-in user's pubkey - will include their inboxes (read relays) and outboxes (write relays) */
userPubkey?: string
/** Explicit relay hints (from bech32 IDs or event tags) */
relayHints?: string[]
/** Relays where an event was seen */
seenRelays?: string[]
/** Relays where a containing event was found (for embedded events) */
containingEventRelays?: string[]
/** Whether to include user's own relays (read/write/local) - for profiles/metadata */
includeUserOwnRelays?: boolean
/** Whether to include PROFILE_FETCH_RELAY_URLS - for profiles/metadata */
includeProfileFetchRelays?: boolean
/** Whether to include FAST_READ_RELAY_URLS as fallback */
includeFastReadRelays?: boolean
/** Whether to include FAST_WRITE_RELAY_URLS as fallback */
includeFastWriteRelays?: boolean
/** Whether to include SEARCHABLE_RELAY_URLS - for search */
includeSearchableRelays?: boolean
/** Blocked relays to filter out */
blockedRelays?: string[]
/** Whether to include local relays from kind 10432 */
includeLocalRelays?: boolean
}
/**
* Build comprehensive relay list according to requirements
*/
export async function buildComprehensiveRelayList(options: RelayListBuilderOptions = {}): Promise<string[]> {
const {
authorPubkey,
userPubkey,
relayHints = [],
seenRelays = [],
containingEventRelays = [],
includeUserOwnRelays = false,
includeProfileFetchRelays = false,
includeFastReadRelays = true,
includeFastWriteRelays = false,
includeSearchableRelays = false,
blockedRelays = [],
includeLocalRelays = true
} = options
const relayUrls = new Set<string>()
const normalizedBlocked = new Set(
(blockedRelays || []).map(url => {
const normalized = normalizeUrl(url) || url
return normalized.toLowerCase()
}).filter((url): url is string => !!url)
)
const addRelay = (url: string | undefined) => {
if (!url) return
const normalized = normalizeUrl(url)
if (!normalized) return
// Filter blocked (case-insensitive comparison)
if (normalizedBlocked.has(normalized.toLowerCase())) return
relayUrls.add(normalized)
}
// 1. Relay hints (highest priority - explicit hints)
relayHints.forEach(addRelay)
// 2. Relays where event was seen
seenRelays.forEach(addRelay)
// 3. Relays where containing event was found (for embedded events)
containingEventRelays.forEach(addRelay)
// 4. Author's outboxes (write relays) - where they publish
if (authorPubkey) {
try {
const authorRelayList = await client.fetchRelayList(authorPubkey)
const authorOutboxes = (authorRelayList.write || []).slice(0, 10)
authorOutboxes.forEach(addRelay)
// Also include author's read relays (inboxes) for better discovery
const authorInboxes = (authorRelayList.read || []).slice(0, 10)
authorInboxes.forEach(addRelay)
logger.debug('[RelayListBuilder] Added author relays', {
author: authorPubkey.substring(0, 8),
outboxes: authorOutboxes.length,
inboxes: authorInboxes.length
})
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch author relay list', { error })
}
}
// 5. User's own relays (for profiles/metadata)
if (includeUserOwnRelays && userPubkey) {
try {
const userRelayList = await client.fetchRelayList(userPubkey)
// Include both read and write
const userRead = (userRelayList.read || []).slice(0, 10)
const userWrite = (userRelayList.write || []).slice(0, 10)
userRead.forEach(addRelay)
userWrite.forEach(addRelay)
// Include local relays from kind 10432
if (includeLocalRelays) {
const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay)
}
logger.debug('[RelayListBuilder] Added user own relays', {
read: userRead.length,
write: userWrite.length,
local: includeLocalRelays ? (await getCacheRelayUrls(userPubkey)).length : 0
})
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch user relay list', { error })
}
} else if (userPubkey) {
// Even if not including user's own relays, still include user's inboxes for reading
try {
const userRelayList = await client.fetchRelayList(userPubkey)
const userInboxes = (userRelayList.read || []).slice(0, 10)
userInboxes.forEach(addRelay)
// Include local relays from kind 10432 if enabled
if (includeLocalRelays) {
const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay)
}
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch user inboxes', { error })
}
}
// 6. Profile fetch relays (for profiles/metadata)
if (includeProfileFetchRelays) {
PROFILE_FETCH_RELAY_URLS.forEach(addRelay)
}
// 7. Fast read relays (fallback)
if (includeFastReadRelays) {
FAST_READ_RELAY_URLS.forEach(addRelay)
}
// 8. Fast write relays (for writing)
if (includeFastWriteRelays) {
FAST_WRITE_RELAY_URLS.forEach(addRelay)
}
// 9. Searchable relays (for search)
if (includeSearchableRelays) {
SEARCHABLE_RELAY_URLS.forEach(addRelay)
}
return Array.from(relayUrls)
}
/**
* Build relay list for reading replies/comments
* READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes
*/
export async function buildReplyReadRelayList(
opAuthorPubkey: string | undefined,
userPubkey: string | undefined,
blockedRelays: string[] = []
): Promise<string[]> {
return buildComprehensiveRelayList({
authorPubkey: opAuthorPubkey,
userPubkey,
includeFastReadRelays: true,
includeLocalRelays: true,
blockedRelays
})
}
/**
* Build relay list for writing replies/comments
* WRITE to: OP author's outboxes + OP author's inboxes + reply-to author's inboxes + user's outboxes + local relay
*/
export async function buildReplyWriteRelayList(
opAuthorPubkey: string | undefined,
replyToAuthorPubkey: string | undefined,
userPubkey: string | undefined,
blockedRelays: string[] = []
): Promise<string[]> {
const relayUrls = new Set<string>()
const normalizedBlocked = new Set(
(blockedRelays || []).map(url => {
const normalized = normalizeUrl(url) || url
return normalized.toLowerCase()
}).filter((url): url is string => !!url)
)
const addRelay = (url: string | undefined) => {
if (!url) return
const normalized = normalizeUrl(url)
if (!normalized) return
// Filter blocked (case-insensitive comparison)
if (normalizedBlocked.has(normalized.toLowerCase())) return
relayUrls.add(normalized)
}
// OP author's outboxes
if (opAuthorPubkey) {
try {
const opRelayList = await client.fetchRelayList(opAuthorPubkey)
const opOutboxes = (opRelayList.write || []).slice(0, 10)
opOutboxes.forEach(addRelay)
// OP author's inboxes
const opInboxes = (opRelayList.read || []).slice(0, 10)
opInboxes.forEach(addRelay)
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch OP author relay list', { error })
}
}
// Reply-to author's inboxes
if (replyToAuthorPubkey && replyToAuthorPubkey !== opAuthorPubkey) {
try {
const replyToRelayList = await client.fetchRelayList(replyToAuthorPubkey)
const replyToInboxes = (replyToRelayList.read || []).slice(0, 10)
replyToInboxes.forEach(addRelay)
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch reply-to author relay list', { error })
}
}
// User's outboxes
if (userPubkey) {
try {
const userRelayList = await client.fetchRelayList(userPubkey)
const userOutboxes = (userRelayList.write || []).slice(0, 10)
userOutboxes.forEach(addRelay)
// User's local relay (kind 10432)
const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay)
} catch (error) {
logger.debug('[RelayListBuilder] Failed to fetch user relay list', { error })
}
}
// Fast write relays as fallback
FAST_WRITE_RELAY_URLS.forEach(addRelay)
return Array.from(relayUrls)
}

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

@ -1,6 +1,4 @@ @@ -1,6 +1,4 @@
import { FAST_READ_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import type { Event as NEvent, Filter } from 'nostr-tools'
import { nip19 } from 'nostr-tools'
import DataLoader from 'dataloader'
@ -8,72 +6,28 @@ import { LRUCache } from 'lru-cache' @@ -8,72 +6,28 @@ import { LRUCache } from 'lru-cache'
import indexedDb from './indexed-db.service'
import type { QueryService } from './client-query.service'
import client from './client.service'
import { isReplaceableEvent } from '@/lib/event'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
/**
* Build comprehensive relay list: author's outboxes + user's inboxes + relay hints + defaults
* Uses the shared relay list builder utility
*/
async function buildComprehensiveRelayList(
async function buildComprehensiveRelayListForEvents(
authorPubkey: string | undefined,
relayHints: string[] = [],
seenRelays: string[] = []
seenRelays: string[] = [],
containingEventRelays: 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
return buildComprehensiveRelayList({
authorPubkey,
userPubkey: client.pubkey,
relayHints,
seenRelays,
containingEventRelays,
includeFastReadRelays: true,
includeLocalRelays: true
})
} 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 {
@ -331,7 +285,7 @@ export class EventService { @@ -331,7 +285,7 @@ export class EventService {
const authorPubkey = filter.authors?.length === 1 ? filter.authors[0] : undefined
// Build comprehensive relay list
const relayUrls = await buildComprehensiveRelayList(authorPubkey, relayHints, seenRelays)
const relayUrls = await buildComprehensiveRelayListForEvents(authorPubkey, relayHints, seenRelays, [])
if (!relayUrls.length) {
// Fallback to default relays if comprehensive list is empty
@ -349,12 +303,27 @@ export class EventService { @@ -349,12 +303,27 @@ export class EventService {
})
const isSingleEventById = filter.ids && filter.ids.length === 1 && filter.limit === 1
// For single-event fetches, always use immediateReturn to return ASAP
// This is especially important for non-replaceable events (not in 10000-19999 or 30000-39999 ranges)
const events = await this.queryService.query(relayUrls, filter, undefined, {
immediateReturn: isSingleEventById,
immediateReturn: isSingleEventById, // Return immediately when found
eoseTimeout: isSingleEventById ? 100 : 500,
globalTimeout: isSingleEventById ? 3000 : 10000
})
return events.sort((a, b) => b.created_at - a.created_at)[0]
const event = events.sort((a, b) => b.created_at - a.created_at)[0]
// For non-replaceable events, we've already returned immediately via immediateReturn
// But log it for debugging
if (event && isSingleEventById && !isReplaceableEvent(event.kind)) {
logger.debug('[EventService] Non-replaceable event returned immediately', {
eventId: event.id.substring(0, 8),
kind: event.kind
})
}
return event
}
/**
@ -364,14 +333,16 @@ export class EventService { @@ -364,14 +333,16 @@ export class EventService {
private async fetchEventsFromBigRelays(ids: readonly string[]): Promise<(NEvent | undefined)[]> {
// Build comprehensive relay list (user's inboxes + defaults)
// Note: For batch fetches, we don't have author info, so we use user's inboxes + defaults
const relayUrls = await buildComprehensiveRelayList(undefined, [], [])
const relayUrls = await buildComprehensiveRelayListForEvents(undefined, [], [], [])
const isSingleEventFetch = ids.length === 1
// For single-event fetches, always use immediateReturn to return ASAP
// This is especially important for non-replaceable events (not in 10000-19999 or 30000-39999 ranges)
const events = await this.queryService.query(relayUrls, {
ids: Array.from(new Set(ids)),
limit: ids.length
}, undefined, {
immediateReturn: isSingleEventFetch,
immediateReturn: isSingleEventFetch, // Return immediately when found
eoseTimeout: isSingleEventFetch ? 100 : 500,
globalTimeout: isSingleEventFetch ? 3000 : 10000
})

3
src/services/client-query.service.ts

@ -177,6 +177,9 @@ export class QueryService { @@ -177,6 +177,9 @@ export class QueryService {
const isSingleEventFetch = maxLimit === 1
const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0)
// For immediateReturn: return as soon as we find the event
// This is critical for non-replaceable events (not in 10000-19999 or 30000-39999 ranges)
// which should be rendered ASAP
if (immediateReturn && hasIdFilter && isSingleEventFetch && events.length > 0) {
resolveWithEvents()
return

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

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { FAST_READ_RELAY_URLS, ExtendedKind, PROFILE_FETCH_RELAY_URLS } from '@/constants'
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { kinds, nip19 } from 'nostr-tools'
import type { Event as NEvent, Filter } from 'nostr-tools'
import DataLoader from 'dataloader'
@ -13,6 +13,7 @@ import type { QueryService } from './client-query.service' @@ -13,6 +13,7 @@ import type { QueryService } from './client-query.service'
import { isReplaceableEvent, getReplaceableCoordinateFromEvent } from '@/lib/event'
import logger from '@/lib/logger'
import client from './client.service'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
export class ReplaceableEventService {
private queryService: QueryService
@ -104,77 +105,47 @@ export class ReplaceableEventService { @@ -104,77 +105,47 @@ export class ReplaceableEventService {
/**
* Build comprehensive relay list: author's outboxes + user's inboxes + relay hints + defaults
* For profiles/metadata: includes user's own relays (read/write/local) + PROFILE_FETCH_RELAY_URLS
*/
private async buildComprehensiveRelayListForAuthor(
authorPubkey: string,
kind: number,
relayHints: string[] = []
relayHints: string[] = [],
containingEventRelays: 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)
})
const isProfileOrMetadata = kind === kinds.Metadata || kind === kinds.RelayList
// 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)
// Use the comprehensive relay list builder
return buildComprehensiveRelayList({
authorPubkey,
userPubkey,
relayHints,
containingEventRelays,
includeUserOwnRelays: isProfileOrMetadata, // For profiles/metadata, include user's own relays
includeProfileFetchRelays: isProfileOrMetadata, // For profiles/metadata, include PROFILE_FETCH_RELAY_URLS
includeFastReadRelays: true,
includeLocalRelays: true
})
}
return Array.from(relayUrls)
}
/**
* Fetch replaceable event (profile, relay list, etc.)
* 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> {
* For profiles/metadata: includes user's own relays (read/write/local) + PROFILE_FETCH_RELAY_URLS
*
* @param pubkey - Author's pubkey
* @param kind - Event kind
* @param d - Optional d-tag for parameterized replaceable events
* @param containingEventRelays - Optional relays where a containing event was found (for profiles, might be on same relay as event)
*/
async fetchReplaceableEvent(
pubkey: string,
kind: number,
d?: string,
containingEventRelays: string[] = []
): Promise<NEvent | undefined> {
const cacheKey = d ? `${kind}:${pubkey}:${d}` : `${kind}:${pubkey}`
// 1. Check in-memory cache FIRST - instant return, no async overhead
@ -216,10 +187,32 @@ export class ReplaceableEventService { @@ -216,10 +187,32 @@ export class ReplaceableEventService {
// 3. Not in cache, fetch from network
// Note: DataLoader will use comprehensive relay list from batch load function
// For profiles: if we have containingEventRelays (from fetchProfileEvent), include them
// Profiles are often on the same relays where the author publishes their events
try {
const event = d
// If we have containing event relays and this is a profile, we need to use a custom relay list
// Otherwise, use DataLoader (which uses comprehensive relay list)
let event: NEvent | undefined
if (containingEventRelays.length > 0 && kind === kinds.Metadata && !d) {
// For profiles with containing event relays (author's relay list), build custom relay list and query directly
const relayUrls = await this.buildComprehensiveRelayListForAuthor(pubkey, kind, containingEventRelays, [])
const events = await this.queryService.query(relayUrls, {
authors: [pubkey],
kinds: [kind]
}, undefined, {
replaceableRace: true,
eoseTimeout: 200,
globalTimeout: 3000
})
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
event = sortedEvents.length > 0 ? sortedEvents[0] : undefined
} else {
// Use DataLoader for batching
const loadedEvent = d
? await this.replaceableEventDataLoader.load({ pubkey, kind, d })
: await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind })
event = loadedEvent || undefined
}
if (event) {
// Extract relay hints from the found event (for future related fetches)
@ -420,11 +413,12 @@ export class ReplaceableEventService { @@ -420,11 +413,12 @@ export class ReplaceableEventService {
await Promise.allSettled(
Array.from(groups.entries()).map(async ([kind, pubkeys]) => {
// ALWAYS use comprehensive relay list: author's outboxes + user's inboxes + defaults
// For profiles/metadata: includes user's own relays (read/write/local) + PROFILE_FETCH_RELAY_URLS
// For each pubkey, build comprehensive relay list
const relayUrlSets = await Promise.all(
pubkeys.map(async (pubkey) => {
// Build comprehensive relay list for this author
return await this.buildComprehensiveRelayListForAuthor(pubkey, kind, [])
return await this.buildComprehensiveRelayListForAuthor(pubkey, kind, [], [])
})
)
@ -632,7 +626,31 @@ export class ReplaceableEventService { @@ -632,7 +626,31 @@ export class ReplaceableEventService {
return localProfile
}
}
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata)
// For profiles: get author's relay list (from cache if available) and use those relays
// Profiles are often on the same relays where the author publishes their events
let authorRelayList: { read?: string[]; write?: string[] } | null = null
try {
authorRelayList = await client.fetchRelayList(pubkey)
// Use author's outboxes (write relays) and inboxes (read relays) - profiles are often there
const authorRelays = [
...(authorRelayList.write || []).slice(0, 10),
...(authorRelayList.read || []).slice(0, 10)
]
relays = [...new Set([...relays, ...authorRelays])]
logger.debug('[ReplaceableEventService] Using author relay list for profile fetch', {
pubkey: formatPubkey(pubkey),
authorRelayCount: authorRelays.length,
totalRelayCount: relays.length
})
} catch (error) {
logger.debug('[ReplaceableEventService] Failed to fetch author relay list for profile', {
pubkey: formatPubkey(pubkey),
error
})
}
const profileEvent = await this.fetchReplaceableEvent(pubkey, kinds.Metadata, undefined, relays)
if (profileEvent) {
await this.indexProfile(profileEvent)
return profileEvent

Loading…
Cancel
Save