Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
017627c008
  1. 30
      src/components/NormalFeed/index.tsx
  2. 181
      src/components/NoteList/index.tsx
  3. 14
      src/components/OthersRelayList/index.tsx
  4. 2
      src/components/Profile/ProfileFeedWithPins.tsx
  5. 1
      src/components/Profile/ProfileMediaFeed.tsx
  6. 5
      src/components/Profile/index.tsx
  7. 29
      src/constants.ts
  8. 27
      src/hooks/useFetchFollowings.tsx
  9. 24
      src/hooks/useFetchRelayList.tsx
  10. 14
      src/lib/favorites-feed-relays.ts
  11. 39
      src/lib/relay-list-sanitize.ts
  12. 6
      src/pages/secondary/OthersRelaySettingsPage/index.tsx
  13. 98
      src/services/client-events.service.ts
  14. 93
      src/services/client-replaceable-events.service.ts
  15. 156
      src/services/client.service.ts
  16. 15
      src/services/note-stats.service.ts

30
src/components/NormalFeed/index.tsx

@ -4,11 +4,12 @@ import Tabs, { TabDefinition } from '@/components/Tabs'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context' import { useUserTrust } from '@/contexts/user-trust-context'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { PROFILE_MEDIA_TAB_KINDS } from '@/constants' import { PROFILE_MEDIA_TAB_KINDS, FAST_READ_RELAY_URLS } from '@/constants'
import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { isWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay'
import type { TPrimaryPageName } from '@/PageManager' import type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types' import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { import {
forwardRef, forwardRef,
@ -22,6 +23,26 @@ import {
} from 'react' } from 'react'
import KindFilter from '../KindFilter' import KindFilter from '../KindFilter'
/**
* Home Gallery: favorites (or chip relays) first, then {@link FAST_READ_RELAY_URLS} so NIP-71 / picture / voice
* events are not starved when the users relay set is mostly text timelines. Deduped by normalized URL.
*/
function galleryRelayUrlsMergedWithReadLayer(favoriteUrls: readonly string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
const add = (raw: string) => {
const n = normalizeAnyRelayUrl(raw.trim()) || raw.trim()
if (!n) return
const k = n.toLowerCase()
if (seen.has(k)) return
seen.add(k)
out.push(n)
}
for (const u of favoriteUrls) add(u)
for (const u of FAST_READ_RELAY_URLS) add(u)
return out
}
const NormalFeed = forwardRef<TNoteListRef, { const NormalFeed = forwardRef<TNoteListRef, {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean areAlgoRelays?: boolean
@ -166,14 +187,15 @@ const NormalFeed = forwardRef<TNoteListRef, {
return base return base
}, [isMainFeed, isWispTrendingOnlyFeed]) }, [isMainFeed, isWispTrendingOnlyFeed])
/** When in media mode, replace each shard's kinds with the media set. */ /** When in media mode, replace each shard's kinds with the media set; on the main home feed, widen relay set. */
const effectiveSubRequests = useMemo(() => { const effectiveSubRequests = useMemo(() => {
if (listMode !== 'media') return subRequests if (listMode !== 'media') return subRequests
return subRequests.map((req) => ({ return subRequests.map((req) => ({
...req, ...req,
urls: isMainFeed ? galleryRelayUrlsMergedWithReadLayer(req.urls) : req.urls,
filter: { ...req.filter, kinds: MEDIA_KINDS } filter: { ...req.filter, kinds: MEDIA_KINDS }
})) }))
}, [listMode, subRequests, MEDIA_KINDS]) }, [listMode, subRequests, MEDIA_KINDS, isMainFeed])
const handleListModeChange = useCallback( const handleListModeChange = useCallback(
(mode: TNoteListMode | string) => { (mode: TNoteListMode | string) => {
@ -321,7 +343,9 @@ const NormalFeed = forwardRef<TNoteListRef, {
mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch} mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch}
followingFeedDeltaSubRequests={followingFeedDeltaSubRequests} followingFeedDeltaSubRequests={followingFeedDeltaSubRequests}
feedTimelineScopeKey={feedTimelineScopeKey} feedTimelineScopeKey={feedTimelineScopeKey}
homeFeedListMode={isMainFeed ? listMode : undefined}
gridLayout={listMode === 'media'} gridLayout={listMode === 'media'}
revealBatchSize={listMode === 'media' && isMainFeed ? 96 : undefined}
useFilterAsIs={listMode === 'media' ? true : useFilterAsIs} useFilterAsIs={listMode === 'media' ? true : useFilterAsIs}
clientSideKindFilter={listMode === 'media' ? false : clientSideKindFilter} clientSideKindFilter={listMode === 'media' ? false : clientSideKindFilter}
allowKindlessRelayExplore={listMode === 'media' ? false : allowKindlessRelayExplore} allowKindlessRelayExplore={listMode === 'media' ? false : allowKindlessRelayExplore}

181
src/components/NoteList/index.tsx

@ -3,6 +3,7 @@ import {
ExtendedKind, ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
PROFILE_MEDIA_TAB_KINDS,
SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS, SINGLE_RELAY_KINDLESS_EOSE_TIMEOUT_MS,
SINGLE_RELAY_KINDLESS_REQ_LIMIT SINGLE_RELAY_KINDLESS_REQ_LIMIT
} from '@/constants' } from '@/constants'
@ -42,7 +43,7 @@ import {
hardReloadPreservingFeedSnapshots, hardReloadPreservingFeedSnapshots,
setSessionFeedSnapshot setSessionFeedSnapshot
} from '@/services/session-feed-snapshot.service' } from '@/services/session-feed-snapshot.service'
import type { TFeedSubRequest, TSubRequestFilter } from '@/types' import type { TFeedSubRequest, TNoteListMode, TSubRequestFilter } from '@/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { type Event, type Filter, kinds } from 'nostr-tools' import { type Event, type Filter, kinds } from 'nostr-tools'
import { decode } from 'nostr-tools/nip19' import { decode } from 'nostr-tools/nip19'
@ -576,8 +577,8 @@ function tightestSinceFromSpellFilters(shardFilters: Filter[]): number | undefin
/** /**
* Profile Posts / Media feeds shard by relay but share one author + kinds REQ. Session + IDB author scans are keyed * Profile Posts / Media feeds shard by relay but share one author + kinds REQ. Session + IDB author scans are keyed
* only on that author/kinds pair unlike {@link ClientService.getTimelineDiskSnapshotEvents}, which misses rows * only on that author/kinds pair. Timeline rows may live under per-shard persist keys; profile async warmup merges
* until each relay-shard timeline has been persisted under its own key. * {@link ClientService.getTimelineDiskSnapshotEvents} with the author archive scan so both layers paint together.
*/ */
function getProfileSingleAuthorWarmupSpec( function getProfileSingleAuthorWarmupSpec(
mapped: Array<{ urls: string[]; filter: TSubRequestFilter }> mapped: Array<{ urls: string[]; filter: TSubRequestFilter }>
@ -604,6 +605,22 @@ function getProfileSingleAuthorWarmupSpec(
return { author: normAuthor, kinds: Array.from(kindUnion).sort((a, b) => a - b) } return { author: normAuthor, kinds: Array.from(kindUnion).sort((a, b) => a - b) }
} }
/** Union of `filter.kinds` across mapped REQ shards; empty if any shard omits kinds (caller should not use fallback). */
function filterEvsToMappedTimelineReqKinds(
evs: Event[],
mapped: Array<{ urls: string[]; filter: Filter }>
): Event[] {
const kindSet = new Set<number>()
for (const { filter } of mapped) {
const ks = filter.kinds
if (!Array.isArray(ks) || ks.length === 0) {
return []
}
for (const k of ks) kindSet.add(k)
}
return evs.filter((e) => kindSet.has(e.kind))
}
const NoteList = forwardRef( const NoteList = forwardRef(
( (
{ {
@ -656,6 +673,12 @@ const NoteList = forwardRef(
* relay URL set is a strict superset of the old one (which would otherwise keep stale rows). * relay URL set is a strict superset of the old one (which would otherwise keep stale rows).
*/ */
feedTimelineScopeKey, feedTimelineScopeKey,
/**
* Home {@link NormalFeed} surface: Notes / Replies / Gallery. Gallery uses fixed media REQ kinds; without
* this, {@link timelineResubscribeKindKey} still tracks the Notes kind picker and tears the live sub on
* unrelated picker churn stale grid + refresh feeling broken.
*/
homeFeedListMode,
/** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */ /** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */
spellFeedInstrumentToken, spellFeedInstrumentToken,
/** Spells page: fired once when the filtered list first has rows after a picker change. */ /** Spells page: fired once when the filtered list first has rows after a picker change. */
@ -674,7 +697,7 @@ const NoteList = forwardRef(
/** /**
* When true, load events with parallel {@link client.fetchEvents} per subRequest instead of * When true, load events with parallel {@link client.fetchEvents} per subRequest instead of
* {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells * {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells
* Refresh re-fetches. * and similar one-shot feeds. Refresh re-fetches.
*/ */
oneShotFetch = false, oneShotFetch = false,
/** Override {@link client.fetchEvents} / query global timeout (default 14s). */ /** Override {@link client.fetchEvents} / query global timeout (default 14s). */
@ -761,6 +784,7 @@ const NoteList = forwardRef(
mergeTimelineWhenSubRequestFiltersMatch?: boolean mergeTimelineWhenSubRequestFiltersMatch?: boolean
followingFeedDeltaSubRequests?: TFeedSubRequest[] followingFeedDeltaSubRequests?: TFeedSubRequest[]
feedTimelineScopeKey?: string feedTimelineScopeKey?: string
homeFeedListMode?: TNoteListMode
spellFeedInstrumentToken?: number spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
timelineLoadingSafetyTimeoutMs?: number timelineLoadingSafetyTimeoutMs?: number
@ -1110,6 +1134,7 @@ const NoteList = forwardRef(
() => () =>
JSON.stringify({ JSON.stringify({
feed: timelineSubscriptionKey, feed: timelineSubscriptionKey,
...(homeFeedListMode ? { homeSurface: homeFeedListMode } : {}),
...(allowKindlessRelayExplore ...(allowKindlessRelayExplore
? { relayKindless: true, showAllKinds } ? { relayKindless: true, showAllKinds }
: { : {
@ -1122,6 +1147,7 @@ const NoteList = forwardRef(
}), }),
[ [
timelineSubscriptionKey, timelineSubscriptionKey,
homeFeedListMode,
showKindsKey, showKindsKey,
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
@ -1133,9 +1159,18 @@ const NoteList = forwardRef(
) )
/** Kindless relay explore ignores the feed kind picker; avoid re-subscribing when it changes. */ /** Kindless relay explore ignores the feed kind picker; avoid re-subscribing when it changes. */
const timelineResubscribeKindKey = allowKindlessRelayExplore const timelineResubscribeKindKey = useMemo(() => {
? 'kindless-relay-explore' if (allowKindlessRelayExplore) return 'kindless-relay-explore'
: `${showKindsKey}|${showKind1OPs}|${showKind1Replies}|${showKind1111}` if (homeFeedListMode === 'media') return 'home-surface-media'
return `${showKindsKey}|${showKind1OPs}|${showKind1Replies}|${showKind1111}`
}, [
allowKindlessRelayExplore,
homeFeedListMode,
showKindsKey,
showKind1OPs,
showKind1Replies,
showKind1111
])
const showKindsRef = useRef(showKinds) const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds showKindsRef.current = showKinds
@ -1169,6 +1204,8 @@ const NoteList = forwardRef(
withKindFilterRef.current = withKindFilter withKindFilterRef.current = withKindFilter
const hostPrimaryPageNameRef = useRef(hostPrimaryPageName) const hostPrimaryPageNameRef = useRef(hostPrimaryPageName)
hostPrimaryPageNameRef.current = hostPrimaryPageName hostPrimaryPageNameRef.current = hostPrimaryPageName
const gridLayoutRef = useRef(gridLayout)
gridLayoutRef.current = gridLayout
const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => { const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
@ -1275,9 +1312,15 @@ const NoteList = forwardRef(
} }
if (shouldHideEvent(evt)) continue if (shouldHideEvent(evt)) continue
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id // Mosaic: one tile per event id. Replaceable-coordinate dedup (correct for profile lists) collapses
if (idSet.has(id)) continue // multiple NIP-71 addressable revisions / instances to a single cell — looks like "extra images flash then vanish".
idSet.add(id) const dedupeKey = gridLayout
? evt.id
: isReplaceableEvent(evt.kind)
? getReplaceableCoordinateFromEvent(evt) || evt.id
: evt.id
if (idSet.has(dedupeKey)) continue
idSet.add(dedupeKey)
out.push(evt) out.push(evt)
} }
const scannedToEndOfBuffer = i >= timelineEventsForFilter.length const scannedToEndOfBuffer = i >= timelineEventsForFilter.length
@ -1292,7 +1335,8 @@ const NoteList = forwardRef(
showKind1OPs, showKind1OPs,
showKind1Replies, showKind1Replies,
showKind1111, showKind1111,
applyKindPickerInUi applyKindPickerInUi,
gridLayout
]) ])
useEffect(() => { useEffect(() => {
@ -1618,9 +1662,11 @@ const NoteList = forwardRef(
const refresh = useCallback(() => { const refresh = useCallback(() => {
scrollToTop() scrollToTop()
// Short delay so scroll-to-top commits before tearing the timeline (avoids merge races); 500ms made
// refresh feel broken on slow tabs (e.g. Gallery) when users clicked again thinking nothing happened.
setTimeout(() => { setTimeout(() => {
setRefreshCount((count) => count + 1) setRefreshCount((count) => count + 1)
}, 500) }, 80)
}, [scrollToTop]) }, [scrollToTop])
const flushPendingNewEventsIntoTimeline = useCallback(() => { const flushPendingNewEventsIntoTimeline = useCallback(() => {
@ -1971,7 +2017,7 @@ const NoteList = forwardRef(
const narrowLiveBatch = (evs: Event[]) => { const narrowLiveBatch = (evs: Event[]) => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (withKindFilterRef.current && !showAllKindsRef.current) { if (withKindFilterRef.current && !showAllKindsRef.current) {
return evs.filter((e) => const out = evs.filter((e) =>
eventPassesNoteListKindPicker( eventPassesNoteListKindPicker(
e, e,
effectiveShowKindsRef.current, effectiveShowKindsRef.current,
@ -1980,10 +2026,26 @@ const NoteList = forwardRef(
showKind1111Ref.current showKind1111Ref.current
) )
) )
if (
out.length > 0 ||
hostPrimaryPageNameRef.current !== 'profile' ||
mappedSubRequests.length === 0
) {
return out
}
return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests)
} }
if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs if (!useFilterAsIsRef.current || !clientSideKindFilterRef.current) return evs
if (!withKindFilterRef.current) return evs if (!withKindFilterRef.current) return evs
return evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind)) const byPicker = evs.filter((e) => effectiveShowKindsRef.current.includes(e.kind))
if (
byPicker.length > 0 ||
hostPrimaryPageNameRef.current !== 'profile' ||
mappedSubRequests.length === 0
) {
return byPicker
}
return filterEvsToMappedTimelineReqKinds(evs, mappedSubRequests)
} }
const eventCapEarly = allowKindlessRelayExplore const eventCapEarly = allowKindlessRelayExplore
@ -2046,6 +2108,72 @@ const NoteList = forwardRef(
}) })
} }
/**
* Home Galerie: paint session + IndexedDB media hits immediately so the grid is not blank while relay
* waves stall (dead localhost relay, NIP-42, etc.). Merges before/alongside disk timeline prime.
*/
const startHomeGalleryLocalWarmup = () => {
if (!gridLayoutRef.current) return
if (hostPrimaryPageNameRef.current !== 'feed') return
if (oneShotFetch || mappedSubRequests.length === 0) return
const mergeLayer = (incoming: Event[], variant: string) => {
if (!effectActive || timelineEffectStale()) return
const narrowed = narrowLiveBatch(incoming)
if (!narrowed.length) return
setEvents((prev) => {
const boot = timelineMergeBootstrapRef.current
const base = boot !== null ? boot : prev
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(base, narrowed, eventCapEarly, areAlgoRelays)
)
if (next.length > 0) {
timelineMergeBootstrapRef.current = next.slice()
lastEventsForTimelinePrefetchRef.current = next
}
return next
})
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant,
mergedCount: narrowed.length
}
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
}
try {
const hits = client.eventService.listSessionEventsByKinds([...PROFILE_MEDIA_TAB_KINDS], {
limit: 800
})
mergeLayer(hits as Event[], 'gallery_session_local')
} catch {
/* ignore */
}
void (async () => {
try {
const since = dayjs().subtract(120, 'day').unix()
const rows = await indexedDb.scanEventArchiveByKinds({
kinds: [...PROFILE_MEDIA_TAB_KINDS],
since,
maxRowsScanned: 28_000,
maxMatches: 220
})
if (!effectActive || timelineEffectStale()) return
if (!gridLayoutRef.current || hostPrimaryPageNameRef.current !== 'feed') return
mergeLayer(rows as Event[], 'gallery_archive_local')
} catch {
/* ignore */
}
})()
}
if (!keepExistingTimelineEvents) { if (!keepExistingTimelineEvents) {
if (restoredFromSession && sessionSnap) { if (restoredFromSession && sessionSnap) {
feedPaintSessionPendingRef.current = true feedPaintSessionPendingRef.current = true
@ -2201,17 +2329,25 @@ const NoteList = forwardRef(
void (async () => { void (async () => {
try { try {
const fromArchive = await indexedDb.scanEventArchiveByAuthorPubkey( const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
profileAuthorWarmSpec.author, const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
{ const [fromArchive, diskSnap] = await Promise.all([
indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
kinds: profileAuthorWarmSpec.kinds, kinds: profileAuthorWarmSpec.kinds,
maxRowsScanned: 16_000, maxRowsScanned: 16_000,
maxMatches: Math.min(2000, Math.max(eventCapEarly, 150)) maxMatches: archiveCap
} }),
) client.getTimelineDiskSnapshotEvents(diskReq)
])
if (!effectActive || timelineEffectStale()) return if (!effectActive || timelineEffectStale()) return
if (fromArchive.length === 0) return const premerged = mergeEventBatchesById(
const narrowed = narrowLiveBatch(fromArchive as Event[]) [],
[...(fromArchive as Event[]), ...(diskSnap as Event[])],
archiveCap,
areAlgoRelays
)
if (premerged.length === 0) return
const narrowed = narrowLiveBatch(premerged)
if (narrowed.length === 0) return if (narrowed.length === 0) return
setEvents((prev) => { setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows( const merged = collapseDuplicateNip18RepostTimelineRows(
@ -2254,6 +2390,7 @@ const NoteList = forwardRef(
} }
if (!oneShotFetch && mappedSubRequests.length > 0) { if (!oneShotFetch && mappedSubRequests.length > 0) {
startHomeGalleryLocalWarmup()
startNonBlockingTimelineDiskPrime() startNonBlockingTimelineDiskPrime()
} }

14
src/components/OthersRelayList/index.tsx

@ -30,9 +30,17 @@ export default function OthersRelayList({ userId }: { userId: string }) {
})} })}
</p> </p>
)} )}
{relayList.originalRelays.map((relay, index) => ( {relayList.originalRelays.length === 0 ? (
<RelayItem key={`read-${relay.url}-${index}`} relay={relay} /> <p className="text-sm text-muted-foreground">
))} {t('othersRelayListEmpty', {
defaultValue: 'No relay URLs to show. Check your connection or try again later.'
})}
</p>
) : (
relayList.originalRelays.map((relay, index) => (
<RelayItem key={`read-${relay.url}-${index}`} relay={relay} />
))
)}
</div> </div>
) )
} }

2
src/components/Profile/ProfileFeedWithPins.tsx

@ -138,7 +138,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
showKind1Replies={showKind1Replies} showKind1Replies={showKind1Replies}
showKind1111={showKind1111} showKind1111={showKind1111}
showFeedClientFilter showFeedClientFilter
timelinePublicReadFallback={false} timelinePublicReadFallback
revealBatchSize={48} revealBatchSize={48}
/> />
</div> </div>

1
src/components/Profile/ProfileMediaFeed.tsx

@ -165,6 +165,7 @@ const ProfileMediaFeed = forwardRef<TNoteListRef, { pubkey: string }>(({ pubkey
showKind1Replies showKind1Replies
showKind1111 showKind1111
hideReplies={false} hideReplies={false}
timelinePublicReadFallback
/> />
</div> </div>
) )

5
src/components/Profile/index.tsx

@ -266,6 +266,11 @@ export default function Profile({
fetchPaymentInfo() fetchPaymentInfo()
}, [profile?.pubkey]) }, [profile?.pubkey])
useEffect(() => {
if (!profile?.pubkey) return
client.prefetchAuthorCoreReplaceables([profile.pubkey], { force: true })
}, [profile?.pubkey])
// Fetch profile event (kind 0) for republishing and viewing JSON // Fetch profile event (kind 0) for republishing and viewing JSON
// Use fetchProfileEvent which does comprehensive search, not fetchReplaceableEvent // Use fetchProfileEvent which does comprehensive search, not fetchReplaceableEvent
useEffect(() => { useEffect(() => {

29
src/constants.ts

@ -146,10 +146,18 @@ export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 20_000
/** /**
* How long {@link ClientService.fetchRelayLists} waits on the network before returning an IndexedDB + default * How long {@link ClientService.fetchRelayLists} waits on the network before returning an IndexedDB + default
* merge. Kept short so users without NIP-65 (or slow relays) get {@link PROFILE_FETCH_RELAY_URLS} immediately; * merge. Must allow {@link ReplaceableEventService.fetchReplaceableEventsFromProfileFetchRelays} (10002 + 10243)
* {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} stays longer for publish / prioritize paths that wrap their own races. * plus kind-10432 discovery to finish on slow relays; otherwise we never persist others NIP-65 and the cache
* stays empty except for the accounts own hydration path.
*/ */
export const FETCH_RELAY_LIST_UI_TIMEOUT_MS = 2_500 export const FETCH_RELAY_LIST_UI_TIMEOUT_MS = 10_000
/**
* Hard cap for {@link useFetchRelayList}: if {@link ClientService.fetchRelayList} never settles (deduped hang,
* IDB edge case), clear the in-flight dedupe entry and fall back to {@link ClientService.peekRelayListFromStorage}
* so the UI cannot stay on loading forever.
*/
export const FETCH_RELAY_LIST_HOOK_MAX_MS = FETCH_RELAY_LIST_UI_TIMEOUT_MS + 12_000
/** /**
* {@link ClientService.prioritizePublishUrlListWithTimeout}: must exceed {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} * {@link ClientService.prioritizePublishUrlListWithTimeout}: must exceed {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS}
@ -604,6 +612,21 @@ export function isNip71StyleVideoKind(kind: number): boolean {
return NIP71_VIDEO_KIND_SET.has(kind) return NIP71_VIDEO_KIND_SET.has(kind)
} }
/**
* When these kinds are ingested via {@link EventService.addEventToCache}, the client prefetches the event
* author's kind 3 + 10002 (contacts + NIP-65) so profile / relay UIs and publish routing stay warm.
* Omits reactions/zaps where `pubkey` is not the primary profile identity for the row.
*/
export const AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS: ReadonlySet<number> = new Set<number>([
kinds.ShortTextNote,
kinds.LongFormArticle,
kinds.Repost,
ExtendedKind.GENERIC_REPOST,
ExtendedKind.PICTURE,
ExtendedKind.VOICE,
...NIP71_VIDEO_KINDS
])
/** Short-form portrait-style bucket (kind 22 or 34236). */ /** Short-form portrait-style bucket (kind 22 or 34236). */
export function isNip71ShortVideoKind(kind: number): boolean { export function isNip71ShortVideoKind(kind: number): boolean {
return kind === ExtendedKind.SHORT_VIDEO || kind === ExtendedKind.SHORT_VIDEO_ADDRESSABLE return kind === ExtendedKind.SHORT_VIDEO || kind === ExtendedKind.SHORT_VIDEO_ADDRESSABLE

27
src/hooks/useFetchFollowings.tsx

@ -10,22 +10,37 @@ export function useFetchFollowings(pubkey?: string | null, refreshNonce = 0) {
const [isFetching, setIsFetching] = useState(true) const [isFetching, setIsFetching] = useState(true)
useEffect(() => { useEffect(() => {
let cancelled = false
const init = async () => { const init = async () => {
setIsFetching(true)
setFollowListEvent(null)
setFollowings([])
try { try {
setIsFetching(true) if (!pubkey?.trim()) {
if (!pubkey) return return
}
const event = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts) ?? null const event = (await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts)) ?? null
if (!event) return if (cancelled) return
if (!event) {
setFollowListEvent(null)
setFollowings([])
return
}
setFollowListEvent(event) setFollowListEvent(event)
setFollowings(getPubkeysFromPTags(event.tags)) setFollowings(getPubkeysFromPTags(event.tags))
} finally { } finally {
setIsFetching(false) if (!cancelled) {
setIsFetching(false)
}
} }
} }
init() void init()
return () => {
cancelled = true
}
}, [pubkey, refreshNonce]) }, [pubkey, refreshNonce])
return { followings, followListEvent, isFetching } return { followings, followListEvent, isFetching }

24
src/hooks/useFetchRelayList.tsx

@ -1,3 +1,4 @@
import { FETCH_RELAY_LIST_HOOK_MAX_MS } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
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'
@ -44,7 +45,22 @@ export function useFetchRelayList(pubkey?: string | null) {
setHasKind10002InStorage(!!k10002) setHasKind10002InStorage(!!k10002)
setRelayList(fromStorage) setRelayList(fromStorage)
const merged = await client.fetchRelayList(targetPk) const merged = await Promise.race([
client.fetchRelayList(targetPk),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('relay-list hook max wait')), FETCH_RELAY_LIST_HOOK_MAX_MS)
})
]).catch(async (err: unknown) => {
const isMaxWait = err instanceof Error && err.message === 'relay-list hook max wait'
if (isMaxWait) {
logger.warn('[useFetchRelayList] fetchRelayList exceeded max wait; clearing dedupe cache', {
pubkeyPrefix: targetPk.slice(0, 12)
})
client.clearRelayListCache(targetPk)
return client.peekRelayListFromStorage(targetPk)
}
throw err
})
if (cancelled) return if (cancelled) return
setRelayList(merged) setRelayList(merged)
const k10002After = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null) const k10002After = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
@ -78,10 +94,8 @@ export function useFetchRelayList(pubkey?: string | null) {
} }
}, [pubkey]) }, [pubkey])
const showingRelayListFallback = /** True when no kind 10002 for this author in IDB — UI may show default discovery relays with a disclaimer. */
!isFetching && const showingRelayListFallback = !isFetching && !hasKind10002InStorage
!hasKind10002InStorage &&
relayList.originalRelays.length === 0
return { relayList, isFetching, hasKind10002InStorage, showingRelayListFallback } return { relayList, isFetching, hasKind10002InStorage, showingRelayListFallback }
} }

14
src/lib/favorites-feed-relays.ts

@ -2,6 +2,7 @@ import {
DEFAULT_FAVORITE_RELAYS, DEFAULT_FAVORITE_RELAYS,
DOCUMENT_RELAY_URLS, DOCUMENT_RELAY_URLS,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
PROFILE_FETCH_RELAY_URLS,
READ_ONLY_RELAY_URLS, READ_ONLY_RELAY_URLS,
isDocumentRelayKind, isDocumentRelayKind,
relayFilterIncludesSocialKindBlockedKind relayFilterIncludesSocialKindBlockedKind
@ -184,17 +185,26 @@ export function buildProfilePageReadRelayUrls(
const list = includeAuthorLocalRelays const list = includeAuthorLocalRelays
? authorRelayList ? authorRelayList
: stripMailboxLocalUrlsForRemoteViewers(authorRelayList) : stripMailboxLocalUrlsForRemoteViewers(authorRelayList)
const authorRead = [...(list.httpRead ?? []), ...(list.read ?? [])]
const authorWrite = [...(list.httpWrite ?? []), ...(list.write ?? [])]
const authorHasNoNip65 = authorRead.length === 0 && authorWrite.length === 0
let urls = getRelayUrlsWithFavoritesFastReadAndInbox( let urls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays, favoriteRelays,
blockedRelays, blockedRelays,
[...(list.httpRead ?? []), ...(list.read ?? [])], authorRead,
{ {
userWriteRelays: [...(list.httpWrite ?? []), ...(list.write ?? [])], userWriteRelays: authorWrite,
authorWriteRelays: [], authorWriteRelays: [],
maxRelays, maxRelays,
applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind applySocialKindBlockedFilter: kindsIncludeSocialBlockedKind
} }
) )
/** Authors without kind 10002: widen REQ targets so notes/metadata are still discoverable on index relays. */
if (authorHasNoNip65) {
const profileFetchLayer = PROFILE_FETCH_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
urls = mergeRelayUrlLayers([urls, profileFetchLayer], blockedRelays).slice(0, maxRelays + 8)
}
if (wantsDocumentLayer) { if (wantsDocumentLayer) {
const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
urls = mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, maxRelays + 6) urls = mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, maxRelays + 6)

39
src/lib/relay-list-sanitize.ts

@ -1,5 +1,5 @@
import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import type { TRelayList } from '@/types' import type { TMailboxRelay, TMailboxRelayScope, TRelayList } from '@/types'
/** True if this URL is not loopback / LAN (safe to open from another user's browser as a REQ target). */ /** True if this URL is not loopback / LAN (safe to open from another user's browser as a REQ target). */
export function urlIsNonLocalForRemoteViewer(url: string): boolean { export function urlIsNonLocalForRemoteViewer(url: string): boolean {
@ -49,3 +49,40 @@ export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayLi
httpOriginalRelays: (list.httpOriginalRelays ?? []).filter((r) => keepUrl(r.url)) httpOriginalRelays: (list.httpOriginalRelays ?? []).filter((r) => keepUrl(r.url))
} }
} }
const normRelayKey = (u: string): string => {
const t = typeof u === 'string' ? u.trim() : ''
if (!t) return ''
return (isHttpRelayUrl(t) ? normalizeAnyRelayUrl(t) : normalizeUrl(t)) || t
}
/**
* When NIP-65 `originalRelays` is empty but `read` / `write` URL lists are filled (e.g. PROFILE_FETCH fallback),
* build mailbox rows so UIs that only map `originalRelays` still render.
*/
export function syntheticOriginalRelaysFromReadWrite(read: string[], write: string[]): TMailboxRelay[] {
const readByKey = new Map<string, string>()
const writeByKey = new Map<string, string>()
for (const u of read) {
const k = normRelayKey(u)
if (!k) continue
if (!readByKey.has(k)) readByKey.set(k, u.trim())
}
for (const u of write) {
const k = normRelayKey(u)
if (!k) continue
if (!writeByKey.has(k)) writeByKey.set(k, u.trim())
}
const keys = new Set([...readByKey.keys(), ...writeByKey.keys()])
const rows: TMailboxRelay[] = []
for (const k of keys) {
const hasR = readByKey.has(k)
const hasW = writeByKey.has(k)
const url = (hasR ? readByKey.get(k) : writeByKey.get(k))!
const scope: TMailboxRelayScope =
hasR && hasW ? 'both' : hasR ? 'read' : 'write'
rows.push({ url, scope })
}
rows.sort((a, b) => a.url.localeCompare(b.url))
return rows
}

6
src/pages/secondary/OthersRelaySettingsPage/index.tsx

@ -53,12 +53,6 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
setJsonOpen(true) setJsonOpen(true)
}, [profile?.pubkey, relayList]) }, [profile?.pubkey, relayList])
useEffect(() => {
if (profile?.pubkey) {
setListKey((k) => k + 1)
}
}, [profile?.pubkey])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)

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

