From c758ece6c64c59f1e6ce8a55a9747c8fa34a1db3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 17 May 2026 00:27:01 +0200 Subject: [PATCH] speed up feeds --- src/components/NoteInteractions/index.tsx | 6 +++- src/components/NoteList/index.tsx | 41 +++++++++++++++++------ src/components/Relay/index.tsx | 6 +++- src/components/ReplyNoteList/index.tsx | 10 ++++-- src/constants.ts | 2 +- src/lib/relay-list-builder.test.ts | 24 ++++++------- src/lib/relay-list-builder.ts | 26 +++++++++++++- src/services/client-query.service.ts | 31 ++++++++++++----- src/services/client.service.ts | 33 +++++++++++++----- 9 files changed, 133 insertions(+), 46 deletions(-) diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index 63803ad5..549a0e34 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -13,7 +13,8 @@ export default function NoteInteractions({ event, showQuotes: showQuotesProp, statsForeground = false, - refreshToken = 0 + refreshToken = 0, + singleRelayAuthoritativeRead = false }: { pageIndex?: number event: Event @@ -23,6 +24,8 @@ export default function NoteInteractions({ statsForeground?: boolean /** Bump to force the reply list to refetch. */ refreshToken?: number + /** Explore single-relay context: scope reply REQ to the browsing relay only. */ + singleRelayAuthoritativeRead?: boolean }) { const { t } = useTranslation() const [replySort, setReplySort] = useState('oldest') @@ -61,6 +64,7 @@ export default function NoteInteractions({ showQuotes={showQuotes} statsForeground={statsForeground} refreshToken={refreshToken} + singleRelayAuthoritativeRead={singleRelayAuthoritativeRead} /> ) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index e81acde2..0fa95ffd 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1947,7 +1947,12 @@ const NoteList = forwardRef( setLoading(true) let diskPrimeCancelled = false const primeDiskWhileAwaitingRelayProbe = async () => { - if (relayAuthoritativeFeedOnlyRef.current) return + const strictSingleRelayAuthoritative = + subRequestsRef.current.length === 1 && + subRequestsRef.current[0]!.urls.length === 1 && + (hostPrimaryPageNameRef.current === 'relay' || + (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) + if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return try { const mapped = stripNostrLandAggrFromTimelineSubRequests( feedSubscriptionKey, @@ -2028,12 +2033,6 @@ const NoteList = forwardRef( keepExistingTimelineEvents && eventsRef.current.length > 0 - const sessionSnap = - !userPulledRefresh && !relayAuthoritativeFeedOnlyRef.current - ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) - : undefined - const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length) - const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests( @@ -2049,6 +2048,18 @@ const NoteList = forwardRef( // key collisions where all offline relay-specific views share the same key. .filter((req) => req.urls.length > 0) + const strictSingleRelayAuthoritativeEarly = + mappedSubRequests.length === 1 && + mappedSubRequests[0]!.urls.length === 1 && + (hostPrimaryPageNameRef.current === 'relay' || + (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) + const sessionSnap = + !userPulledRefresh && + (!relayAuthoritativeFeedOnlyRef.current || strictSingleRelayAuthoritativeEarly) + ? getSessionFeedSnapshot(sessionSnapshotIdentityKey) + : undefined + const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length) + const filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0 const invalidFilters = mappedSubRequests.filter(({ urls, filter: f }) => { if (seeAllNoSpell) return false @@ -2131,7 +2142,12 @@ const NoteList = forwardRef( * {@link onEvents} so rows appear as soon as local sources resolve. */ const startNonBlockingTimelineDiskPrime = () => { - if (relayAuthoritativeFeedOnlyRef.current) return + const strictSingleRelayAuthoritative = + mappedSubRequests.length === 1 && + mappedSubRequests[0]!.urls.length === 1 && + (hostPrimaryPageNameRef.current === 'relay' || + (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) + if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return if (oneShotFetch || mappedSubRequests.length === 0) return if (isSpellPageLocalWarmup) return const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> @@ -2252,7 +2268,7 @@ const NoteList = forwardRef( } if (!keepExistingTimelineEvents) { - if (restoredFromSession && sessionSnap) { + if (restoredFromSession && sessionSnap && sessionSnap.length > 0) { feedPaintSessionPendingRef.current = true const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap) timelineMergeBootstrapRef.current = restored.slice() @@ -3087,7 +3103,12 @@ const NoteList = forwardRef( setProgressiveLayersSearching(false) followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current = null - if (!relayAuthoritativeFeedOnlyRef.current) { + const strictSingleRelayAuthoritativeCleanup = + subRequestsRef.current.length === 1 && + subRequestsRef.current[0]!.urls.length === 1 && + (hostPrimaryPageNameRef.current === 'relay' || + (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) + if (!relayAuthoritativeFeedOnlyRef.current || strictSingleRelayAuthoritativeCleanup) { setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current) } if (kindlessEoseTimeoutRef.current) { diff --git a/src/components/Relay/index.tsx b/src/components/Relay/index.tsx index e71fd61f..a3ac24bc 100644 --- a/src/components/Relay/index.tsx +++ b/src/components/Relay/index.tsx @@ -113,6 +113,9 @@ const Relay = forwardRef< ) const shouldHideEventNotFromThisRelay = useCallback( (ev: Event) => { + if (hostPrimaryPageName === 'relay' || allowKindlessRelayExplore) { + return false + } if (!relaySeenMatchKey) return false // LAN/loopback: REQ already targets this relay; "seen on" often lists another URL first // (favorites merge, localhost vs 127.0.0.1, etc.) — hiding would empty the relay-only feed. @@ -121,7 +124,7 @@ const Relay = forwardRef< if (seen.length === 0) return false return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey) }, - [relaySeenMatchKey, normalizedUrl] + [relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore] ) const alexandriaFeedEmptyUrl = useMemo(() => { @@ -158,6 +161,7 @@ const Relay = forwardRef< showAllKinds showFeedClientFilter hostPrimaryPageName={hostPrimaryPageName} + feedTimelineScopeKey={`relay:${normalizedUrl}`} extraShouldHideEvent={shouldHideEventNotFromThisRelay} extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay} relayAuthoritativeFeedOnly diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index bf3e4181..bb405fed 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -342,7 +342,8 @@ function ReplyNoteList({ showQuotes = true, duplicateWebPreviewCleanedUrlHints, statsForeground = false, - refreshToken = 0 + refreshToken = 0, + singleRelayAuthoritativeRead }: { index?: number event: NEvent @@ -355,6 +356,8 @@ function ReplyNoteList({ statsForeground?: boolean /** Bump to force the relay reply scan to run again. */ refreshToken?: number + /** Explore single-relay: only query the active browsing relay (see `useCurrentRelays`). */ + singleRelayAuthoritativeRead?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigation() @@ -367,6 +370,8 @@ function ReplyNoteList({ const { zapReplyThreshold } = useZap() const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays() + const relayAuthoritativeRead = + singleRelayAuthoritativeRead ?? browsingRelayUrls.length === 1 const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION @@ -1064,7 +1069,8 @@ function ReplyNoteList({ opAuthorPubkey, userPubkey || undefined, replyBlockedRelays, - threadRelayHints + threadRelayHints, + relayAuthoritativeRead ? { relayAuthoritative: true } : undefined ) // URL/article threads (NIP-22 `#i`): synthetic root has no e-tags or seen-relay hints — merge the same diff --git a/src/constants.ts b/src/constants.ts index 356301de..c3ca9c46 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -80,7 +80,7 @@ export const DEFAULT_FAVORITE_RELAYS = [ * Max concurrent relay connection + REQ setups (ensureRelay + subscribe) app-wide. * Limits parallel WebSocket handshakes when many relays or timeline shards open at once. */ -export const MAX_CONCURRENT_RELAY_CONNECTIONS = 10 +export const MAX_CONCURRENT_RELAY_CONNECTIONS = 12 /** * Max concurrent live REQ subscriptions on a single relay. Some relays enforce ≤10 SUBs; stay under diff --git a/src/lib/relay-list-builder.test.ts b/src/lib/relay-list-builder.test.ts index 59204e43..28043c26 100644 --- a/src/lib/relay-list-builder.test.ts +++ b/src/lib/relay-list-builder.test.ts @@ -1,17 +1,15 @@ import { describe, expect, it } from 'vitest' -import { pickAuthorNip65RelaysPreferringViewerOverlap } from './relay-list-builder' +import { buildReplyReadRelayList } from '@/lib/relay-list-builder' -describe('pickAuthorNip65RelaysPreferringViewerOverlap', () => { - it('prefers relays shared with the viewer, capped at max', () => { - const author = [ - 'wss://author-only.example/', - 'wss://shared.example/', - 'wss://author-two.example/' - ] - const viewer = ['wss://shared.example/', 'wss://viewer-only.example/'] - expect(pickAuthorNip65RelaysPreferringViewerOverlap(author, viewer, 2)).toEqual([ - 'wss://shared.example/', - 'wss://author-only.example/' - ]) +describe('buildReplyReadRelayList relayAuthoritative', () => { + it('returns only thread hints and author/user layers without favorite bootstrap', async () => { + const out = await buildReplyReadRelayList( + undefined, + undefined, + [], + ['wss://nostr.land/'], + { relayAuthoritative: true } + ) + expect(out).toEqual(['wss://nostr.land/']) }) }) diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 9832cfde..388b6aa4 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -572,12 +572,36 @@ export async function buildPollResultsReadRelayUrls(options: { * Build relay list for reading replies/comments: thread hints, author/user NIP-65, favorites, cache — * then default favorite relays only when global bootstrap applies (signed-out or no configured stack). */ +export type BuildReplyReadRelayListOptions = { + /** When true (e.g. Explore single-relay page), query only thread hints + author/user NIP-65 — no favorite/fast-read bootstrap layer. */ + relayAuthoritative?: boolean +} + export async function buildReplyReadRelayList( opAuthorPubkey: string | undefined, userPubkey: string | undefined, blockedRelays: string[] = [], - threadRelayHints: string[] = [] + threadRelayHints: string[] = [], + options?: BuildReplyReadRelayListOptions ): Promise { + if (options?.relayAuthoritative) { + const scoped = await buildComprehensiveRelayList({ + authorPubkey: opAuthorPubkey, + userPubkey, + relayHints: threadRelayHints, + includeUserOwnRelays: Boolean(userPubkey), + includeFastReadRelays: false, + useGlobalRelayDefaults: false, + includeSearchableRelays: false, + includeLocalRelays: true, + includeFavoriteRelays: false, + preferPublicReadRelaysEarly: false, + includeProfileFetchRelays: false, + blockedRelays + }) + return scoped + } + let useGlobal = true if (userPubkey) { try { diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 4d497af6..4aa83a01 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -264,7 +264,7 @@ export class QueryService { /** App-wide cap on parallel ensureRelay + initial subscribe setup (any relay). */ private globalRelayConnectionSlotsInUse = 0 - private globalRelayConnectionWaitQueue: Array<() => void> = [] + private globalRelayConnectionWaitQueue: Array<{ resolve: () => void; priority: boolean }> = [] /** * Aborted whenever {@link interruptBackgroundQueries} runs. Default {@link query} runs listen until close so @@ -281,23 +281,38 @@ export class QueryService { this.backgroundInterruptController = new AbortController() } - async acquireGlobalRelayConnectionSlot(): Promise { + async acquireGlobalRelayConnectionSlot(opts?: { priority?: boolean }): Promise { + const priority = opts?.priority === true if (this.globalRelayConnectionSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) { this.globalRelayConnectionSlotsInUse++ return } await new Promise((resolve) => { - this.globalRelayConnectionWaitQueue.push(() => { - this.globalRelayConnectionSlotsInUse++ - resolve() - }) + const entry = { resolve, priority } + if (priority) { + this.globalRelayConnectionWaitQueue.unshift(entry) + } else { + this.globalRelayConnectionWaitQueue.push(entry) + } }) } releaseGlobalRelayConnectionSlot(): void { this.globalRelayConnectionSlotsInUse = Math.max(0, this.globalRelayConnectionSlotsInUse - 1) - const next = this.globalRelayConnectionWaitQueue.shift() - if (next) next() + const pickNext = (): (() => void) | undefined => { + const priIdx = this.globalRelayConnectionWaitQueue.findIndex((e) => e.priority) + if (priIdx >= 0) { + const [entry] = this.globalRelayConnectionWaitQueue.splice(priIdx, 1) + return entry!.resolve + } + const entry = this.globalRelayConnectionWaitQueue.shift() + return entry?.resolve + } + const next = pickNext() + if (next) { + this.globalRelayConnectionSlotsInUse++ + next() + } } constructor(pool: SimplePool, relaySession?: QueryServiceRelaySessionOptions) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 614ff12e..4b2eb02e 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -137,6 +137,7 @@ import { stripLocalNetworkRelaysFromRelayList, stripMailboxLocalUrlsForRemoteViewers, syntheticOriginalRelaysFromReadWrite, + stripLocalNetworkRelaysForWssReq, urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { @@ -2422,20 +2423,25 @@ class ClientService extends EventTarget { oneose, onclose, startLogin, - onAllClose + onAllClose, + connectionSlotPriority }: { onevent?: (evt: NEvent) => void oneose?: (eosed: boolean) => void onclose?: (url: string, reason: string) => void startLogin?: () => void onAllClose?: (reasons: string[]) => void + /** Jump the global connection queue (single-relay authoritative timelines). */ + connectionSlotPriority?: boolean }, relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } ) { const originalDedupedRelays = Array.from(new Set(urls)) let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) - // While offline, silently drop every non-local relay so nothing is added to groupedRequests. - if (!navigator.onLine) { + if (navigator.onLine) { + relays = stripLocalNetworkRelaysForWssReq(relays) + } else { + // While offline, silently drop every non-local relay so nothing is added to groupedRequests. relays = relays.filter((url) => isLocalNetworkUrl(url)) } const filters = sanitizeSubscribeFiltersBeforeReq(filter) @@ -2605,9 +2611,12 @@ class ClientService extends EventTarget { /** Ignore a follow-up `closed by caller` while NIP-42 auth + resubscribe is in flight (parent `close()` must not finalize the batch early). */ const nip42ResubscribePending = new Set() const nip42HasAuthedOnce = new Set() + const slotPriority = connectionSlotPriority === true const allOpened = Promise.all( groupedRequests.map(async ({ url, filters: relayFilters }, i) => { - await that.queryService.acquireGlobalRelayConnectionSlot() + await that.queryService.acquireGlobalRelayConnectionSlot( + slotPriority ? { priority: true } : undefined + ) try { const relayKey = normalizeUrl(url) || url await that.queryService.acquireSubSlot(relayKey) @@ -2657,7 +2666,9 @@ class ClientService extends EventTarget { }) .then(async () => { nip42HasAuthedOnce.add(i) - await that.queryService.acquireGlobalRelayConnectionSlot() + await that.queryService.acquireGlobalRelayConnectionSlot( + slotPriority ? { priority: true } : undefined + ) try { await that.queryService.acquireSubSlot(relayKey) // After AUTH the socket may be closed or the relay dropped from the pool; @@ -2816,9 +2827,11 @@ class ClientService extends EventTarget { } = {} ) { let relays = Array.from(new Set(urls)) - // While offline, strip non-local relays before any further processing so the - // capital-letter-tag fallback below cannot re-introduce internet relays. - if (!navigator.onLine) { + if (navigator.onLine) { + relays = stripLocalNetworkRelaysForWssReq(relays) + } else { + // While offline, strip non-local relays before any further processing so the + // capital-letter-tag fallback below cannot re-introduce internet relays. relays = relays.filter((url) => isLocalNetworkUrl(url)) } if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) { @@ -3124,7 +3137,9 @@ class ClientService extends EventTarget { applySubscribedTimelineEvent(evt) }, oneose: httpOnlyShard ? undefined : handleTimelineEose, - onclose: onClose + onclose: onClose, + connectionSlotPriority: + relayAuthoritativeTimeline && wsRelays.length === 1 && navigator.onLine }, httpOnlyShard ? undefined : relayReqLog)