From 9319da677a39bf584259f8a32167a7fe06d02bb9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 24 Mar 2026 13:11:55 +0100 Subject: [PATCH] fix pulse flakiness --- package-lock.json | 4 +- package.json | 2 +- src/components/CacheRelaysSetting/index.tsx | 2 +- src/components/PostEditor/PostContent.tsx | 73 ++++---- .../PostEditor/PostTextarea/index.tsx | 6 +- src/constants.ts | 2 + src/lib/pubkey.ts | 2 +- .../DiscussionsPage/CreateThreadDialog.tsx | 4 +- .../FavoriteRelaysActivityProvider.tsx | 132 +++++++++----- src/providers/NostrProvider/index.tsx | 12 ++ src/services/post-editor-cache.service.ts | 171 +++++++++++++++--- 11 files changed, 285 insertions(+), 125 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1af8b32f..64e0d0bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "19.3.4", + "version": "19.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "19.3.4", + "version": "19.4.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 2b7a4081..1869a25b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "19.3.4", + "version": "19.4.0", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx index 8128d527..791b9178 100644 --- a/src/components/CacheRelaysSetting/index.tsx +++ b/src/components/CacheRelaysSetting/index.tsx @@ -272,7 +272,7 @@ export default function CacheRelaysSetting() { } // Clear post editor cache - postEditorCache.clearPostCache({}) + postEditorCache.clearAllPostCaches() // Clear in-memory caches so profile pics and reactions work after clear client.clearInMemoryCaches() diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 45d90ae0..d6b7acdb 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -308,39 +308,6 @@ export default function PostContent({ } }, [initialHighlightData]) - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - const cachedSettings = postEditorCache.getPostSettingsCache({ - defaultContent, - parentEvent - }) - if (cachedSettings) { - setIsNsfw(cachedSettings.isNsfw ?? false) - setIsPoll(cachedSettings.isPoll ?? false) - setPollCreateData( - cachedSettings.pollCreateData ?? { - isMultipleChoice: false, - options: ['', ''], - endsAt: undefined, - relays: [] - } - ) - setAddClientTag(cachedSettings.addClientTag ?? storage.getAddClientTag()) - } - return - } - postEditorCache.setPostSettingsCache( - { defaultContent, parentEvent }, - { - isNsfw, - isPoll, - pollCreateData, - addClientTag - } - ) - }, [defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) - // Extract mentions from content for public messages const extractMentionsFromContent = useCallback(async (content: string) => { try { @@ -440,6 +407,40 @@ export default function PostContent({ parentEvent ]) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + const cachedSettings = postEditorCache.getPostSettingsCache({ + kind: getDeterminedKind, + defaultContent, + parentEvent + }) + if (cachedSettings) { + setIsNsfw(cachedSettings.isNsfw ?? false) + setIsPoll(cachedSettings.isPoll ?? false) + setPollCreateData( + cachedSettings.pollCreateData ?? { + isMultipleChoice: false, + options: ['', ''], + endsAt: undefined, + relays: [] + } + ) + setAddClientTag(cachedSettings.addClientTag ?? storage.getAddClientTag()) + } + return + } + postEditorCache.setPostSettingsCache( + { kind: getDeterminedKind, defaultContent, parentEvent }, + { + isNsfw, + isPoll, + pollCreateData, + addClientTag + } + ) + }, [getDeterminedKind, defaultContent, parentEvent, isNsfw, isPoll, pollCreateData, addClientTag]) + const rssReplyExtraPreviewTags = useMemo((): string[][] | undefined => { if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined const raw = @@ -920,7 +921,7 @@ export default function PostContent({ } // Full success - clean up and close - postEditorCache.clearPostCache({ defaultContent, parentEvent }) + postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) deleteDraftEventCache(draftEvent) const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined const cleanEvent = { ...newEvent } @@ -970,7 +971,7 @@ export default function PostContent({ delete (clean as any).relayStatuses mergePublishedReplyIntoThread(clean, (error as any).relayStatuses) } - postEditorCache.clearPostCache({ defaultContent, parentEvent }) + postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) if (draftEvent) deleteDraftEventCache(draftEvent) onPublishSuccess?.() close() @@ -1482,7 +1483,7 @@ export default function PostContent({ const handleClear = () => { // Clear the post editor cache - postEditorCache.clearPostCache({ defaultContent, parentEvent }) + postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent }) // Clear the editor content textareaRef.current?.clear() diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 73680262..3eb35240 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -153,10 +153,10 @@ const PostTextarea = forwardRef< return parseEditorJsonToText(content.toJSON()) } }, - content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }), + content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }), onUpdate(props) { setText(parseEditorJsonToText(props.editor.getJSON())) - postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON()) + postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON()) }, onCreate(props) { setText(parseEditorJsonToText(props.editor.getJSON())) @@ -207,7 +207,7 @@ const PostTextarea = forwardRef< // Clear the editor content and reset to empty document editor.chain().clearContent().run() // Also clear the cache - postEditorCache.setPostContentCache({ defaultContent, parentEvent }, editor.getJSON()) + postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) setText('') } }, diff --git a/src/constants.ts b/src/constants.ts index 6848f8a2..7d0cc358 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -122,6 +122,8 @@ export const StorageKey = { SHOW_RSS_FEED: 'showRssFeed', PANE_MODE: 'paneMode', ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', + /** Temporary draft cache: new notes and replies. Persisted after 30s idle; restored on refresh; cleared on logout/switch. */ + POST_EDITOR_DRAFT: 'postEditorDraft', MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated diff --git a/src/lib/pubkey.ts b/src/lib/pubkey.ts index 55cd835e..3e8ad15d 100644 --- a/src/lib/pubkey.ts +++ b/src/lib/pubkey.ts @@ -78,7 +78,7 @@ export function hexPubkeysEqual(a: string, b: string): boolean { } export function isValidPubkey(pubkey: string) { - return /^[0-9a-f]{64}$/.test(pubkey) + return /^[0-9a-f]{64}$/i.test(pubkey) } const pubkeyImageCache = new LRUCache({ max: 1000 }) diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index 5f3fab15..cdc8b36e 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -290,7 +290,7 @@ export default function CreateThreadDialog({ setTopicInput(displayTopicLabel('general', DISCUSSION_TOPICS)) setErrors({}) postEditorCache.clearThreadDraft() - postEditorCache.clearPostCache({ parentEvent: THREAD_POST_EDITOR_PARENT }) + postEditorCache.clearPostCache({ kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT }) postTextareaRef.current?.clear() }, []) @@ -542,7 +542,7 @@ export default function CreateThreadDialog({ } postEditorCache.clearThreadDraft() - postEditorCache.clearPostCache({ parentEvent: THREAD_POST_EDITOR_PARENT }) + postEditorCache.clearPostCache({ kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT }) onThreadCreated(publishedEvent) onClose() } else { diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index 43954eb2..f0df2a8c 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -1,10 +1,11 @@ import logger from '@/lib/logger' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' -import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' +import { hexPubkeysEqual, normalizeHexPubkey, userIdToPubkey } from '@/lib/pubkey' +import { getPubkeysFromPTags } from '@/lib/tag' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useFollowListOptional } from '@/providers/FollowListProvider' import { useNostr } from '@/providers/NostrProvider' import { queryService, replaceableEventService } from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' import { kinds } from 'nostr-tools' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -14,6 +15,7 @@ import { } from './favorite-relays-activity-context' const ACTIVE_WINDOW_SEC = 3600 +const FETCH_RETRY_DELAY_MS = 2500 /** Wall-clock cadence while the tab is visible */ const POLL_INTERVAL_MS = 60 * 60 * 1000 /** Enough events to surface many distinct authors without overloading relays */ @@ -40,13 +42,16 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) { } } const followSet = new Set( - followings.map((p) => normalizeHexPubkey(p)).filter((p) => p.length === 64) + followings + .map((p) => userIdToPubkey(p)) + .filter((hex): hex is string => !!hex && /^[0-9a-f]{64}$/i.test(hex)) + .map((hex) => hex.toLowerCase()) ) const followPubkeys: string[] = [] const otherPubkeys: string[] = [] for (const pk of orderedPubkeys) { - const normalized = normalizeHexPubkey(pk) - if (normalized.length === 64 && followSet.has(normalized)) followPubkeys.push(pk) + const hex = normalizeHexPubkey(pk) + if (hex.length === 64 && followSet.has(hex)) followPubkeys.push(pk) else otherPubkeys.push(pk) } return { @@ -59,9 +64,11 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) { export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { const { favoriteRelays, blockedRelays } = useFavoriteRelays() - const followList = useFollowListOptional() - const followings = followList?.followings ?? [] - const { pubkey: viewerPubkey } = useNostr() + const { pubkey: viewerPubkey, followListEvent } = useNostr() + const followings = useMemo( + () => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []), + [followListEvent] + ) const [orderedPubkeys, setOrderedPubkeys] = useState([]) const [loading, setLoading] = useState(false) const [relayActivityReady, setRelayActivityReady] = useState(false) @@ -69,49 +76,54 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState>({}) const [profilesLoading, setProfilesLoading] = useState(false) const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false) + const [fallbackFollowings, setFallbackFollowings] = useState([]) const lastCompletedFetchAtRef = useRef(Date.now()) const relayKey = useMemo( () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'), [favoriteRelays, blockedRelays] ) - const fetchActive = useCallback(async () => { - const urls = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) - if (urls.length === 0) { - setOrderedPubkeys([]) - setProfileKind0ByPubkey({}) - setLoading(false) - setRelayActivityReady(true) - const now = Date.now() - lastCompletedFetchAtRef.current = now - setLastFetchedAtMs(now) - return - } - setLoading(true) - const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC - try { - const events = await queryService.fetchEvents( - urls, - { since, limit: REQ_LIMIT }, - { - firstRelayResultGraceMs: false, - eoseTimeout: 1800, - globalTimeout: 14_000 + const fetchActive = useCallback( + async (useDefaultRelays = false) => { + const urls = useDefaultRelays + ? getFavoritesFeedRelayUrls([], blockedRelays) + : getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + if (urls.length === 0) { + setLoading(false) + setRelayActivityReady(true) + const now = Date.now() + lastCompletedFetchAtRef.current = now + setLastFetchedAtMs(now) + return + } + setLoading(true) + const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC + try { + const events = await queryService.fetchEvents( + urls, + { since, limit: REQ_LIMIT }, + { + firstRelayResultGraceMs: false, + eoseTimeout: 1800, + globalTimeout: 14_000 + } + ) + const now = Date.now() + setOrderedPubkeys(aggregatePubkeysByRecency(events)) + lastCompletedFetchAtRef.current = now + setLastFetchedAtMs(now) + } catch (error) { + logger.debug('[FavoriteRelaysActivity] fetch failed', { error, useDefaultRelays }) + if (!useDefaultRelays && favoriteRelays.length > 0) { + setTimeout(() => void fetchRef.current(true), FETCH_RETRY_DELAY_MS) } - ) - setOrderedPubkeys(aggregatePubkeysByRecency(events)) - } catch (error) { - logger.debug('[FavoriteRelaysActivity] fetch failed', { error }) - setOrderedPubkeys([]) - setProfileKind0ByPubkey({}) - } finally { - setLoading(false) - setRelayActivityReady(true) - const now = Date.now() - lastCompletedFetchAtRef.current = now - setLastFetchedAtMs(now) - } - }, [favoriteRelays, blockedRelays]) + } finally { + setLoading(false) + setRelayActivityReady(true) + } + }, + [favoriteRelays, blockedRelays] + ) const fetchRef = useRef(fetchActive) fetchRef.current = fetchActive @@ -123,7 +135,8 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R setProfileKind0ByPubkey({}) }, []) - /** Initial fetch on mount and when relay set changes (refresh snapshot, not hourly cadence). */ + /** Initial fetch on mount and when relay set changes. Use stale-while-revalidate: keep previous + * data visible until new fetch completes instead of clearing and showing skeleton. */ const prevRelayKeyRef = useRef(undefined) useEffect(() => { if (prevRelayKeyRef.current === undefined) { @@ -133,20 +146,40 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R } if (prevRelayKeyRef.current === relayKey) return prevRelayKeyRef.current = relayKey - resetForRefetch() void fetchRef.current() - }, [relayKey, resetForRefetch]) + }, [relayKey]) /** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */ const prevViewerRef = useRef(undefined) useEffect(() => { if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) { resetForRefetch() + setFallbackFollowings([]) void fetchRef.current() } prevViewerRef.current = viewerPubkey ?? undefined }, [viewerPubkey, resetForRefetch]) + /** When follow list from context is empty but we have a logged-in viewer, try IndexedDB cache. + * Fixes race where pulse data arrives before NostrProvider has hydrated follow list from cache. */ + useEffect(() => { + if (!viewerPubkey || followings.length > 0) { + setFallbackFollowings([]) + return + } + let cancelled = false + indexedDb + .getReplaceableEvent(viewerPubkey, kinds.Contacts) + .then((evt) => { + if (cancelled || !evt) return + setFallbackFollowings(getPubkeysFromPTags(evt.tags)) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [viewerPubkey, followings.length]) + /** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */ useEffect(() => { let intervalId: ReturnType | undefined @@ -222,9 +255,10 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) }, [orderedPubkeys, viewerPubkey]) + const effectiveFollowings = followings.length > 0 ? followings : fallbackFollowings const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo( - () => partitionByFollows(displayPubkeys, followings), - [displayPubkeys, followings] + () => partitionByFollows(displayPubkeys, effectiveFollowings), + [displayPubkeys, effectiveFollowings] ) const pubkeys = useMemo( diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 2e06a0bb..2d01d452 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -27,6 +27,7 @@ import client from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service' import customEmojiService from '@/services/custom-emoji.service' import indexedDb from '@/services/indexed-db.service' +import postEditorCache from '@/services/post-editor-cache.service' import storage from '@/services/local-storage.service' import noteStatsService from '@/services/note-stats.service' import { @@ -688,6 +689,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } }, [account, accountNetworkHydrateBump]) + /** Clear persisted post draft when user logs out or switches accounts (not on initial load). */ + const prevAccountPubkeyRef = useRef(undefined) + useEffect(() => { + const prev = prevAccountPubkeyRef.current + const curr = account?.pubkey ?? null + prevAccountPubkeyRef.current = curr + if (prev !== undefined && prev !== curr) { + postEditorCache.clearOnAccountChange() + } + }, [account?.pubkey]) + /** Recovery: if hydrate finished but follow list is still null, fetch using user write + search relays. */ useEffect(() => { if (!account || followListEvent !== null || isAccountSessionHydrating) return diff --git a/src/services/post-editor-cache.service.ts b/src/services/post-editor-cache.service.ts index f57be1f2..b872a446 100644 --- a/src/services/post-editor-cache.service.ts +++ b/src/services/post-editor-cache.service.ts @@ -1,7 +1,11 @@ +import { StorageKey } from '@/constants' +import storage from '@/services/local-storage.service' import { TPollCreateData } from '@/types' import { Content } from '@tiptap/react' import { Event } from 'nostr-tools' +const PERSIST_DEBOUNCE_MS = 30_000 + type TPostSettings = { isNsfw?: boolean isPoll?: boolean @@ -9,6 +13,12 @@ type TPostSettings = { addClientTag?: boolean } +type TCacheKeyParams = { + kind: number + defaultContent?: string + parentEvent?: Event +} + /** Cached draft for the Discussions "Create Thread" dialog (kind 11). */ export type TThreadDraft = { title: string @@ -16,12 +26,21 @@ export type TThreadDraft = { topic: string } +type TPersistedDraft = { + accountPubkey: string + postContentCache: Record + postSettingsCache: Record + threadDraft: TThreadDraft | null +} + class PostEditorCacheService { static instance: PostEditorCacheService private postContentCache: Map = new Map() private postSettingsCache: Map = new Map() private threadDraftCache: TThreadDraft | null = null + private persistTimeoutId: ReturnType | null = null + private restoredFromStorage = false constructor() { if (!PostEditorCacheService.instance) { @@ -38,11 +57,89 @@ class PostEditorCacheService { return text.replace(/&/g, '&') } - getPostContentCache({ - defaultContent, - parentEvent - }: { defaultContent?: string; parentEvent?: Event } = {}) { - const cached = this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) + private restoreFromStorageIfNeeded() { + if (this.restoredFromStorage) return + this.restoredFromStorage = true + const account = storage.getCurrentAccount() + if (!account?.pubkey) return + try { + const raw = window.localStorage.getItem(StorageKey.POST_EDITOR_DRAFT) + if (!raw) return + const data = JSON.parse(raw) as TPersistedDraft + if (data.accountPubkey !== account.pubkey) return + if (data.postContentCache && typeof data.postContentCache === 'object') { + Object.entries(data.postContentCache).forEach(([k, v]) => { + if (v) this.postContentCache.set(k, v) + }) + } + if (data.postSettingsCache && typeof data.postSettingsCache === 'object') { + Object.entries(data.postSettingsCache).forEach(([k, v]) => { + if (v) this.postSettingsCache.set(k, v) + }) + } + if (data.threadDraft) { + this.threadDraftCache = data.threadDraft + } + } catch { + // Ignore corrupt or stale data + } + } + + private schedulePersist() { + if (this.persistTimeoutId) { + clearTimeout(this.persistTimeoutId) + } + this.persistTimeoutId = setTimeout(() => { + this.persistTimeoutId = null + this.persistNow() + }, PERSIST_DEBOUNCE_MS) + } + + private persistNow() { + const account = storage.getCurrentAccount() + if (!account?.pubkey) return + try { + const postContentCache: Record = {} + this.postContentCache.forEach((v, k) => { + postContentCache[k] = v + }) + const postSettingsCache: Record = {} + this.postSettingsCache.forEach((v, k) => { + postSettingsCache[k] = v + }) + const data: TPersistedDraft = { + accountPubkey: account.pubkey, + postContentCache, + postSettingsCache, + threadDraft: this.threadDraftCache + } + window.localStorage.setItem(StorageKey.POST_EDITOR_DRAFT, JSON.stringify(data)) + } catch { + // Ignore quota / serialization errors + } + } + + /** Call when user logs out or switches accounts. Clears in-memory cache and persisted draft. */ + clearOnAccountChange() { + if (this.persistTimeoutId) { + clearTimeout(this.persistTimeoutId) + this.persistTimeoutId = null + } + this.postContentCache.clear() + this.postSettingsCache.clear() + this.threadDraftCache = null + this.restoredFromStorage = false + try { + window.localStorage.removeItem(StorageKey.POST_EDITOR_DRAFT) + } catch { + // Ignore + } + } + + getPostContentCache({ kind, defaultContent, parentEvent }: TCacheKeyParams) { + this.restoreFromStorageIfNeeded() + const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent }) + const cached = this.postContentCache.get(cacheKey) if (cached !== undefined) return cached if (defaultContent !== undefined && defaultContent !== '') { return this.escapeAmpersandsForHtml(defaultContent) @@ -50,53 +147,67 @@ class PostEditorCacheService { return defaultContent } - setPostContentCache( - { defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event }, - content: Content - ) { - this.postContentCache.set(this.generateCacheKey(defaultContent, parentEvent), content) + setPostContentCache({ kind, defaultContent, parentEvent }: TCacheKeyParams, content: Content) { + const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent }) + this.postContentCache.set(cacheKey, content) + this.schedulePersist() } - getPostSettingsCache({ - defaultContent, - parentEvent - }: { defaultContent?: string; parentEvent?: Event } = {}): TPostSettings | undefined { - return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentEvent)) + getPostSettingsCache({ kind, defaultContent, parentEvent }: TCacheKeyParams): TPostSettings | undefined { + this.restoreFromStorageIfNeeded() + return this.postSettingsCache.get(this.generateCacheKey({ kind, defaultContent, parentEvent })) } - setPostSettingsCache( - { defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event }, - settings: TPostSettings - ) { - this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentEvent), settings) + setPostSettingsCache({ kind, defaultContent, parentEvent }: TCacheKeyParams, settings: TPostSettings) { + const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent }) + this.postSettingsCache.set(cacheKey, settings) + this.schedulePersist() } - clearPostCache({ - defaultContent, - parentEvent - }: { - defaultContent?: string - parentEvent?: Event - }) { - const cacheKey = this.generateCacheKey(defaultContent, parentEvent) + clearPostCache({ kind, defaultContent, parentEvent }: TCacheKeyParams) { + const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent }) this.postContentCache.delete(cacheKey) this.postSettingsCache.delete(cacheKey) + if (this.persistTimeoutId) { + clearTimeout(this.persistTimeoutId) + this.persistTimeoutId = null + } + this.persistNow() + } + + /** Clear all post and settings drafts. Use when user explicitly clears caches. */ + clearAllPostCaches() { + this.postContentCache.clear() + this.postSettingsCache.clear() + if (this.persistTimeoutId) { + clearTimeout(this.persistTimeoutId) + this.persistTimeoutId = null + } + this.persistNow() } - generateCacheKey(defaultContent: string = '', parentEvent?: Event): string { - return parentEvent ? parentEvent.id : defaultContent + generateCacheKey({ kind, defaultContent = '', parentEvent }: TCacheKeyParams): string { + const parentPart = parentEvent ? parentEvent.id : '' + return `${kind}:${parentPart}` } getThreadDraft(): TThreadDraft | null { + this.restoreFromStorageIfNeeded() return this.threadDraftCache } setThreadDraft(draft: TThreadDraft): void { this.threadDraftCache = draft + this.schedulePersist() } clearThreadDraft(): void { this.threadDraftCache = null + if (this.persistTimeoutId) { + clearTimeout(this.persistTimeoutId) + this.persistTimeoutId = null + } + this.persistNow() } }