Browse Source

speed up feeds

imwald
Silberengel 4 weeks ago
parent
commit
c758ece6c6
  1. 6
      src/components/NoteInteractions/index.tsx
  2. 41
      src/components/NoteList/index.tsx
  3. 6
      src/components/Relay/index.tsx
  4. 10
      src/components/ReplyNoteList/index.tsx
  5. 2
      src/constants.ts
  6. 24
      src/lib/relay-list-builder.test.ts
  7. 26
      src/lib/relay-list-builder.ts
  8. 31
      src/services/client-query.service.ts
  9. 33
      src/services/client.service.ts

6
src/components/NoteInteractions/index.tsx

@ -13,7 +13,8 @@ export default function NoteInteractions({
event, event,
showQuotes: showQuotesProp, showQuotes: showQuotesProp,
statsForeground = false, statsForeground = false,
refreshToken = 0 refreshToken = 0,
singleRelayAuthoritativeRead = false
}: { }: {
pageIndex?: number pageIndex?: number
event: Event event: Event
@ -23,6 +24,8 @@ export default function NoteInteractions({
statsForeground?: boolean statsForeground?: boolean
/** Bump to force the reply list to refetch. */ /** Bump to force the reply list to refetch. */
refreshToken?: number refreshToken?: number
/** Explore single-relay context: scope reply REQ to the browsing relay only. */
singleRelayAuthoritativeRead?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [replySort, setReplySort] = useState<ReplySortOption>('oldest') const [replySort, setReplySort] = useState<ReplySortOption>('oldest')
@ -61,6 +64,7 @@ export default function NoteInteractions({
showQuotes={showQuotes} showQuotes={showQuotes}
statsForeground={statsForeground} statsForeground={statsForeground}
refreshToken={refreshToken} refreshToken={refreshToken}
singleRelayAuthoritativeRead={singleRelayAuthoritativeRead}
/> />
</> </>
) )

41
src/components/NoteList/index.tsx

@ -1947,7 +1947,12 @@ const NoteList = forwardRef(
setLoading(true) setLoading(true)
let diskPrimeCancelled = false let diskPrimeCancelled = false
const primeDiskWhileAwaitingRelayProbe = async () => { 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 { try {
const mapped = stripNostrLandAggrFromTimelineSubRequests( const mapped = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey, feedSubscriptionKey,
@ -2028,12 +2033,6 @@ const NoteList = forwardRef(
keepExistingTimelineEvents && keepExistingTimelineEvents &&
eventsRef.current.length > 0 eventsRef.current.length > 0
const sessionSnap =
!userPulledRefresh && !relayAuthoritativeFeedOnlyRef.current
? getSessionFeedSnapshot(sessionSnapshotIdentityKey)
: undefined
const restoredFromSession = !keepExistingTimelineEvents && !!(sessionSnap?.length)
const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const seeAllNoSpell = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests( const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests(
@ -2049,6 +2048,18 @@ const NoteList = forwardRef(
// key collisions where all offline relay-specific views share the same key. // key collisions where all offline relay-specific views share the same key.
.filter((req) => req.urls.length > 0) .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 filterMissingKinds = (f: Filter) => !f.kinds || f.kinds.length === 0
const invalidFilters = mappedSubRequests.filter(({ urls, filter: f }) => { const invalidFilters = mappedSubRequests.filter(({ urls, filter: f }) => {
if (seeAllNoSpell) return false if (seeAllNoSpell) return false
@ -2131,7 +2142,12 @@ const NoteList = forwardRef(
* {@link onEvents} so rows appear as soon as local sources resolve. * {@link onEvents} so rows appear as soon as local sources resolve.
*/ */
const startNonBlockingTimelineDiskPrime = () => { 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 (oneShotFetch || mappedSubRequests.length === 0) return
if (isSpellPageLocalWarmup) return if (isSpellPageLocalWarmup) return
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
@ -2252,7 +2268,7 @@ const NoteList = forwardRef(
} }
if (!keepExistingTimelineEvents) { if (!keepExistingTimelineEvents) {
if (restoredFromSession && sessionSnap) { if (restoredFromSession && sessionSnap && sessionSnap.length > 0) {
feedPaintSessionPendingRef.current = true feedPaintSessionPendingRef.current = true
const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap) const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap)
timelineMergeBootstrapRef.current = restored.slice() timelineMergeBootstrapRef.current = restored.slice()
@ -3087,7 +3103,12 @@ const NoteList = forwardRef(
setProgressiveLayersSearching(false) setProgressiveLayersSearching(false)
followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null 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) setSessionFeedSnapshot(snapshotKeyForCleanup, eventsRef.current)
} }
if (kindlessEoseTimeoutRef.current) { if (kindlessEoseTimeoutRef.current) {

6
src/components/Relay/index.tsx

@ -113,6 +113,9 @@ const Relay = forwardRef<
) )
const shouldHideEventNotFromThisRelay = useCallback( const shouldHideEventNotFromThisRelay = useCallback(
(ev: Event) => { (ev: Event) => {
if (hostPrimaryPageName === 'relay' || allowKindlessRelayExplore) {
return false
}
if (!relaySeenMatchKey) return false if (!relaySeenMatchKey) return false
// LAN/loopback: REQ already targets this relay; "seen on" often lists another URL first // 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. // (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 if (seen.length === 0) return false
return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey) return !seen.some((u) => (normalizeAnyRelayUrl(u) || u).toLowerCase() === relaySeenMatchKey)
}, },
[relaySeenMatchKey, normalizedUrl] [relaySeenMatchKey, normalizedUrl, hostPrimaryPageName, allowKindlessRelayExplore]
) )
const alexandriaFeedEmptyUrl = useMemo(() => { const alexandriaFeedEmptyUrl = useMemo(() => {
@ -158,6 +161,7 @@ const Relay = forwardRef<
showAllKinds showAllKinds
showFeedClientFilter showFeedClientFilter
hostPrimaryPageName={hostPrimaryPageName} hostPrimaryPageName={hostPrimaryPageName}
feedTimelineScopeKey={`relay:${normalizedUrl}`}
extraShouldHideEvent={shouldHideEventNotFromThisRelay} extraShouldHideEvent={shouldHideEventNotFromThisRelay}
extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay} extraShouldHideRepliesEvent={shouldHideEventNotFromThisRelay}
relayAuthoritativeFeedOnly relayAuthoritativeFeedOnly

10
src/components/ReplyNoteList/index.tsx

@ -342,7 +342,8 @@ function ReplyNoteList({
showQuotes = true, showQuotes = true,
duplicateWebPreviewCleanedUrlHints, duplicateWebPreviewCleanedUrlHints,
statsForeground = false, statsForeground = false,
refreshToken = 0 refreshToken = 0,
singleRelayAuthoritativeRead
}: { }: {
index?: number index?: number
event: NEvent event: NEvent
@ -355,6 +356,8 @@ function ReplyNoteList({
statsForeground?: boolean statsForeground?: boolean
/** Bump to force the relay reply scan to run again. */ /** Bump to force the relay reply scan to run again. */
refreshToken?: number refreshToken?: number
/** Explore single-relay: only query the active browsing relay (see `useCurrentRelays`). */
singleRelayAuthoritativeRead?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation() const { navigateToNote } = useSmartNoteNavigation()
@ -367,6 +370,8 @@ function ReplyNoteList({
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const { blockedRelays, favoriteRelays } = useFavoriteRelays() const { blockedRelays, favoriteRelays } = useFavoriteRelays()
const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const relayAuthoritativeRead =
singleRelayAuthoritativeRead ?? browsingRelayUrls.length === 1
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReply()
const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION
@ -1064,7 +1069,8 @@ function ReplyNoteList({
opAuthorPubkey, opAuthorPubkey,
userPubkey || undefined, userPubkey || undefined,
replyBlockedRelays, 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 // URL/article threads (NIP-22 `#i`): synthetic root has no e-tags or seen-relay hints — merge the same

2
src/constants.ts

@ -80,7 +80,7 @@ export const DEFAULT_FAVORITE_RELAYS = [
* Max concurrent relay connection + REQ setups (ensureRelay + subscribe) app-wide. * Max concurrent relay connection + REQ setups (ensureRelay + subscribe) app-wide.
* Limits parallel WebSocket handshakes when many relays or timeline shards open at once. * 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 * Max concurrent live REQ subscriptions on a single relay. Some relays enforce 10 SUBs; stay under

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

@ -1,17 +1,15 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { pickAuthorNip65RelaysPreferringViewerOverlap } from './relay-list-builder' import { buildReplyReadRelayList } from '@/lib/relay-list-builder'
describe('pickAuthorNip65RelaysPreferringViewerOverlap', () => { describe('buildReplyReadRelayList relayAuthoritative', () => {
it('prefers relays shared with the viewer, capped at max', () => { it('returns only thread hints and author/user layers without favorite bootstrap', async () => {
const author = [ const out = await buildReplyReadRelayList(
'wss://author-only.example/', undefined,
'wss://shared.example/', undefined,
'wss://author-two.example/' [],
] ['wss://nostr.land/'],
const viewer = ['wss://shared.example/', 'wss://viewer-only.example/'] { relayAuthoritative: true }
expect(pickAuthorNip65RelaysPreferringViewerOverlap(author, viewer, 2)).toEqual([ )
'wss://shared.example/', expect(out).toEqual(['wss://nostr.land/'])
'wss://author-only.example/'
])
}) })
}) })

26
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 * 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). * 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( export async function buildReplyReadRelayList(
opAuthorPubkey: string | undefined, opAuthorPubkey: string | undefined,
userPubkey: string | undefined, userPubkey: string | undefined,
blockedRelays: string[] = [], blockedRelays: string[] = [],
threadRelayHints: string[] = [] threadRelayHints: string[] = [],
options?: BuildReplyReadRelayListOptions
): Promise<string[]> { ): Promise<string[]> {
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 let useGlobal = true
if (userPubkey) { if (userPubkey) {
try { try {

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

@ -264,7 +264,7 @@ export class QueryService {
/** App-wide cap on parallel ensureRelay + initial subscribe setup (any relay). */ /** App-wide cap on parallel ensureRelay + initial subscribe setup (any relay). */
private globalRelayConnectionSlotsInUse = 0 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 * Aborted whenever {@link interruptBackgroundQueries} runs. Default {@link query} runs listen until close so
@ -281,23 +281,38 @@ export class QueryService {
this.backgroundInterruptController = new AbortController() this.backgroundInterruptController = new AbortController()
} }
async acquireGlobalRelayConnectionSlot(): Promise<void> { async acquireGlobalRelayConnectionSlot(opts?: { priority?: boolean }): Promise<void> {
const priority = opts?.priority === true
if (this.globalRelayConnectionSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) { if (this.globalRelayConnectionSlotsInUse < MAX_CONCURRENT_RELAY_CONNECTIONS) {
this.globalRelayConnectionSlotsInUse++ this.globalRelayConnectionSlotsInUse++
return return
} }
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
this.globalRelayConnectionWaitQueue.push(() => { const entry = { resolve, priority }
this.globalRelayConnectionSlotsInUse++ if (priority) {
resolve() this.globalRelayConnectionWaitQueue.unshift(entry)
}) } else {
this.globalRelayConnectionWaitQueue.push(entry)
}
}) })
} }
releaseGlobalRelayConnectionSlot(): void { releaseGlobalRelayConnectionSlot(): void {
this.globalRelayConnectionSlotsInUse = Math.max(0, this.globalRelayConnectionSlotsInUse - 1) this.globalRelayConnectionSlotsInUse = Math.max(0, this.globalRelayConnectionSlotsInUse - 1)
const next = this.globalRelayConnectionWaitQueue.shift() const pickNext = (): (() => void) | undefined => {
if (next) next() 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) { constructor(pool: SimplePool, relaySession?: QueryServiceRelaySessionOptions) {

33
src/services/client.service.ts

@ -137,6 +137,7 @@ import {
stripLocalNetworkRelaysFromRelayList, stripLocalNetworkRelaysFromRelayList,
stripMailboxLocalUrlsForRemoteViewers, stripMailboxLocalUrlsForRemoteViewers,
syntheticOriginalRelaysFromReadWrite, syntheticOriginalRelaysFromReadWrite,
stripLocalNetworkRelaysForWssReq,
urlIsNonLocalForRemoteViewer urlIsNonLocalForRemoteViewer
} from '@/lib/relay-list-sanitize' } from '@/lib/relay-list-sanitize'
import { import {
@ -2422,20 +2423,25 @@ class ClientService extends EventTarget {
oneose, oneose,
onclose, onclose,
startLogin, startLogin,
onAllClose onAllClose,
connectionSlotPriority
}: { }: {
onevent?: (evt: NEvent) => void onevent?: (evt: NEvent) => void
oneose?: (eosed: boolean) => void oneose?: (eosed: boolean) => void
onclose?: (url: string, reason: string) => void onclose?: (url: string, reason: string) => void
startLogin?: () => void startLogin?: () => void
onAllClose?: (reasons: string[]) => void onAllClose?: (reasons: string[]) => void
/** Jump the global connection queue (single-relay authoritative timelines). */
connectionSlotPriority?: boolean
}, },
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) 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)) relays = relays.filter((url) => isLocalNetworkUrl(url))
} }
const filters = sanitizeSubscribeFiltersBeforeReq(filter) 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). */ /** 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<number>() const nip42ResubscribePending = new Set<number>()
const nip42HasAuthedOnce = new Set<number>() const nip42HasAuthedOnce = new Set<number>()
const slotPriority = connectionSlotPriority === true
const allOpened = Promise.all( const allOpened = Promise.all(
groupedRequests.map(async ({ url, filters: relayFilters }, i) => { groupedRequests.map(async ({ url, filters: relayFilters }, i) => {
await that.queryService.acquireGlobalRelayConnectionSlot() await that.queryService.acquireGlobalRelayConnectionSlot(
slotPriority ? { priority: true } : undefined
)
try { try {
const relayKey = normalizeUrl(url) || url const relayKey = normalizeUrl(url) || url
await that.queryService.acquireSubSlot(relayKey) await that.queryService.acquireSubSlot(relayKey)
@ -2657,7 +2666,9 @@ class ClientService extends EventTarget {
}) })
.then(async () => { .then(async () => {
nip42HasAuthedOnce.add(i) nip42HasAuthedOnce.add(i)
await that.queryService.acquireGlobalRelayConnectionSlot() await that.queryService.acquireGlobalRelayConnectionSlot(
slotPriority ? { priority: true } : undefined
)
try { try {
await that.queryService.acquireSubSlot(relayKey) await that.queryService.acquireSubSlot(relayKey)
// After AUTH the socket may be closed or the relay dropped from the pool; // 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)) let relays = Array.from(new Set(urls))
// While offline, strip non-local relays before any further processing so the if (navigator.onLine) {
// capital-letter-tag fallback below cannot re-introduce internet relays. relays = stripLocalNetworkRelaysForWssReq(relays)
if (!navigator.onLine) { } 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)) relays = relays.filter((url) => isLocalNetworkUrl(url))
} }
if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) { if (relayFiltersUseCapitalLetterTagKeys(filter as Filter)) {
@ -3124,7 +3137,9 @@ class ClientService extends EventTarget {
applySubscribedTimelineEvent(evt) applySubscribedTimelineEvent(evt)
}, },
oneose: httpOnlyShard ? undefined : handleTimelineEose, oneose: httpOnlyShard ? undefined : handleTimelineEose,
onclose: onClose onclose: onClose,
connectionSlotPriority:
relayAuthoritativeTimeline && wsRelays.length === 1 && navigator.onLine
}, },
httpOnlyShard ? undefined : relayReqLog) httpOnlyShard ? undefined : relayReqLog)

Loading…
Cancel
Save