Browse Source

make embedded events appear faster

imwald
Silberengel 1 month ago
parent
commit
e3b692e4f4
  1. 225
      src/components/Embedded/EmbeddedNote.tsx
  2. 4
      src/components/LogoutDialog/index.tsx
  3. 10
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 23
      src/hooks/useFetchEvent.tsx
  5. 33
      src/lib/event.ts
  6. 24
      src/lib/relay-list-builder.ts
  7. 11
      src/pages/secondary/NotePage/index.tsx
  8. 236
      src/services/client-events.service.ts
  9. 28
      src/services/client-query.service.ts
  10. 8
      src/services/client.service.ts

225
src/components/Embedded/EmbeddedNote.tsx

@ -9,9 +9,11 @@ import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import nip66Service from '@/services/nip66.service'
import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { relayHintWssUrlsFromEvent } from '@/lib/event'
import { Event, nip19 } from 'nostr-tools' import { Event, nip19 } from 'nostr-tools'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard' import MainNoteCard from '../NoteCard/MainNoteCard'
@ -80,6 +82,7 @@ export function EmbeddedNote({
noteId: string noteId: string
className?: string className?: string
containingEvent?: Event containingEvent?: Event
/** True = full long-form/publication body; false = compact card (use inside articles). */
showFull?: boolean showFull?: boolean
}) { }) {
const suppress = useSuppressEmbeddedNoteId() const suppress = useSuppressEmbeddedNoteId()
@ -199,47 +202,33 @@ function EmbeddedNoteFetched({
showFull: boolean showFull: boolean
allowLiveEmbeds: boolean allowLiveEmbeds: boolean
}) { }) {
const { event, isFetching } = useFetchEvent(noteId) const relayHints = useMemo(
const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined) () => relayHintWssUrlsFromEvent(containingEvent),
const [isRetrying, setIsRetrying] = useState(false) [containingEvent?.id]
const [retryCount, setRetryCount] = useState(0) )
const maxRetries = 3 const fetchRelayOpts = useMemo(
() => (relayHints.length > 0 ? { relayHints } : undefined),
// If the first fetch fails, try a force retry (max 3 attempts) [relayHints]
useEffect(() => { )
if (!isFetching && !event && !isRetrying && retryCount < maxRetries) { const { event, isFetching } = useFetchEvent(noteId, undefined, fetchRelayOpts)
setIsRetrying(true) /** Filled when “Try external relays” / IndexedDB recovery finds the event after the hook missed. */
setRetryCount(prev => prev + 1) const [resolvedEvent, setResolvedEvent] = useState<Event | undefined>(undefined)
client.fetchEventForceRetry(noteId)
.then((retryResult: any) => {
if (retryResult) {
setRetryEvent(retryResult)
}
})
.catch((error: any) => {
logger.warn('EmbeddedNote retry failed', {
attempt: retryCount + 1,
maxRetries,
noteId,
error
})
})
.finally(() => {
setIsRetrying(false)
})
}
}, [isFetching, event, noteId, isRetrying, retryCount])
const finalEvent = event || retryEvent const finalEvent = event || resolvedEvent
const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries)
if (finalIsFetching) { if (isFetching && !finalEvent) {
return <EmbeddedNoteSkeleton className={className} /> return <EmbeddedNoteSkeleton className={className} />
} }
if (!finalEvent) { if (!finalEvent) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} containingEvent={containingEvent} /> return (
<EmbeddedNoteNotFound
className={className}
noteId={noteId}
onEventFound={setResolvedEvent}
containingEvent={containingEvent}
/>
)
} }
if ( if (
@ -340,6 +329,24 @@ function EmbeddedNoteContent({
) )
} }
function dedupeRelayUrls(urls: readonly string[]): string[] {
return [...new Set(urls.map((u) => (normalizeUrl(u?.trim()) || '') as string).filter(Boolean))]
}
/** Prefer relays that usually hold / index replaceables so REQ opens useful targets first. */
function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] {
const score = (u: string) => {
const x = u.toLowerCase()
if (x.includes('nos.lol')) return 0
if (x.includes('nostr.land')) return 1
if (x.includes('relay.damus.io')) return 2
if (x.includes('relay.primal.net')) return 3
if (x.includes('nostr.wine')) return 4
return 30
}
return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b))
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) { function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return ( return (
<div <div
@ -381,108 +388,114 @@ function EmbeddedNoteNotFound({
) )
const [isSearchingExternal, setIsSearchingExternal] = useState(false) const [isSearchingExternal, setIsSearchingExternal] = useState(false)
const [triedExternal, setTriedExternal] = useState(false) const [triedExternal, setTriedExternal] = useState(false)
const [externalRelays, setExternalRelays] = useState<string[]>([]) const [asyncHintRelays, setAsyncHintRelays] = useState<string[]>([])
const [hexEventId, setHexEventId] = useState<string | null>(null)
const [externalSearchDetail, setExternalSearchDetail] = useState< const [externalSearchDetail, setExternalSearchDetail] = useState<
null | 'unparseable' | 'no_relays' | 'searched' null | 'unparseable' | 'no_relays' | 'searched'
>(null) >(null)
// Relays for "Try external relays": hints + searchable + FAST_READ. const resolvedHexId = useMemo(() => {
// Initial embed fetch uses short per-relay timeouts; this pass uses longer timeouts (see fetchEventWithExternalRelays). const h = hexEventIdFromNoteId(noteId)
// We intentionally include FAST_READ again so slow/default relays get a second chance. if (h) return h
try {
const { type, data } = nip19.decode(noteId.trim())
if (type === 'nevent') return data.id
if (type === 'note') return data
} catch {
/* plain hex handled above */
}
return null
}, [noteId])
/** Always available immediately: static searchable + fast-read + favorites + NIP-66 search-capable relays. */
const coreExternalRelays = useMemo(
() =>
preferPublicIndexRelaysFirst(
dedupeRelayUrls([
...nip66Service.getSearchableRelayUrls(),
...SEARCHABLE_RELAY_URLS,
...FAST_READ_RELAY_URLS,
...menuRelayUrls,
])
),
[menuRelayUrls]
)
const externalRelays = useMemo(
() => preferPublicIndexRelaysFirst(dedupeRelayUrls([...asyncHintRelays, ...coreExternalRelays])),
[asyncHintRelays, coreExternalRelays]
)
// Extra hints (parent tags, NIP-65, nevent/naddr relay lists, “seen on”) — merged on top of {@link coreExternalRelays}.
useEffect(() => { useEffect(() => {
const getExternalRelays = async () => { let cancelled = false
let hintRelays: string[] = [] const loadHints = async () => {
let extractedHexEventId: string | null = null const hintRelays: string[] = []
// 1. Extract relay hints from containing event (e, a, q tags - 3rd position)
if (containingEvent) { if (containingEvent) {
for (const tag of containingEvent.tags) { for (const tag of containingEvent.tags) {
if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') { if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') {
const hint = tag[2] const hint = tag[2]
if (hint.startsWith('wss://') || hint.startsWith('ws://')) { if (hint.startsWith('wss://') || hint.startsWith('ws://')) hintRelays.push(hint)
hintRelays.push(hint)
}
} }
} }
// Also get containing event author's relays
try { try {
const containingAuthorRelayList = await client.fetchRelayList(containingEvent.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) const containingAuthorRelayList = await client
hintRelays.push(...(containingAuthorRelayList.read ?? []).slice(0, 10), ...(containingAuthorRelayList.write ?? []).slice(0, 10)) .fetchRelayList(containingEvent.pubkey)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(
...(containingAuthorRelayList.read ?? []).slice(0, 10),
...(containingAuthorRelayList.write ?? []).slice(0, 10)
)
} catch (err) { } catch (err) {
logger.debug('Failed to fetch containing event author relays', { error: err }) logger.debug('Failed to fetch containing event author relays', { error: err })
} }
} }
// 2. Hex id (any case) or bech32; hints from nevent/naddr for extra relays
const quickHex = hexEventIdFromNoteId(noteId)
if (quickHex) {
extractedHexEventId = quickHex
}
try { try {
const { type, data } = nip19.decode(noteId) const { type, data } = nip19.decode(noteId.trim())
if (type === 'nevent') { if (type === 'nevent') {
extractedHexEventId = data.id
if (data.relays) hintRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
if (data.author) { if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author).catch(() => ({ read: [] as string[], write: [] as string[] })) const authorRelayList = await client
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 10), ...(authorRelayList.write ?? []).slice(0, 10)) .fetchRelayList(data.author)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(
...(authorRelayList.read ?? []).slice(0, 10),
...(authorRelayList.write ?? []).slice(0, 10)
)
} }
} else if (type === 'naddr') { } else if (type === 'naddr') {
if (data.relays) hintRelays.push(...data.relays) if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey).catch(() => ({ read: [] as string[], write: [] as string[] })) const authorRelayList = await client
hintRelays.push(...(authorRelayList.read ?? []).slice(0, 10), ...(authorRelayList.write ?? []).slice(0, 10)) .fetchRelayList(data.pubkey)
} else if (type === 'note') { .catch(() => ({ read: [] as string[], write: [] as string[] }))
extractedHexEventId = data hintRelays.push(
...(authorRelayList.read ?? []).slice(0, 10),
...(authorRelayList.write ?? []).slice(0, 10)
)
} }
} catch { } catch {
// Plain hex ids are not valid bech32 — already handled via quickHex /* invalid bech32 */
} }
setHexEventId(extractedHexEventId)
// 3. Get relays where this embedded event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
hintRelays.push(...seenOn)
// Normalize all hint relays
const normalizedHints = hintRelays
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
const normalizedFastRead = FAST_READ_RELAY_URLS
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
const externalRelays = Array.from(
new Set([
...normalizedHints,
...menuRelayUrls,
...normalizedSearchableRelays,
...normalizedFastRead
])
)
setExternalRelays(externalRelays) const seenOn = resolvedHexId ? client.getSeenEventRelayUrls(resolvedHexId) : []
hintRelays.push(...seenOn)
logger.debug('External relays calculated', { if (!cancelled) {
noteId, setAsyncHintRelays(dedupeRelayUrls(hintRelays))
hintRelaysCount: normalizedHints.length, logger.debug('External relay hints merged', {
searchableRelaysCount: normalizedSearchableRelays.length, noteId,
fastReadRelaysCount: normalizedFastRead.length, hintCount: hintRelays.length,
externalRelaysCount: externalRelays.length, totalRelays: dedupeRelayUrls([...hintRelays, ...coreExternalRelays]).length
externalRelays: externalRelays.slice(0, 10) })
}) }
} }
getExternalRelays() void loadHints()
// containingEvent supplies e/a/q relay hints + author NIP-65 list — must rerun when parent loads return () => {
}, [noteId, containingEvent?.id, menuRelayUrls]) cancelled = true
}
}, [noteId, containingEvent?.id, resolvedHexId, coreExternalRelays])
const handleTryExternalRelays = async () => { const handleTryExternalRelays = async () => {
if (isSearchingExternal) return if (isSearchingExternal) return
@ -505,7 +518,7 @@ function EmbeddedNoteNotFound({
setExternalSearchDetail(null) setExternalSearchDetail(null)
let found: Event | undefined let found: Event | undefined
try { try {
const idHex = hexEventId ?? hexEventIdFromNoteId(noteId) const idHex = resolvedHexId ?? hexEventIdFromNoteId(noteId)
if (idHex) { if (idHex) {
const fromDb = await indexedDb.getEventFromPublicationStore(idHex) const fromDb = await indexedDb.getEventFromPublicationStore(idHex)
if (fromDb) { if (fromDb) {

4
src/components/LogoutDialog/index.tsx

@ -18,7 +18,7 @@ import {
DrawerTitle DrawerTitle
} from '@/components/ui/drawer' } from '@/components/ui/drawer'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function LogoutDialog({ export default function LogoutDialog({
@ -29,7 +29,7 @@ export default function LogoutDialog({
setOpen: (open: boolean) => void setOpen: (open: boolean) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen = false } = useScreenSizeOptional() ?? {}
const { account, switchAccount } = useNostr() const { account, switchAccount } = useNostr()
const handleLogout = () => { const handleLogout = () => {

10
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -2646,7 +2646,7 @@ function parseMarkdownContentLegacy(
// Embedded events should be block-level and fill width // Embedded events should be block-level and fill width
parts.push( parts.push(
<div key={`nostr-${patternIdx}`} className="w-full my-2"> <div key={`nostr-${patternIdx}`} className="w-full my-2">
<EmbeddedNote noteId={bech32Id} showFull={!lazyMedia} /> <EmbeddedNote noteId={bech32Id} showFull={false} />
</div> </div>
) )
} }
@ -3567,7 +3567,7 @@ function parseMarkdownContentMarked(
} }
return ( return (
<div key={`${key}-nostr-event`} className="w-full my-2"> <div key={`${key}-nostr-event`} className="w-full my-2">
<EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} showFull={!lazyMedia} /> <EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} showFull={false} />
</div> </div>
) )
} }
@ -3733,7 +3733,7 @@ function parseMarkdownContentMarked(
} }
return ( return (
<div key={`${key}-line-event-${lineIdx}`} className="w-full my-2"> <div key={`${key}-line-event-${lineIdx}`} className="w-full my-2">
<EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} showFull={!lazyMedia} /> <EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} showFull={false} />
</div> </div>
) )
} }
@ -3782,7 +3782,7 @@ function parseMarkdownContentMarked(
} else { } else {
nodes.push( nodes.push(
<div key={`${key}-nostr-raw-event-${segmentIdx++}`} className="w-full my-2"> <div key={`${key}-nostr-raw-event-${segmentIdx++}`} className="w-full my-2">
<EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} showFull={!lazyMedia} /> <EmbeddedNote noteId={bech32Id} containingEvent={containingEvent} showFull={false} />
</div> </div>
) )
} }
@ -4096,7 +4096,7 @@ function parseMarkdownContentMarked(
} else { } else {
nodes.push( nodes.push(
<div key={`${key}-nostr-inline-event-${idx}`} className="w-full my-2"> <div key={`${key}-nostr-inline-event-${idx}`} className="w-full my-2">
<EmbeddedNote noteId={bech32} containingEvent={containingEvent} showFull={!lazyMedia} /> <EmbeddedNote noteId={bech32} containingEvent={containingEvent} showFull={false} />
</div> </div>
) )
} }

23
src/hooks/useFetchEvent.tsx

@ -6,7 +6,11 @@ import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
export function useFetchEvent(eventId?: string, initialEvent?: Event) { export function useFetchEvent(
eventId?: string,
initialEvent?: Event,
fetchOpts?: { relayHints?: string[] }
) {
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply() const { addReplies } = useReply()
const [error, setError] = useState<Error | null>(null) const [error, setError] = useState<Error | null>(null)
@ -18,6 +22,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
setRefetchToken((n) => n + 1) setRefetchToken((n) => n + 1)
}, []) }, [])
/** Content-based key so a new `relayHints` array with the same URLs does not restart the fetch. */
const relayHintsSerialized = fetchOpts?.relayHints?.join('\0') ?? ''
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -75,10 +82,10 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
try { try {
// First load: DataLoader dedupes. Refetches (incl. session-waiter) clear a prior undefined so // First load: DataLoader dedupes. Refetches (incl. session-waiter) clear a prior undefined so
// timeline-cached events resolve after the embed mounted first. // timeline-cached events resolve after the embed mounted first.
const fetchedEvent = const opts = fetchOpts?.relayHints?.length ? fetchOpts : undefined
skipShortcuts const fetchedEvent = skipShortcuts
? await eventService.fetchEventForceRetry(eventId) ? await eventService.fetchEventForceRetry(eventId, opts)
: await eventService.fetchEvent(eventId) : await eventService.fetchEvent(eventId, opts)
if (cancelled) return if (cancelled) return
if (fetchedEvent && !isEventDeleted(fetchedEvent)) { if (fetchedEvent && !isEventDeleted(fetchedEvent)) {
setEvent(fetchedEvent) setEvent(fetchedEvent)
@ -99,8 +106,12 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
return () => { return () => {
cancelled = true cancelled = true
// If deps change (e.g. embed relay hints) or Strict Mode re-runs the effect while a fetch is
// still in flight, `finally` skips `setIsFetching(false)` when `cancelled` — without this,
// loading can stay true forever and embeds show an endless skeleton.
setIsFetching(false)
} }
}, [eventId, initialEvent, isEventDeleted, addReplies, refetchToken]) }, [eventId, initialEvent, isEventDeleted, addReplies, refetchToken, relayHintsSerialized])
useEffect(() => { useEffect(() => {
if (event && isEventDeleted(event)) { if (event && isEventDeleted(event)) {

33
src/lib/event.ts

@ -1,7 +1,7 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { muteSetHas } from '@/lib/mute-set' import { muteSetHas } from '@/lib/mute-set'
import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns'
import { cleanUrl } from '@/lib/url' import { cleanUrl, normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TImetaInfo } from '@/types' import { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
@ -616,6 +616,37 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): {
} }
} }
/**
* `wss://` / `ws://` hints from `e`/`a`/`q` third field, `relays` tags, and relays that delivered the parent event.
* Used to resolve embedded notes from the same context (e.g. long-form body) before the generic relay fan-out.
*/
export function relayHintWssUrlsFromEvent(event: Event | undefined): string[] {
if (!event) return []
const hints: string[] = []
for (const tag of event.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://')) hints.push(hint)
}
}
const relaysTag = event.tags.find((t) => t[0] === 'relays')
if (relaysTag) {
for (let i = 1; i < relaysTag.length; i++) {
const u = relaysTag[i]
if (typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://'))) hints.push(u)
}
}
try {
hints.push(...client.getSeenEventRelayUrls(event.id))
} catch {
/* ignore */
}
const normalized = hints
.map((u) => normalizeUrl(u))
.filter((u): u is string => Boolean(u))
return [...new Set(normalized)]
}
function getEmbeddedPubkeys(event: Event) { function getEmbeddedPubkeys(event: Event) {
const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id) const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id)
if (cache) return cache if (cache) return cache

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

@ -64,6 +64,13 @@ export interface RelayListBuilderOptions {
includeLocalRelays?: boolean includeLocalRelays?: boolean
/** Whether to include user's favorite relays (kind 10012) */ /** Whether to include user's favorite relays (kind 10012) */
includeFavoriteRelays?: boolean includeFavoriteRelays?: boolean
/**
* When true with fast-read / searchable includes: insert `FAST_READ_RELAY_URLS` and
* `SEARCHABLE_RELAY_URLS` immediately after hints/seen/containing and **before** author + user
* NIP-65 lists. Used for single-event / embed fetches so public mirrors (e.g. nos.lol) are not
* queued behind dozens of personal relays under the global connection cap.
*/
preferPublicReadRelaysEarly?: boolean
} }
/** /**
@ -83,7 +90,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
includeSearchableRelays = false, includeSearchableRelays = false,
blockedRelays = [], blockedRelays = [],
includeLocalRelays = true, includeLocalRelays = true,
includeFavoriteRelays = false includeFavoriteRelays = false,
preferPublicReadRelaysEarly = false
} = options } = options
const relayUrls = new Set<string>() const relayUrls = new Set<string>()
@ -114,6 +122,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
// 3. Relays where containing event was found (for embedded events) // 3. Relays where containing event was found (for embedded events)
containingEventRelays.forEach(addRelay) containingEventRelays.forEach(addRelay)
// 3b. Public read / index relays before author + user NIP-65 expansion (embed + fetchEvent).
if (preferPublicReadRelaysEarly) {
if (includeFastReadRelays) {
FAST_READ_RELAY_URLS.forEach(addRelay)
}
if (includeSearchableRelays) {
SEARCHABLE_RELAY_URLS.forEach(addRelay)
}
}
// 4. Author's outboxes (write relays) - where they publish // 4. Author's outboxes (write relays) - where they publish
if (authorPubkey) { if (authorPubkey) {
try { try {
@ -257,7 +275,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} }
// 7. Fast read relays (fallback) // 7. Fast read relays (fallback)
if (includeFastReadRelays) { if (includeFastReadRelays && !preferPublicReadRelaysEarly) {
FAST_READ_RELAY_URLS.forEach(addRelay) FAST_READ_RELAY_URLS.forEach(addRelay)
} }
@ -267,7 +285,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} }
// 9. Searchable relays (for search) // 9. Searchable relays (for search)
if (includeSearchableRelays) { if (includeSearchableRelays && !preferPublicReadRelaysEarly) {
SEARCHABLE_RELAY_URLS.forEach(addRelay) SEARCHABLE_RELAY_URLS.forEach(addRelay)
} }

11
src/pages/secondary/NotePage/index.tsx

@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks' import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { import {
collectEmbeddedEventPrefetchTargets,
getParentBech32Id, getParentBech32Id,
getParentETag, getParentETag,
getParentEventHexId, getParentEventHexId,
@ -191,6 +192,16 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
void client.fetchProfilesForPubkeys([pk]) void client.fetchProfilesForPubkeys([pk])
}, [finalEvent?.id, finalEvent?.pubkey]) }, [finalEvent?.id, finalEvent?.pubkey])
/** Warm session cache so markdown/embed cards resolve before each {@link EmbeddedNote} mounts. */
useEffect(() => {
if (!finalEvent) return
const { hexIds, nip19Pointers } = collectEmbeddedEventPrefetchTargets(finalEvent)
if (hexIds.length > 0) void client.prefetchHexEventIds(hexIds)
for (const pointer of nip19Pointers) {
void client.fetchEvent(pointer)
}
}, [finalEvent?.id])
const getNoteTypeTitle = (kind: number): string => { const getNoteTypeTitle = (kind: number): string => {
switch (kind) { switch (kind) {
case 1: // kinds.ShortTextNote case 1: // kinds.ShortTextNote

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

@ -31,6 +31,26 @@ import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
/** NIP-33 / NIP-01 `a` coordinate: `<kind>:<hex pubkey>:<d identifier>`. */
function parseReplaceableAtagCoordinate(atag: string): {
kind: number
pubkey: string
identifier: string
} | null {
const s = atag.trim()
const i0 = s.indexOf(':')
if (i0 < 0) return null
const kind = Number.parseInt(s.slice(0, i0), 10)
if (!Number.isFinite(kind)) return null
const rest = s.slice(i0 + 1)
const i1 = rest.indexOf(':')
if (i1 < 0) return null
const pubkey = rest.slice(0, i1).toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pubkey)) return null
const identifier = rest.slice(i1 + 1)
return { kind, pubkey, identifier }
}
/** /**
* Build comprehensive relay list for event-by-id fetch: user's inboxes (+ cache), **favorite relays * Build comprehensive relay list for event-by-id fetch: user's inboxes (+ cache), **favorite relays
* (kind 10012, same as sidebar menu)**, relay hints, author outboxes/inboxes when known, * (kind 10012, same as sidebar menu)**, relay hints, author outboxes/inboxes when known,
@ -51,7 +71,8 @@ async function buildComprehensiveRelayListForEvents(
includeFastReadRelays: true, includeFastReadRelays: true,
includeSearchableRelays: true, includeSearchableRelays: true,
includeLocalRelays: true, includeLocalRelays: true,
includeFavoriteRelays: Boolean(client.pubkey) includeFavoriteRelays: Boolean(client.pubkey),
preferPublicReadRelaysEarly: true
}) })
} }
@ -70,6 +91,8 @@ export class EventService {
private sessionMetadataByPubkey = new Map<string, NEvent>() private sessionMetadataByPubkey = new Map<string, NEvent>()
/** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */ /** Callbacks waiting for an event id to appear in {@link sessionEventCache} (e.g. embed loads before timeline caches the note). */
private sessionEventWaiters = new Map<string, Set<() => void>>() private sessionEventWaiters = new Map<string, Set<() => void>>()
/** Waiters keyed like {@link replaceableWaiterKey} — naddr embeds have no hex id until a REQ returns. */
private sessionReplaceableWaiters = new Map<string, Set<() => void>>()
private eventDataLoader: DataLoader<string, NEvent | undefined> private eventDataLoader: DataLoader<string, NEvent | undefined>
private fetchEventFromBigRelaysDataloader: DataLoader<string, NEvent | undefined> private fetchEventFromBigRelaysDataloader: DataLoader<string, NEvent | undefined>
@ -128,7 +151,7 @@ export class EventService {
if (!isReplaceableEvent(ev.kind)) continue if (!isReplaceableEvent(ev.kind)) continue
if (ev.kind !== kind || ev.pubkey.toLowerCase() !== pk) continue if (ev.kind !== kind || ev.pubkey.toLowerCase() !== pk) continue
const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? '' const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? ''
if (d === identifier) return ev if (d === (identifier ?? '')) return ev
} }
return undefined return undefined
} }
@ -145,6 +168,21 @@ export class EventService {
} }
} }
private notifyReplaceableCoordinateWaiters(ev: NEvent): void {
if (!isReplaceableEvent(ev.kind)) return
const dTag = ev.tags.find((t) => t[0] === 'd')?.[1] ?? ''
const key = `${ev.kind}:${ev.pubkey.toLowerCase()}:${dTag}`
const waiters = this.sessionReplaceableWaiters.get(key)
if (!waiters?.size) return
for (const cb of [...waiters]) {
try {
cb()
} catch (e) {
logger.warn('[EventService] replaceable session waiter failed', { key, e })
}
}
}
/** /**
* Read parent/root (or any) event from the session cache without removing it. * Read parent/root (or any) event from the session cache without removing it.
* Accepts hex, note1, nevent1, or naddr1 (replaceable match in session LRU only). * Accepts hex, note1, nevent1, or naddr1 (replaceable match in session LRU only).
@ -161,7 +199,7 @@ export class EventService {
return this.getSessionEventIfMatchingNaddr({ return this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey, pubkey: data.pubkey,
kind: data.kind, kind: data.kind,
identifier: data.identifier identifier: data.identifier ?? ''
}) })
} }
} catch { } catch {
@ -171,35 +209,69 @@ export class EventService {
} }
/** /**
* When an event with this id is added to the session cache, invoke `callback` (and when already cached). * When a matching event is added to the session cache, invoke `callback` (and when already cached).
* Only supports hex, note1, and nevent1 (not naddr). * Supports hex / note1 / nevent1 and **naddr1** (replaceable coordinate: kind + pubkey + `d`).
*/ */
subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void { subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void {
const hex = this.resolveHexWaiterKey(eventId) const hex = this.resolveHexWaiterKey(eventId)
if (!hex) return () => {} if (hex) {
if (this.getSessionEventIfAllowed(hex)) {
queueMicrotask(() => callback())
}
if (this.getSessionEventIfAllowed(hex)) { let set = this.sessionEventWaiters.get(hex)
queueMicrotask(() => callback()) if (!set) {
set = new Set()
this.sessionEventWaiters.set(hex, set)
}
set.add(callback)
return () => {
set!.delete(callback)
if (set!.size === 0) {
this.sessionEventWaiters.delete(hex)
}
}
} }
let set = this.sessionEventWaiters.get(hex) try {
if (!set) { const { type, data } = nip19.decode(eventId.trim())
set = new Set() if (type === 'naddr') {
this.sessionEventWaiters.set(hex, set) const identifier = data.identifier ?? ''
} if (
set.add(callback) this.getSessionEventIfMatchingNaddr({
return () => { pubkey: data.pubkey,
set!.delete(callback) kind: data.kind,
if (set!.size === 0) { identifier
this.sessionEventWaiters.delete(hex) })
) {
queueMicrotask(() => callback())
}
const key = `${data.kind}:${data.pubkey.toLowerCase()}:${identifier}`
let rset = this.sessionReplaceableWaiters.get(key)
if (!rset) {
rset = new Set()
this.sessionReplaceableWaiters.set(key, rset)
}
rset.add(callback)
return () => {
rset!.delete(callback)
if (rset!.size === 0) {
this.sessionReplaceableWaiters.delete(key)
}
}
} }
} catch {
/* invalid bech32 */
} }
return () => {}
} }
/** /**
* Fetch single event by ID (hex, note1, nevent1, naddr1) * Fetch single event by ID (hex, note1, nevent1, naddr1).
* Optional `relayHints` (e.g. from the parent articles tags) are merged first so REQ targets the same relays that likely hold the embed.
*/ */
async fetchEvent(id: string): Promise<NEvent | undefined> { async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
const trimmed = id.trim() const trimmed = id.trim()
let hexId: string | undefined let hexId: string | undefined
if (/^[0-9a-f]{64}$/i.test(trimmed)) { if (/^[0-9a-f]{64}$/i.test(trimmed)) {
@ -218,7 +290,7 @@ export class EventService {
const fromSession = this.getSessionEventIfMatchingNaddr({ const fromSession = this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey, pubkey: data.pubkey,
kind: data.kind, kind: data.kind,
identifier: data.identifier identifier: data.identifier ?? ''
}) })
if (fromSession) return fromSession if (fromSession) return fromSession
break break
@ -246,6 +318,10 @@ export class EventService {
this.eventDataLoader.clear(hexId) this.eventDataLoader.clear(hexId)
} }
} }
if (opts?.relayHints?.length) {
const hinted = await this._fetchEvent(trimmed, opts.relayHints)
if (hinted && !shouldDropEventOnIngest(hinted)) return hinted
}
const loaded = await this.eventDataLoader.load(hexId ?? trimmed) const loaded = await this.eventDataLoader.load(hexId ?? trimmed)
if (hexId) { if (hexId) {
const fromSessionAfter = this.getSessionEventIfAllowed(hexId) const fromSessionAfter = this.getSessionEventIfAllowed(hexId)
@ -284,9 +360,9 @@ export class EventService {
/** /**
* Force retry fetch event * Force retry fetch event
*/ */
async fetchEventForceRetry(eventId: string): Promise<NEvent | undefined> { async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
this.clearDataloaderCacheForFetchId(eventId) this.clearDataloaderCacheForFetchId(eventId)
return this.fetchEvent(eventId) return this.fetchEvent(eventId, opts)
} }
/** /**
@ -345,10 +421,13 @@ export class EventService {
if (type === 'note') return { ids: [data], limit: 1 } if (type === 'note') return { ids: [data], limit: 1 }
if (type === 'nevent') return { ids: [data.id], limit: 1 } if (type === 'nevent') return { ids: [data.id], limit: 1 }
if (type === 'naddr') { if (type === 'naddr') {
const pk = data.pubkey.toLowerCase()
const ident = data.identifier ?? ''
/** NIP-33 coordinate query; `#a` alone often misses — many relays index `authors` + `#d`. */
return { return {
kinds: [data.kind], kinds: [data.kind],
authors: [data.pubkey], authors: [pk],
'#d': [data.identifier], '#d': [ident],
limit: 1 limit: 1
} }
} }
@ -378,7 +457,9 @@ export class EventService {
const logKey = const logKey =
'ids' in filter && filter.ids?.[0] 'ids' in filter && filter.ids?.[0]
? filter.ids[0].slice(0, 8) ? filter.ids[0].slice(0, 8)
: `${filter.kinds?.[0]}:${(filter.authors?.[0] ?? '').slice(0, 8)}` : Array.isArray(filter['#a']) && filter['#a'][0]
? String(filter['#a'][0]).slice(0, 40)
: `${filter.kinds?.[0]}:${(filter.authors?.[0] ?? '').slice(0, 8)}`
logger.debug('fetchEventWithExternalRelays: Starting search', { logger.debug('fetchEventWithExternalRelays: Starting search', {
noteIdKey: logKey, noteIdKey: logKey,
@ -387,10 +468,11 @@ export class EventService {
}) })
const startTime = Date.now() const startTime = Date.now()
/** User-driven “try everywhere”: wait for EOSE-ish completion so slower relays (e.g. nos.lol) can answer. */
const events = await this.queryService.query(externalRelays, filter, undefined, { const events = await this.queryService.query(externalRelays, filter, undefined, {
eoseTimeout: 10000, eoseTimeout: 12_000,
globalTimeout: 20000, globalTimeout: 35_000,
immediateReturn: true immediateReturn: false
}) })
const duration = Date.now() - startTime const duration = Date.now() - startTime
@ -401,7 +483,10 @@ export class EventService {
durationMs: duration durationMs: duration
}) })
return events[0] const usable = events
.filter((e) => !shouldDropEventOnIngest(e))
.sort((a, b) => b.created_at - a.created_at)
return usable[0]
} }
/** /**
@ -427,6 +512,7 @@ export class EventService {
} }
} }
this.notifySessionEventWaiters(id) this.notifySessionEventWaiters(id)
this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent)
if ( if (
cleanEvent.kind === ExtendedKind.PUBLICATION || cleanEvent.kind === ExtendedKind.PUBLICATION ||
@ -722,6 +808,7 @@ export class EventService {
this.sessionMetadataByPubkey.clear() this.sessionMetadataByPubkey.clear()
this.eventCacheMap.clear() this.eventCacheMap.clear()
this.sessionEventWaiters.clear() this.sessionEventWaiters.clear()
this.sessionReplaceableWaiters.clear()
this.fetchEventFromBigRelaysDataloader.clearAll() this.fetchEventFromBigRelaysDataloader.clearAll()
invalidateArchiveFootprintCache() invalidateArchiveFootprintCache()
logger.info('[EventService] In-memory caches cleared') logger.info('[EventService] In-memory caches cleared')
@ -730,10 +817,19 @@ export class EventService {
/** /**
* Private: Fetch event by ID (internal implementation) * Private: Fetch event by ID (internal implementation)
*/ */
private async _fetchEvent(id: string): Promise<NEvent | undefined> { private async _fetchEvent(id: string, extraRelayHints?: string[]): Promise<NEvent | undefined> {
let filter: Filter | undefined let filter: Filter | undefined
let relays: string[] = [] let relays: string[] = []
if (extraRelayHints?.length) {
relays = [
...new Set(
extraRelayHints
.map((u) => normalizeUrl(u))
.filter((u): u is string => Boolean(u))
)
]
}
if (/^[0-9a-f]{64}$/i.test(id)) { if (/^[0-9a-f]{64}$/i.test(id)) {
filter = { ids: [id.toLowerCase()], limit: 1 } filter = { ids: [id.toLowerCase()], limit: 1 }
} else { } else {
@ -744,19 +840,20 @@ export class EventService {
break break
case 'nevent': case 'nevent':
filter = { ids: [data.id], limit: 1 } filter = { ids: [data.id], limit: 1 }
if (data.relays) relays = [...data.relays] if (data.relays) relays = [...new Set([...relays, ...data.relays])]
break break
case 'naddr': case 'naddr': {
const pk = data.pubkey.toLowerCase()
const ident = data.identifier ?? ''
filter = { filter = {
authors: [data.pubkey],
kinds: [data.kind], kinds: [data.kind],
authors: [pk],
'#d': [ident],
limit: 1 limit: 1
} }
if (data.identifier) { if (data.relays) relays = [...new Set([...relays, ...data.relays])]
filter['#d'] = [data.identifier]
}
if (data.relays) relays = [...data.relays]
break break
}
} }
} }
@ -816,6 +913,28 @@ export class EventService {
if (sess) return sess if (sess) return sess
} }
if (filter.authors?.length === 1 && filter.kinds?.length === 1 && Array.isArray(filter['#d'])) {
const ident = filter['#d'][0] ?? ''
const sessAddr = this.getSessionEventIfMatchingNaddr({
pubkey: filter.authors[0]!,
kind: filter.kinds[0]!,
identifier: ident
})
if (sessAddr) return sessAddr
}
if (Array.isArray(filter['#a']) && filter['#a'][0]) {
const parsed = parseReplaceableAtagCoordinate(String(filter['#a'][0]))
if (parsed) {
const sessA = this.getSessionEventIfMatchingNaddr({
pubkey: parsed.pubkey,
kind: parsed.kind,
identifier: parsed.identifier
})
if (sessA) return sessA
}
}
return undefined return undefined
} }
@ -831,9 +950,13 @@ export class EventService {
// Get seen relays if we have an event ID // Get seen relays if we have an event ID
const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : [] const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : []
// Get author pubkey const parsedAtag =
const authorPubkey = filter.authors?.length === 1 ? filter.authors[0] : undefined Array.isArray(filter['#a']) && typeof filter['#a'][0] === 'string'
? parseReplaceableAtagCoordinate(filter['#a'][0] as string)
: null
const authorPubkey =
filter.authors?.length === 1 ? filter.authors[0] : parsedAtag?.pubkey
// Build comprehensive relay list // Build comprehensive relay list
const relayUrls = await buildComprehensiveRelayListForEvents(authorPubkey, relayHints, seenRelays, []) const relayUrls = await buildComprehensiveRelayListForEvents(authorPubkey, relayHints, seenRelays, [])
@ -852,22 +975,25 @@ export class EventService {
hasSeen: seenRelays.length > 0 hasSeen: seenRelays.length > 0
}) })
const isSingleEventById = filter.ids && filter.ids.length === 1 && filter.limit === 1 const isSingleEventById = Boolean(filter.ids && filter.ids.length === 1 && filter.limit === 1)
/** Replaceable coordinate: `#a` (preferred) or legacy `authors` + `#d`. */
// For single-event fetches, always use immediateReturn to return ASAP const isReplaceableCoordinateFetch =
// This is especially important for non-replaceable events (not in 10000-19999 or 30000-39999 ranges) filter.limit === 1 &&
filter.kinds?.length === 1 &&
((Array.isArray(filter['#a']) && filter['#a'].length >= 1) ||
(filter.authors?.length === 1 && Array.isArray(filter['#d']) && filter['#d'].length >= 1))
const useFastSingleHitQuery = isSingleEventById || isReplaceableCoordinateFetch
const events = await this.queryService.query(relayUrls, filter, undefined, { const events = await this.queryService.query(relayUrls, filter, undefined, {
immediateReturn: isSingleEventById, // Return immediately when found immediateReturn: useFastSingleHitQuery,
eoseTimeout: isSingleEventById ? 1500 : 500, eoseTimeout: useFastSingleHitQuery ? 2500 : 500,
globalTimeout: isSingleEventById ? 12000 : 10000 globalTimeout: useFastSingleHitQuery ? 20_000 : 10000
}) })
const event = events const event = events
.filter((e) => !shouldDropEventOnIngest(e)) .filter((e) => !shouldDropEventOnIngest(e))
.sort((a, b) => b.created_at - a.created_at)[0] .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)) { if (event && isSingleEventById && !isReplaceableEvent(event.kind)) {
logger.debug('[EventService] Non-replaceable event returned immediately', { logger.debug('[EventService] Non-replaceable event returned immediately', {
eventId: event.id.substring(0, 8), eventId: event.id.substring(0, 8),
@ -910,8 +1036,8 @@ export class EventService {
undefined, undefined,
{ {
immediateReturn: isSingleEventFetch, immediateReturn: isSingleEventFetch,
eoseTimeout: isSingleEventFetch ? 1500 : 500, eoseTimeout: isSingleEventFetch ? 2500 : 500,
globalTimeout: isSingleEventFetch ? 12000 : 10000 globalTimeout: isSingleEventFetch ? 20_000 : 10000
} }
) )

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

@ -112,6 +112,24 @@ function sanitizeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] {
return splitFiltersByMaxKindCount(sanitized) return splitFiltersByMaxKindCount(sanitized)
} }
/** True for single-replaceable REQ (`#a` coordinate or legacy `authors` + `#d`). */
function filterHasReplaceableCoordinate(f: Filter): boolean {
if ((f.limit ?? 0) !== 1 || !f.kinds?.length) return false
const a = (f as Record<string, unknown>)['#a']
if (Array.isArray(a) && a.length > 0 && typeof a[0] === 'string' && String(a[0]).includes(':')) {
return true
}
if (f.authors?.length === 1) {
const d = (f as Record<string, unknown>)['#d']
return Array.isArray(d) && d.length > 0
}
return false
}
function someFilterHasReplaceableCoordinate(filters: Filter[]): boolean {
return filters.some(filterHasReplaceableCoordinate)
}
export interface QueryOptions { export interface QueryOptions {
eoseTimeout?: number eoseTimeout?: number
globalTimeout?: number globalTimeout?: number
@ -439,12 +457,18 @@ export class QueryService {
const filters = sanitizedFilters const filters = sanitizedFilters
const maxLimit = Math.max(...filters.map((f) => (f.limit ?? 0) as number), 0) const maxLimit = Math.max(...filters.map((f) => (f.limit ?? 0) as number), 0)
const isSingleEventFetch = maxLimit === 1 const isSingleEventFetch = maxLimit === 1
const hasIdFilter = filters.some(f => f.ids && f.ids.length > 0) const hasIdFilter = filters.some((f) => f.ids && f.ids.length > 0)
const hasReplaceableCoordFilter = someFilterHasReplaceableCoordinate(filters)
// For immediateReturn: return as soon as we find the event // 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) // This is critical for non-replaceable events (not in 10000-19999 or 30000-39999 ranges)
// which should be rendered ASAP // which should be rendered ASAP
if (immediateReturn && hasIdFilter && isSingleEventFetch && events.length > 0) { if (
immediateReturn &&
(hasIdFilter || hasReplaceableCoordFilter) &&
isSingleEventFetch &&
events.length > 0
) {
resolveWithEvents() resolveWithEvents()
return return
} }

8
src/services/client.service.ts

@ -3068,14 +3068,14 @@ class ClientService extends EventTarget {
* (4) if still missing and filter has authors: author's read+write again in tryHarderToFetchEvent * (4) if still missing and filter has authors: author's read+write again in tryHarderToFetchEvent
* (5) SEARCHABLE_RELAY_URLS as final fallback. Author relays are used so embedded notes load from the author's relays. * (5) SEARCHABLE_RELAY_URLS as final fallback. Author relays are used so embedded notes load from the author's relays.
*/ */
async fetchEvent(id: string): Promise<NEvent | undefined> { async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
return this.eventService.fetchEvent(id) return this.eventService.fetchEvent(id, opts)
} }
// Legacy fetchEvent implementation removed - now delegated to EventService // Legacy fetchEvent implementation removed - now delegated to EventService
async fetchEventForceRetry(eventId: string): Promise<NEvent | undefined> { async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
return this.eventService.fetchEventForceRetry(eventId) return this.eventService.fetchEventForceRetry(eventId, opts)
} }
/** Batch-prefetch by hex id into session cache (feed embeds). */ /** Batch-prefetch by hex id into session cache (feed embeds). */

Loading…
Cancel
Save