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)