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