diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index d043703f..4d0ae100 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -451,7 +451,7 @@ export default function Content({ } if (node.type === 'event') { const id = node.data.split(':')[1] - return + return } if (node.type === 'mention') { return diff --git a/src/components/Embedded/EmbeddedNote.tsx b/src/components/Embedded/EmbeddedNote.tsx index ec6eafb0..35fb8dab 100644 --- a/src/components/Embedded/EmbeddedNote.tsx +++ b/src/components/Embedded/EmbeddedNote.tsx @@ -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(undefined) const [isRetrying, setIsRetrying] = useState(false) @@ -59,7 +67,7 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? } if (!finalEvent) { - return + return } // Check if this event has bookstr tags (at least "book" tag) @@ -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({ const [hexEventId, setHexEventId] = useState(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() @@ -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({ 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({ 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) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 09b90318..65147571 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,4 +1,4 @@ -import { FAST_READ_RELAY_URLS, ExtendedKind } from '@/constants' +import { ExtendedKind } from '@/constants' import { getParentETag, getReplaceableCoordinateFromEvent, @@ -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 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(undefined) const { repliesMap, addReplies } = useReply() @@ -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') { diff --git a/src/components/SearchResult/index.tsx b/src/components/SearchResult/index.tsx index ae9664fd..1f91f684 100644 --- a/src/components/SearchResult/index.tsx +++ b/src/components/SearchResult/index.tsx @@ -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 } @@ -19,7 +52,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa if (searchParams.type === 'notes') { return ( ) @@ -27,7 +60,7 @@ export default function SearchResult({ searchParams }: { searchParams: TSearchPa if (searchParams.type === 'hashtag') { return ( ) diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts new file mode 100644 index 00000000..cf5b7157 --- /dev/null +++ b/src/lib/relay-list-builder.ts @@ -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 { + const { + authorPubkey, + userPubkey, + relayHints = [], + seenRelays = [], + containingEventRelays = [], + includeUserOwnRelays = false, + includeProfileFetchRelays = false, + includeFastReadRelays = true, + includeFastWriteRelays = false, + includeSearchableRelays = false, + blockedRelays = [], + includeLocalRelays = true + } = options + + const relayUrls = new Set() + 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 { + 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 { + const relayUrls = new Set() + 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) +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index bc0eced5..5de9afa4 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -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' 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 { - const relayUrls = new Set() - - // 1. Add relay hints (highest priority - these are explicit hints) - relayHints.forEach(url => { - const normalized = normalizeUrl(url) - if (normalized) relayUrls.add(normalized) + return buildComprehensiveRelayList({ + authorPubkey, + userPubkey: client.pubkey, + relayHints, + seenRelays, + containingEventRelays, + includeFastReadRelays: true, + includeLocalRelays: true }) - - // 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 { @@ -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 { }) 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 { 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 }) diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 90fdd4d8..de3649ff 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -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 diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 9df7efb7..b19cf850 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -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' 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 { /** * 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 { - const relayUrls = new Set() - - // 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 }) - } - } + const isProfileOrMetadata = kind === kinds.Metadata || kind === kinds.RelayList - // 4. Add default fast read relays as fallback - FAST_READ_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 }) - - // 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.) * 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 { + * 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 { 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 { // 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 - ? await this.replaceableEventDataLoader.load({ pubkey, kind, d }) - : await this.replaceableEventFromBigRelaysDataloader.load({ pubkey, kind }) + // 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 { 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 { 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