Browse Source

make embedded events appear faster

imwald
Silberengel 1 month ago
parent
commit
e3b692e4f4
  1. 219
      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. 202
      src/services/client-events.service.ts
  9. 28
      src/services/client-query.service.ts
  10. 8
      src/services/client.service.ts

219
src/components/Embedded/EmbeddedNote.tsx

@ -9,9 +9,11 @@ import { normalizeUrl } from '@/lib/url' @@ -9,9 +9,11 @@ import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import nip66Service from '@/services/nip66.service'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { relayHintWssUrlsFromEvent } from '@/lib/event'
import { Event, nip19 } from 'nostr-tools'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
@ -80,6 +82,7 @@ export function EmbeddedNote({ @@ -80,6 +82,7 @@ export function EmbeddedNote({
noteId: string
className?: string
containingEvent?: Event
/** True = full long-form/publication body; false = compact card (use inside articles). */
showFull?: boolean
}) {
const suppress = useSuppressEmbeddedNoteId()
@ -199,47 +202,33 @@ function EmbeddedNoteFetched({ @@ -199,47 +202,33 @@ function EmbeddedNoteFetched({
showFull: boolean
allowLiveEmbeds: boolean
}) {
const { event, isFetching } = useFetchEvent(noteId)
const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined)
const [isRetrying, setIsRetrying] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const maxRetries = 3
// If the first fetch fails, try a force retry (max 3 attempts)
useEffect(() => {
if (!isFetching && !event && !isRetrying && retryCount < maxRetries) {
setIsRetrying(true)
setRetryCount(prev => prev + 1)
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 relayHints = useMemo(
() => relayHintWssUrlsFromEvent(containingEvent),
[containingEvent?.id]
)
const fetchRelayOpts = useMemo(
() => (relayHints.length > 0 ? { relayHints } : undefined),
[relayHints]
)
const { event, isFetching } = useFetchEvent(noteId, undefined, fetchRelayOpts)
/** Filled when “Try external relays” / IndexedDB recovery finds the event after the hook missed. */
const [resolvedEvent, setResolvedEvent] = useState<Event | undefined>(undefined)
const finalEvent = event || retryEvent
const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries)
const finalEvent = event || resolvedEvent
if (finalIsFetching) {
if (isFetching && !finalEvent) {
return <EmbeddedNoteSkeleton className={className} />
}
if (!finalEvent) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} containingEvent={containingEvent} />
return (
<EmbeddedNoteNotFound
className={className}
noteId={noteId}
onEventFound={setResolvedEvent}
containingEvent={containingEvent}
/>
)
}
if (
@ -340,6 +329,24 @@ function EmbeddedNoteContent({ @@ -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 }) {
return (
<div
@ -381,108 +388,114 @@ function EmbeddedNoteNotFound({ @@ -381,108 +388,114 @@ function EmbeddedNoteNotFound({
)
const [isSearchingExternal, setIsSearchingExternal] = useState(false)
const [triedExternal, setTriedExternal] = useState(false)
const [externalRelays, setExternalRelays] = useState<string[]>([])
const [hexEventId, setHexEventId] = useState<string | null>(null)
const [asyncHintRelays, setAsyncHintRelays] = useState<string[]>([])
const [externalSearchDetail, setExternalSearchDetail] = useState<
null | 'unparseable' | 'no_relays' | 'searched'
>(null)
// Relays for "Try external relays": hints + searchable + FAST_READ.
// Initial embed fetch uses short per-relay timeouts; this pass uses longer timeouts (see fetchEventWithExternalRelays).
// We intentionally include FAST_READ again so slow/default relays get a second chance.
const resolvedHexId = useMemo(() => {
const h = hexEventIdFromNoteId(noteId)
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(() => {
const getExternalRelays = async () => {
let hintRelays: string[] = []
let extractedHexEventId: string | null = null
let cancelled = false
const loadHints = async () => {
const hintRelays: string[] = []
// 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)
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))
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. Hex id (any case) or bech32; hints from nevent/naddr for extra relays
const quickHex = hexEventIdFromNoteId(noteId)
if (quickHex) {
extractedHexEventId = quickHex
}
try {
const { type, data } = nip19.decode(noteId)
const { type, data } = nip19.decode(noteId.trim())
if (type === 'nevent') {
extractedHexEventId = data.id
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, 10), ...(authorRelayList.write ?? []).slice(0, 10))
const authorRelayList = await client
.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') {
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, 10), ...(authorRelayList.write ?? []).slice(0, 10))
} else if (type === 'note') {
extractedHexEventId = data
const authorRelayList = await client
.fetchRelayList(data.pubkey)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(
...(authorRelayList.read ?? []).slice(0, 10),
...(authorRelayList.write ?? []).slice(0, 10)
)
}
} 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) : []
const seenOn = resolvedHexId ? client.getSeenEventRelayUrls(resolvedHexId) : []
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)
logger.debug('External relays calculated', {
if (!cancelled) {
setAsyncHintRelays(dedupeRelayUrls(hintRelays))
logger.debug('External relay hints merged', {
noteId,
hintRelaysCount: normalizedHints.length,
searchableRelaysCount: normalizedSearchableRelays.length,
fastReadRelaysCount: normalizedFastRead.length,
externalRelaysCount: externalRelays.length,
externalRelays: externalRelays.slice(0, 10)
hintCount: hintRelays.length,
totalRelays: dedupeRelayUrls([...hintRelays, ...coreExternalRelays]).length
})
}
}
getExternalRelays()
// containingEvent supplies e/a/q relay hints + author NIP-65 list — must rerun when parent loads
}, [noteId, containingEvent?.id, menuRelayUrls])
void loadHints()
return () => {
cancelled = true
}
}, [noteId, containingEvent?.id, resolvedHexId, coreExternalRelays])
const handleTryExternalRelays = async () => {
if (isSearchingExternal) return
@ -505,7 +518,7 @@ function EmbeddedNoteNotFound({ @@ -505,7 +518,7 @@ function EmbeddedNoteNotFound({
setExternalSearchDetail(null)
let found: Event | undefined
try {
const idHex = hexEventId ?? hexEventIdFromNoteId(noteId)
const idHex = resolvedHexId ?? hexEventIdFromNoteId(noteId)
if (idHex) {
const fromDb = await indexedDb.getEventFromPublicationStore(idHex)
if (fromDb) {

4
src/components/LogoutDialog/index.tsx

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

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

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

23
src/hooks/useFetchEvent.tsx

@ -6,7 +6,11 @@ import { navigationEventStore } from '@/services/navigation-event-store' @@ -6,7 +6,11 @@ import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools'
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 { addReplies } = useReply()
const [error, setError] = useState<Error | null>(null)
@ -18,6 +22,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { @@ -18,6 +22,9 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
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(() => {
let cancelled = false
@ -75,10 +82,10 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { @@ -75,10 +82,10 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
try {
// First load: DataLoader dedupes. Refetches (incl. session-waiter) clear a prior undefined so
// timeline-cached events resolve after the embed mounted first.
const fetchedEvent =
skipShortcuts
? await eventService.fetchEventForceRetry(eventId)
: await eventService.fetchEvent(eventId)
const opts = fetchOpts?.relayHints?.length ? fetchOpts : undefined
const fetchedEvent = skipShortcuts
? await eventService.fetchEventForceRetry(eventId, opts)
: await eventService.fetchEvent(eventId, opts)
if (cancelled) return
if (fetchedEvent && !isEventDeleted(fetchedEvent)) {
setEvent(fetchedEvent)
@ -99,8 +106,12 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) { @@ -99,8 +106,12 @@ export function useFetchEvent(eventId?: string, initialEvent?: Event) {
return () => {
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(() => {
if (event && isEventDeleted(event)) {

33
src/lib/event.ts

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import { muteSetHas } from '@/lib/mute-set'
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 { TImetaInfo } from '@/types'
import { LRUCache } from 'lru-cache'
@ -616,6 +616,37 @@ export function collectEmbeddedEventPrefetchTargets(event: Event): { @@ -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) {
const cache = EVENT_EMBEDDED_PUBKEYS_CACHE.get(event.id)
if (cache) return cache

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

@ -64,6 +64,13 @@ export interface RelayListBuilderOptions { @@ -64,6 +64,13 @@ export interface RelayListBuilderOptions {
includeLocalRelays?: boolean
/** Whether to include user's favorite relays (kind 10012) */
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 @@ -83,7 +90,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
includeSearchableRelays = false,
blockedRelays = [],
includeLocalRelays = true,
includeFavoriteRelays = false
includeFavoriteRelays = false,
preferPublicReadRelaysEarly = false
} = options
const relayUrls = new Set<string>()
@ -114,6 +122,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -114,6 +122,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
// 3. Relays where containing event was found (for embedded events)
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
if (authorPubkey) {
try {
@ -257,7 +275,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -257,7 +275,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
// 7. Fast read relays (fallback)
if (includeFastReadRelays) {
if (includeFastReadRelays && !preferPublicReadRelaysEarly) {
FAST_READ_RELAY_URLS.forEach(addRelay)
}
@ -267,7 +285,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -267,7 +285,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
// 9. Searchable relays (for search)
if (includeSearchableRelays) {
if (includeSearchableRelays && !preferPublicReadRelaysEarly) {
SEARCHABLE_RELAY_URLS.forEach(addRelay)
}

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

@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -15,6 +15,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useFetchEvent, useFetchProfile, useNip84HighlightTargetEvents } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import {
collectEmbeddedEventPrefetchTargets,
getParentBech32Id,
getParentETag,
getParentEventHexId,
@ -191,6 +192,16 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -191,6 +192,16 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
void client.fetchProfilesForPubkeys([pk])
}, [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 => {
switch (kind) {
case 1: // kinds.ShortTextNote

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

@ -31,6 +31,26 @@ import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' @@ -31,6 +31,26 @@ import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
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
* (kind 10012, same as sidebar menu)**, relay hints, author outboxes/inboxes when known,
@ -51,7 +71,8 @@ async function buildComprehensiveRelayListForEvents( @@ -51,7 +71,8 @@ async function buildComprehensiveRelayListForEvents(
includeFastReadRelays: true,
includeSearchableRelays: true,
includeLocalRelays: true,
includeFavoriteRelays: Boolean(client.pubkey)
includeFavoriteRelays: Boolean(client.pubkey),
preferPublicReadRelaysEarly: true
})
}
@ -70,6 +91,8 @@ export class EventService { @@ -70,6 +91,8 @@ export class EventService {
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). */
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 fetchEventFromBigRelaysDataloader: DataLoader<string, NEvent | undefined>
@ -128,7 +151,7 @@ export class EventService { @@ -128,7 +151,7 @@ export class EventService {
if (!isReplaceableEvent(ev.kind)) continue
if (ev.kind !== kind || ev.pubkey.toLowerCase() !== pk) continue
const d = ev.tags.find((t) => t[0] === 'd')?.[1] ?? ''
if (d === identifier) return ev
if (d === (identifier ?? '')) return ev
}
return undefined
}
@ -145,6 +168,21 @@ export class EventService { @@ -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.
* Accepts hex, note1, nevent1, or naddr1 (replaceable match in session LRU only).
@ -161,7 +199,7 @@ export class EventService { @@ -161,7 +199,7 @@ export class EventService {
return this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey,
kind: data.kind,
identifier: data.identifier
identifier: data.identifier ?? ''
})
}
} catch {
@ -171,13 +209,12 @@ export class EventService { @@ -171,13 +209,12 @@ export class EventService {
}
/**
* When an event with this id is added to the session cache, invoke `callback` (and when already cached).
* Only supports hex, note1, and nevent1 (not naddr).
* When a matching event is added to the session cache, invoke `callback` (and when already cached).
* Supports hex / note1 / nevent1 and **naddr1** (replaceable coordinate: kind + pubkey + `d`).
*/
subscribeWhenSessionHasEvent(eventId: string, callback: () => void): () => void {
const hex = this.resolveHexWaiterKey(eventId)
if (!hex) return () => {}
if (hex) {
if (this.getSessionEventIfAllowed(hex)) {
queueMicrotask(() => callback())
}
@ -196,10 +233,45 @@ export class EventService { @@ -196,10 +233,45 @@ export class EventService {
}
}
try {
const { type, data } = nip19.decode(eventId.trim())
if (type === 'naddr') {
const identifier = data.identifier ?? ''
if (
this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey,
kind: data.kind,
identifier
})
) {
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()
let hexId: string | undefined
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
@ -218,7 +290,7 @@ export class EventService { @@ -218,7 +290,7 @@ export class EventService {
const fromSession = this.getSessionEventIfMatchingNaddr({
pubkey: data.pubkey,
kind: data.kind,
identifier: data.identifier
identifier: data.identifier ?? ''
})
if (fromSession) return fromSession
break
@ -246,6 +318,10 @@ export class EventService { @@ -246,6 +318,10 @@ export class EventService {
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)
if (hexId) {
const fromSessionAfter = this.getSessionEventIfAllowed(hexId)
@ -284,9 +360,9 @@ export class EventService { @@ -284,9 +360,9 @@ export class EventService {
/**
* Force retry fetch event
*/
async fetchEventForceRetry(eventId: string): Promise<NEvent | undefined> {
async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
this.clearDataloaderCacheForFetchId(eventId)
return this.fetchEvent(eventId)
return this.fetchEvent(eventId, opts)
}
/**
@ -345,10 +421,13 @@ export class EventService { @@ -345,10 +421,13 @@ export class EventService {
if (type === 'note') return { ids: [data], limit: 1 }
if (type === 'nevent') return { ids: [data.id], limit: 1 }
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 {
kinds: [data.kind],
authors: [data.pubkey],
'#d': [data.identifier],
authors: [pk],
'#d': [ident],
limit: 1
}
}
@ -378,6 +457,8 @@ export class EventService { @@ -378,6 +457,8 @@ export class EventService {
const logKey =
'ids' in filter && filter.ids?.[0]
? filter.ids[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', {
@ -387,10 +468,11 @@ export class EventService { @@ -387,10 +468,11 @@ export class EventService {
})
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, {
eoseTimeout: 10000,
globalTimeout: 20000,
immediateReturn: true
eoseTimeout: 12_000,
globalTimeout: 35_000,
immediateReturn: false
})
const duration = Date.now() - startTime
@ -401,7 +483,10 @@ export class EventService { @@ -401,7 +483,10 @@ export class EventService {
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 { @@ -427,6 +512,7 @@ export class EventService {
}
}
this.notifySessionEventWaiters(id)
this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent)
if (
cleanEvent.kind === ExtendedKind.PUBLICATION ||
@ -722,6 +808,7 @@ export class EventService { @@ -722,6 +808,7 @@ export class EventService {
this.sessionMetadataByPubkey.clear()
this.eventCacheMap.clear()
this.sessionEventWaiters.clear()
this.sessionReplaceableWaiters.clear()
this.fetchEventFromBigRelaysDataloader.clearAll()
invalidateArchiveFootprintCache()
logger.info('[EventService] In-memory caches cleared')
@ -730,9 +817,18 @@ export class EventService { @@ -730,9 +817,18 @@ export class EventService {
/**
* 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 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)) {
filter = { ids: [id.toLowerCase()], limit: 1 }
@ -744,21 +840,22 @@ export class EventService { @@ -744,21 +840,22 @@ export class EventService {
break
case 'nevent':
filter = { ids: [data.id], limit: 1 }
if (data.relays) relays = [...data.relays]
if (data.relays) relays = [...new Set([...relays, ...data.relays])]
break
case 'naddr':
case 'naddr': {
const pk = data.pubkey.toLowerCase()
const ident = data.identifier ?? ''
filter = {
authors: [data.pubkey],
kinds: [data.kind],
authors: [pk],
'#d': [ident],
limit: 1
}
if (data.identifier) {
filter['#d'] = [data.identifier]
}
if (data.relays) relays = [...data.relays]
if (data.relays) relays = [...new Set([...relays, ...data.relays])]
break
}
}
}
if (!filter) return undefined
@ -816,6 +913,28 @@ export class EventService { @@ -816,6 +913,28 @@ export class EventService {
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
}
@ -831,8 +950,12 @@ export class EventService { @@ -831,8 +950,12 @@ export class EventService {
// Get seen relays if we have an event ID
const seenRelays = filter.ids?.length ? client.getSeenEventRelayUrls(filter.ids[0]) : []
// Get author pubkey
const authorPubkey = filter.authors?.length === 1 ? filter.authors[0] : undefined
const parsedAtag =
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
const relayUrls = await buildComprehensiveRelayListForEvents(authorPubkey, relayHints, seenRelays, [])
@ -852,22 +975,25 @@ export class EventService { @@ -852,22 +975,25 @@ export class EventService {
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`. */
const isReplaceableCoordinateFetch =
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
// 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, // Return immediately when found
eoseTimeout: isSingleEventById ? 1500 : 500,
globalTimeout: isSingleEventById ? 12000 : 10000
immediateReturn: useFastSingleHitQuery,
eoseTimeout: useFastSingleHitQuery ? 2500 : 500,
globalTimeout: useFastSingleHitQuery ? 20_000 : 10000
})
const event = events
.filter((e) => !shouldDropEventOnIngest(e))
.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),
@ -910,8 +1036,8 @@ export class EventService { @@ -910,8 +1036,8 @@ export class EventService {
undefined,
{
immediateReturn: isSingleEventFetch,
eoseTimeout: isSingleEventFetch ? 1500 : 500,
globalTimeout: isSingleEventFetch ? 12000 : 10000
eoseTimeout: isSingleEventFetch ? 2500 : 500,
globalTimeout: isSingleEventFetch ? 20_000 : 10000
}
)

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

@ -112,6 +112,24 @@ function sanitizeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] { @@ -112,6 +112,24 @@ function sanitizeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] {
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 {
eoseTimeout?: number
globalTimeout?: number
@ -439,12 +457,18 @@ export class QueryService { @@ -439,12 +457,18 @@ export class QueryService {
const filters = sanitizedFilters
const maxLimit = Math.max(...filters.map((f) => (f.limit ?? 0) as number), 0)
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
// 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) {
if (
immediateReturn &&
(hasIdFilter || hasReplaceableCoordFilter) &&
isSingleEventFetch &&
events.length > 0
) {
resolveWithEvents()
return
}

8
src/services/client.service.ts

@ -3068,14 +3068,14 @@ class ClientService extends EventTarget { @@ -3068,14 +3068,14 @@ class ClientService extends EventTarget {
* (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.
*/
async fetchEvent(id: string): Promise<NEvent | undefined> {
return this.eventService.fetchEvent(id)
async fetchEvent(id: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
return this.eventService.fetchEvent(id, opts)
}
// Legacy fetchEvent implementation removed - now delegated to EventService
async fetchEventForceRetry(eventId: string): Promise<NEvent | undefined> {
return this.eventService.fetchEventForceRetry(eventId)
async fetchEventForceRetry(eventId: string, opts?: { relayHints?: string[] }): Promise<NEvent | undefined> {
return this.eventService.fetchEventForceRetry(eventId, opts)
}
/** Batch-prefetch by hex id into session cache (feed embeds). */

Loading…
Cancel
Save