Browse Source

efficiency gains

imwald
Silberengel 4 weeks ago
parent
commit
f15f3475fa
  1. 23
      src/components/ReplyNoteList/index.tsx
  2. 23
      src/hooks/useFetchProfile.tsx
  3. 54
      src/lib/profile-batch-coordinator.ts
  4. 22
      src/pages/secondary/NotePage/index.tsx
  5. 137
      src/providers/ThreadProfileBatchProvider.tsx
  6. 12
      src/services/client-replaceable-events.service.ts

23
src/components/ReplyNoteList/index.tsx

@ -42,6 +42,7 @@ import client, { eventService, queryService } from '@/services/client.service' @@ -42,6 +42,7 @@ import client, { eventService, queryService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req'
@ -69,7 +70,7 @@ const SHOW_COUNT = 10 @@ -69,7 +70,7 @@ const SHOW_COUNT = 10
/** Some relays cap `#e` array length; chunk parent-id batches for nested-thread REQs. */
const MAX_PARENT_IDS_PER_NESTED_REQ = 64
/** Short debounce so thread / detail headers populate avatars quickly after events arrive. */
const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 400
const THREAD_PROFILE_BATCH_DEBOUNCE_MS = 120
const THREAD_PROFILE_CHUNK = 80
function partitionZapReceipts(items: NEvent[]) {
@ -643,25 +644,7 @@ function ReplyNoteList({ @@ -643,25 +644,7 @@ function ReplyNoteList({
useEffect(() => {
const handle = window.setTimeout(() => {
const gen = threadProfileBatchGenRef.current
const candidates = new Set<string>()
const addPk = (p: string | undefined) => {
if (p && p.length === 64 && /^[0-9a-f]{64}$/i.test(p)) {
candidates.add(p.toLowerCase())
}
}
const addFromEvt = (e: NEvent) => {
addPk(e.pubkey)
let n = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addPk(tag[1])
n++
if (n >= 4) break
}
}
}
addFromEvt(event)
for (const e of mergedFeed) addFromEvt(e)
const candidates = new Set(collectProfilePubkeysFromEvents([event, ...mergedFeed]))
const parentProfiles = parentNoteFeed?.profiles
const parentPending = parentNoteFeed?.pendingPubkeys

23
src/hooks/useFetchProfile.tsx

@ -2,6 +2,7 @@ import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS @@ -2,6 +2,7 @@ import { FEED_PROFILE_PENDING_BATCH_ESCAPE_MS, PROFILE_FETCH_PROMISE_TIMEOUT_MS
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed'
import { isPubkeyAwaitingProfileBatch } from '@/lib/profile-batch-coordinator'
import { normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey'
import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
@ -354,7 +355,7 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -354,7 +355,7 @@ export function useFetchProfile(id?: string, skipCache = false) {
setIsFetching(false)
setError(null)
}
if (noteFeed.pendingPubkeys.has(pkL)) {
if (noteFeed.pendingPubkeys.has(pkL) || isPubkeyAwaitingProfileBatch(pkL)) {
const sessionEv = eventService.getSessionMetadataForPubkey(pkL)
if (sessionEv) {
const quick = getProfileFromEvent(sessionEv)
@ -449,6 +450,26 @@ export function useFetchProfile(id?: string, skipCache = false) { @@ -449,6 +450,26 @@ export function useFetchProfile(id?: string, skipCache = false) {
}
}
if (extractedPubkey && !skipCache && isPubkeyAwaitingProfileBatch(extractedPubkey)) {
const pkL = extractedPubkey.toLowerCase()
const sessionEv = eventService.getSessionMetadataForPubkey(pkL)
if (sessionEv) {
const quick = getProfileFromEvent(sessionEv)
setProfile(quick)
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
processingPubkeyRef.current = extractedPubkey
initializedPubkeysRef.current.add(extractedPubkey)
effectRunCountRef.current.delete(extractedPubkey)
return
}
setPubkey(extractedPubkey)
setIsFetching(false)
setError(null)
return
}
// Skip only when this pubkey already has an in-flight fetch (global dedupe + local flag).
if (extractedPubkey) {
if (processingPubkeyRef.current === extractedPubkey) {

54
src/lib/profile-batch-coordinator.ts

@ -0,0 +1,54 @@ @@ -0,0 +1,54 @@
/**
* Tracks pubkeys currently loaded via {@link ReplaceableEventService.fetchProfilesForPubkeys}
* so per-avatar {@link ReplaceableEventService.fetchProfileEvent} does not open parallel
* 17-relay fallback REQ storms while a batch is in flight.
*/
const awaitingBatch = new Set<string>()
function norm(pk: string): string {
return pk.trim().toLowerCase()
}
export function registerProfileBatchPubkeys(pubkeys: readonly string[]): void {
for (const pk of pubkeys) {
if (pk.length === 64 && /^[0-9a-f]{64}$/i.test(pk)) {
awaitingBatch.add(norm(pk))
}
}
}
export function unregisterProfileBatchPubkeys(pubkeys: readonly string[]): void {
for (const pk of pubkeys) {
awaitingBatch.delete(norm(pk))
}
}
export function isPubkeyAwaitingProfileBatch(pubkey: string): boolean {
const pk = norm(pubkey)
return pk.length === 64 && awaitingBatch.has(pk)
}
export function collectProfilePubkeysFromEvents(
events: readonly { pubkey: string; tags: string[][] }[],
maxPTagsPerEvent = 4
): string[] {
const out = new Set<string>()
const addPk = (p: string | undefined) => {
if (p && p.length === 64 && /^[0-9a-f]{64}$/i.test(p)) {
out.add(norm(p))
}
}
for (const e of events) {
addPk(e.pubkey)
let n = 0
for (const tag of e.tags) {
if (tag[0] === 'p' && tag[1]) {
addPk(tag[1])
n++
if (n >= maxPTagsPerEvent) break
}
}
}
return [...out]
}

22
src/pages/secondary/NotePage/index.tsx

@ -38,7 +38,7 @@ import { cn } from '@/lib/utils' @@ -38,7 +38,7 @@ import { cn } from '@/lib/utils'
import { Ellipsis } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useState, type MouseEvent } from 'react'
import { forwardRef, useCallback, useEffect, useMemo, useState, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns'
import {
@ -51,6 +51,7 @@ import { @@ -51,6 +51,7 @@ import {
updateMetaTag
} from '@/lib/document-meta'
import NotFound from './NotFound'
import { ThreadProfileBatchProvider } from '@/providers/ThreadProfileBatchProvider'
// Helper function to get event type name (matching WebPreview)
function getEventTypeName(kind: number): string {
@ -217,10 +218,21 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -217,10 +218,21 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
// Fetch profile for author (for OpenGraph metadata)
const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey)
/** Resolve nostr embeds with the open note (parent relay hints), before embed cards mount. */
useLayoutEffect(() => {
/** Resolve nostr embeds after first paint — avoids competing with thread/profile batch on open. */
useEffect(() => {
if (!finalEvent) return
client.prefetchEmbeddedEventsForParents([finalEvent])
const run = () => client.prefetchEmbeddedEventsForParents([finalEvent])
const idleId =
typeof requestIdleCallback === 'function'
? requestIdleCallback(run, { timeout: 4_000 })
: window.setTimeout(run, 400)
return () => {
if (typeof cancelIdleCallback === 'function') {
cancelIdleCallback(idleId as number)
} else {
window.clearTimeout(idleId as number)
}
}
}, [finalEvent?.id])
const getNoteTypeTitle = (kind: number): string => {
@ -503,6 +515,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -503,6 +515,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
}
return (
<ThreadProfileBatchProvider seedEvents={finalEvent ? [finalEvent] : []}>
<SecondaryPageLayout
ref={ref}
index={index}
@ -572,6 +585,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: @@ -572,6 +585,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
/>
</div>
</SecondaryPageLayout>
</ThreadProfileBatchProvider>
)
})
NotePage.displayName = 'NotePage'

137
src/providers/ThreadProfileBatchProvider.tsx

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
import client from '@/services/client.service'
import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import {
NoteFeedProfileContext,
type NoteFeedProfileContextValue,
useNoteFeedProfileContext
} from '@/providers/NoteFeedProfileContext'
import { TProfile } from '@/types'
import type { Event } from 'nostr-tools'
import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
const PROFILE_CHUNK = 80
type TBatchState = {
profiles: Map<string, TProfile>
pending: Set<string>
version: number
}
function emptyBatch(): TBatchState {
return { profiles: new Map(), pending: new Set(), version: 0 }
}
/**
* Batches kind-0 fetches for note/thread views. Seeds run immediately (no debounce) so the
* open-note author is in {@link NoteFeedProfileContext.pendingPubkeys} before avatars mount.
*/
export function ThreadProfileBatchProvider({
seedEvents,
children
}: {
seedEvents: readonly Event[]
children: ReactNode
}) {
const parentNoteFeed = useNoteFeedProfileContext()
const loadedRef = useRef<Set<string>>(new Set())
const genRef = useRef(0)
const [batch, setBatch] = useState<TBatchState>(emptyBatch)
const runBatch = (need: string[], gen: number) => {
if (need.length === 0) return
need.forEach((pk) => loadedRef.current.add(pk))
setBatch((prev) => {
const pending = new Set(prev.pending)
let changed = false
for (const pk of need) {
if (!pending.has(pk)) {
pending.add(pk)
changed = true
}
}
return changed ? { ...prev, pending } : prev
})
void (async () => {
const chunks: string[][] = []
for (let i = 0; i < need.length; i += PROFILE_CHUNK) {
chunks.push(need.slice(i, i + PROFILE_CHUNK))
}
const settled = await Promise.allSettled(
chunks.map((chunk) => client.fetchProfilesForPubkeys(chunk))
)
if (gen !== genRef.current) return
setBatch((prev) => {
const next = new Map(prev.profiles)
const pend = new Set(prev.pending)
settled.forEach((res, idx) => {
const chunk = chunks[idx]!
if (res.status === 'rejected') {
chunk.forEach((pk) => {
loadedRef.current.delete(pk)
pend.delete(pk)
})
return
}
for (const p of res.value) {
const pkNorm = p.pubkey.toLowerCase()
next.set(pkNorm, { ...p, pubkey: pkNorm })
pend.delete(pkNorm)
}
for (const pk of chunk) {
const pkNorm = pk.toLowerCase()
pend.delete(pkNorm)
if (!next.has(pkNorm)) {
next.set(pkNorm, {
pubkey: pkNorm,
npub: pubkeyToNpub(pkNorm) ?? '',
username: formatPubkey(pkNorm),
batchPlaceholder: true
})
}
}
})
return { profiles: next, pending: pend, version: prev.version + 1 }
})
})()
}
const seedKey = useMemo(
() => seedEvents.map((e) => e.id).join('\x1e'),
[seedEvents]
)
useLayoutEffect(() => {
genRef.current += 1
const gen = genRef.current
loadedRef.current.clear()
setBatch(emptyBatch())
const candidates = collectProfilePubkeysFromEvents(seedEvents)
const parentProfiles = parentNoteFeed?.profiles
const parentPending = parentNoteFeed?.pendingPubkeys
const need = candidates.filter((pk) => {
if (parentProfiles?.has(pk)) return false
if (parentPending?.has(pk)) return false
return true
})
runBatch(need, gen)
}, [seedKey])
const value = useMemo<NoteFeedProfileContextValue>(() => {
const profiles = new Map<string, TProfile>(parentNoteFeed?.profiles ?? [])
for (const [k, v] of batch.profiles) profiles.set(k, v)
const pending = new Set<string>(parentNoteFeed?.pendingPubkeys ?? [])
batch.pending.forEach((p) => pending.add(p))
return {
profiles,
pendingPubkeys: pending,
version: (parentNoteFeed?.version ?? 0) * 1_000_000 + batch.version
}
}, [parentNoteFeed, batch])
return <NoteFeedProfileContext.Provider value={value}>{children}</NoteFeedProfileContext.Provider>
}

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

@ -33,6 +33,11 @@ import { @@ -33,6 +33,11 @@ import {
import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility'
import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import {
isPubkeyAwaitingProfileBatch,
registerProfileBatchPubkeys,
unregisterProfileBatchPubkeys
} from '@/lib/profile-batch-coordinator'
import { isPromiseTimeoutError, racePromiseWithTimeout } from '@/lib/async-timeout'
import { networkKindsForReplaceableFetch } from '@/lib/replaceable-fetch-kinds'
@ -975,6 +980,10 @@ export class ReplaceableEventService { @@ -975,6 +980,10 @@ export class ReplaceableEventService {
return profileEvent
}
if (isPubkeyAwaitingProfileBatch(pubkey)) {
return sessionFallback
}
await ReplaceableEventService.acquireProfileFallbackNetworkSlot()
try {
// Step 2: Only after cache + default relays miss — NIP-65 relay list (timeout-capped), then hints + outbox/inbox + defaults.
@ -1133,6 +1142,7 @@ export class ReplaceableEventService { @@ -1133,6 +1142,7 @@ export class ReplaceableEventService {
async fetchProfilesForPubkeys(pubkeys: string[]): Promise<TProfile[]> {
const deduped = Array.from(new Set(pubkeys.filter((p) => p && p.length === 64)))
if (deduped.length === 0) return []
registerProfileBatchPubkeys(deduped)
try {
return await racePromiseWithTimeout(
this.fetchProfilesForPubkeysBody(deduped),
@ -1151,6 +1161,8 @@ export class ReplaceableEventService { @@ -1151,6 +1161,8 @@ export class ReplaceableEventService {
})
}
return this.fetchProfilesForPubkeysLocalFallback(deduped)
} finally {
unregisterProfileBatchPubkeys(deduped)
}
}

Loading…
Cancel
Save