@ -1,4 +1,9 @@
import { ExtendedKind, isDocumentRelayKind } from '@/constants' import {
AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS,
ExtendedKind,
isDocumentRelayKind,
NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT
} from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
getParentATag, getParentATag,
@ -81,6 +86,9 @@ async function buildComprehensiveRelayListForEvents(
const PREFETCH_HEX_IDS_CHUNK = 48 const PREFETCH_HEX_IDS_CHUNK = 48
/** Cap session LRU scan per note-stats target — cache iterates newest-first; avoids O(session)×batch stalls. */
const NOTE_STATS_SESSION_PREMERGE_SCAN_MAX = 6000
export class EventService { export class EventService {
private queryService: QueryService private queryService: QueryService
private eventCacheMap = new Map<string, Promise<NEvent | undefined>>() private eventCacheMap = new Map<string, Promise<NEvent | undefined>>()
@ -522,6 +530,20 @@ export class EventService {
this.sessionMetadataByPubkey.set(pk, cleanEvent as NEvent) this.sessionMetadataByPubkey.set(pk, cleanEvent as NEvent)
} }
} }
// NIP-65 (10002) and contacts (3) are not “document” replaceables; without this they never hit IndexedDB
// from timeline/REQ ingest—only the logged-in account’s list was hydrated in NostrProvider / prewarm.
if (
(cleanEvent.kind === kinds.RelayList || cleanEvent.kind === kinds.Contacts) &&
indexedDb.hasReplaceableEventStoreForKind(cleanEvent.kind)
) {
void client.replaceableEventService.updateReplaceableEventCache(cleanEvent as NEvent).catch(() => {})
}
if (AUTHOR_CORE_PREFETCH_ON_INGEST_KINDS.has(cleanEvent.kind)) {
const pk = cleanEvent.pubkey
if (pk && /^[0-9a-f]{64}$/i.test(pk)) {
void client.prefetchAuthorCoreReplaceables([pk.toLowerCase()])
}
}
this.notifySessionEventWaiters(id) this.notifySessionEventWaiters(id)
this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent) this.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent) queuePersistSeenEvent(cleanEvent as NEvent)
@ -741,6 +763,80 @@ export class EventService {
return out return out
} }
/**
* Session LRU rows that already reference `rootEvent` for {@link NoteStatsService} (reposts, reactions, zaps,
* replies, quotes, etc.). Iteration is recency-ordered; only the first {@link NOTE_STATS_SESSION_PREMERGE_SCAN_MAX}
* rows are scanned so large session caps do not stall stats batches.
*/
getSessionEventsForNoteStatsTarget(rootEvent: NEvent, options?: { maxScan?: number }): NEvent[] {
const maxScan = Math.min(Math.max(options?.maxScan ?? NOTE_STATS_SESSION_PREMERGE_SCAN_MAX, 200), 40_000)
const id = rootEvent.id.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) return []
const coordRaw = isReplaceableEvent(rootEvent.kind)
? getReplaceableCoordinateFromEvent(rootEvent)?.trim()
: undefined
const coordNorm = coordRaw ? normalizeReplaceableCoordinateString(coordRaw) : undefined
const kindAllow = new Set<number>([
kinds.Reaction,
kinds.Repost,
ExtendedKind.GENERIC_REPOST,
kinds.Zap,
kinds.ShortTextNote,
ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT,
kinds.Highlights,
ExtendedKind.EXTERNAL_REACTION,
ExtendedKind.WEB_BOOKMARK,
...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT
])
const hexMatchesRoot = (hex: string | undefined) => {
if (!hex || !/^[0-9a-f]{64}$/i.test(hex)) return false
return hex.toLowerCase() === id
}
const coordMatches = (atag: string | undefined) => {
if (!coordNorm || !atag?.trim()) return false
return normalizeReplaceableCoordinateString(atag) === coordNorm
}
const out: NEvent[] = []
let scanned = 0
for (const [, event] of this.sessionEventCache.entries()) {
if (++scanned > maxScan) break
if (shouldDropEventOnIngest(event)) continue
if (!kindAllow.has(event.kind)) continue
let hit = false
for (const t of event.tags) {
const name = t[0]
const v = t[1]?.trim()
if (!v) continue
if (name === 'e' || name === 'E') {
if (hexMatchesRoot(v)) {
hit = true
break
}
} else if (name === 'q') {
if (hexMatchesRoot(v)) {
hit = true
break
}
} else if (name === 'a' || name === 'A') {
if (coordMatches(v)) {
hit = true
break
}
}
}
if (hit) out.push(event)
}
out.sort((a, b) => b.created_at - a.created_at)
return out
}
/** /**
* Kind 31925 in session LRU for this calendar replaceable: `a` coordinate match, or `e` pointing at this * Kind 31925 in session LRU for this calendar replaceable: `a` coordinate match, or `e` pointing at this
* revisions id (some clients tag the instance id only). Used so RSVP lists populate from feeds before * revisions id (some clients tag the instance id only). Used so RSVP lists populate from feeds before

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

@ -162,6 +162,30 @@ export class ReplaceableEventService {
} }
} }
// Kind 3 / NIP-65: IndexedDB + session LRU before DataLoader (newest wins); then background network refresh.
if (!d && (kind === kinds.Contacts || kind === kinds.RelayList)) {
let idbEv: NEvent | undefined | null
try {
idbEv = await indexedDb.getReplaceableEvent(pubkey, kind, d)
} catch {
idbEv = undefined
}
const idbOk = idbEv && !shouldDropEventOnIngest(idbEv) ? idbEv : undefined
const sessionHits = client.eventService.listSessionEventsAuthoredBy(pubkey, {
kinds: [kind],
limit: 20
})
const ses = sessionHits[0]
const sesOk = ses && !shouldDropEventOnIngest(ses) ? ses : undefined
const pick = !idbOk ? sesOk : !sesOk ? idbOk : sesOk.created_at >= idbOk.created_at ? sesOk : idbOk
if (pick) {
this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(pick))
void indexedDb.putReplaceableEvent(pick).catch(() => {})
void this.refreshInBackground(pubkey, kind, d).catch(() => {})
return pick
}
}
// If we have containing event relays and this is a profile, we need to use a custom relay list // If we have containing event relays and this is a profile, we need to use a custom relay list
// Otherwise, use DataLoader (which batches IndexedDB checks and network fetches) // Otherwise, use DataLoader (which batches IndexedDB checks and network fetches)
let event: NEvent | undefined let event: NEvent | undefined
@ -301,8 +325,8 @@ export class ReplaceableEventService {
} }
/** /**
* Batch fetch replaceable events from profile fetch relays * Batch fetch replaceable events from profile fetch relays.
* Checks IndexedDB first, then network * Order: IndexedDB, then session LRU for kind 3 / 10002 gaps, then network.
*/ */
async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> { async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> {
const results: (NEvent | undefined)[] = new Array(pubkeys.length) const results: (NEvent | undefined)[] = new Array(pubkeys.length)
@ -350,6 +374,21 @@ export class ReplaceableEventService {
} }
} }
if (needsIndexedDb.length > 0 && (kind === kinds.Contacts || kind === kinds.RelayList)) {
for (const { pubkey, index } of needsIndexedDb) {
if (results[index] !== undefined) continue
const hits = client.eventService.listSessionEventsAuthoredBy(pubkey, {
kinds: [kind],
limit: 20
})
const ev = hits[0]
if (ev && !shouldDropEventOnIngest(ev)) {
results[index] = ev
this.replaceableEventFromBigRelaysDataloader.prime({ pubkey, kind }, Promise.resolve(ev))
}
}
}
const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined) const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined)
if (stillMissing.length > 0) { if (stillMissing.length > 0) {
const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany( const newEvents = await this.replaceableEventFromBigRelaysDataloader.loadMany(
@ -464,6 +503,25 @@ export class ReplaceableEventService {
} }
}) })
) )
for (let mi = missingParams.length - 1; mi >= 0; mi--) {
const m = missingParams[mi]!
if (m.kind !== kinds.Contacts && m.kind !== kinds.RelayList) continue
const hits = client.eventService.listSessionEventsAuthoredBy(m.pubkey, {
kinds: [m.kind],
limit: 20
})
const sessionEv = hits[0]
if (sessionEv && !shouldDropEventOnIngest(sessionEv)) {
results[m.index] = sessionEv
eventsMap.set(`${m.pubkey}:${m.kind}`, sessionEv)
this.replaceableEventFromBigRelaysDataloader.prime(
{ pubkey: m.pubkey, kind: m.kind },
Promise.resolve(sessionEv)
)
missingParams.splice(mi, 1)
}
}
// Step 2: Only fetch missing events from network // Step 2: Only fetch missing events from network
if (missingParams.length === 0) { if (missingParams.length === 0) {
@ -559,12 +617,27 @@ export class ReplaceableEventService {
) )
).filter(Boolean) ).filter(Boolean)
} else if (kind === kinds.Contacts) { } else if (kind === kinds.Contacts) {
// Contacts (follow list) are published to user's write relays; use write + read + profile relays // Contacts (kind 3): often on write relays; aggregators/profile mirrors also carry copies.
relayUrls = Array.from( relayUrls = Array.from(
new Set( new Set(
[...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS].map( [
(u) => normalizeUrl(u) || u ...FAST_WRITE_RELAY_URLS,
) ...READ_ONLY_RELAY_URLS,
...PROFILE_FETCH_RELAY_URLS,
...FAST_READ_RELAY_URLS
].map((u) => normalizeUrl(u) || u)
)
).filter(Boolean)
} else if (kind === kinds.RelayList) {
// NIP-65 (10002): almost always on the author's write/outbox relays; FAST_READ-only misses most users.
relayUrls = Array.from(
new Set(
[
...FAST_WRITE_RELAY_URLS,
...READ_ONLY_RELAY_URLS,
...PROFILE_FETCH_RELAY_URLS,
...FAST_READ_RELAY_URLS
].map((u) => normalizeUrl(u) || u)
) )
).filter(Boolean) ).filter(Boolean)
} else if (kind === ExtendedKind.PAYMENT_INFO) { } else if (kind === ExtendedKind.PAYMENT_INFO) {
@ -598,8 +671,14 @@ export class ReplaceableEventService {
relayCount: relayUrls.length relayCount: relayUrls.length
}) })
} }
// Contacts + NIP-65 need the same patience as pins/payment: 100ms EOSE loses the race on slow relays
// and multi-author batches must not use replaceableRace (first EVENT may not be the latest per author).
const isSlowReplaceableBatch = const isSlowReplaceableBatch =
kind === kinds.Metadata || kind === 10001 || kind === ExtendedKind.PAYMENT_INFO kind === kinds.Metadata ||
kind === 10001 ||
kind === ExtendedKind.PAYMENT_INFO ||
kind === kinds.Contacts ||
kind === kinds.RelayList
const multiAuthorBatch = pubkeys.length > 1 const multiAuthorBatch = pubkeys.length > 1
// replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0 // replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0
// (many `authors` in one filter) that stops the subscription while most profiles are still in flight. // (many `authors` in one filter) that stops the subscription while most profiles are still in flight.

