Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
685c1b0af9
  1. 22
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  2. 512
      src/components/NoteList/index.tsx
  3. 4
      src/main.tsx
  4. 157
      src/services/session-feed-snapshot.service.ts

22
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -110,6 +110,13 @@ function resolveImetaForMarkdownImageUrl(
*/ */
const MD_PARAGRAPH_FLOW_CLASS = 'mb-1 last:mb-0' 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}). */ /** Author custom emoji image URL → slide index in the note lightbox ({@link lightboxSlideFromImeta}). */
type TInlineEmojiLightbox = { type TInlineEmojiLightbox = {
imageIndexMap: Map<string, number> imageIndexMap: Map<string, number>
@ -3681,6 +3688,9 @@ function parseMarkdownContentMarked(
const renderParagraph = (token: any, key: string): React.ReactNode => { const renderParagraph = (token: any, key: string): React.ReactNode => {
const rawParagraphText = String(token.text ?? token.raw ?? '') const rawParagraphText = String(token.text ?? token.raw ?? '')
const paragraphText = rawParagraphText.trim() const paragraphText = rawParagraphText.trim()
if (!paragraphText) {
return null
}
const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText) const displayMathSplit = splitParagraphByDisplayMath(rawParagraphText)
if (displayMathSplit) { if (displayMathSplit) {
return ( return (
@ -4552,8 +4562,13 @@ function parseMarkdownContentMarked(
} }
const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`) const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`)
const emojiOnly = isEmojiOnlyParagraphText(paragraphText)
return ( return (
<div key={`${key}-p`} role="paragraph" className={MD_PARAGRAPH_FLOW_CLASS}> <div
key={`${key}-p`}
role="paragraph"
className={emojiOnly ? 'mb-1 last:mb-0 leading-none' : MD_PARAGRAPH_FLOW_CLASS}
>
{inlineNodes} {inlineNodes}
</div> </div>
) )
@ -4573,7 +4588,10 @@ function parseMarkdownContentMarked(
const key = `${keyPrefix}-${i}` const key = `${keyPrefix}-${i}`
switch (token.type) { switch (token.type) {
case 'space': { 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) { if (gapEm > 0) {
nodes.push( nodes.push(
<div <div

512
src/components/NoteList/index.tsx

@ -2244,9 +2244,11 @@ const NoteList = forwardRef(
) )
.filter((req) => req.urls.length > 0) .filter((req) => req.urls.length > 0)
if (mapped.length === 0) return if (mapped.length === 0) return
const disk = await client.getTimelineDiskSnapshotEvents( const diskReq = mapped as Array<{ urls: string[]; filter: TSubRequestFilter }>
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 if (diskPrimeCancelled || timelineEffectStale() || !disk.length) return
const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT const cap = areAlgoRelays ? ALGO_LIMIT : LIMIT
const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays)) const merged = collapseDuplicateNip18RepostTimelineRows(mergeEventBatchesById([], disk, cap, areAlgoRelays))
@ -2529,6 +2531,20 @@ const NoteList = forwardRef(
? ALGO_LIMIT ? ALGO_LIMIT
: 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). */ /** Profile feeds: bounded fetch in parallel with subscribe (do not wait for EOSE / outcomes). */
const runProfileTimelineNetworkFetch = (variant: string) => { const runProfileTimelineNetworkFetch = (variant: string) => {
if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return if (!profileAuthorWarmSpecForRefresh || !profileMappedForRefresh) return
@ -2588,12 +2604,18 @@ const NoteList = forwardRef(
) )
} }
const shouldAwaitLocalDiskWarmup =
!oneShotFetch &&
mappedSubRequests.length > 0 &&
!relayAuthoritativeFeedOnlyRef.current
const isSpellPageLocalWarmup = 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 * 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. * {@link onEvents} so rows appear as soon as local sources resolve.
* Skipped when {@link shouldAwaitLocalDiskWarmup} already painted from disk in `init`.
*/ */
const startNonBlockingTimelineDiskPrime = () => { const startNonBlockingTimelineDiskPrime = () => {
const strictSingleRelayAuthoritative = const strictSingleRelayAuthoritative =
@ -2603,7 +2625,7 @@ const NoteList = forwardRef(
(allowKindlessRelayExploreRef.current && useFilterAsIsRef.current)) (allowKindlessRelayExploreRef.current && useFilterAsIsRef.current))
if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return if (relayAuthoritativeFeedOnlyRef.current && !strictSingleRelayAuthoritative) return
if (oneShotFetch || mappedSubRequests.length === 0) return if (oneShotFetch || mappedSubRequests.length === 0) return
if (isSpellPageLocalWarmup) return if (shouldAwaitLocalDiskWarmup) return
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const strictSingleRelayShard = const strictSingleRelayShard =
mappedSubRequests.length === 1 && mappedSubRequests.length === 1 &&
@ -2733,80 +2755,54 @@ const NoteList = forwardRef(
setLoading(!!oneShotFetch) setLoading(!!oneShotFetch)
} else { } else {
let primedFromDisk = false let primedFromDisk = false
let spellLocalMergeBase: Event[] = [] let localMergeBase: Event[] = []
const profileMapped = mappedSubRequests as Array<{
if (isSpellPageLocalWarmup) { urls: string[]
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) filter: TSubRequestFilter
const matchesSpellLocal = (ev: Event) => }>
shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f)) const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped)
const kindsForScan = unionKindsForSpellLocalWarmup(
shardFilters, if (shouldAwaitLocalDiskWarmup) {
effectiveShowKindsRef.current if (isSpellPageLocalWarmup) {
) const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const sinceTightest = tightestSinceFromSpellFilters(shardFilters) const matchesSpellLocal = (ev: Event) =>
const localLayerCap = Math.min( shardFilters.some((f) => eventMatchesSubRequestFilterWithWindow(ev, f))
FEED_FULL_SEARCH_MERGE_CAP, const kindsForScan = unionKindsForSpellLocalWarmup(
Math.max(eventCapEarly, 200) shardFilters,
) effectiveShowKindsRef.current
const sessionScanCap = Math.min(800, localLayerCap * 4) )
const sinceTightest = tightestSinceFromSpellFilters(shardFilters)
const sessionHits = client const localLayerCap = Math.min(
.getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan) FEED_FULL_SEARCH_MERGE_CAP,
.filter(matchesSpellLocal) Math.max(eventCapEarly, 200)
.sort((a, b) => b.created_at - a.created_at) )
const sessionScanCap = Math.min(800, localLayerCap * 4)
if (!timelineEffectStale() && sessionHits.length > 0) {
const narrowedS = narrowLiveBatch(sessionHits) const sessionHits = client
if (narrowedS.length > 0) { .getSessionEventsMatchingSearch('', sessionScanCap, kindsForScan)
const mergedS = collapseDuplicateNip18RepostTimelineRows( .filter(matchesSpellLocal)
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) .sort((a, b) => b.created_at - a.created_at)
)
if (mergedS.length > 0) { if (!timelineEffectStale() && sessionHits.length > 0) {
spellLocalMergeBase = mergedS const narrowedS = narrowLiveBatch(sessionHits)
timelineMergeBootstrapRef.current = mergedS.slice() if (narrowedS.length > 0) {
setEvents(mergedS) const mergedS = collapseDuplicateNip18RepostTimelineRows(
lastEventsForTimelinePrefetchRef.current = mergedS mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
setNewEvents([]) )
setShowCount(revealBatchSize ?? SHOW_COUNT) if (mergedS.length > 0) {
setLoading(false) localMergeBase = mergedS
feedPaintRelayPendingRef.current = true primedFromDisk = true
feedPaintRelayMetaRef.current = { paintLocalWarmupTimeline(mergedS, 'spell_local_session')
variant: 'spell_local_session',
mergedCount: mergedS.length
} }
primedFromDisk = true
} }
} }
}
void (async () => {
try { try {
const filterAwareDiskReq = mappedSubRequests as Array<{ const filterAwareDiskReq = mappedSubRequests as Array<{
urls: string[] urls: string[]
filter: TSubRequestFilter 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) const mentionRecipients = recipientPubkeysFromSpellFilters(shardFilters)
if (mentionRecipients.length === 1) { if (mentionRecipients.length === 1) {
try { try {
@ -2814,10 +2810,19 @@ const NoteList = forwardRef(
mentionRecipients[0]!, mentionRecipients[0]!,
localLayerCap localLayerCap
) )
mergeSpellLocalDiskLayer( const payRows = paymentNotifications.filter(matchesSpellLocal)
paymentNotifications.filter(matchesSpellLocal), if (payRows.length > 0 && !timelineEffectStale()) {
'spell_payment_notifications_idb' 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 { } catch {
/* best-effort */ /* best-effort */
} }
@ -2839,108 +2844,89 @@ const NoteList = forwardRef(
maxMatches: localLayerCap * 2 maxMatches: localLayerCap * 2
}) })
]) ])
if (!effectActive || timelineEffectStale()) return if (effectActive && !timelineEffectStale()) {
const seen = new Set<string>() const seen = new Set<string>()
const combinedRaw: Event[] = [] const combinedRaw: Event[] = []
for (const ev of diskRaw) { for (const ev of diskRaw) {
if (seen.has(ev.id)) continue if (seen.has(ev.id)) continue
seen.add(ev.id) seen.add(ev.id)
combinedRaw.push(ev) combinedRaw.push(ev)
} }
for (const ev of filterAwareLocalRaw) { for (const ev of filterAwareLocalRaw) {
if (seen.has(ev.id)) continue if (seen.has(ev.id)) continue
seen.add(ev.id) seen.add(ev.id)
combinedRaw.push(ev) combinedRaw.push(ev)
} }
for (const ev of fromPub) { for (const ev of fromPub) {
if (seen.has(ev.id)) continue if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue if (!matchesSpellLocal(ev)) continue
seen.add(ev.id) seen.add(ev.id)
combinedRaw.push(ev) combinedRaw.push(ev)
} }
for (const ev of fromArch) { for (const ev of fromArch) {
if (seen.has(ev.id)) continue if (seen.has(ev.id)) continue
if (!matchesSpellLocal(ev)) continue if (!matchesSpellLocal(ev)) continue
seen.add(ev.id) seen.add(ev.id)
combinedRaw.push(ev) 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 if (primedFromDisk && localMergeBase.length > 0 && !timelineEffectStale()) {
const diskNarrowed = narrowLiveBatch(combinedRaw) paintLocalWarmupTimeline(localMergeBase, 'spell_local_disk')
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
} }
} catch { } catch {
/* spell local + disk snapshot is best-effort */ /* spell local + disk snapshot is best-effort */
} }
})() } else if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) {
} else {
const profileMapped = mappedSubRequests as Array<{
urls: string[]
filter: TSubRequestFilter
}>
const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped)
if (isProfileTimelineFeed && profileAuthorWarmSpec && !timelineEffectStale()) {
profileLocalPrimingPendingRef.current = true profileLocalPrimingPendingRef.current = true
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) try {
const sessionHits = client.eventService.listSessionEventsAuthoredBy( const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
profileAuthorWarmSpec.author, const sessionHits = client.eventService.listSessionEventsAuthoredBy(
{ kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit } profileAuthorWarmSpec.author,
) { kinds: profileAuthorWarmSpec.kinds, limit: sessionScanLimit }
if (sessionHits.length > 0) { )
const narrowedS = narrowLiveBatch(sessionHits as Event[]) if (sessionHits.length > 0) {
if (narrowedS.length > 0) { const narrowedS = narrowLiveBatch(sessionHits as Event[])
const mergedS = collapseDuplicateNip18RepostTimelineRows( if (narrowedS.length > 0) {
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) const mergedS = collapseDuplicateNip18RepostTimelineRows(
) mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
if (mergedS.length > 0) { )
timelineMergeBootstrapRef.current = mergedS.slice() if (mergedS.length > 0) {
setEvents(mergedS) localMergeBase = mergedS
lastEventsForTimelinePrefetchRef.current = mergedS primedFromDisk = true
setNewEvents([]) paintLocalWarmupTimeline(mergedS, 'profile_local_session')
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'profile_local_session',
mergedCount: mergedS.length
} }
primedFromDisk = true
} }
} }
}
void (async () => { const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
try { const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([
const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150)) indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, {
const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([ kinds: profileAuthorWarmSpec.kinds,
indexedDb.scanEventArchiveByAuthorPubkey(profileAuthorWarmSpec.author, { maxRowsScanned: 16_000,
kinds: profileAuthorWarmSpec.kinds, maxMatches: archiveCap
maxRowsScanned: 16_000, }),
maxMatches: archiveCap client.getTimelineDiskSnapshotEvents(diskReq),
}), client.getLocalFeedEvents(diskReq, {
client.getTimelineDiskSnapshotEvents(diskReq), maxRowsScanned: 16_000,
client.getLocalFeedEvents(diskReq, { maxMatches: archiveCap
maxRowsScanned: 16_000, })
maxMatches: archiveCap ])
}) if (effectActive && !timelineEffectStale()) {
])
if (!effectActive || timelineEffectStale()) return
const premerged = mergeEventBatchesById( const premerged = mergeEventBatchesById(
[], [],
[...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])], [...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])],
@ -2950,34 +2936,22 @@ const NoteList = forwardRef(
if (premerged.length > 0) { if (premerged.length > 0) {
const narrowed = narrowLiveBatch(premerged) const narrowed = narrowLiveBatch(premerged)
if (narrowed.length > 0) { if (narrowed.length > 0) {
setEvents((prev) => { const merged = collapseDuplicateNip18RepostTimelineRows(
const merged = collapseDuplicateNip18RepostTimelineRows( mergeEventBatchesById(localMergeBase, narrowed, eventCapEarly, areAlgoRelays)
mergeEventBatchesById(prev, narrowed, eventCapEarly, areAlgoRelays) )
) if (merged.length > 0) {
if (merged.length > 0) { localMergeBase = merged
timelineMergeBootstrapRef.current = merged.slice() primedFromDisk = true
} paintLocalWarmupTimeline(merged, 'profile_local_disk')
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 relayUrls = getProfileTimelineFetchRelayUrls(profileMapped) const relayUrls = getProfileTimelineFetchRelayUrls(profileMapped)
if (relayUrls.length > 0) { if (relayUrls.length > 0 && effectActive && !timelineEffectStale()) {
const fetched = await client.fetchEvents( void client
.fetchEvents(
relayUrls, relayUrls,
{ {
authors: [profileAuthorWarmSpec.author], authors: [profileAuthorWarmSpec.author],
@ -2992,45 +2966,128 @@ const NoteList = forwardRef(
foreground: true foreground: true
} }
) )
if (!effectActive || timelineEffectStale()) return .then((fetched) => {
if (fetched.length > 0) { if (!effectActive || timelineEffectStale() || fetched.length === 0) return
const narrowedFetch = narrowLiveBatch(fetched) const narrowedFetch = narrowLiveBatch(fetched)
if (narrowedFetch.length > 0) { if (narrowedFetch.length === 0) return
setEvents((prev) => { setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows( const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays) mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
) )
if (merged.length > 0) { if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice() timelineMergeBootstrapRef.current = merged.slice()
}
lastEventsForTimelinePrefetchRef.current = merged
return merged
})
feedRelayReturnedAnyEventRef.current = true
if (!feedPaintLiveRelayDoneRef.current) {
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
} }
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 try {
if (!effectActive || timelineEffectStale()) return const [diskRaw, filterAwareLocalRaw, fromArch] = await Promise.all([
if (!feedPaintLiveRelayDoneRef.current) { client.getTimelineDiskSnapshotEvents(filterAwareDiskReq),
feedPaintLiveRelayDoneRef.current = true client.getLocalFeedEvents(filterAwareDiskReq, {
setLoading(false) maxRowsScanned: 50_000,
setFeedEmptyToastGateTick((n) => n + 1) maxMatches: localLayerCap * 3
setFeedTimelineEmptyUiReady(true) }),
indexedDb.scanEventArchiveByKinds({
kinds: kindsForScan,
since: sinceTightest,
maxRowsScanned: 50_000,
maxMatches: localLayerCap * 2
})
])
if (effectActive && !timelineEffectStale()) {
const seen = new Set<string>()
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) if (!keepRowsVisible) setLoading(true)
timelineMergeBootstrapRef.current = [] timelineMergeBootstrapRef.current = []
setEvents([]) setEvents([])
@ -3933,6 +3990,21 @@ const NoteList = forwardRef(
eventsRef.current = events eventsRef.current = events
}, [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(() => { useEffect(() => {
newEventsRef.current = newEvents newEventsRef.current = newEvents
}, [newEvents]) }, [newEvents])

4
src/main.tsx

@ -12,7 +12,7 @@ import { createRoot } from 'react-dom/client'
import App from './App.tsx' import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary.tsx' import { ErrorBoundary } from './components/ErrorBoundary.tsx'
import { initI18n } from './i18n' 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 { installStaleBuildChunkRecovery } from './lib/stale-chunk-recovery'
import { initPwaUpdate } from './lib/pwa-update' import { initPwaUpdate } from './lib/pwa-update'
import { installViewportHeightListeners } from './lib/viewport-height' 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)') 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. // Mark session storage as used so it's visible in DevTools; VersionUpdateBanner and NotePage also use it.
try { try {
sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now())) sessionStorage.setItem(SESSION_STORAGE_KEY, String(Date.now()))

157
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. */ /** Max distinct feeds kept in memory for the tab session. */
const MAX_FEED_KEYS = 48 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 HARD_REFRESH_SESSION_KEY = 'jumble:hardRefreshFeedSnapshots'
const snapshots = new Map<string, Event[]>() const snapshots = new Map<string, Event[]>()
const accessOrder: string[] = [] const accessOrder: string[] = []
let persistDebounceId: ReturnType<typeof setTimeout> | null = null
function bumpAccess(key: string) { function bumpAccess(key: string) {
const i = accessOrder.indexOf(key) const i = accessOrder.indexOf(key)
if (i >= 0) accessOrder.splice(i, 1) 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<string, Event[]> = {}
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<string, unknown>, 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 * 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. * 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 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 if (!key) return
const capped = events.slice(0, MAX_EVENTS_PER_FEED).map((e) => ({ ...e })) const capped = events.slice(0, MAX_EVENTS_PER_FEED).map((e) => ({ ...e }))
snapshots.set(key, capped) snapshots.set(key, capped)
bumpAccess(key) 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<string, unknown>
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<string, unknown>
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}. * 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 { export function hardReloadPreservingFeedSnapshots(): void {
persistSessionFeedSnapshotsForHardRefresh() persistFeedSnapshotsToSessionStorage()
if (isImwaldElectron() && typeof window.imwaldElectron?.reloadApp === 'function') { if (isImwaldElectron() && typeof window.imwaldElectron?.reloadApp === 'function') {
void window.imwaldElectron.reloadApp() void window.imwaldElectron.reloadApp()
return return
@ -54,57 +144,22 @@ export function hardReloadPreservingFeedSnapshots(): void {
window.location.reload() window.location.reload()
} }
/** @deprecated Use {@link persistFeedSnapshotsToSessionStorage} — kept for callers. */
export function persistSessionFeedSnapshotsForHardRefresh(): void { export function persistSessionFeedSnapshotsForHardRefresh(): void {
try { persistFeedSnapshotsToSessionStorage()
if (snapshots.size === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
return
}
const payload: Record<string, Event[]> = {}
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 })
}
} }
/** @deprecated Use {@link restorePersistedFeedSnapshots}. */
export function restoreSessionFeedSnapshotsAfterHardRefresh(): void { export function restoreSessionFeedSnapshotsAfterHardRefresh(): void {
try { restorePersistedFeedSnapshots()
const raw = sessionStorage.getItem(HARD_REFRESH_SESSION_KEY) }
if (!raw) return
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) if (typeof window !== 'undefined') {
const payload = JSON.parse(raw) as Record<string, unknown> window.addEventListener('pagehide', () => {
if (!payload || typeof payload !== 'object') return if (persistDebounceId != null) {
let restored = 0 clearTimeout(persistDebounceId)
for (const [k, rows] of Object.entries(payload)) { persistDebounceId = null
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
} }
} persistFeedSnapshotsToSessionStorage()
})
} }

Loading…
Cancel
Save