diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 4d3b0a09..9ecdae54 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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 /** 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({ useEffect(() => { const handle = window.setTimeout(() => { const gen = threadProfileBatchGenRef.current - const candidates = new Set() - 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 diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 9dbfa7dc..62b0ce08 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -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) { 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) { } } + 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) { diff --git a/src/lib/profile-batch-coordinator.ts b/src/lib/profile-batch-coordinator.ts new file mode 100644 index 00000000..e94b57ea --- /dev/null +++ b/src/lib/profile-batch-coordinator.ts @@ -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() + +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() + 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] +} diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 9bb1a114..87a967e3 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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 { 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 }: // 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 }: } return ( + + ) }) NotePage.displayName = 'NotePage' diff --git a/src/providers/ThreadProfileBatchProvider.tsx b/src/providers/ThreadProfileBatchProvider.tsx new file mode 100644 index 00000000..9d119bdc --- /dev/null +++ b/src/providers/ThreadProfileBatchProvider.tsx @@ -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 + pending: Set + 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>(new Set()) + const genRef = useRef(0) + const [batch, setBatch] = useState(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(() => { + const profiles = new Map(parentNoteFeed?.profiles ?? []) + for (const [k, v] of batch.profiles) profiles.set(k, v) + const pending = new Set(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 {children} +} diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 7f252a3f..ece7c94b 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -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 { 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 { async fetchProfilesForPubkeys(pubkeys: string[]): Promise { 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 { }) } return this.fetchProfilesForPubkeysLocalFallback(deduped) + } finally { + unregisterProfileBatchPubkeys(deduped) } }