6 changed files with 246 additions and 25 deletions
@ -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] |
||||
} |
||||
@ -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> |
||||
} |
||||
Loading…
Reference in new issue