From 685c1b0af976dd0a7f7c6975259e7c92a2dad329 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 08:03:33 +0200 Subject: [PATCH] bug-fixes --- .../Note/MarkdownArticle/MarkdownArticle.tsx | 22 +- src/components/NoteList/index.tsx | 512 ++++++++++-------- src/main.tsx | 4 +- src/services/session-feed-snapshot.service.ts | 157 ++++-- 4 files changed, 420 insertions(+), 275 deletions(-) diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 950c69dc..83a715ed 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -110,6 +110,13 @@ function resolveImetaForMarkdownImageUrl( */ const MD_PARAGRAPH_FLOW_CLASS = 'mb-1 last:mb-0' +/** Paragraph that is only a single `:shortcode:` (custom or native) — often a trailing reaction emoji. */ +const EMOJI_ONLY_PARAGRAPH_RE = new RegExp(`^${EMOJI_SHORT_CODE_REGEX.source}$`) + +function isEmojiOnlyParagraphText(text: string): boolean { + return EMOJI_ONLY_PARAGRAPH_RE.test(text.trim()) +} + /** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */ type TInlineEmojiLightbox = { imageIndexMap: Map @@ -3681,6 +3688,9 @@ function parseMarkdownContentMarked( const renderParagraph = (token: any, key: string): React.ReactNode => { const rawParagraphText = String(token.text ?? token.raw ?? '') const paragraphText = rawParagraphText.trim() + if (!paragraphText) { + return null + } const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText) if (displayMathSplit) { return ( @@ -4552,8 +4562,13 @@ function parseMarkdownContentMarked( } const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`) + const emojiOnly = isEmojiOnlyParagraphText(paragraphText) return ( -
+
{inlineNodes}
) @@ -4573,7 +4588,10 @@ function parseMarkdownContentMarked( const key = `${keyPrefix}-${i}` switch (token.type) { case 'space': { - const gapEm = spaceTokenExtraGapEm(token) + const next = tokens[i + 1] + const nextIsEmojiOnly = + next?.type === 'paragraph' && isEmojiOnlyParagraphText(String(next.text ?? next.raw ?? '')) + const gapEm = nextIsEmojiOnly ? 0 : spaceTokenExtraGapEm(token) if (gapEm > 0) { nodes.push(
req.urls.length > 0) if (mapped.length === 0) return - const disk = await client.getTimelineDiskSnapshotEvents( - mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> - ) + const diskReq = mapped as Array<{ urls: string[]; filter: TSubRequestFilter }> + const disk = await client.getLocalFeedEvents(diskReq, { + maxRowsScanned: 50_000, + maxMatches: Math.min(FEED_FULL_SEARCH_MERGE_CAP, Math.max(LIMIT, 200)) + }) if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays)) @@ -2529,6 +2531,20 @@ const NoteList = forwardRef( ? ALGO_LIMIT : LIMIT + const paintLocalWarmupTimeline = (merged: Event[], variant: string) => { + if (merged.length === 0 || timelineEffectStale()) return + timelineMergeBootstrapRef.current = merged.slice() + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { variant, mergedCount: merged.length } + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } + /** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */ const runProfileTimelineNetworkFetch = (variant: string) => { if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return @@ -2588,12 +2604,18 @@ const NoteList = forwardRef( ) } + const shouldAwaitLocalDiskWarmup = + !oneShotFetch && + mappedSubRequests.length > 0 && + !relayAuthoritativeFeedOnlyRef.current + const isSpellPageLocalWarmup = - hostPrimaryPageName === 'spells' && !oneShotFetch && mappedSubRequests.length > 0 + hostPrimaryPageName === 'spells' && shouldAwaitLocalDiskWarmup /** * Session + IndexedDB hydration without blocking relay REQ/subscribe. Merges the same way as live * {@link onEvents} so rows appear as soon as local sources resolve. + * Skipped when {@link shouldAwaitLocalDiskWarmup} already painted from disk in `init`. */ const startNonBlockingTimelineDiskPrime = () => { const strictSingleRelayAuthoritative = @@ -2603,7 +2625,7 @@ const NoteList = forwardRef( (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return if (oneShotFetch || mappedSubRequests.length === 0) return - if (isSpellPageLocalWarmup) return + if (shouldAwaitLocalDiskWarmup) return const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> const strictSingleRelayShard = mappedSubRequests.length === 1 && @@ -2733,80 +2755,54 @@ const NoteList = forwardRef( setLoading(!!oneShotFetch) } else { let primedFromDisk = false - let spellLocalMergeBase: Event[] = [] - - if (isSpellPageLocalWarmup) { - const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) - const matchesSpellLocal = (ev: Event) => - shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f)) - const kindsForScan = unionKindsForSpellLocalWarmup( - shardFilters, - effectiveShowKindsRef.current - ) - const sinceTightest = tightestSinceFromSpellFilters(shardFilters) - const localLayerCap = Math.min( - FEED_FULL_SEARCH_MERGE_CAP, - Math.max(eventCapEarly, 200) - ) - const sessionScanCap = Math.min(800, localLayerCap * 4) - - const sessionHits = client - .getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan) - .filter(matchesSpellLocal) - .sort((a, b) => b.created_at - a.created_at) - - if (!timelineEffectStale() && sessionHits.length > 0) { - const narrowedS = narrowLiveBatch(sessionHits) - if (narrowedS.length > 0) { - const mergedS = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) - ) - if (mergedS.length > 0) { - spellLocalMergeBase = mergedS - timelineMergeBootstrapRef.current = mergedS.slice() - setEvents(mergedS) - lastEventsForTimelinePrefetchRef.current = mergedS - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: 'spell_local_session', - mergedCount: mergedS.length + let localMergeBase: Event[] = [] + const profileMapped = mappedSubRequests as Array<{ + urls: string[] + filter: TSubRequestFilter + }> + const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped) + + if (shouldAwaitLocalDiskWarmup) { + if (isSpellPageLocalWarmup) { + const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) + const matchesSpellLocal = (ev: Event) => + shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f)) + const kindsForScan = unionKindsForSpellLocalWarmup( + shardFilters, + effectiveShowKindsRef.current + ) + const sinceTightest = tightestSinceFromSpellFilters(shardFilters) + const localLayerCap = Math.min( + FEED_FULL_SEARCH_MERGE_CAP, + Math.max(eventCapEarly, 200) + ) + const sessionScanCap = Math.min(800, localLayerCap * 4) + + const sessionHits = client + .getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan) + .filter(matchesSpellLocal) + .sort((a, b) => b.created_at - a.created_at) + + if (!timelineEffectStale() && sessionHits.length > 0) { + const narrowedS = narrowLiveBatch(sessionHits) + if (narrowedS.length > 0) { + const mergedS = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) + ) + if (mergedS.length > 0) { + localMergeBase = mergedS + primedFromDisk = true + paintLocalWarmupTimeline(mergedS, 'spell_local_session') } - primedFromDisk = true } } - } - void (async () => { try { const filterAwareDiskReq = mappedSubRequests as Array<{ urls: string[] filter: TSubRequestFilter }> - const mergeSpellLocalDiskLayer = (incoming: Event[], variant: string) => { - if (!effectActive || timelineEffectStale()) return - const narrowed = narrowLiveBatch(incoming) - if (narrowed.length === 0) return - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById(spellLocalMergeBase, narrowed, eventCapEarly, areAlgoRelays) - ) - if (merged.length === 0) return - spellLocalMergeBase = merged - timelineMergeBootstrapRef.current = merged.slice() - setEvents(merged) - lastEventsForTimelinePrefetchRef.current = merged - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { variant, mergedCount: merged.length } - setFeedEmptyToastGateTick((n) => n + 1) - setFeedTimelineEmptyUiReady(true) - } - const mentionRecipients = recipientPubkeysFromSpellFilters(shardFilters) if (mentionRecipients.length === 1) { try { @@ -2814,10 +2810,19 @@ const NoteList = forwardRef( mentionRecipients[0]!, localLayerCap ) - mergeSpellLocalDiskLayer( - paymentNotifications.filter(matchesSpellLocal), - 'spell_payment_notifications_idb' - ) + const payRows = paymentNotifications.filter(matchesSpellLocal) + if (payRows.length > 0 && !timelineEffectStale()) { + const narrowedPay = narrowLiveBatch(payRows) + if (narrowedPay.length > 0) { + const mergedPay = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(localMergeBase, narrowedPay, eventCapEarly, areAlgoRelays) + ) + if (mergedPay.length > 0) { + localMergeBase = mergedPay + primedFromDisk = true + } + } + } } catch { /* best-effort */ } @@ -2839,108 +2844,89 @@ const NoteList = forwardRef( maxMatches: localLayerCap * 2 }) ]) - if (!effectActive || timelineEffectStale()) return - const seen = new Set() - const combinedRaw: Event[] = [] - for (const ev of diskRaw) { - if (seen.has(ev.id)) continue - seen.add(ev.id) - combinedRaw.push(ev) - } - for (const ev of filterAwareLocalRaw) { - if (seen.has(ev.id)) continue - seen.add(ev.id) - combinedRaw.push(ev) - } - for (const ev of fromPub) { - if (seen.has(ev.id)) continue - if (!matchesSpellLocal(ev)) continue - seen.add(ev.id) - combinedRaw.push(ev) - } - for (const ev of fromArch) { - if (seen.has(ev.id)) continue - if (!matchesSpellLocal(ev)) continue - seen.add(ev.id) - combinedRaw.push(ev) + if (effectActive && !timelineEffectStale()) { + const seen = new Set() + const combinedRaw: Event[] = [] + for (const ev of diskRaw) { + if (seen.has(ev.id)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of filterAwareLocalRaw) { + if (seen.has(ev.id)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of fromPub) { + if (seen.has(ev.id)) continue + if (!matchesSpellLocal(ev)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of fromArch) { + if (seen.has(ev.id)) continue + if (!matchesSpellLocal(ev)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + combinedRaw.sort((a, b) => b.created_at - a.created_at) + if (combinedRaw.length > 0) { + const diskNarrowed = narrowLiveBatch(combinedRaw) + if (diskNarrowed.length > 0) { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(localMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + localMergeBase = merged + primedFromDisk = true + } + } + } } - combinedRaw.sort((a, b) => b.created_at - a.created_at) - if (combinedRaw.length === 0) return - const diskNarrowed = narrowLiveBatch(combinedRaw) - if (diskNarrowed.length === 0) return - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) - ) - if (merged.length === 0) return - timelineMergeBootstrapRef.current = merged.slice() - setEvents(merged) - lastEventsForTimelinePrefetchRef.current = merged - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: - spellLocalMergeBase.length > 0 ? 'spell_local_merged' : 'disk_snapshot', - mergedCount: merged.length + + if (primedFromDisk && localMergeBase.length > 0 && !timelineEffectStale()) { + paintLocalWarmupTimeline(localMergeBase, 'spell_local_disk') } } catch { /* spell local + disk snapshot is best-effort */ } - })() - } else { - const profileMapped = mappedSubRequests as Array<{ - urls: string[] - filter: TSubRequestFilter - }> - const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped) - if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) { + } else if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) { profileLocalPrimingPendingRef.current = true - const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) - const sessionHits = client.eventService.listSessionEventsAuthoredBy( - profileAuthorWarmSpec.author, - { kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit } - ) - if (sessionHits.length > 0) { - const narrowedS = narrowLiveBatch(sessionHits as Event[]) - if (narrowedS.length > 0) { - const mergedS = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) - ) - if (mergedS.length > 0) { - timelineMergeBootstrapRef.current = mergedS.slice() - setEvents(mergedS) - lastEventsForTimelinePrefetchRef.current = mergedS - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: 'profile_local_session', - mergedCount: mergedS.length + try { + const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) + const sessionHits = client.eventService.listSessionEventsAuthoredBy( + profileAuthorWarmSpec.author, + { kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit } + ) + if (sessionHits.length > 0) { + const narrowedS = narrowLiveBatch(sessionHits as Event[]) + if (narrowedS.length > 0) { + const mergedS = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) + ) + if (mergedS.length > 0) { + localMergeBase = mergedS + primedFromDisk = true + paintLocalWarmupTimeline(mergedS, 'profile_local_session') } - primedFromDisk = true } } - } - void (async () => { - try { - const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> - const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150)) - const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([ - indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, { - kinds: profileAuthorWarmSpec.kinds, - maxRowsScanned: 16_000, - maxMatches: archiveCap - }), - client.getTimelineDiskSnapshotEvents(diskReq), - client.getLocalFeedEvents(diskReq, { - maxRowsScanned: 16_000, - maxMatches: archiveCap - }) - ]) - if (!effectActive || timelineEffectStale()) return + const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> + const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150)) + const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([ + indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, { + kinds: profileAuthorWarmSpec.kinds, + maxRowsScanned: 16_000, + maxMatches: archiveCap + }), + client.getTimelineDiskSnapshotEvents(diskReq), + client.getLocalFeedEvents(diskReq, { + maxRowsScanned: 16_000, + maxMatches: archiveCap + }) + ]) + if (effectActive && !timelineEffectStale()) { const premerged = mergeEventBatchesById( [], [...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])], @@ -2950,34 +2936,22 @@ const NoteList = forwardRef( if (premerged.length > 0) { const narrowed = narrowLiveBatch(premerged) if (narrowed.length > 0) { - setEvents((prev) => { - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById(prev, narrowed, eventCapEarly, areAlgoRelays) - ) - if (merged.length > 0) { - timelineMergeBootstrapRef.current = merged.slice() - } - lastEventsForTimelinePrefetchRef.current = merged - return merged - }) - setNewEvents([]) - setShowCount(revealBatchSize ?? SHOW_COUNT) - if (!feedPaintLiveRelayDoneRef.current) { - setLoading(false) - feedPaintRelayPendingRef.current = true - feedPaintRelayMetaRef.current = { - variant: 'profile_local_archive', - mergedCount: narrowed.length - } - setFeedEmptyToastGateTick((n) => n + 1) - setFeedTimelineEmptyUiReady(true) + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(localMergeBase, narrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + localMergeBase = merged + primedFromDisk = true + paintLocalWarmupTimeline(merged, 'profile_local_disk') } } } + } - const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped) - if (relayUrls.length > 0) { - const fetched = await client.fetchEvents( + const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped) + if (relayUrls.length > 0 && effectActive && !timelineEffectStale()) { + void client + .fetchEvents( relayUrls, { authors: [profileAuthorWarmSpec.author], @@ -2992,45 +2966,128 @@ const NoteList = forwardRef( foreground: true } ) - if (!effectActive || timelineEffectStale()) return - if (fetched.length > 0) { + .then((fetched) => { + if (!effectActive || timelineEffectStale() || fetched.length === 0) return const narrowedFetch = narrowLiveBatch(fetched) - if (narrowedFetch.length > 0) { - setEvents((prev) => { - const merged = collapseDuplicateNip18RepostTimelineRows( - mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays) - ) - if (merged.length > 0) { - timelineMergeBootstrapRef.current = merged.slice() - } - lastEventsForTimelinePrefetchRef.current = merged - return merged - }) - feedRelayReturnedAnyEventRef.current = true - if (!feedPaintLiveRelayDoneRef.current) { - setLoading(false) - setFeedEmptyToastGateTick((n) => n + 1) - setFeedTimelineEmptyUiReady(true) + if (narrowedFetch.length === 0) return + setEvents((prev) => { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + timelineMergeBootstrapRef.current = merged.slice() } + lastEventsForTimelinePrefetchRef.current = merged + return merged + }) + feedRelayReturnedAnyEventRef.current = true + if (!feedPaintLiveRelayDoneRef.current) { + setLoading(false) + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) } - } + }) + .catch(() => { + /* best-effort */ + }) + } + } catch { + /* profile local archive is best-effort */ + } finally { + profileLocalPrimingPendingRef.current = false + } + } else { + const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) + const matchesTimelineLocal = (ev: Event) => + shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f)) + const kindsForScan = unionKindsForSpellLocalWarmup( + shardFilters, + effectiveShowKindsRef.current + ) + const sinceTightest = tightestSinceFromSpellFilters(shardFilters) + const localLayerCap = Math.min( + FEED_FULL_SEARCH_MERGE_CAP, + Math.max(eventCapEarly, 200) + ) + const sessionScanCap = Math.min(800, localLayerCap * 4) + const filterAwareDiskReq = mappedSubRequests as Array<{ + urls: string[] + filter: TSubRequestFilter + }> + + const sessionHits = client + .getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan) + .filter(matchesTimelineLocal) + .sort((a, b) => b.created_at - a.created_at) + + if (!timelineEffectStale() && sessionHits.length > 0) { + const narrowedS = narrowLiveBatch(sessionHits) + if (narrowedS.length > 0) { + const mergedS = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) + ) + if (mergedS.length > 0) { + localMergeBase = mergedS + primedFromDisk = true + paintLocalWarmupTimeline(mergedS, 'timeline_local_session') } - } catch { - /* profile local archive is best-effort */ - } finally { - profileLocalPrimingPendingRef.current = false - if (!effectActive || timelineEffectStale()) return - if (!feedPaintLiveRelayDoneRef.current) { - feedPaintLiveRelayDoneRef.current = true - setLoading(false) - setFeedEmptyToastGateTick((n) => n + 1) - setFeedTimelineEmptyUiReady(true) + } + } + + try { + const [diskRaw, filterAwareLocalRaw, fromArch] = await Promise.all([ + client.getTimelineDiskSnapshotEvents(filterAwareDiskReq), + client.getLocalFeedEvents(filterAwareDiskReq, { + maxRowsScanned: 50_000, + maxMatches: localLayerCap * 3 + }), + indexedDb.scanEventArchiveByKinds({ + kinds: kindsForScan, + since: sinceTightest, + maxRowsScanned: 50_000, + maxMatches: localLayerCap * 2 + }) + ]) + if (effectActive && !timelineEffectStale()) { + const seen = new Set() + const combinedRaw: Event[] = [] + for (const ev of diskRaw) { + if (seen.has(ev.id)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of filterAwareLocalRaw) { + if (seen.has(ev.id)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + for (const ev of fromArch as Event[]) { + if (seen.has(ev.id)) continue + if (!matchesTimelineLocal(ev)) continue + seen.add(ev.id) + combinedRaw.push(ev) + } + combinedRaw.sort((a, b) => b.created_at - a.created_at) + if (combinedRaw.length > 0) { + const diskNarrowed = narrowLiveBatch(combinedRaw) + if (diskNarrowed.length > 0) { + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(localMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length > 0) { + localMergeBase = merged + primedFromDisk = true + paintLocalWarmupTimeline(merged, 'timeline_local_disk') + } + } } } - })() + } catch { + /* generic local + disk snapshot is best-effort */ + } } } - if (!primedFromDisk && !profileRelayStackRefinement) { + if (!primedFromDisk && !profileRelayStackRefinement && !shouldAwaitLocalDiskWarmup) { if (!keepRowsVisible) setLoading(true) timelineMergeBootstrapRef.current = [] setEvents([]) @@ -3933,6 +3990,21 @@ const NoteList = forwardRef( eventsRef.current = events }, [events]) + /** Debounced session snapshot so F5 / tab reload restores the last painted timeline (Spells notifications, etc.). */ + useEffect(() => { + if (!sessionSnapshotIdentityKey || events.length === 0) 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 + const timer = window.setTimeout(() => { + setSessionFeedSnapshot(sessionSnapshotIdentityKey, events) + }, 400) + return () => window.clearTimeout(timer) + }, [events, sessionSnapshotIdentityKey, allowKindlessRelayExplore, useFilterAsIs]) + useEffect(() => { newEventsRef.current = newEvents }, [newEvents]) diff --git a/src/main.tsx b/src/main.tsx index 4a63ca8b..56bfce64 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -12,7 +12,7 @@ import { createRoot } from 'react-dom/client' import App from './App.tsx' import { ErrorBoundary } from './components/ErrorBoundary.tsx' import { initI18n } from './i18n' -import { restoreSessionFeedSnapshotsAfterHardRefresh } from './services/session-feed-snapshot.service' +import { restorePersistedFeedSnapshots } from './services/session-feed-snapshot.service' import { installStaleBuildChunkRecovery } from './lib/stale-chunk-recovery' import { initPwaUpdate } from './lib/pwa-update' import { installViewportHeightListeners } from './lib/viewport-height' @@ -51,7 +51,7 @@ async function bootstrap() { })() ]) console.info('[imwald] Boot: mounting React (UI shell will appear; Nostr session restores next)') - restoreSessionFeedSnapshotsAfterHardRefresh() + restorePersistedFeedSnapshots() // Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it. try { sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now())) diff --git a/src/services/session-feed-snapshot.service.ts b/src/services/session-feed-snapshot.service.ts index b468e347..ba727645 100644 --- a/src/services/session-feed-snapshot.service.ts +++ b/src/services/session-feed-snapshot.service.ts @@ -7,11 +7,16 @@ const MAX_EVENTS_PER_FEED = 120 /** Max distinct feeds kept in memory for the tab session. */ const MAX_FEED_KEYS = 48 +/** Survives normal reload (F5) within the same browser tab. */ +const PERSISTED_FEEDS_SESSION_KEY = 'jumble:feedSnapshots' +/** Legacy one-shot key written by {@link hardReloadPreservingFeedSnapshots}. */ const HARD_REFRESH_SESSION_KEY = 'jumble:hardRefreshFeedSnapshots' const snapshots = new Map() const accessOrder: string[] = [] +let persistDebounceId: ReturnType | null = null + function bumpAccess(key: string) { const i = accessOrder.indexOf(key) if (i >= 0) accessOrder.splice(i, 1) @@ -22,6 +27,56 @@ function bumpAccess(key: string) { } } +function persistFeedSnapshotsToSessionStorage(): void { + try { + if (snapshots.size === 0) { + sessionStorage.removeItem(PERSISTED_FEEDS_SESSION_KEY) + return + } + const payload: Record = {} + for (const [k, rows] of snapshots) { + if (rows?.length) { + payload[k] = rows.map((e) => ({ ...e })) + } + } + if (Object.keys(payload).length === 0) { + sessionStorage.removeItem(PERSISTED_FEEDS_SESSION_KEY) + return + } + sessionStorage.setItem(PERSISTED_FEEDS_SESSION_KEY, JSON.stringify(payload)) + } catch (e) { + logger.warn('[feed-snapshot] Could not persist to sessionStorage', { error: e }) + } +} + +function schedulePersistToSessionStorage(): void { + if (typeof window === 'undefined') return + if (persistDebounceId != null) clearTimeout(persistDebounceId) + persistDebounceId = setTimeout(() => { + persistDebounceId = null + persistFeedSnapshotsToSessionStorage() + }, 250) +} + +function loadPayloadIntoMemory(payload: Record, logLabel: string): number { + let restored = 0 + for (const [k, rows] of Object.entries(payload)) { + if (!k || !Array.isArray(rows) || rows.length === 0) continue + const capped = rows + .filter((e): e is Event => e != null && typeof (e as Event).id === 'string') + .slice(0, MAX_EVENTS_PER_FEED) + .map((e) => ({ ...e })) + if (capped.length > 0) { + setSessionFeedSnapshot(k, capped, { skipPersist: true }) + restored++ + } + } + if (restored > 0) { + logger.info(`[feed-snapshot] ${logLabel}`, { feeds: restored }) + } + return restored +} + /** * In-memory feed rows for the current tab session. Lets NoteList restore immediately when * remounting the same feed (page / spell / relay) and merge fresh REQ results on top. @@ -34,19 +89,54 @@ export function getSessionFeedSnapshot(key: string): Event[] | undefined { return rows } -export function setSessionFeedSnapshot(key: string, events: readonly Event[]): void { +export function setSessionFeedSnapshot( + key: string, + events: readonly Event[], + options?: { skipPersist?: boolean } +): void { if (!key) return const capped = events.slice(0, MAX_EVENTS_PER_FEED).map((e) => ({ ...e })) snapshots.set(key, capped) bumpAccess(key) + if (!options?.skipPersist) { + schedulePersistToSessionStorage() + } +} + +/** + * Load feed snapshots from sessionStorage into memory (normal reload + legacy hard-reload key). + * Call once during app bootstrap ({@link main.tsx}). + */ +export function restorePersistedFeedSnapshots(): void { + try { + const raw = sessionStorage.getItem(PERSISTED_FEEDS_SESSION_KEY) + if (raw) { + const payload = JSON.parse(raw) as Record + if (payload && typeof payload === 'object') { + loadPayloadIntoMemory(payload, 'Restored from sessionStorage') + } + } + + const legacy = sessionStorage.getItem(HARD_REFRESH_SESSION_KEY) + if (legacy) { + sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) + const payload = JSON.parse(legacy) as Record + if (payload && typeof payload === 'object') { + loadPayloadIntoMemory(payload, 'Restored legacy hard-reload snapshots') + persistFeedSnapshotsToSessionStorage() + } + } + } catch (e) { + logger.warn('[feed-snapshot] Could not restore from sessionStorage', { error: e }) + } } /** * Persist in-memory feed snapshots to sessionStorage, then call {@link window.location.reload}. - * {@link restoreSessionFeedSnapshotsAfterHardRefresh} runs on next boot (see `main.tsx`). + * {@link restorePersistedFeedSnapshots} runs on next boot (see `main.tsx`). */ export function hardReloadPreservingFeedSnapshots(): void { - persistSessionFeedSnapshotsForHardRefresh() + persistFeedSnapshotsToSessionStorage() if (isImwaldElectron() && typeof window.imwaldElectron?.reloadApp === 'function') { void window.imwaldElectron.reloadApp() return @@ -54,57 +144,22 @@ export function hardReloadPreservingFeedSnapshots(): void { window.location.reload() } +/** @deprecated Use {@link persistFeedSnapshotsToSessionStorage} — kept for callers. */ export function persistSessionFeedSnapshotsForHardRefresh(): void { - try { - if (snapshots.size === 0) { - sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) - return - } - const payload: Record = {} - for (const [k, rows] of snapshots) { - if (rows?.length) { - payload[k] = rows.map((e) => ({ ...e })) - } - } - if (Object.keys(payload).length === 0) { - sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) - return - } - sessionStorage.setItem(HARD_REFRESH_SESSION_KEY, JSON.stringify(payload)) - logger.info('[feed-snapshot] Persisted for hard reload', { feedKeys: Object.keys(payload).length }) - } catch (e) { - logger.warn('[feed-snapshot] Could not persist for hard reload', { error: e }) - } + persistFeedSnapshotsToSessionStorage() } +/** @deprecated Use {@link restorePersistedFeedSnapshots}. */ export function restoreSessionFeedSnapshotsAfterHardRefresh(): void { - try { - const raw = sessionStorage.getItem(HARD_REFRESH_SESSION_KEY) - if (!raw) return - sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) - const payload = JSON.parse(raw) as Record - if (!payload || typeof payload !== 'object') return - let restored = 0 - for (const [k, rows] of Object.entries(payload)) { - if (!k || !Array.isArray(rows) || rows.length === 0) continue - const capped = rows - .filter((e): e is Event => e != null && typeof (e as Event).id === 'string') - .slice(0, MAX_EVENTS_PER_FEED) - .map((e) => ({ ...e })) - if (capped.length > 0) { - setSessionFeedSnapshot(k, capped) - restored++ - } - } - if (restored > 0) { - logger.info('[feed-snapshot] Restored after hard reload', { feeds: restored }) - } - } catch (e) { - logger.warn('[feed-snapshot] Could not restore after hard reload', { error: e }) - try { - sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) - } catch { - // ignore + restorePersistedFeedSnapshots() +} + +if (typeof window !== 'undefined') { + window.addEventListener('pagehide', () => { + if (persistDebounceId != null) { + clearTimeout(persistDebounceId) + persistDebounceId = null } - } + persistFeedSnapshotsToSessionStorage() + }) }