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( @@ -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<string, number>
@ -3681,6 +3688,9 @@ function parseMarkdownContentMarked( @@ -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( @@ -4552,8 +4562,13 @@ function parseMarkdownContentMarked(
}
const inlineNodes = renderInlineTokens(paragraphTokens, `${key}-inline`)
const emojiOnly = isEmojiOnlyParagraphText(paragraphText)
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}
</div>
)
@ -4573,7 +4588,10 @@ function parseMarkdownContentMarked( @@ -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(
<div

298
src/components/NoteList/index.tsx

@ -2244,9 +2244,11 @@ const NoteList = forwardRef( @@ -2244,9 +2244,11 @@ const NoteList = forwardRef(
)
.filter((req) => 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( @@ -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( @@ -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( @@ -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,8 +2755,14 @@ const NoteList = forwardRef( @@ -2733,8 +2755,14 @@ const NoteList = forwardRef(
setLoading(!!oneShotFetch)
} else {
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) {
const shardFilters = mappedSubRequests.map(({ filter }) => filter as Filter)
const matchesSpellLocal = (ev: Event) =>
@ -2762,51 +2790,19 @@ const NoteList = forwardRef( @@ -2762,51 +2790,19 @@ const NoteList = forwardRef(
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
}
localMergeBase = mergedS
primedFromDisk = true
paintLocalWarmupTimeline(mergedS, 'spell_local_session')
}
}
}
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( @@ -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,7 +2844,7 @@ const NoteList = forwardRef( @@ -2839,7 +2844,7 @@ const NoteList = forwardRef(
maxMatches: localLayerCap * 2
})
])
if (!effectActive || timelineEffectStale()) return
if (effectActive && !timelineEffectStale()) {
const seen = new Set<string>()
const combinedRaw: Event[] = []
for (const ev of diskRaw) {
@ -2865,37 +2870,29 @@ const NoteList = forwardRef( @@ -2865,37 +2870,29 @@ const NoteList = forwardRef(
combinedRaw.push(ev)
}
combinedRaw.sort((a, b) => b.created_at - a.created_at)
if (combinedRaw.length === 0) return
if (combinedRaw.length > 0) {
const diskNarrowed = narrowLiveBatch(combinedRaw)
if (diskNarrowed.length === 0) return
if (diskNarrowed.length > 0) {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(spellLocalMergeBase, diskNarrowed, eventCapEarly, areAlgoRelays)
mergeEventBatchesById(localMergeBase, 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 (merged.length > 0) {
localMergeBase = merged
primedFromDisk = true
}
}
}
}
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
try {
const sessionScanLimit = Math.min(4000, Math.max(eventCapEarly * 4, 800))
const sessionHits = client.eventService.listSessionEventsAuthoredBy(
profileAuthorWarmSpec.author,
@ -2908,24 +2905,13 @@ const NoteList = forwardRef( @@ -2908,24 +2905,13 @@ const NoteList = forwardRef(
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
}
localMergeBase = mergedS
primedFromDisk = true
paintLocalWarmupTimeline(mergedS, 'profile_local_session')
}
}
}
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([
@ -2940,7 +2926,7 @@ const NoteList = forwardRef( @@ -2940,7 +2926,7 @@ const NoteList = forwardRef(
maxMatches: archiveCap
})
])
if (!effectActive || timelineEffectStale()) return
if (effectActive && !timelineEffectStale()) {
const premerged = mergeEventBatchesById(
[],
[...(fromArchive as Event[]), ...(diskSnap as Event[]), ...(fromLocalFeed as Event[])],
@ -2950,34 +2936,22 @@ const NoteList = forwardRef( @@ -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)
mergeEventBatchesById(localMergeBase, narrowed, eventCapEarly, areAlgoRelays)
)
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)
if (relayUrls.length > 0) {
const fetched = await client.fetchEvents(
if (relayUrls.length > 0 && effectActive && !timelineEffectStale()) {
void client
.fetchEvents(
relayUrls,
{
authors: [profileAuthorWarmSpec.author],
@ -2992,10 +2966,10 @@ const NoteList = forwardRef( @@ -2992,10 +2966,10 @@ 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) {
if (narrowedFetch.length === 0) return
setEvents((prev) => {
const merged = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowedFetch, eventCapEarly, areAlgoRelays)
@ -3012,25 +2986,108 @@ const NoteList = forwardRef( @@ -3012,25 +2986,108 @@ const NoteList = forwardRef(
setFeedEmptyToastGateTick((n) => n + 1)
setFeedTimelineEmptyUiReady(true)
}
}
}
})
.catch(() => {
/* best-effort */
})
}
} 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)
}
} 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)
timelineMergeBootstrapRef.current = []
setEvents([])
@ -3933,6 +3990,21 @@ const NoteList = forwardRef( @@ -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])

4
src/main.tsx

@ -12,7 +12,7 @@ import { createRoot } from 'react-dom/client' @@ -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() { @@ -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()))

157
src/services/session-feed-snapshot.service.ts

@ -7,11 +7,16 @@ const MAX_EVENTS_PER_FEED = 120 @@ -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<string, Event[]>()
const accessOrder: string[] = []
let persistDebounceId: ReturnType<typeof setTimeout> | null = null
function bumpAccess(key: string) {
const i = accessOrder.indexOf(key)
if (i >= 0) accessOrder.splice(i, 1)
@ -22,42 +27,10 @@ function bumpAccess(key: string) { @@ -22,42 +27,10 @@ function bumpAccess(key: string) {
}
}
/**
* 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 {
function persistFeedSnapshotsToSessionStorage(): void {
try {
if (snapshots.size === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
sessionStorage.removeItem(PERSISTED_FEEDS_SESSION_KEY)
return
}
const payload: Record<string, Event[]> = {}
@ -67,23 +40,25 @@ export function persistSessionFeedSnapshotsForHardRefresh(): void { @@ -67,23 +40,25 @@ export function persistSessionFeedSnapshotsForHardRefresh(): void {
}
}
if (Object.keys(payload).length === 0) {
sessionStorage.removeItem(HARD_REFRESH_SESSION_KEY)
sessionStorage.removeItem(PERSISTED_FEEDS_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 })
sessionStorage.setItem(PERSISTED_FEEDS_SESSION_KEY, JSON.stringify(payload))
} 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 {
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<string, unknown>
if (!payload || typeof payload !== 'object') return
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
@ -92,19 +67,99 @@ export function restoreSessionFeedSnapshotsAfterHardRefresh(): void { @@ -92,19 +67,99 @@ export function restoreSessionFeedSnapshotsAfterHardRefresh(): void {
.slice(0, MAX_EVENTS_PER_FEED)
.map((e) => ({ ...e }))
if (capped.length > 0) {
setSessionFeedSnapshot(k, capped)
setSessionFeedSnapshot(k, capped, { skipPersist: true })
restored++
}
}
if (restored > 0) {
logger.info('[feed-snapshot] Restored after hard reload', { feeds: restored })
logger.info(`[feed-snapshot] ${logLabel}`, { feeds: restored })
}
} catch (e) {
logger.warn('[feed-snapshot] Could not restore after hard reload', { error: e })
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.
*/
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 {
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)
} catch {
// ignore
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}.
* {@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