Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
685c1b0af9
  1. 22
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  2. 298
      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

298
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,8 +2755,14 @@ 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<{
urls: string[]
filter: TSubRequestFilter
}>
const profileAuthorWarmSpec = getProfileAuthorWarmupSpec(profileMapped)
if (shouldAwaitLocalDiskWarmup) {
if (isSpellPageLocalWarmup) { if (isSpellPageLocalWarmup) {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter) const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const matchesSpellLocal = (ev: Event) => const matchesSpellLocal = (ev: Event) =>
@ -2762,51 +2790,19 @@ const NoteList = forwardRef(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
) )
if (mergedS.length > 0) { if (mergedS.length > 0) {
spellLocalMergeBase = mergedS localMergeBase = 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
}
primedFromDisk = true primedFromDisk = true
paintLocalWarmupTimeline(mergedS, 'spell_local_session')
} }
} }
} }
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,7 +2844,7 @@ 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) {
@ -2865,37 +2870,29 @@ const NoteList = forwardRef(
combinedRaw.push(ev) combinedRaw.push(ev)
} }
combinedRaw.sort((a, b) => b.created_at - a.created_at) combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length === 0) return if (combinedRaw.length > 0) {
const diskNarrowed = narrowLiveBatch(combinedRaw) const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length === 0) return if (diskNarrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows( const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays) mergeEventBatchesById(localMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
) )
if (merged.length === 0) return if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice() localMergeBase = merged
setEvents(merged) primedFromDisk = true
lastEventsForTimelinePrefetchRef.current = merged }
setNewEvents([]) }
setShowCount(revealBatchSize ?? SHOW_COUNT) }
setLoading(false) }
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = { if (primedFromDisk && localMergeBase.length > 0 && !timelineEffectStale()) {
variant: paintLocalWarmupTimeline(localMergeBase, 'spell_local_disk')
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
try {
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800)) const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy( const sessionHits = client.eventService.listSessionEventsAuthoredBy(
profileAuthorWarmSpec.author, profileAuthorWarmSpec.author,
@ -2908,24 +2905,13 @@ const NoteList = forwardRef(
mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays) mergeEventBatchesById([], narrowedS, eventCapEarly, areAlgoRelays)
) )
if (mergedS.length > 0) { if (mergedS.length > 0) {
timelineMergeBootstrapRef.current = mergedS.slice() localMergeBase = mergedS
setEvents(mergedS)
lastEventsForTimelinePrefetchRef.current = mergedS
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(false)
feedPaintRelayPendingRef.current = true
feedPaintRelayMetaRef.current = {
variant: 'profile_local_session',
mergedCount: mergedS.length
}
primedFromDisk = true primedFromDisk = true
paintLocalWarmupTimeline(mergedS, 'profile_local_session')
} }
} }
} }
void (async () => {
try {
const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }> const diskReq = mappedSubRequests as Array<{ urls: string[]; filter: TSubRequestFilter }>
const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150)) const archiveCap = Math.min(2000, Math.max(eventCapEarly, 150))
const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([ const [fromArchive, diskSnap, fromLocalFeed] = await Promise.all([
@ -2940,7 +2926,7 @@ const NoteList = forwardRef(
maxMatches: archiveCap maxMatches: archiveCap
}) })
]) ])
if (!effectActive || timelineEffectStale()) return if (effectActive && !timelineEffectStale()) {
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(prev, narrowed, eventCapEarly, areAlgoRelays) mergeEventBatchesById(localMergeBase, narrowed, eventCapEarly, areAlgoRelays)
) )
if (merged.length > 0) { if (merged.length > 0) {
timelineMergeBootstrapRef.current = merged.slice() localMergeBase = merged
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,10 +2966,10 @@ 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)
@ -3012,25 +2986,108 @@ const NoteList = forwardRef(
setFeedEmptyToastGateTick((n) => n + 1) setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true) setFeedTimelineEmptyUiReady(true)
} }
} })
} .catch(() => {
/* best-effort */
})
} }
} catch { } catch {
/* profile local archive is best-effort */ /* profile local archive is best-effort */
} finally { } finally {
profileLocalPrimingPendingRef.current = false profileLocalPrimingPendingRef.current = false
if (!effectActive || timelineEffectStale()) return
if (!feedPaintLiveRelayDoneRef.current) {
feedPaintLiveRelayDoneRef.current = true
setLoading(false)
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
} }
} 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')
} }
})()
} }
} }
if (!primedFromDisk && !profileRelayStackRefinement) {
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<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 && !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,42 +27,10 @@ function bumpAccess(key: string) {
} }
} }
/** function persistFeedSnapshotsToSessionStorage(): void {
* 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.
*/
export function getSessionFeedSnapshot(key: string): Event[] | undefined {
if (!key) return undefined
const rows = snapshots.get(key)
if (!rows?.length) return undefined
bumpAccess(key)
return rows
}
export function setSessionFeedSnapshot(key: string, events: readonly Event[]): void {
if (!key) return
const capped = events.slice(0, MAX_EVENTS_PER_FEED).map((e) => ({ ...e }))
snapshots.set(key, capped)
bumpAccess(key)
}
/**
* Persist in-memory feed snapshots to sessionStorage, then call {@link window.location.reload}.
* {@link restoreSessionFeedSnapshotsAfterHardRefresh} runs on next boot (see `main.tsx`).
*/
export function hardReloadPreservingFeedSnapshots(): void {
persistSessionFeedSnapshotsForHardRefresh()
if (isImwaldElectron() && typeof window.imwaldElectron?.reloadApp === 'function') {
void window.imwaldElectron.reloadApp()
return
}
window.location.reload()
}
export function persistSessionFeedSnapshotsForHardRefresh(): void {
try { try {
if (snapshots.size === 0) { if (snapshots.size === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) sessionStorage.removeItem(PERSISTED_FEEDS_SESSION_KEY)
return return
} }
const payload: Record<string, Event[]> = {} const payload: Record<string, Event[]> = {}
@ -67,23 +40,25 @@ export function persistSessionFeedSnapshotsForHardRefresh(): void {
} }
} }
if (Object.keys(payload).length === 0) { if (Object.keys(payload).length === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) sessionStorage.removeItem(PERSISTED_FEEDS_SESSION_KEY)
return return
} }
sessionStorage.setItem(HARD_REFRESH_SESSION_KEY, JSON.stringify(payload)) sessionStorage.setItem(PERSISTED_FEEDS_SESSION_KEY, JSON.stringify(payload))
logger.info('[feed-snapshot] Persisted for hard reload', { feedKeys: Object.keys(payload).length })
} catch (e) { } catch (e) {
logger.warn('[feed-snapshot] Could not persist for hard reload', { error: e }) logger.warn('[feed-snapshot] Could not persist to sessionStorage', { error: e })
} }
} }
export function restoreSessionFeedSnapshotsAfterHardRefresh(): void { function schedulePersistToSessionStorage(): void {
try { if (typeof window === 'undefined') return
const raw = sessionStorage.getItem(HARD_REFRESH_SESSION_KEY) if (persistDebounceId != null) clearTimeout(persistDebounceId)
if (!raw) return persistDebounceId = setTimeout(() => {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY) persistDebounceId = null
const payload = JSON.parse(raw) as Record<string, unknown> persistFeedSnapshotsToSessionStorage()
if (!payload || typeof payload !== 'object') return }, 250)
}
function loadPayloadIntoMemory(payload: Record<string, unknown>, logLabel: string): number {
let restored = 0 let restored = 0
for (const [k, rows] of Object.entries(payload)) { for (const [k, rows] of Object.entries(payload)) {
if (!k || !Array.isArray(rows) || rows.length === 0) continue if (!k || !Array.isArray(rows) || rows.length === 0) continue
@ -92,19 +67,99 @@ export function restoreSessionFeedSnapshotsAfterHardRefresh(): void {
.slice(0, MAX_EVENTS_PER_FEED) .slice(0, MAX_EVENTS_PER_FEED)
.map((e) => ({ ...e })) .map((e) => ({ ...e }))
if (capped.length > 0) { if (capped.length > 0) {
setSessionFeedSnapshot(k, capped) setSessionFeedSnapshot(k, capped, { skipPersist: true })
restored++ restored++
} }
} }
if (restored > 0) { if (restored > 0) {
logger.info('[feed-snapshot] Restored after hard reload', { feeds: restored }) logger.info(`[feed-snapshot] ${logLabel}`, { feeds: restored })
} }
} catch (e) { return restored
logger.warn('[feed-snapshot] Could not restore after hard reload', { error: e }) }
/**
* 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.
*/
export function getSessionFeedSnapshot(key: string): Event[] | undefined {
if (!key) return undefined
const rows = snapshots.get(key)
if (!rows?.length) return undefined
bumpAccess(key)
return rows
}
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 { 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) sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
} catch { const payload = JSON.parse(legacy) as Record<string, unknown>
// ignore 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 restorePersistedFeedSnapshots} runs on next boot (see `main.tsx`).
*/
export function hardReloadPreservingFeedSnapshots(): void {
persistFeedSnapshotsToSessionStorage()
if (isImwaldElectron() && typeof window.imwaldElectron?.reloadApp === 'function') {
void window.imwaldElectron.reloadApp()
return
} }
window.location.reload()
} }
/** @deprecated Use {@link persistFeedSnapshotsToSessionStorage} — kept for callers. */
export function persistSessionFeedSnapshotsForHardRefresh(): void {
persistFeedSnapshotsToSessionStorage()
}
/** @deprecated Use {@link restorePersistedFeedSnapshots}. */
export function restoreSessionFeedSnapshotsAfterHardRefresh(): void {
restorePersistedFeedSnapshots()
}
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', () => {
if (persistDebounceId != null) {
clearTimeout(persistDebounceId)
persistDebounceId = null
}
persistFeedSnapshotsToSessionStorage()
})
} }

Loading…
Cancel
Save