From 017627c008173f6ad783a8e1cf8f83676bf23305 Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Mon, 11 May 2026 12:10:39 +0200
Subject: [PATCH] bug-fixes
---
src/components/NormalFeed/index.tsx | 30 ++-
src/components/NoteList/index.tsx | 181 +++++++++++++++---
src/components/OthersRelayList/index.tsx | 14 +-
.../Profile/ProfileFeedWithPins.tsx | 2 +-
src/components/Profile/ProfileMediaFeed.tsx | 1 +
src/components/Profile/index.tsx | 5 +
src/constants.ts | 29 ++-
src/hooks/useFetchFollowings.tsx | 27 ++-
src/hooks/useFetchRelayList.tsx | 24 ++-
src/lib/favorites-feed-relays.ts | 14 +-
src/lib/relay-list-sanitize.ts | 39 +++-
.../OthersRelaySettingsPage/index.tsx | 6 -
src/services/client-events.service.ts | 98 +++++++++-
.../client-replaceable-events.service.ts | 93 ++++++++-
src/services/client.service.ts | 156 ++++++++++++---
src/services/note-stats.service.ts | 15 ++
16 files changed, 647 insertions(+), 87 deletions(-)
diff --git a/src/components/NormalFeed/index.tsx b/src/components/NormalFeed/index.tsx
index a7d4eac0..8e373562 100644
--- a/src/components/NormalFeed/index.tsx
+++ b/src/components/NormalFeed/index.tsx
@@ -4,11 +4,12 @@ import Tabs, { TabDefinition } from '@/components/Tabs'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
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 type { TPrimaryPageName } from '@/PageManager'
import { TFeedSubRequest, TNoteListMode } from '@/types'
import { cn } from '@/lib/utils'
+import { normalizeAnyRelayUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
import {
forwardRef,
@@ -22,6 +23,26 @@ import {
} from 'react'
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 user’s relay set is mostly text timelines. Deduped by normalized URL.
+ */
+function galleryRelayUrlsMergedWithReadLayer(favoriteUrls: readonly string[]): string[] {
+ const seen = new Set()
+ 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 {
if (listMode !== 'media') return subRequests
return subRequests.map((req) => ({
...req,
+ urls: isMainFeed ? galleryRelayUrlsMergedWithReadLayer(req.urls) : req.urls,
filter: { ...req.filter, kinds: MEDIA_KINDS }
}))
- }, [listMode, subRequests, MEDIA_KINDS])
+ }, [listMode, subRequests, MEDIA_KINDS, isMainFeed])
const handleListModeChange = useCallback(
(mode: TNoteListMode | string) => {
@@ -321,7 +343,9 @@ const NormalFeed = forwardRef
@@ -604,6 +605,22 @@ function getProfileSingleAuthorWarmupSpec(
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()
+ 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(
(
{
@@ -656,6 +673,12 @@ const NoteList = forwardRef(
* relay URL set is a strict superset of the old one (which would otherwise keep stale rows).
*/
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}. */
spellFeedInstrumentToken,
/** 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
* {@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,
/** Override {@link client.fetchEvents} / query global timeout (default 14s). */
@@ -761,6 +784,7 @@ const NoteList = forwardRef(
mergeTimelineWhenSubRequestFiltersMatch?: boolean
followingFeedDeltaSubRequests?: TFeedSubRequest[]
feedTimelineScopeKey?: string
+ homeFeedListMode?: TNoteListMode
spellFeedInstrumentToken?: number
onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void
timelineLoadingSafetyTimeoutMs?: number
@@ -1110,6 +1134,7 @@ const NoteList = forwardRef(
() =>
JSON.stringify({
feed: timelineSubscriptionKey,
+ ...(homeFeedListMode ? { homeSurface: homeFeedListMode } : {}),
...(allowKindlessRelayExplore
? { relayKindless: true, showAllKinds }
: {
@@ -1122,6 +1147,7 @@ const NoteList = forwardRef(
}),
[
timelineSubscriptionKey,
+ homeFeedListMode,
showKindsKey,
showKind1OPs,
showKind1Replies,
@@ -1133,9 +1159,18 @@ const NoteList = forwardRef(
)
/** Kindless relay explore ignores the feed kind picker; avoid re-subscribing when it changes. */
- const timelineResubscribeKindKey = allowKindlessRelayExplore
- ? 'kindless-relay-explore'
- : `${showKindsKey}|${showKind1OPs}|${showKind1Replies}|${showKind1111}`
+ const timelineResubscribeKindKey = useMemo(() => {
+ if (allowKindlessRelayExplore) return 'kindless-relay-explore'
+ if (homeFeedListMode === 'media') return 'home-surface-media'
+ return `${showKindsKey}|${showKind1OPs}|${showKind1Replies}|${showKind1111}`
+ }, [
+ allowKindlessRelayExplore,
+ homeFeedListMode,
+ showKindsKey,
+ showKind1OPs,
+ showKind1Replies,
+ showKind1111
+ ])
const showKindsRef = useRef(showKinds)
showKindsRef.current = showKinds
@@ -1169,6 +1204,8 @@ const NoteList = forwardRef(
withKindFilterRef.current = withKindFilter
const hostPrimaryPageNameRef = useRef(hostPrimaryPageName)
hostPrimaryPageNameRef.current = hostPrimaryPageName
+ const gridLayoutRef = useRef(gridLayout)
+ gridLayoutRef.current = gridLayout
const narrowLiveBatchUsingRefs = (evs: Event[]): Event[] => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
@@ -1275,9 +1312,15 @@ const NoteList = forwardRef(
}
if (shouldHideEvent(evt)) continue
- const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
- if (idSet.has(id)) continue
- idSet.add(id)
+ // Mosaic: one tile per event id. Replaceable-coordinate dedup (correct for profile lists) collapses
+ // multiple NIP-71 addressable revisions / instances to a single cell — looks like "extra images flash then vanish".
+ 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)
}
const scannedToEndOfBuffer = i >= timelineEventsForFilter.length
@@ -1292,7 +1335,8 @@ const NoteList = forwardRef(
showKind1OPs,
showKind1Replies,
showKind1111,
- applyKindPickerInUi
+ applyKindPickerInUi,
+ gridLayout
])
useEffect(() => {
@@ -1618,9 +1662,11 @@ const NoteList = forwardRef(
const refresh = useCallback(() => {
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(() => {
setRefreshCount((count) => count + 1)
- }, 500)
+ }, 80)
}, [scrollToTop])
const flushPendingNewEventsIntoTimeline = useCallback(() => {
@@ -1971,7 +2017,7 @@ const NoteList = forwardRef(
const narrowLiveBatch = (evs: Event[]) => {
if (allowKindlessRelayExploreRef.current && showAllKindsRef.current) return evs
if (withKindFilterRef.current && !showAllKindsRef.current) {
- return evs.filter((e) =>
+ const out = evs.filter((e) =>
eventPassesNoteListKindPicker(
e,
effectiveShowKindsRef.current,
@@ -1980,10 +2026,26 @@ const NoteList = forwardRef(
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 (!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
@@ -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 (restoredFromSession && sessionSnap) {
feedPaintSessionPendingRef.current = true
@@ -2201,17 +2329,25 @@ const NoteList = forwardRef(
void (async () => {
try {
- const fromArchive = await indexedDb.scanEventArchiveByAuthorPubkey(
- profileAuthorWarmSpec.author,
- {
+ const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
+ const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
+ const [fromArchive, diskSnap] = await Promise.all([
+ indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
kinds: profileAuthorWarmSpec.kinds,
maxRowsScanned: 16_000,
- maxMatches: Math.min(2000, Math.max(eventCapEarly, 150))
- }
- )
+ maxMatches: archiveCap
+ }),
+ client.getTimelineDiskSnapshotEvents(diskReq)
+ ])
if (!effectActive || timelineEffectStale()) return
- if (fromArchive.length === 0) return
- const narrowed = narrowLiveBatch(fromArchive as Event[])
+ const premerged = mergeEventBatchesById(
+ [],
+ [...(fromArchive as Event[]), ...(diskSnap as Event[])],
+ archiveCap,
+ areAlgoRelays
+ )
+ if (premerged.length === 0) return
+ const narrowed = narrowLiveBatch(premerged)
if (narrowed.length === 0) return
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
@@ -2254,6 +2390,7 @@ const NoteList = forwardRef(
}
if (!oneShotFetch && mappedSubRequests.length > 0) {
+ startHomeGalleryLocalWarmup()
startNonBlockingTimelineDiskPrime()
}
diff --git a/src/components/OthersRelayList/index.tsx b/src/components/OthersRelayList/index.tsx
index b4d3c708..2a2a4d2a 100644
--- a/src/components/OthersRelayList/index.tsx
+++ b/src/components/OthersRelayList/index.tsx
@@ -30,9 +30,17 @@ export default function OthersRelayList({ userId }: { userId: string }) {
})}
)}
- {relayList.originalRelays.map((relay, index) => (
-
- ))}
+ {relayList.originalRelays.length === 0 ? (
+
+ {t('othersRelayListEmpty', {
+ defaultValue: 'No relay URLs to show. Check your connection or try again later.'
+ })}
+
+ ) : (
+ relayList.originalRelays.map((relay, index) => (
+
+ ))
+ )}
)
}
diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx
index 00b1661d..a971e2ef 100644
--- a/src/components/Profile/ProfileFeedWithPins.tsx
+++ b/src/components/Profile/ProfileFeedWithPins.tsx
@@ -138,7 +138,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
showKind1Replies={showKind1Replies}
showKind1111={showKind1111}
showFeedClientFilter
- timelinePublicReadFallback={false}
+ timelinePublicReadFallback
revealBatchSize={48}
/>
diff --git a/src/components/Profile/ProfileMediaFeed.tsx b/src/components/Profile/ProfileMediaFeed.tsx
index f07221dd..153619b1 100644
--- a/src/components/Profile/ProfileMediaFeed.tsx
+++ b/src/components/Profile/ProfileMediaFeed.tsx
@@ -165,6 +165,7 @@ const ProfileMediaFeed = forwardRef(({ pubkey
showKind1Replies
showKind1111
hideReplies={false}
+ timelinePublicReadFallback
/>
)
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 9953bdcc..4479203d 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -266,6 +266,11 @@ export default function Profile({
fetchPaymentInfo()
}, [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
// Use fetchProfileEvent which does comprehensive search, not fetchReplaceableEvent
useEffect(() => {
diff --git a/src/constants.ts b/src/constants.ts
index 3b7d8a9d..575b9bb9 100644
--- a/src/constants.ts
+++ b/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
- * merge. Kept short so users without NIP-65 (or slow relays) get {@link PROFILE_FETCH_RELAY_URLS} immediately;
- * {@link PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS} stays longer for publish / prioritize paths that wrap their own races.
+ * merge. Must allow {@link ReplaceableEventService.fetchReplaceableEventsFromProfileFetchRelays} (10002 + 10243)
+ * plus kind-10432 discovery to finish on slow relays; otherwise we never persist others’ NIP-65 and the cache
+ * stays empty except for the account’s 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}
@@ -604,6 +612,21 @@ export function isNip71StyleVideoKind(kind: number): boolean {
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 = new Set([
+ 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). */
export function isNip71ShortVideoKind(kind: number): boolean {
return kind === ExtendedKind.SHORT_VIDEO || kind === ExtendedKind.SHORT_VIDEO_ADDRESSABLE
diff --git a/src/hooks/useFetchFollowings.tsx b/src/hooks/useFetchFollowings.tsx
index 6c2cd0a8..c9589690 100644
--- a/src/hooks/useFetchFollowings.tsx
+++ b/src/hooks/useFetchFollowings.tsx
@@ -10,22 +10,37 @@ export function useFetchFollowings(pubkey?: string | null, refreshNonce = 0) {
const [isFetching, setIsFetching] = useState(true)
useEffect(() => {
+ let cancelled = false
const init = async () => {
+ setIsFetching(true)
+ setFollowListEvent(null)
+ setFollowings([])
try {
- setIsFetching(true)
- if (!pubkey) return
+ if (!pubkey?.trim()) {
+ return
+ }
- const event = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts) ?? null
- if (!event) return
+ const event = (await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts)) ?? null
+ if (cancelled) return
+ if (!event) {
+ setFollowListEvent(null)
+ setFollowings([])
+ return
+ }
setFollowListEvent(event)
setFollowings(getPubkeysFromPTags(event.tags))
} finally {
- setIsFetching(false)
+ if (!cancelled) {
+ setIsFetching(false)
+ }
}
}
- init()
+ void init()
+ return () => {
+ cancelled = true
+ }
}, [pubkey, refreshNonce])
return { followings, followListEvent, isFetching }
diff --git a/src/hooks/useFetchRelayList.tsx b/src/hooks/useFetchRelayList.tsx
index 61e551a5..b8fd2a3d 100644
--- a/src/hooks/useFetchRelayList.tsx
+++ b/src/hooks/useFetchRelayList.tsx
@@ -1,3 +1,4 @@
+import { FETCH_RELAY_LIST_HOOK_MAX_MS } from '@/constants'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
@@ -44,7 +45,22 @@ export function useFetchRelayList(pubkey?: string | null) {
setHasKind10002InStorage(!!k10002)
setRelayList(fromStorage)
- const merged = await client.fetchRelayList(targetPk)
+ const merged = await Promise.race([
+ client.fetchRelayList(targetPk),
+ new Promise((_, 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
setRelayList(merged)
const k10002After = await indexedDb.getReplaceableEvent(targetPk, kinds.RelayList).catch(() => null)
@@ -78,10 +94,8 @@ export function useFetchRelayList(pubkey?: string | null) {
}
}, [pubkey])
- const showingRelayListFallback =
- !isFetching &&
- !hasKind10002InStorage &&
- relayList.originalRelays.length === 0
+ /** True when no kind 10002 for this author in IDB — UI may show default discovery relays with a disclaimer. */
+ const showingRelayListFallback = !isFetching && !hasKind10002InStorage
return { relayList, isFetching, hasKind10002InStorage, showingRelayListFallback }
}
diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts
index f41f5096..bc82f0c1 100644
--- a/src/lib/favorites-feed-relays.ts
+++ b/src/lib/favorites-feed-relays.ts
@@ -2,6 +2,7 @@ import {
DEFAULT_FAVORITE_RELAYS,
DOCUMENT_RELAY_URLS,
FAST_READ_RELAY_URLS,
+ PROFILE_FETCH_RELAY_URLS,
READ_ONLY_RELAY_URLS,
isDocumentRelayKind,
relayFilterIncludesSocialKindBlockedKind
@@ -184,17 +185,26 @@ export function buildProfilePageReadRelayUrls(
const list = includeAuthorLocalRelays
? 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(
favoriteRelays,
blockedRelays,
- [...(list.httpRead ?? []), ...(list.read ?? [])],
+ authorRead,
{
- userWriteRelays: [...(list.httpWrite ?? []), ...(list.write ?? [])],
+ userWriteRelays: authorWrite,
authorWriteRelays: [],
maxRelays,
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) {
const docLayer = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]
urls = mergeRelayUrlLayers([urls, docLayer], blockedRelays).slice(0, maxRelays + 6)
diff --git a/src/lib/relay-list-sanitize.ts b/src/lib/relay-list-sanitize.ts
index 5f34a81a..456aa18b 100644
--- a/src/lib/relay-list-sanitize.ts
+++ b/src/lib/relay-list-sanitize.ts
@@ -1,5 +1,5 @@
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). */
export function urlIsNonLocalForRemoteViewer(url: string): boolean {
@@ -49,3 +49,40 @@ export function stripLocalNetworkRelaysFromRelayList(list: TRelayList): TRelayLi
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()
+ const writeByKey = new Map()
+ 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
+}
diff --git a/src/pages/secondary/OthersRelaySettingsPage/index.tsx b/src/pages/secondary/OthersRelaySettingsPage/index.tsx
index 4a4e95d1..38fdb700 100644
--- a/src/pages/secondary/OthersRelaySettingsPage/index.tsx
+++ b/src/pages/secondary/OthersRelaySettingsPage/index.tsx
@@ -53,12 +53,6 @@ const RelaySettingsPage = forwardRef(({ id, index, hideTitlebar = false }: { id?
setJsonOpen(true)
}, [profile?.pubkey, relayList])
- useEffect(() => {
- if (profile?.pubkey) {
- setListKey((k) => k + 1)
- }
- }, [profile?.pubkey])
-
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index 6af0aca6..209293b7 100644
--- a/src/services/client-events.service.ts
+++ b/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 {
getParentATag,
@@ -81,6 +86,9 @@ async function buildComprehensiveRelayListForEvents(
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 {
private queryService: QueryService
private eventCacheMap = new Map>()
@@ -522,6 +530,20 @@ export class EventService {
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.notifyReplaceableCoordinateWaiters(cleanEvent as NEvent)
queuePersistSeenEvent(cleanEvent as NEvent)
@@ -741,6 +763,80 @@ export class EventService {
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([
+ 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
* revision’s id (some clients tag the instance id only). Used so RSVP lists populate from feeds before
diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts
index a52b4f7e..f0e6328d 100644
--- a/src/services/client-replaceable-events.service.ts
+++ b/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
// Otherwise, use DataLoader (which batches IndexedDB checks and network fetches)
let event: NEvent | undefined
@@ -301,8 +325,8 @@ export class ReplaceableEventService {
}
/**
- * Batch fetch replaceable events from profile fetch relays
- * Checks IndexedDB first, then network
+ * Batch fetch replaceable events from profile fetch relays.
+ * Order: IndexedDB, then session LRU for kind 3 / 10002 gaps, then network.
*/
async fetchReplaceableEventsFromProfileFetchRelays(pubkeys: string[], kind: number): Promise<(NEvent | undefined)[]> {
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)
if (stillMissing.length > 0) {
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
if (missingParams.length === 0) {
@@ -559,12 +617,27 @@ export class ReplaceableEventService {
)
).filter(Boolean)
} 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(
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)
} else if (kind === ExtendedKind.PAYMENT_INFO) {
@@ -598,8 +671,14 @@ export class ReplaceableEventService {
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 =
- 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
// 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.
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 77a12f96..c701cf1a 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -123,7 +123,12 @@ import {
relayFiltersUseCapitalLetterTagKeys,
relayUrlsStripExtendedTagReqBlocked
} 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 {
canonicalRelayStrikeKey,
isHttpRelayUrl,
@@ -288,10 +293,14 @@ class ClientService extends EventTarget {
private sessionRelayPublishStats = new Map()
/**
- * 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}
*/
private sessionPrewarmBaseCompleted = false
+ /** Per-pubkey cooldown for {@link prefetchAuthorCoreReplaceables} from feed ingest (avoid REQ storms). */
+ private authorCorePrefetchCooldownUntilMs = new Map()
+ private static readonly AUTHOR_CORE_PREFETCH_COOLDOWN_MS = 90_000
constructor() {
super()
@@ -746,10 +755,10 @@ class ClientService extends EventTarget {
this.fetchRelayList(pubkey),
new Promise((resolve) =>
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)
})
- resolve(empty)
+ void this.peekRelayListFromStorage(pubkey).then(resolve).catch(() => resolve(empty))
}, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS)
)
])
@@ -758,7 +767,11 @@ class ClientService extends EventTarget {
pubkeySlice: pubkey.slice(0, 12),
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),
new Promise((resolve) =>
setTimeout(() => {
- logger.warn('[DetermineTargetRelays] fetchRelayLists timed out; skipping context inbox merge', {
+ logger.warn('[DetermineTargetRelays] fetchRelayLists timed out; using IndexedDB / default merge', {
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)
)
])
@@ -781,7 +796,11 @@ class ClientService extends EventTarget {
pubkeyCount: pubkeys.length,
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 =========== */
// 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()
+ 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. */
private async initUserIndexFromFollowings(pubkey: string, signal: AbortSignal) {
const followings = await this.replaceableEventService.fetchFollowings(pubkey)
@@ -3099,11 +3164,12 @@ class ClientService extends EventTarget {
})
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),
followingCount: followings.length
})
let relayListResolved = 0
+ let contactsResolved = 0
const chunkSize = 20
for (let i = 0; i * chunkSize < followings.length; i++) {
if (signal.aborted) {
@@ -3111,17 +3177,20 @@ class ClientService extends EventTarget {
return
}
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.Contacts),
Promise.all(chunk.map((pk) => this.fetchProfileEvent(pk)))
])
relayListResolved += relayListEvents.filter(Boolean).length
+ contactsResolved += contactsEvents.filter(Boolean).length
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),
followingCount: followings.length,
- relayListEventsResolved: relayListResolved
+ relayListEventsResolved: relayListResolved,
+ contactsEventsResolved: contactsResolved
})
}
@@ -3577,10 +3646,12 @@ class ClientService extends EventTarget {
const [fallback] = await this.mergeRelayListsFromStoredOnly([pubkey])
return fallback!
} catch {
+ const read = PROFILE_FETCH_RELAY_URLS
+ const write = PROFILE_FETCH_RELAY_URLS
return {
- write: PROFILE_FETCH_RELAY_URLS,
- read: PROFILE_FETCH_RELAY_URLS,
- originalRelays: [],
+ write,
+ read,
+ originalRelays: syntheticOriginalRelaysFromReadWrite(read, write),
httpRead: [],
httpWrite: [],
httpOriginalRelays: []
@@ -3604,10 +3675,28 @@ class ClientService extends EventTarget {
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 {
+ 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 {
- const storedRelayEvents = await Promise.all(
- pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, kinds.RelayList))
- )
+ const storedRelayEvents = await Promise.all(pubkeys.map((pk) => this.getKind10002FromIdbOrSession(pk)))
const storedCacheRelayEvents = await Promise.all(
pubkeys.map((pk) => indexedDb.getReplaceableEvent(pk, ExtendedKind.CACHE_RELAYS))
)
@@ -3708,10 +3797,23 @@ class ClientService extends EventTarget {
...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({
- write: PROFILE_FETCH_RELAY_URLS,
- read: PROFILE_FETCH_RELAY_URLS,
- originalRelays: [],
+ write,
+ read,
+ originalRelays: syntheticOriginalRelaysFromReadWrite(read, write),
...emptyHttp
})
}
@@ -3746,9 +3848,7 @@ class ClientService extends EventTarget {
if (pubkeys.length === 0) return []
try {
- const storedRelayEvents = await Promise.all(
- pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
- )
+ const storedRelayEvents = await Promise.all(pubkeys.map((pk) => this.getKind10002FromIdbOrSession(pk)))
const storedCacheRelayEvents = await Promise.all(
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS))
)
@@ -3906,10 +4006,12 @@ class ClientService extends EventTarget {
try {
return await this.mergeRelayListsFromStoredOnly(pubkeys)
} catch {
+ const read = PROFILE_FETCH_RELAY_URLS
+ const write = PROFILE_FETCH_RELAY_URLS
return pubkeys.map(() => ({
- write: PROFILE_FETCH_RELAY_URLS,
- read: PROFILE_FETCH_RELAY_URLS,
- originalRelays: [] as TMailboxRelay[],
+ write,
+ read,
+ originalRelays: syntheticOriginalRelaysFromReadWrite(read, write),
httpRead: [] as string[],
httpWrite: [] as string[],
httpOriginalRelays: [] as TMailboxRelay[]
diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts
index 062105e6..d6693247 100644
--- a/src/services/note-stats.service.ts
+++ b/src/services/note-stats.service.ts
@@ -371,6 +371,21 @@ class NoteStatsService {
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 replaceableCoordinate = isReplaceableEvent(resolvedEvent.kind)