From 6a426fe14bb3036e175388afaad2e28cf56b3c93 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 5 May 2026 12:19:41 +0200 Subject: [PATCH] bug-fixes --- .../Note/MarkdownArticle/MarkdownArticle.tsx | 149 +++++++++++++----- src/components/NoteBoostBadges/index.tsx | 8 +- src/components/NoteCard/MainNoteCard.tsx | 7 +- .../NoteList/VirtualizedFeedRows.tsx | 10 +- src/components/NoteList/index.tsx | 143 ++++++++++++----- src/constants.ts | 7 +- src/hooks/useFetchProfile.tsx | 11 +- src/providers/NostrProvider/index.tsx | 21 +-- .../client-replaceable-events.service.ts | 123 ++++++++++++--- src/services/client.service.ts | 6 +- src/services/indexed-db.service.ts | 4 +- src/services/note-stats.service.ts | 21 +++ 12 files changed, 370 insertions(+), 140 deletions(-) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index b5e1e687..e6110653 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -92,6 +92,13 @@ function resolveImetaForMarkdownImageUrl( return { url: cleaned, pubkey: eventPubkey } } +/** + * Host for marked paragraph bodies that may include block-level nodes from {@link renderInlineTokens} + * (e.g. `![](https://…mp4)` → {@link MediaPlayer}). `

` cannot wrap `

`; use flow + * `
` so the DOM stays valid and React stops `validateDOMNesting` warnings. + */ +const MD_PARAGRAPH_FLOW_CLASS = 'mb-1 last:mb-0' + /** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */ type TInlineEmojiLightbox = { imageIndexMap: Map @@ -1925,9 +1932,13 @@ function parseMarkdownContentLegacy( if (normalizedText) { const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-img-${imgIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push( -

+

{textContent} -

+
) } } @@ -1988,9 +1999,13 @@ function parseMarkdownContentLegacy( if (normalizedText) { const textContent = parseInlineMarkdown(normalizedText, `text-${patternIdx}-para-${paraIdx}-final`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push( -

+

{textContent} -

+
) } } @@ -2009,9 +2024,13 @@ function parseMarkdownContentLegacy( const paraContent = parseInlineMarkdown(normalizedPara, `text-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) // Wrap in paragraph tag (no whitespace-pre-wrap, let normal text wrapping handle it) parts.push( -

+

{paraContent} -

+
) } else if (paraIdx > 0) { // Empty paragraph between non-empty paragraphs - add spacing @@ -2446,9 +2465,13 @@ function parseMarkdownContentLegacy( const paragraphContent = parseInlineMarkdown(paragraphText, `blockquote-${patternIdx}-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) return ( -

+

{paragraphContent} -

+
) }) @@ -2476,12 +2499,12 @@ function parseMarkdownContentLegacy( }) parts.push( - {greentextContent} - +
) } else if (pattern.type === 'fenced-code-block') { const { code, language } = pattern.data @@ -2735,9 +2758,13 @@ function parseMarkdownContentLegacy( if (normalizedPara) { const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${imgIdx}-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push( -

+

{paraContent} -

+
) } }) @@ -2791,9 +2818,13 @@ function parseMarkdownContentLegacy( if (normalizedPara) { const paraContent = parseInlineMarkdown(normalizedPara, `text-end-final-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push( -

+

{paraContent} -

+
) } }) @@ -2810,9 +2841,13 @@ function parseMarkdownContentLegacy( if (normalizedPara) { const paraContent = parseInlineMarkdown(normalizedPara, `text-end-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) parts.push( -

+

{paraContent} -

+
) } }) @@ -2833,9 +2868,9 @@ function parseMarkdownContentLegacy( if (!normalizedPara) return null const paraContent = parseInlineMarkdown(normalizedPara, `text-only-para-${paraIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) return ( -

+

{paraContent} -

+
) }).filter(Boolean) return { nodes: formattedParagraphs, hashtagsInContent, footnotes, citations } @@ -2953,9 +2988,9 @@ function parseMarkdownContentLegacy( // Render the original line with inline markdown processing const lineContent = parseInlineMarkdown(originalLine, `single-list-item-${partIdx}`, footnotes, emojiInfos, undefined, emojiLightbox) wrappedParts.push( - +
{lineContent} - +
) } else { // Fallback: render the list item content @@ -3451,12 +3486,12 @@ function parseMarkdownContentMarked( displayMode /> ) : ( -

+

{renderInlineTokens( lexInlineProtected(seg.text.trim()), `${key}-dmt-${idx}` )} -

+
) )}
@@ -3673,9 +3708,9 @@ function parseMarkdownContentMarked( } return ( -

+

{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-inline-${lineIdx}`)} -

