Browse Source

fix pulse flakiness

imwald
Silberengel 1 month ago
parent
commit
9319da677a
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 2
      src/components/CacheRelaysSetting/index.tsx
  4. 73
      src/components/PostEditor/PostContent.tsx
  5. 6
      src/components/PostEditor/PostTextarea/index.tsx
  6. 2
      src/constants.ts
  7. 2
      src/lib/pubkey.ts
  8. 4
      src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx
  9. 132
      src/providers/FavoriteRelaysActivityProvider.tsx
  10. 12
      src/providers/NostrProvider/index.tsx
  11. 171
      src/services/post-editor-cache.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "19.3.4", "version": "19.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "19.3.4", "version": "19.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "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", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

2
src/components/CacheRelaysSetting/index.tsx

@ -272,7 +272,7 @@ export default function CacheRelaysSetting() {
} }
// Clear post editor cache // Clear post editor cache
postEditorCache.clearPostCache({}) postEditorCache.clearAllPostCaches()
// Clear in-memory caches so profile pics and reactions work after clear // Clear in-memory caches so profile pics and reactions work after clear
client.clearInMemoryCaches() client.clearInMemoryCaches()

73
src/components/PostEditor/PostContent.tsx

@ -308,39 +308,6 @@ export default function PostContent({
} }
}, [initialHighlightData]) }, [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 // Extract mentions from content for public messages
const extractMentionsFromContent = useCallback(async (content: string) => { const extractMentionsFromContent = useCallback(async (content: string) => {
try { try {
@ -440,6 +407,40 @@ export default function PostContent({
parentEvent 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 => { const rssReplyExtraPreviewTags = useMemo((): string[][] | undefined => {
if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined if (!parentEvent || parentEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) return undefined
const raw = const raw =
@ -920,7 +921,7 @@ export default function PostContent({
} }
// Full success - clean up and close // Full success - clean up and close
postEditorCache.clearPostCache({ defaultContent, parentEvent }) postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent })
deleteDraftEventCache(draftEvent) deleteDraftEventCache(draftEvent)
const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined const relayStatuses = (newEvent as any).relayStatuses as TRelayPublishStatus[] | undefined
const cleanEvent = { ...newEvent } const cleanEvent = { ...newEvent }
@ -970,7 +971,7 @@ export default function PostContent({
delete (clean as any).relayStatuses delete (clean as any).relayStatuses
mergePublishedReplyIntoThread(clean, (error as any).relayStatuses) mergePublishedReplyIntoThread(clean, (error as any).relayStatuses)
} }
postEditorCache.clearPostCache({ defaultContent, parentEvent }) postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent })
if (draftEvent) deleteDraftEventCache(draftEvent) if (draftEvent) deleteDraftEventCache(draftEvent)
onPublishSuccess?.() onPublishSuccess?.()
close() close()
@ -1482,7 +1483,7 @@ export default function PostContent({
const handleClear = () => { const handleClear = () => {
// Clear the post editor cache // Clear the post editor cache
postEditorCache.clearPostCache({ defaultContent, parentEvent }) postEditorCache.clearPostCache({ kind: getDeterminedKind, defaultContent, parentEvent })
// Clear the editor content // Clear the editor content
textareaRef.current?.clear() textareaRef.current?.clear()

6
src/components/PostEditor/PostTextarea/index.tsx

@ -153,10 +153,10 @@ const PostTextarea = forwardRef<
return parseEditorJsonToText(content.toJSON()) return parseEditorJsonToText(content.toJSON())
} }
}, },
content: postEditorCache.getPostContentCache({ defaultContent, parentEvent }), content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }),
onUpdate(props) { onUpdate(props) {
setText(parseEditorJsonToText(props.editor.getJSON())) setText(parseEditorJsonToText(props.editor.getJSON()))
postEditorCache.setPostContentCache({ defaultContent, parentEvent }, props.editor.getJSON()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON())
}, },
onCreate(props) { onCreate(props) {
setText(parseEditorJsonToText(props.editor.getJSON())) setText(parseEditorJsonToText(props.editor.getJSON()))
@ -207,7 +207,7 @@ const PostTextarea = forwardRef<
// Clear the editor content and reset to empty document // Clear the editor content and reset to empty document
editor.chain().clearContent().run() editor.chain().clearContent().run()
// Also clear the cache // Also clear the cache
postEditorCache.setPostContentCache({ defaultContent, parentEvent }, editor.getJSON()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON())
setText('') setText('')
} }
}, },

2
src/constants.ts

@ -122,6 +122,8 @@ export const StorageKey = {
SHOW_RSS_FEED: 'showRssFeed', SHOW_RSS_FEED: 'showRssFeed',
PANE_MODE: 'paneMode', PANE_MODE: 'paneMode',
ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', 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 MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated HIDE_UNTRUSTED_EVENTS: 'hideUntrustedEvents', // deprecated
ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated ACCOUNT_RELAY_LIST_EVENT_MAP: 'accountRelayListEventMap', // deprecated

2
src/lib/pubkey.ts

@ -78,7 +78,7 @@ export function hexPubkeysEqual(a: string, b: string): boolean {
} }
export function isValidPubkey(pubkey: string) { export function isValidPubkey(pubkey: string) {
return /^[0-9a-f]{64}$/.test(pubkey) return /^[0-9a-f]{64}$/i.test(pubkey)
} }
const pubkeyImageCache = new LRUCache<string, string>({ max: 1000 }) const pubkeyImageCache = new LRUCache<string, string>({ max: 1000 })

4
src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx

@ -290,7 +290,7 @@ export default function CreateThreadDialog({
setTopicInput(displayTopicLabel('general', DISCUSSION_TOPICS)) setTopicInput(displayTopicLabel('general', DISCUSSION_TOPICS))
setErrors({}) setErrors({})
postEditorCache.clearThreadDraft() postEditorCache.clearThreadDraft()
postEditorCache.clearPostCache({ parentEvent: THREAD_POST_EDITOR_PARENT }) postEditorCache.clearPostCache({ kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT })
postTextareaRef.current?.clear() postTextareaRef.current?.clear()
}, []) }, [])
@ -542,7 +542,7 @@ export default function CreateThreadDialog({
} }
postEditorCache.clearThreadDraft() postEditorCache.clearThreadDraft()
postEditorCache.clearPostCache({ parentEvent: THREAD_POST_EDITOR_PARENT }) postEditorCache.clearPostCache({ kind: ExtendedKind.DISCUSSION, parentEvent: THREAD_POST_EDITOR_PARENT })
onThreadCreated(publishedEvent) onThreadCreated(publishedEvent)
onClose() onClose()
} else { } else {

132
src/providers/FavoriteRelaysActivityProvider.tsx

@ -1,10 +1,11 @@
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' 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 { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useFollowListOptional } from '@/providers/FollowListProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -14,6 +15,7 @@ import {
} from './favorite-relays-activity-context' } from './favorite-relays-activity-context'
const ACTIVE_WINDOW_SEC = 3600 const ACTIVE_WINDOW_SEC = 3600
const FETCH_RETRY_DELAY_MS = 2500
/** Wall-clock cadence while the tab is visible */ /** Wall-clock cadence while the tab is visible */
const POLL_INTERVAL_MS = 60 * 60 * 1000 const POLL_INTERVAL_MS = 60 * 60 * 1000
/** Enough events to surface many distinct authors without overloading relays */ /** Enough events to surface many distinct authors without overloading relays */
@ -40,13 +42,16 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) {
} }
} }
const followSet = new Set( 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 followPubkeys: string[] = []
const otherPubkeys: string[] = [] const otherPubkeys: string[] = []
for (const pk of orderedPubkeys) { for (const pk of orderedPubkeys) {
const normalized = normalizeHexPubkey(pk) const hex = normalizeHexPubkey(pk)
if (normalized.length === 64 && followSet.has(normalized)) followPubkeys.push(pk) if (hex.length === 64 && followSet.has(hex)) followPubkeys.push(pk)
else otherPubkeys.push(pk) else otherPubkeys.push(pk)
} }
return { return {
@ -59,9 +64,11 @@ function partitionByFollows(orderedPubkeys: string[], followings: string[]) {
export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) { export function FavoriteRelaysActivityProvider({ children }: { children: React.ReactNode }) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const followList = useFollowListOptional() const { pubkey: viewerPubkey, followListEvent } = useNostr()
const followings = followList?.followings ?? [] const followings = useMemo(
const { pubkey: viewerPubkey } = useNostr() () => (followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []),
[followListEvent]
)
const [orderedPubkeys, setOrderedPubkeys] = useState<string[]>([]) const [orderedPubkeys, setOrderedPubkeys] = useState<string[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [relayActivityReady, setRelayActivityReady] = useState(false) const [relayActivityReady, setRelayActivityReady] = useState(false)
@ -69,49 +76,54 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState<Record<string, Event>>({}) const [profileKind0ByPubkey, setProfileKind0ByPubkey] = useState<Record<string, Event>>({})
const [profilesLoading, setProfilesLoading] = useState(false) const [profilesLoading, setProfilesLoading] = useState(false)
const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false) const [activeNpubsDrawerOpen, setActiveNpubsDrawerOpen] = useState(false)
const [fallbackFollowings, setFallbackFollowings] = useState<string[]>([])
const lastCompletedFetchAtRef = useRef(Date.now()) const lastCompletedFetchAtRef = useRef(Date.now())
const relayKey = useMemo( const relayKey = useMemo(
() => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'), () => getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays).join('\n'),
[favoriteRelays, blockedRelays] [favoriteRelays, blockedRelays]
) )
const fetchActive = useCallback(async () => { const fetchActive = useCallback(
const urls = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) async (useDefaultRelays = false) => {
if (urls.length === 0) { const urls = useDefaultRelays
setOrderedPubkeys([]) ? getFavoritesFeedRelayUrls([], blockedRelays)
setProfileKind0ByPubkey({}) : getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
setLoading(false) if (urls.length === 0) {
setRelayActivityReady(true) setLoading(false)
const now = Date.now() setRelayActivityReady(true)
lastCompletedFetchAtRef.current = now const now = Date.now()
setLastFetchedAtMs(now) lastCompletedFetchAtRef.current = now
return setLastFetchedAtMs(now)
} return
setLoading(true) }
const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC setLoading(true)
try { const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC
const events = await queryService.fetchEvents( try {
urls, const events = await queryService.fetchEvents(
{ since, limit: REQ_LIMIT }, urls,
{ { since, limit: REQ_LIMIT },
firstRelayResultGraceMs: false, {
eoseTimeout: 1800, firstRelayResultGraceMs: false,
globalTimeout: 14_000 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)
} }
) } finally {
setOrderedPubkeys(aggregatePubkeysByRecency(events)) setLoading(false)
} catch (error) { setRelayActivityReady(true)
logger.debug('[FavoriteRelaysActivity] fetch failed', { error }) }
setOrderedPubkeys([]) },
setProfileKind0ByPubkey({}) [favoriteRelays, blockedRelays]
} finally { )
setLoading(false)
setRelayActivityReady(true)
const now = Date.now()
lastCompletedFetchAtRef.current = now
setLastFetchedAtMs(now)
}
}, [favoriteRelays, blockedRelays])
const fetchRef = useRef(fetchActive) const fetchRef = useRef(fetchActive)
fetchRef.current = fetchActive fetchRef.current = fetchActive
@ -123,7 +135,8 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
setProfileKind0ByPubkey({}) 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<string | undefined>(undefined) const prevRelayKeyRef = useRef<string | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (prevRelayKeyRef.current === undefined) { if (prevRelayKeyRef.current === undefined) {
@ -133,20 +146,40 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
} }
if (prevRelayKeyRef.current === relayKey) return if (prevRelayKeyRef.current === relayKey) return
prevRelayKeyRef.current = relayKey prevRelayKeyRef.current = relayKey
resetForRefetch()
void fetchRef.current() void fetchRef.current()
}, [relayKey, resetForRefetch]) }, [relayKey])
/** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */ /** Logged-in user changed — refetch for the new account. Follow list changes update partition via useMemo. */
const prevViewerRef = useRef<string | undefined>(undefined) const prevViewerRef = useRef<string | undefined>(undefined)
useEffect(() => { useEffect(() => {
if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) { if (prevViewerRef.current !== undefined && prevViewerRef.current !== viewerPubkey) {
resetForRefetch() resetForRefetch()
setFallbackFollowings([])
void fetchRef.current() void fetchRef.current()
} }
prevViewerRef.current = viewerPubkey ?? undefined prevViewerRef.current = viewerPubkey ?? undefined
}, [viewerPubkey, resetForRefetch]) }, [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. */ /** While the document is visible: poll once per hour; when returning after a long background, catch up if due. */
useEffect(() => { useEffect(() => {
let intervalId: ReturnType<typeof setInterval> | undefined let intervalId: ReturnType<typeof setInterval> | undefined
@ -222,9 +255,10 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R
return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey)) return orderedPubkeys.filter((pk) => !hexPubkeysEqual(pk, viewerPubkey))
}, [orderedPubkeys, viewerPubkey]) }, [orderedPubkeys, viewerPubkey])
const effectiveFollowings = followings.length > 0 ? followings : fallbackFollowings
const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo( const { followPubkeys, otherPubkeys, followCount, otherCount } = useMemo(
() => partitionByFollows(displayPubkeys, followings), () => partitionByFollows(displayPubkeys, effectiveFollowings),
[displayPubkeys, followings] [displayPubkeys, effectiveFollowings]
) )
const pubkeys = useMemo( const pubkeys = useMemo(

12
src/providers/NostrProvider/index.tsx

@ -27,6 +27,7 @@ import client from '@/services/client.service'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService, replaceableEventService } from '@/services/client.service'
import customEmojiService from '@/services/custom-emoji.service' import customEmojiService from '@/services/custom-emoji.service'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import postEditorCache from '@/services/post-editor-cache.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import { import {
@ -688,6 +689,17 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
}, [account, accountNetworkHydrateBump]) }, [account, accountNetworkHydrateBump])
/** Clear persisted post draft when user logs out or switches accounts (not on initial load). */
const prevAccountPubkeyRef = useRef<string | null | undefined>(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. */ /** Recovery: if hydrate finished but follow list is still null, fetch using user write + search relays. */
useEffect(() => { useEffect(() => {
if (!account || followListEvent !== null || isAccountSessionHydrating) return if (!account || followListEvent !== null || isAccountSessionHydrating) return

171
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 { TPollCreateData } from '@/types'
import { Content } from '@tiptap/react' import { Content } from '@tiptap/react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
const PERSIST_DEBOUNCE_MS = 30_000
type TPostSettings = { type TPostSettings = {
isNsfw?: boolean isNsfw?: boolean
isPoll?: boolean isPoll?: boolean
@ -9,6 +13,12 @@ type TPostSettings = {
addClientTag?: boolean addClientTag?: boolean
} }
type TCacheKeyParams = {
kind: number
defaultContent?: string
parentEvent?: Event
}
/** Cached draft for the Discussions "Create Thread" dialog (kind 11). */ /** Cached draft for the Discussions "Create Thread" dialog (kind 11). */
export type TThreadDraft = { export type TThreadDraft = {
title: string title: string
@ -16,12 +26,21 @@ export type TThreadDraft = {
topic: string topic: string
} }
type TPersistedDraft = {
accountPubkey: string
postContentCache: Record<string, Content>
postSettingsCache: Record<string, TPostSettings>
threadDraft: TThreadDraft | null
}
class PostEditorCacheService { class PostEditorCacheService {
static instance: PostEditorCacheService static instance: PostEditorCacheService
private postContentCache: Map<string, Content> = new Map() private postContentCache: Map<string, Content> = new Map()
private postSettingsCache: Map<string, TPostSettings> = new Map() private postSettingsCache: Map<string, TPostSettings> = new Map()
private threadDraftCache: TThreadDraft | null = null private threadDraftCache: TThreadDraft | null = null
private persistTimeoutId: ReturnType<typeof setTimeout> | null = null
private restoredFromStorage = false
constructor() { constructor() {
if (!PostEditorCacheService.instance) { if (!PostEditorCacheService.instance) {
@ -38,11 +57,89 @@ class PostEditorCacheService {
return text.replace(/&/g, '&amp;') return text.replace(/&/g, '&amp;')
} }
getPostContentCache({ private restoreFromStorageIfNeeded() {
defaultContent, if (this.restoredFromStorage) return
parentEvent this.restoredFromStorage = true
}: { defaultContent?: string; parentEvent?: Event } = {}) { const account = storage.getCurrentAccount()
const cached = this.postContentCache.get(this.generateCacheKey(defaultContent, parentEvent)) 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<string, Content> = {}
this.postContentCache.forEach((v, k) => {
postContentCache[k] = v
})
const postSettingsCache: Record<string, TPostSettings> = {}
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 (cached !== undefined) return cached
if (defaultContent !== undefined && defaultContent !== '') { if (defaultContent !== undefined && defaultContent !== '') {
return this.escapeAmpersandsForHtml(defaultContent) return this.escapeAmpersandsForHtml(defaultContent)
@ -50,53 +147,67 @@ class PostEditorCacheService {
return defaultContent return defaultContent
} }
setPostContentCache( setPostContentCache({ kind, defaultContent, parentEvent }: TCacheKeyParams, content: Content) {
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event }, const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent })
content: Content this.postContentCache.set(cacheKey, content)
) { this.schedulePersist()
this.postContentCache.set(this.generateCacheKey(defaultContent, parentEvent), content)
} }
getPostSettingsCache({ getPostSettingsCache({ kind, defaultContent, parentEvent }: TCacheKeyParams): TPostSettings | undefined {
defaultContent, this.restoreFromStorageIfNeeded()
parentEvent return this.postSettingsCache.get(this.generateCacheKey({ kind, defaultContent, parentEvent }))
}: { defaultContent?: string; parentEvent?: Event } = {}): TPostSettings | undefined {
return this.postSettingsCache.get(this.generateCacheKey(defaultContent, parentEvent))
} }
setPostSettingsCache( setPostSettingsCache({ kind, defaultContent, parentEvent }: TCacheKeyParams, settings: TPostSettings) {
{ defaultContent, parentEvent }: { defaultContent?: string; parentEvent?: Event }, const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent })
settings: TPostSettings this.postSettingsCache.set(cacheKey, settings)
) { this.schedulePersist()
this.postSettingsCache.set(this.generateCacheKey(defaultContent, parentEvent), settings)
} }
clearPostCache({ clearPostCache({ kind, defaultContent, parentEvent }: TCacheKeyParams) {
defaultContent, const cacheKey = this.generateCacheKey({ kind, defaultContent, parentEvent })
parentEvent
}: {
defaultContent?: string
parentEvent?: Event
}) {
const cacheKey = this.generateCacheKey(defaultContent, parentEvent)
this.postContentCache.delete(cacheKey) this.postContentCache.delete(cacheKey)
this.postSettingsCache.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 { generateCacheKey({ kind, defaultContent = '', parentEvent }: TCacheKeyParams): string {
return parentEvent ? parentEvent.id : defaultContent const parentPart = parentEvent ? parentEvent.id : ''
return `${kind}:${parentPart}`
} }
getThreadDraft(): TThreadDraft | null { getThreadDraft(): TThreadDraft | null {
this.restoreFromStorageIfNeeded()
return this.threadDraftCache return this.threadDraftCache
} }
setThreadDraft(draft: TThreadDraft): void { setThreadDraft(draft: TThreadDraft): void {
this.threadDraftCache = draft this.threadDraftCache = draft
this.schedulePersist()
} }
clearThreadDraft(): void { clearThreadDraft(): void {
this.threadDraftCache = null this.threadDraftCache = null
if (this.persistTimeoutId) {
clearTimeout(this.persistTimeoutId)
this.persistTimeoutId = null
}
this.persistNow()
} }
} }

Loading…
Cancel
Save