156
src/services/client.service.ts

@ -123,7 +123,12 @@ import {
relayFiltersUseCapitalLetterTagKeys, relayFiltersUseCapitalLetterTagKeys,
relayUrlsStripExtendedTagReqBlocked relayUrlsStripExtendedTagReqBlocked
} from '@/lib/relay-extended-tag-req-blocks' } from '@/lib/relay-extended-tag-req-blocks'
import { stripLocalNetworkRelaysFromRelayList } from '@/lib/relay-list-sanitize' import {
stripLocalNetworkRelaysFromRelayList,
stripMailboxLocalUrlsForRemoteViewers,
syntheticOriginalRelaysFromReadWrite,
urlIsNonLocalForRemoteViewer
} from '@/lib/relay-list-sanitize'
import { import {
canonicalRelayStrikeKey, canonicalRelayStrikeKey,
isHttpRelayUrl, isHttpRelayUrl,
@ -288,10 +293,14 @@ class ClientService extends EventTarget {
private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>() private sessionRelayPublishStats = new Map<string, { successCount: number; sumLatencyMs: number }>()
/** /**
* IndexedDB profile index + NIP-66 relay discovery run once per page session; followings prewarm (metadata + kind 10002) runs when logged in. * IndexedDB profile index + NIP-66 relay discovery run once per page session; when logged in,
* {@link initUserIndexFromFollowings} hydrates each follow's kind 0, 3, and 10002 in batches.
* @see {@link runSessionPrewarm} * @see {@link runSessionPrewarm}
*/ */
private sessionPrewarmBaseCompleted = false private sessionPrewarmBaseCompleted = false
/** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */
private authorCorePrefetchCooldownUntilMs = new Map<string, number>()
private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 90_000
constructor() { constructor() {
super() super()
@ -746,10 +755,10 @@ class ClientService extends EventTarget {
this.fetchRelayList(pubkey), this.fetchRelayList(pubkey),
new Promise<TRelayList>((resolve) => new Promise<TRelayList>((resolve) =>
setTimeout(() => { setTimeout(() => {
logger.warn('[DetermineTargetRelays] fetchRelayList timed out; using empty outbox', { logger.warn('[DetermineTargetRelays] fetchRelayList timed out; using IndexedDB / default merge', {
pubkeySlice: pubkey.slice(0, 12) pubkeySlice: pubkey.slice(0, 12)
}) })
resolve(empty) void this.peekRelayListFromStorage(pubkey).then(resolve).catch(() => resolve(empty))
}, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) }, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS)
) )
]) ])
@ -758,7 +767,11 @@ class ClientService extends EventTarget {
pubkeySlice: pubkey.slice(0, 12), pubkeySlice: pubkey.slice(0, 12),
error: err instanceof Error ? err.message : String(err) error: err instanceof Error ? err.message : String(err)
}) })
return empty try {
return await this.peekRelayListFromStorage(pubkey)
} catch {
return empty
}
} }
} }
@ -769,10 +782,12 @@ class ClientService extends EventTarget {
this.fetchRelayLists(pubkeys), this.fetchRelayLists(pubkeys),
new Promise<TRelayList[]>((resolve) => new Promise<TRelayList[]>((resolve) =>
setTimeout(() => { setTimeout(() => {
logger.warn('[DetermineTargetRelays] fetchRelayLists timed out; skipping context inbox merge', { logger.warn('[DetermineTargetRelays] fetchRelayLists timed out; using IndexedDB / default merge', {
pubkeyCount: pubkeys.length pubkeyCount: pubkeys.length
}) })
resolve(pubkeys.map(() => this.emptyRelayListForPublish())) void Promise.all(pubkeys.map((pk) => this.peekRelayListFromStorage(pk)))
.then(resolve)
.catch(() => resolve(pubkeys.map(() => this.emptyRelayListForPublish())))
}, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS) }, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS)
) )
]) ])
@ -781,7 +796,11 @@ class ClientService extends EventTarget {
pubkeyCount: pubkeys.length, pubkeyCount: pubkeys.length,
error: err instanceof Error ? err.message : String(err) error: err instanceof Error ? err.message : String(err)
}) })
return pubkeys.map(() => this.emptyRelayListForPublish()) try {
return await Promise.all(pubkeys.map((pk) => this.peekRelayListFromStorage(pk)))
} catch {
return pubkeys.map(() => this.emptyRelayListForPublish())
}
} }
} }
@ -3090,6 +3109,52 @@ class ClientService extends EventTarget {
/** =========== Followings =========== */ /** =========== Followings =========== */
// Moved to ReplaceableEventService // Moved to ReplaceableEventService
/**
* Best-effort: fetch and persist each author's kind 3 + 10002 (contacts + NIP-65) via the same batched path
* as profile relay discovery. Call from profile mounts and opportunistically from feed ingest.
*/
prefetchAuthorCoreReplaceables(
pubkeys: string | readonly string[],
options?: { force?: boolean; cooldownMs?: number }
): void {
const raw = typeof pubkeys === 'string' ? [pubkeys] : [...pubkeys]
const cooldown = options?.cooldownMs ?? ClientService.AUTHOR_CORE_PREFETCH_COOLDOWN_MS
const now = Date.now()
const unique: string[] = []
const seen = new Set<string>()
for (const p of raw) {
const pk = typeof p === 'string' ? p.trim().toLowerCase() : ''
if (!/^[0-9a-f]{64}$/.test(pk) || seen.has(pk)) continue
seen.add(pk)
if (!options?.force) {
const until = this.authorCorePrefetchCooldownUntilMs.get(pk) ?? 0
if (now < until) continue
this.authorCorePrefetchCooldownUntilMs.set(pk, now + cooldown)
}
unique.push(pk)
}
if (unique.length === 0) return
void (async () => {
try {
await Promise.all([
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.RelayList),
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(unique, kinds.Contacts)
])
} catch (err) {
if (!options?.force) {
for (const pk of unique) {
this.authorCorePrefetchCooldownUntilMs.delete(pk)
}
}
logger.debug('[client] prefetchAuthorCoreReplaceables failed', {
count: unique.length,
error: err instanceof Error ? err.message : String(err)
})
}
})()
}
/** Part of {@link runSessionPrewarm}; batches followings to limit relay load. */ /** Part of {@link runSessionPrewarm}; batches followings to limit relay load. */
private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) { private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
const followings = await this.replaceableEventService.fetchFollowings(pubkey) const followings = await this.replaceableEventService.fetchFollowings(pubkey)
@ -3099,11 +3164,12 @@ class ClientService extends EventTarget {
}) })
return return
} }
logger.info('[client] Prewarm: following profile + NIP-65 relay list fetch started', { logger.info('[client] Prewarm: following profile + contacts + NIP-65 fetch started', {
pubkeySlice: pubkey.slice(0, 12), pubkeySlice: pubkey.slice(0, 12),
followingCount: followings.length followingCount: followings.length
}) })
let relayListResolved = 0 let relayListResolved = 0
let contactsResolved = 0
const chunkSize = 20 const chunkSize = 20
for (let i = 0; i * chunkSize < followings.length; i++) { for (let i = 0; i * chunkSize < followings.length; i++) {
if (signal.aborted) { if (signal.aborted) {
@ -3111,17 +3177,20 @@ class ClientService extends EventTarget {
return return
} }
const chunk = followings.slice(i * chunkSize, (i + 1) * chunkSize) const chunk = followings.slice(i * chunkSize, (i + 1) * chunkSize)
const [relayListEvents] = await Promise.all([ const [relayListEvents, contactsEvents, _profiles] = await Promise.all([
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(chunk, kinds.RelayList), this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(chunk, kinds.RelayList),
this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(chunk, kinds.Contacts),
Promise.all(chunk.map((pk) => this.fetchProfileEvent(pk))) Promise.all(chunk.map((pk) => this.fetchProfileEvent(pk)))
]) ])
relayListResolved += relayListEvents.filter(Boolean).length relayListResolved += relayListEvents.filter(Boolean).length
contactsResolved += contactsEvents.filter(Boolean).length
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
} }
logger.info('[client] Prewarm: following profile + NIP-65 relay list fetch finished', { logger.info('[client] Prewarm: following profile + contacts + NIP-65 fetch finished', {
pubkeySlice: pubkey.slice(0, 12), pubkeySlice: pubkey.slice(0, 12),
followingCount: followings.length, followingCount: followings.length,
relayListEventsResolved: relayListResolved relayListEventsResolved: relayListResolved,
contactsEventsResolved: contactsResolved
}) })
} }
@ -3577,10 +3646,12 @@ class ClientService extends EventTarget {
const [fallback] = await this.mergeRelayListsFromStoredOnly([pubkey]) const [fallback] = await this.mergeRelayListsFromStoredOnly([pubkey])
return fallback! return fallback!
} catch { } catch {
const read = PROFILE_FETCH_RELAY_URLS
const write = PROFILE_FETCH_RELAY_URLS
return { return {
write: PROFILE_FETCH_RELAY_URLS, write,
read: PROFILE_FETCH_RELAY_URLS, read,
originalRelays: [], originalRelays: syntheticOriginalRelaysFromReadWrite(read, write),
httpRead: [], httpRead: [],
httpWrite: [], httpWrite: [],
httpOriginalRelays: [] httpOriginalRelays: []
@ -3604,10 +3675,28 @@ class ClientService extends EventTarget {
return rl! return rl!
} }
/** Newest kind 10002 for `pubkey` from IndexedDB and/or session LRU (session may hold a copy not persisted yet). */
private async getKind10002FromIdbOrSession(pubkey: string): Promise<NEvent | undefined | null> {
let idb: NEvent | undefined | null
try {
idb = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
} catch {
idb = undefined
}
const idbOk = idb && !shouldDropEventOnIngest(idb) ? idb : undefined
const sessionHits = this.eventService.listSessionEventsAuthoredBy(pubkey, {
kinds: [kinds.RelayList],
limit: 20
})
const ses = sessionHits[0]
const sesOk = ses && !shouldDropEventOnIngest(ses) ? ses : undefined
if (!idbOk) return sesOk
if (!sesOk) return idbOk
return sesOk.created_at >= idbOk.created_at ? sesOk : idbOk
}
private async mergeRelayListsFromStoredOnly(pubkeys: string[]): Promise<TRelayList[]> { private async mergeRelayListsFromStoredOnly(pubkeys: string[]): Promise<TRelayList[]> {
const storedRelayEvents = await Promise.all( const storedRelayEvents = await Promise.all(pubkeys.map((pk) => this.getKind10002FromIdbOrSession(pk)))
pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, kinds.RelayList))
)
const storedCacheRelayEvents = await Promise.all( const storedCacheRelayEvents = await Promise.all(
pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS)) pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS))
) )
@ -3708,10 +3797,23 @@ class ClientService extends EventTarget {
...emptyHttp ...emptyHttp
}) })
} }
let read = PROFILE_FETCH_RELAY_URLS
let write = PROFILE_FETCH_RELAY_URLS
if (!isOwnRelayList) {
const stripped = stripMailboxLocalUrlsForRemoteViewers({ read, write })
read =
stripped.read.length > 0 ? stripped.read : read.filter(urlIsNonLocalForRemoteViewer)
write =
stripped.write.length > 0 ? stripped.write : write.filter(urlIsNonLocalForRemoteViewer)
if (read.length === 0 && write.length === 0) {
read = [...FAST_READ_RELAY_URLS]
write = [...FAST_READ_RELAY_URLS]
}
}
return mergeKind10243({ return mergeKind10243({
write: PROFILE_FETCH_RELAY_URLS, write,
read: PROFILE_FETCH_RELAY_URLS, read,
originalRelays: [], originalRelays: syntheticOriginalRelaysFromReadWrite(read, write),
...emptyHttp ...emptyHttp
}) })
} }
@ -3746,9 +3848,7 @@ class ClientService extends EventTarget {
if (pubkeys.length === 0) return [] if (pubkeys.length === 0) return []
try { try {
const storedRelayEvents = await Promise.all( const storedRelayEvents = await Promise.all(pubkeys.map((pk) => this.getKind10002FromIdbOrSession(pk)))
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
)
const storedCacheRelayEvents = await Promise.all( const storedCacheRelayEvents = await Promise.all(
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS))
) )
@ -3906,10 +4006,12 @@ class ClientService extends EventTarget {
try { try {
return await this.mergeRelayListsFromStoredOnly(pubkeys) return await this.mergeRelayListsFromStoredOnly(pubkeys)
} catch { } catch {
const read = PROFILE_FETCH_RELAY_URLS
const write = PROFILE_FETCH_RELAY_URLS
return pubkeys.map(() => ({ return pubkeys.map(() => ({
write: PROFILE_FETCH_RELAY_URLS, write,
read: PROFILE_FETCH_RELAY_URLS, read,
originalRelays: [] as TMailboxRelay[], originalRelays: syntheticOriginalRelaysFromReadWrite(read, write),
httpRead: [] as string[], httpRead: [] as string[],
httpWrite: [] as string[], httpWrite: [] as string[],
httpOriginalRelays: [] as TMailboxRelay[] httpOriginalRelays: [] as TMailboxRelay[]

15
src/services/note-stats.service.ts

@ -371,6 +371,21 @@ class NoteStatsService {
return return
} }
// Feed/timeline often already has reposts, reactions, zaps in the session LRU — merge before relay list + REQ
// so boost strips and counts paint without waiting on fetchRelayList / index relays.
if (resolvedEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) {
const preFromSession = eventService.getSessionEventsForNoteStatsTarget(resolvedEvent)
if (preFromSession.length > 0) {
this.updateNoteStatsByEvents(preFromSession, resolvedEvent.pubkey, {
statsRootEvent: resolvedEvent
})
logger.debug('[NoteStats] processSingleEvent: pre-merged session interactions', {
eventId: `${resolvedEvent.id.slice(0, 12)}`,
count: preFromSession.length
})
}
}
const finalRelayUrls = await this.buildNoteStatsRelayList(resolvedEvent, favoriteRelays) const finalRelayUrls = await this.buildNoteStatsRelayList(resolvedEvent, favoriteRelays)
const replaceableCoordinate = isReplaceableEvent(resolvedEvent.kind) const replaceableCoordinate = isReplaceableEvent(resolvedEvent.kind)

Loading…
Cancel
Save