+
) } @@ -3704,9 +3739,9 @@ function parseMarkdownContentMarked( } return ( -

+

{renderInlineTokens(lexInlineProtected(line) as any[], `${key}-line-fallback-inline-${lineIdx}`)} -

+
) }) @@ -3729,9 +3764,13 @@ function parseMarkdownContentMarked( const before = rawParagraphText.slice(cursor, start) if (before.trim().length > 0) { nodes.push( -

+

{parseInlineMarkdown(before, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)} -

+
) } if (bech32Id.startsWith('naddr') && fullCalendarInvite && bech32Id === fullCalendarInvite.naddr) { @@ -3752,9 +3791,13 @@ function parseMarkdownContentMarked( const after = rawParagraphText.slice(cursor) if (after.trim().length > 0) { nodes.push( -

+

{parseInlineMarkdown(after, `${key}-nostr-raw-segment-${segmentIdx}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)} -

+
) } if (nodes.length > 0) { @@ -3907,9 +3950,13 @@ function parseMarkdownContentMarked( const flushInlineSegment = (segmentIdx: number) => { if (inlineSegment.length === 0) return nodes.push( -

+

{renderInlineTokens(inlineSegment, `${key}-media-inline-segment-${segmentIdx}`)} -

+
) inlineSegment = [] } @@ -4015,9 +4062,13 @@ function parseMarkdownContentMarked( const flushInlineSegment = (segmentIdx: number) => { if (inlineSegment.length === 0) return nodes.push( -

+

{renderInlineTokens(inlineSegment, `${key}-nostr-inline-segment-${segmentIdx}`)} -

+
) inlineSegment = [] } @@ -4070,9 +4121,13 @@ function parseMarkdownContentMarked( const flushInlineSegment = (segmentIdx: number) => { if (inlineSegment.length === 0) return nodes.push( -

+

{renderInlineTokens(inlineSegment, `${key}-yt-inline-segment-${segmentIdx}`)} -

+
) inlineSegment = [] } @@ -4118,9 +4173,13 @@ function parseMarkdownContentMarked( const flushInlineSegment = (segmentIdx: number) => { if (inlineSegment.length === 0) return nodes.push( -

+

{renderInlineTokens(inlineSegment, `${key}-direct-media-inline-segment-${segmentIdx}`)} -

+
) inlineSegment = [] } @@ -4182,9 +4241,9 @@ function parseMarkdownContentMarked( } if (!isImage(cleaned) || !isSafeMediaUrl(cleaned)) { return ( -

+

{renderInlineTokens(paragraphTokens, `${key}-img-inline-fallback`)} -

+
) } const imageIdx = imageIndexMap.get(cleaned) @@ -4208,7 +4267,11 @@ function parseMarkdownContentMarked( } const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`) - return

{inlineNodes}

+ return ( +
+ {inlineNodes} +
+ ) } const renderBlockTokens = (tokens: any[], keyPrefix: string): React.ReactNode[] => { @@ -4448,9 +4511,9 @@ function parseMarkdownContentMarked( nodes.push(...renderBlockTokens(token.tokens, `${key}-nested`)) } else if (typeof token.text === 'string' && token.text.trim()) { nodes.push( -

+

{renderInlineTokens(lexInlineProtected(String(token.text ?? token.raw ?? '')) as any[], `${key}-fallback-inline`)} -

+
) } } diff --git a/src/components/NoteBoostBadges/index.tsx b/src/components/NoteBoostBadges/index.tsx index 4f3482c9..37b318a6 100644 --- a/src/components/NoteBoostBadges/index.tsx +++ b/src/components/NoteBoostBadges/index.tsx @@ -2,7 +2,6 @@ import { ExtendedKind } from '@/constants' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { shouldHideInteractions } from '@/lib/event-filtering' import { cn } from '@/lib/utils' -import { useUserTrust } from '@/contexts/user-trust-context' import { Event } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -15,15 +14,12 @@ const MAX_VISIBLE = 28 */ export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() - const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const noteStats = useNoteStatsById(event.id) const boosters = useMemo(() => { if (event.kind === ExtendedKind.DISCUSSION) return [] - return (noteStats?.reposts ?? []) - .filter((r) => !hideUntrustedInteractions || isUserTrusted(r.pubkey)) - .sort((a, b) => b.created_at - a.created_at) - }, [noteStats, event.kind, hideUntrustedInteractions, isUserTrusted]) + return [...(noteStats?.reposts ?? [])].sort((a, b) => b.created_at - a.created_at) + }, [noteStats, event.kind]) if (shouldHideInteractions(event) || boosters.length === 0) { return null diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 8a43e5e3..f0b8d38f 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -1,4 +1,3 @@ -import { useNip84HighlightTargetEvents } from '@/hooks' import { ExtendedKind } from '@/constants' import { Separator } from '@/components/ui/separator' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' @@ -6,7 +5,7 @@ import { toNote } from '@/lib/link' import { useSmartNoteNavigationOptional } from '@/PageManager' import client from '@/services/client.service' import { Pin } from 'lucide-react' -import { Event, kinds } from 'nostr-tools' +import { Event } from 'nostr-tools' import { useTranslation } from 'react-i18next' import Collapsible from '../Collapsible' import NoteBoostBadges from '../NoteBoostBadges' @@ -41,9 +40,6 @@ export default function MainNoteCard({ }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() - const nip84HighlightEvents = useNip84HighlightTargetEvents( - event.kind === kinds.ShortTextNote ? event : null - ) const isZapFeedCard = event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST const showNoteStatsRow = !embedded || isZapFeedCard @@ -104,7 +100,6 @@ export default function MainNoteCard({ hideParentNotePreview={hideParentNotePreview} zapPollVoteHighlightOption={zapPollVoteHighlightOption} showFull={showFull} - nip84HighlightEvents={nip84HighlightEvents} /> {!embedded ? : null} diff --git a/src/components/NoteList/VirtualizedFeedRows.tsx b/src/components/NoteList/VirtualizedFeedRows.tsx index 7998f25d..51c0c7c9 100644 --- a/src/components/NoteList/VirtualizedFeedRows.tsx +++ b/src/components/NoteList/VirtualizedFeedRows.tsx @@ -37,7 +37,10 @@ const WindowRows = memo(function WindowRows({ }) return ( -
+
{virtualizer.getVirtualItems().map((vi) => (
+
{virtualizer.getVirtualItems().map((vi) => (
, raw: string | undefined) { + if (!raw) return + const t = raw.trim() + if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) { + candidates.add(t.toLowerCase()) + } +} + +/** Kind-0 prefetch targets for feed rows: author, mentions, `e`/`E` pubkey hints, NIP-18 embedded author. */ +function collectProfilePrefetchPubkeysFromEvent(e: Event, candidates: Set) { + addLowerHexPubkeyCandidate(candidates, e.pubkey) + + let pCount = 0 + for (const tag of e.tags) { + if (tag[0] === 'p' && tag[1]) { + addLowerHexPubkeyCandidate(candidates, tag[1]) + pCount++ + if (pCount >= FEED_PROFILE_PREFETCH_MAX_P_TAGS) break + } + if ((tag[0] === 'e' || tag[0] === 'E') && tag[4]) { + addLowerHexPubkeyCandidate(candidates, tag[4]) + } + } + + if (!isNip18RepostKind(e.kind)) return + const raw = e.content?.trim() + if (!raw) return + try { + const emb = JSON.parse(raw) as { pubkey?: string; pubKey?: string } + const pk = emb.pubkey ?? emb.pubKey + if (pk) addLowerHexPubkeyCandidate(candidates, pk) + } catch { + /* ignore */ + } +} + +function collectProfilePrefetchPubkeysFromNoteStats( + st: { reposts?: { pubkey: string }[]; likes?: { pubkey: string }[] } | undefined, + candidates: Set +) { + if (!st) return + if (st.reposts?.length) { + for (const r of st.reposts.slice(0, FEED_STATS_PROFILE_REPOSTS_CAP)) { + addLowerHexPubkeyCandidate(candidates, r.pubkey) + } + } + if (st.likes?.length) { + for (const l of st.likes.slice(0, FEED_STATS_PROFILE_LIKES_PER_NOTE)) { + addLowerHexPubkeyCandidate(candidates, l.pubkey) + } + } +} + function mergeEventBatchesById( prev: Event[], incoming: Event[], @@ -894,30 +951,11 @@ const NoteList = forwardRef( /** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */ useLayoutEffect(() => { const candidates = new Set() - const addPk = (p: string | undefined) => { - if (!p) return - const t = p.trim() - if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) { - candidates.add(t.toLowerCase()) - } - } - const addPkFromEventTags = (e: Event) => { - let n = 0 - for (const tag of e.tags) { - if (tag[0] === 'p' && tag[1]) { - addPk(tag[1]) - n++ - if (n >= 4) break - } - } - } for (const e of timelineEventsForFilter) { - addPk(e.pubkey) - addPkFromEventTags(e) + collectProfilePrefetchPubkeysFromEvent(e, candidates) } for (const e of newEvents) { - addPk(e.pubkey) - addPkFromEventTags(e) + collectProfilePrefetchPubkeysFromEvent(e, candidates) } setFeedProfileBatch((prev) => { @@ -1286,8 +1324,34 @@ const NoteList = forwardRef( [showFeedClientFilter, applyClientFeedFilter, filteredEvents] ) + /** Bumps when {@link noteStatsService} updates any visible row so profile batch can include boosters/likers. */ + const [feedStatsProfileBump, setFeedStatsProfileBump] = useState(0) + const visibleNoteIdsForStatsPrefetchKey = useMemo( + () => + clientFilteredEvents + .slice(0, Math.min(120, Math.max(showCount + 64, 64))) + .map((e) => e.id) + .join('\n'), + [clientFilteredEvents, showCount] + ) + + useEffect(() => { + if (!visibleNoteIdsForStatsPrefetchKey) return + const ids = visibleNoteIdsForStatsPrefetchKey.split('\n').filter(Boolean) + const bump = () => setFeedStatsProfileBump((n) => n + 1) + const unsubs = ids.map((id) => noteStatsService.subscribeNoteStats(id, bump)) + return () => { + unsubs.forEach((u) => u()) + } + }, [visibleNoteIdsForStatsPrefetchKey]) + const [feedVirtualScrollParent, setFeedVirtualScrollParent] = useState(null) const [feedVirtualScrollMarginTop, setFeedVirtualScrollMarginTop] = useState(0) + /** + * Resolve the scroll container once per feed / refresh — not on every {@link clientFilteredEvents} length tick. + * Re-running this on each timeline merge re-set scroll state and interacted badly with the virtualizer while rows + * were still settling (absolute rows could paint past the list bounds). + */ useLayoutEffect(() => { const root = feedRootRef.current if (!root) { @@ -1297,7 +1361,7 @@ const NoteList = forwardRef( } setFeedVirtualScrollParent(getNearestScrollableAncestor(root)) setFeedVirtualScrollMarginTop(root.offsetTop) - }, [timelineSubscriptionKey, refreshCount, clientFilteredEvents.length]) + }, [timelineSubscriptionKey, refreshCount]) const clientFilteredNewEvents = useMemo( () => @@ -1350,28 +1414,14 @@ const NoteList = forwardRef( const handle = window.setTimeout(() => { const gen = feedProfileBatchGenRef.current const candidates = new Set() - const addPk = (p: string | undefined) => { - if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) { - candidates.add(p.toLowerCase()) - } - } - const addPkFromEventTags = (e: Event) => { - let n = 0 - for (const tag of e.tags) { - if (tag[0] === 'p' && tag[1]) { - addPk(tag[1]) - n++ - if (n >= 4) break - } - } - } for (const e of timelineEventsForFilter) { - addPk(e.pubkey) - addPkFromEventTags(e) + collectProfilePrefetchPubkeysFromEvent(e, candidates) } for (const e of newEvents) { - addPk(e.pubkey) - addPkFromEventTags(e) + collectProfilePrefetchPubkeysFromEvent(e, candidates) + } + for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) { + collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates) } const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) @@ -1437,7 +1487,7 @@ const NoteList = forwardRef( })() }, FEED_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [timelineEventsForFilter, newEvents]) + }, [timelineEventsForFilter, newEvents, clientFilteredEvents, showCount, feedStatsProfileBump]) const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { setTimeout(() => { @@ -3463,6 +3513,7 @@ const NoteList = forwardRef( ) : null} {clientFilteredEvents.length > 0 ? ( - {loading ? : null} + {loading ? ( + clientFilteredEvents.length > 0 ? ( +
+ ) : ( + + ) + ) : null}
) : listSourceEvents.length > 0 ? (
{t('no more notes')}
diff --git a/src/constants.ts b/src/constants.ts index 8e4a1c30..a4f34c5f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -196,7 +196,12 @@ export const ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 10 * Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried. */ export const METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS = 16000 -export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 500 +/** After all relays EOSE, wait this long before closing so slow EVENTs still land (slot queue + TLS). */ +export const METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS = 2800 +/** + * Max `authors` per REQ for batched kind-0; large arrays are split so relays return more complete rows. + */ +export const METADATA_BATCH_AUTHORS_CHUNK = 22 /** * useFetchProfile: outer Promise.race on fetchProfileEvent and wait-for-shared-promise timeouts. diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 405dae67..dd7c0d48 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -377,6 +377,13 @@ export function useFetchProfile(id?: string, skipCache = false) { effectRunCountRef.current.delete(extractedPubkey) return } + if (fromBatch?.batchPlaceholder) { + initializedPubkeysRef.current.delete(extractedPubkey) + setProfile(fromBatch) + setPubkey(extractedPubkey) + setIsFetching(false) + setError(null) + } if (noteFeed.pendingPubkeys.has(extractedPubkey)) { const pkLower = extractedPubkey.toLowerCase() const sessionEv = eventService.getSessionMetadataForPubkey(pkLower) @@ -450,7 +457,7 @@ export function useFetchProfile(id?: string, skipCache = false) { // CRITICAL: Early exit if we already have a profile for this pubkey // This prevents re-fetching when we already have the profile - if (extractedPubkey && profile && profile.pubkey === extractedPubkey) { + if (extractedPubkey && profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { // Ensure processingPubkeyRef is set to prevent re-fetch if (processingPubkeyRef.current !== extractedPubkey) { processingPubkeyRef.current = extractedPubkey @@ -561,7 +568,7 @@ export function useFetchProfile(id?: string, skipCache = false) { processingPubkeyRef.current = extractedPubkey } - if (profile && profile.pubkey === extractedPubkey) { + if (profile && profile.pubkey === extractedPubkey && !profile.batchPlaceholder) { logger.debug('[useFetchProfile] Already have profile for this pubkey (safety check)', { extractedPubkey }) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index fa73cf8d..8441c235 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1287,16 +1287,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } - logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) - const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey) - const relays = await client.determineTargetRelays(event, { - ...options, - favoriteRelayUrls, - blockedRelayUrls: options.blockedRelayUrls ?? blockedRelayUrlsFromEvent(blockedRelaysEvent) - }) - logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) }) - + noteStatsService.beginPublishPriority() try { + logger.debug('[Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) + const favoriteRelayUrls = favoriteRelayUrlsForPublish(favoriteRelaysEvent, account.pubkey) + const relays = await client.determineTargetRelays(event, { + ...options, + favoriteRelayUrls, + blockedRelayUrls: options.blockedRelayUrls ?? blockedRelayUrlsFromEvent(blockedRelaysEvent) + }) + logger.debug('[Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) }) + logger.debug('[Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) }) const publishResult = await client.publishEvent(relays, event, { favoriteRelayUrls }) logger.debug('[Publish] publishEvent completed', { @@ -1383,6 +1384,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { // Re-throw the error so the UI can handle it appropriately throw error + } finally { + noteStatsService.endPublishPriority() } } diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index cd44db8f..b8ce8268 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -3,6 +3,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, MAX_CONCURRENT_RELAY_CONNECTIONS, + METADATA_BATCH_AUTHORS_CHUNK, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, PROFILE_FETCH_RELAY_URLS, @@ -323,18 +324,31 @@ export class ReplaceableEventService { needsIndexedDb.push({ pubkey, index }) } - await Promise.allSettled( - needsIndexedDb.map(async ({ pubkey, index }) => { - try { - const event = await indexedDb.getReplaceableEvent(pubkey, kind) - if (event) { - results[index] = event + if (needsIndexedDb.length > 0) { + try { + const orderedPubkeys = needsIndexedDb.map((n) => n.pubkey) + const fromIdb = await indexedDb.getManyReplaceableEvents(orderedPubkeys, kind) + fromIdb.forEach((event, i) => { + if (event && !shouldDropEventOnIngest(event)) { + const slot = needsIndexedDb[i] + if (slot) results[slot.index] = event } - } catch { - /* ignore */ - } - }) - ) + }) + } catch { + await Promise.allSettled( + needsIndexedDb.map(async ({ pubkey, index }) => { + try { + const event = await indexedDb.getReplaceableEvent(pubkey, kind) + if (event && !shouldDropEventOnIngest(event)) { + results[index] = event + } + } catch { + /* ignore */ + } + }) + ) + } + } const stillMissing = needsIndexedDb.filter(({ index }) => results[index] === undefined) if (stillMissing.length > 0) { @@ -521,7 +535,8 @@ export class ReplaceableEventService { includeFastReadRelays: true, includeFavoriteRelays: true, includeLocalRelays: true, - includeFastWriteRelays: false, + /** Many users publish kind 0 to NIP-65 write relays; batch path skipped these before. */ + includeFastWriteRelays: true, includeSearchableRelays: false }) } catch { @@ -575,19 +590,40 @@ export class ReplaceableEventService { // (many `authors` in one filter) that stops the subscription while most profiles are still in flight. const useReplaceableRace = !isSlowReplaceableBatch || !multiAuthorBatch - const events = await this.queryService.query( - relayUrls, - { - authors: pubkeys, - kinds: [kind] - }, - undefined, - { - replaceableRace: useReplaceableRace, - eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, - globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 + const queryOpts = { + replaceableRace: useReplaceableRace, + eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, + globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 + } + + let events: NEvent[] + if (kind === kinds.Metadata && pubkeys.length > METADATA_BATCH_AUTHORS_CHUNK) { + const merged: NEvent[] = [] + for (let off = 0; off < missingItems.length; off += METADATA_BATCH_AUTHORS_CHUNK) { + const slice = missingItems.slice(off, off + METADATA_BATCH_AUTHORS_CHUNK) + const chunkPubkeys = slice.map((m) => m.pubkey) + const chunkMulti = chunkPubkeys.length > 1 + const chunkRace = !isSlowReplaceableBatch || !chunkMulti + const evts = await this.queryService.query( + relayUrls, + { authors: chunkPubkeys, kinds: [kind] }, + undefined, + { ...queryOpts, replaceableRace: chunkRace } + ) + merged.push(...evts) } - ) + events = merged + } else { + events = await this.queryService.query( + relayUrls, + { + authors: pubkeys, + kinds: [kind] + }, + undefined, + queryOpts + ) + } // Only log at info level for large batches or if many events found if (pubkeys.length > 50 || events.length > 100) { logger.debug('[ReplaceableEventService] Query completed for batch', { @@ -656,6 +692,23 @@ export class ReplaceableEventService { } } } + + const idbFill = missingItems.filter(({ index }) => results[index] == null) + if (idbFill.length > 0) { + try { + const order = idbFill.map((m) => m.pubkey) + const late = await indexedDb.getManyReplaceableEvents(order, kind) + late.forEach((ev, j) => { + if (!ev || shouldDropEventOnIngest(ev)) return + const slot = idbFill[j] + if (!slot) return + results[slot.index] = ev + eventsMap.set(`${slot.pubkey}:${kind}`, ev) + }) + } catch { + /* ignore */ + } + } // Log when no events are found (helps debug relay failures) if (kind === kinds.Metadata && events.length === 0 && pubkeys.length > 0) { @@ -1043,7 +1096,27 @@ 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 [] - const events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata) + let events = await this.fetchReplaceableEventsFromProfileFetchRelays(deduped, kinds.Metadata) + const gapIdx: number[] = [] + for (let i = 0; i < deduped.length; i++) { + if (!events[i]) gapIdx.push(i) + } + if (gapIdx.length > 0) { + try { + const order = gapIdx.map((i) => deduped[i]!) + const late = await indexedDb.getManyReplaceableEvents(order, kinds.Metadata) + const patched = [...events] + gapIdx.forEach((origIdx, j) => { + const ev = late[j] + if (ev && !shouldDropEventOnIngest(ev)) { + patched[origIdx] = ev + } + }) + events = patched + } catch { + /* ignore */ + } + } const profiles: TProfile[] = [] for (let i = 0; i < deduped.length; i++) { const ev = events[i] diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 739c77fd..d9f5488b 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1486,10 +1486,11 @@ class ClientService extends EventTarget { logger.debug('[PublishEvent] Starting Promise.allSettled for all relays') const relayPublishAllSettled = Promise.allSettled( uniqueRelayUrls.map(async (url, index) => { - const startMs = Date.now() - logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this + await that.queryService.acquireGlobalRelayConnectionSlot() + const startMs = Date.now() + logger.debug(`[PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) const isLocal = isLocalNetworkUrl(url) const connectionTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote const publishTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote @@ -1623,6 +1624,7 @@ class ClientService extends EventTarget { }) that.recordSessionRelayFailure(url) } finally { + that.queryService.releaseGlobalRelayConnectionSlot() clearTimeout(relayTimeout) const currentFinished = ++finishedCount logger.debug(`[PublishEvent] Relay finished`, { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index bc16a12e..5a9427bf 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1068,7 +1068,9 @@ class IndexedDbService { } private getReplaceableEventKey(pubkey: string, d?: string): string { - return d === undefined ? pubkey : `${pubkey}:${d}` + const trimmed = pubkey.trim() + const canonPk = /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed.toLowerCase() : trimmed + return d === undefined ? canonPk : `${canonPk}:${d}` } private getStoreNameByKind(kind: number): string | undefined { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index ad96c73f..cfb195e4 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -78,6 +78,8 @@ class NoteStatsService { private batchTimeout: NodeJS.Timeout | null = null /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */ private processBatchRunning = false + /** While greater than zero, {@link processBatch} defers so user publishes are not starved for WebSocket pool / bandwidth. */ + private publishPriorityDepth = 0 private readonly BATCH_DELAY = 200 /** Small slices so a slow batch does not block newer cards (e.g. spell feed swaps placeholder rows → discussions). */ private readonly MAX_BATCH_SIZE = 8 @@ -236,7 +238,26 @@ class NoteStatsService { }) } + /** Call around user-initiated {@link client.publishEvent} so stats REQ waves defer briefly. */ + beginPublishPriority(): void { + this.publishPriorityDepth++ + } + + endPublishPriority(): void { + this.publishPriorityDepth = Math.max(0, this.publishPriorityDepth - 1) + } + private async processBatch() { + if (this.publishPriorityDepth > 0) { + if (this.batchTimeout) { + clearTimeout(this.batchTimeout) + } + this.batchTimeout = setTimeout(() => { + this.batchTimeout = null + void this.processBatch() + }, 450) + return + } if (this.processBatchRunning) { logger.debug('[NoteStats] processBatch: skipped (already running)', { pendingForeground: this.pendingForeground.size,