Browse Source

speed up feeds

imwald
Silberengel 1 month ago
parent
commit
2c12b822fb
  1. 38
      src/components/CacheRelaysSetting/index.tsx
  2. 4
      src/components/Explore/ExploreRelayReviews.tsx
  3. 8
      src/components/NoteList/index.tsx
  4. 10
      src/constants.ts
  5. 42
      src/pages/primary/NoteListPage/FollowingFeed.tsx
  6. 49
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  7. 20
      src/pages/secondary/ProfileEditorPage/index.tsx
  8. 270
      src/providers/NostrProvider/index.tsx
  9. 5
      src/providers/nostr-context.tsx
  10. 139
      src/services/client.service.ts
  11. 24
      src/services/local-storage.service.ts

38
src/components/CacheRelaysSetting/index.tsx

@ -41,12 +41,21 @@ import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } f
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner' import { toast } from 'sonner'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
export default function CacheRelaysSetting() { export default function CacheRelaysSetting() {
const { t } = useTranslation() const { t } = useTranslation()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { pubkey, cacheRelayListEvent, checkLogin, publish, updateCacheRelayListEvent } = useNostr() const {
pubkey,
cacheRelayListEvent,
checkLogin,
publish,
updateCacheRelayListEvent,
relayList,
requestAccountNetworkHydrate
} = useNostr()
const [relays, setRelays] = useState<TMailboxRelay[]>([]) const [relays, setRelays] = useState<TMailboxRelay[]>([])
const [hasChange, setHasChange] = useState(false) const [hasChange, setHasChange] = useState(false)
const [pushing, setPushing] = useState(false) const [pushing, setPushing] = useState(false)
@ -62,6 +71,7 @@ export default function CacheRelaysSetting() {
const [showConsoleLogs, setShowConsoleLogs] = useState(false) const [showConsoleLogs, setShowConsoleLogs] = useState(false)
const [consoleLogSearch, setConsoleLogSearch] = useState('') const [consoleLogSearch, setConsoleLogSearch] = useState('')
const [consoleLogLevel, setConsoleLogLevel] = useState<'errors-warnings' | 'all'>('all') const [consoleLogLevel, setConsoleLogLevel] = useState<'errors-warnings' | 'all'>('all')
const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false)
const consoleLogRef = useRef<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([]) const consoleLogRef = useRef<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([])
const sensors = useSensors( const sensors = useSensors(
@ -282,16 +292,19 @@ export default function CacheRelaysSetting() {
const handleRefreshCache = async () => { const handleRefreshCache = async () => {
try { try {
// Force database upgrade to update structure setCacheRefreshBusy(true)
await indexedDb.forceDatabaseUpgrade() await indexedDb.forceDatabaseUpgrade()
// Reload cache info
await loadCacheInfo() await loadCacheInfo()
if (pubkey) {
await requestAccountNetworkHydrate()
await syncUserDeletionTombstones(pubkey, relayList)
}
toast.success(t('Cache refreshed successfully')) toast.success(t('Cache refreshed successfully'))
} catch (error) { } catch (error) {
logger.error('Failed to refresh cache', { error }) logger.error('Failed to refresh cache', { error })
toast.error(t('Failed to refresh cache')) toast.error(t('Failed to refresh cache'))
} finally {
setCacheRefreshBusy(false)
} }
} }
@ -848,14 +861,25 @@ export default function CacheRelaysSetting() {
<h3 className="text-sm font-semibold">{t('In-Browser Cache')}</h3> <h3 className="text-sm font-semibold">{t('In-Browser Cache')}</h3>
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
<div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div> <div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div>
<div>
{t('refreshCacheButtonExplainer', {
defaultValue:
'Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.'
})}
</div>
</div> </div>
<div className="flex min-w-0 flex-wrap gap-2"> <div className="flex min-w-0 flex-wrap gap-2">
<Button variant="outline" className="shrink-0" onClick={handleClearCache}> <Button variant="outline" className="shrink-0" onClick={handleClearCache}>
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t('Clear Cache')} {t('Clear Cache')}
</Button> </Button>
<Button variant="outline" className="shrink-0" onClick={handleRefreshCache}> <Button
<RefreshCw className="mr-2 h-4 w-4" /> variant="outline"
className="shrink-0"
onClick={handleRefreshCache}
disabled={cacheRefreshBusy}
>
<RefreshCw className={`mr-2 h-4 w-4 ${cacheRefreshBusy ? 'animate-spin' : ''}`} />
{t('Refresh Cache')} {t('Refresh Cache')}
</Button> </Button>
<Button variant="outline" className="shrink-0" onClick={handleBrowseCache}> <Button variant="outline" className="shrink-0" onClick={handleBrowseCache}>

4
src/components/Explore/ExploreRelayReviews.tsx

@ -1,6 +1,6 @@
import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata'
import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
@ -81,7 +81,7 @@ export default function ExploreRelayReviews() {
setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e])) setEvents((prev) => dedupeRelayReviewsNewestFirst([...prev, e]))
} }
}, },
firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS, firstRelayResultGraceMs: false,
globalTimeout: 12_000, globalTimeout: 12_000,
eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS, eoseTimeout: EXPLORE_REVIEWS_EOSE_TAIL_MS,
cache: true cache: true

8
src/components/NoteList/index.tsx

@ -638,9 +638,11 @@ const NoteList = forwardRef(
} }
const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0) const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0)
// Wide REQ batches open many sockets; a short race rejects and drops the subscription before first paint. // Many relays are opened under MAX_CONCURRENT_RELAY_CONNECTIONS; a short race aborts the whole feed.
const subscribeSetupRaceMs = const subscribeSetupRaceMs = Math.min(
totalRelayUrls > 24 ? 30_000 : totalRelayUrls > 8 ? 15_000 : 5000 300_000,
Math.max(90_000, 25_000 + totalRelayUrls * 2_500)
)
let closer: (() => void) | undefined let closer: (() => void) | undefined
let timelineKey: string | undefined let timelineKey: string | undefined

10
src/constants.ts

@ -34,7 +34,7 @@ export const MAX_PUBLISH_RELAYS = MAX_CONCURRENT_RELAY_CONNECTIONS
export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS export const MAX_REQ_RELAY_URLS = MAX_CONCURRENT_RELAY_CONNECTIONS
/** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */ /** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */
export const FIRST_RELAY_RESULT_GRACE_MS = 2000 export const FIRST_RELAY_RESULT_GRACE_MS = 5000
/** Legacy name: was used to cap spell NoteList skeleton time; loading now ends on EOSE / first events / safety timeouts. Kept for forks. */ /** Legacy name: was used to cap spell NoteList skeleton time; loading now ends on EOSE / first events / safety timeouts. Kept for forks. */
export const SPELL_FEED_LOADING_MAX_MS = 1000 export const SPELL_FEED_LOADING_MAX_MS = 1000
@ -48,6 +48,12 @@ export const SPELL_FEED_FIRST_RELAY_GRACE_MS = SPELL_FEED_LOADING_MAX_MS
*/ */
export const FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT = 200 export const FEED_FIRST_RELAY_RESULT_GRACE_MIN_LIMIT = 200
/**
* Minimum time between full account network hydrates (NostrProvider: relay + replaceable fetch from relays).
* IndexedDB cache still applies on every load; this only skips redundant network merges after a recent run.
*/
export const ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000
/** /**
* Batched kind-0 queries (ReplaceableEventService) over many relays (inbox, favorites, cache, defaults). * Batched kind-0 queries (ReplaceableEventService) over many relays (inbox, favorites, cache, defaults).
* Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried. * Too low causes empty profiles and NIP-05 gaps when relays are slow or many URLs are queried.
@ -86,6 +92,8 @@ export const StorageKey = {
QUICK_ZAP: 'quickZap', QUICK_ZAP: 'quickZap',
ZAP_REPLY_THRESHOLD: 'zapReplyThreshold', ZAP_REPLY_THRESHOLD: 'zapReplyThreshold',
ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap', ACCOUNT_FEED_INFO_MAP: 'accountFeedInfoMap',
/** Per-pubkey ms timestamps: last full network hydrate (see ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS). */
ACCOUNT_NETWORK_HYDRATE_AT_MAP: 'accountNetworkHydrateAtMap',
AUTOPLAY: 'autoplay', AUTOPLAY: 'autoplay',
HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions', HIDE_UNTRUSTED_INTERACTIONS: 'hideUntrustedInteractions',
HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications', HIDE_UNTRUSTED_NOTIFICATIONS: 'hideUntrustedNotifications',

42
src/pages/primary/NoteListPage/FollowingFeed.tsx

@ -1,13 +1,14 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { augmentSubRequestsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' import { augmentSubRequestsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import { TFeedSubRequest } from '@/types' import { TFeedSubRequest } from '@/types'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { forwardRef, useEffect, useState } from 'react' import { forwardRef, useEffect, useMemo, useState } from 'react'
const FollowingFeed = forwardRef< const FollowingFeed = forwardRef<
TNoteListRef, TNoteListRef,
@ -21,6 +22,43 @@ const FollowingFeed = forwardRef<
const { feedInfo } = useFeed() const { feedInfo } = useFeed()
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([]) const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
const favoriteRelaysKey = useMemo(
() =>
[...favoriteRelays]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('\0'),
[favoriteRelays]
)
const blockedRelaysKey = useMemo(
() =>
[...blockedRelays]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('\0'),
[blockedRelays]
)
const relayReadKey = useMemo(
() =>
[...(relayList?.read ?? [])]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('\0'),
[relayList?.read]
)
const relayWriteKey = useMemo(
() =>
[...(relayList?.write ?? [])]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('\0'),
[relayList?.write]
)
useEffect(() => { useEffect(() => {
async function init() { async function init() {
if (feedInfo.feedType !== 'following' || !pubkey) { if (feedInfo.feedType !== 'following' || !pubkey) {
@ -42,7 +80,7 @@ const FollowingFeed = forwardRef<
} }
void init() void init()
}, [feedInfo.feedType, pubkey, favoriteRelays, blockedRelays, relayList]) }, [feedInfo.feedType, pubkey, favoriteRelaysKey, blockedRelaysKey, relayReadKey, relayWriteKey])
return ( return (
<NormalFeed <NormalFeed

49
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -1,11 +1,12 @@
import NormalFeed from '@/components/NormalFeed' import NormalFeed from '@/components/NormalFeed'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { checkAlgoRelay } from '@/lib/relay' import { checkAlgoRelay } from '@/lib/relay'
import { normalizeUrl } from '@/lib/url'
import { useFeed } from '@/providers/FeedProvider' import { useFeed } from '@/providers/FeedProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import React, { forwardRef, useEffect, useMemo, useState, useRef } from 'react' import React, { forwardRef, useEffect, useMemo, useState } from 'react'
const RelaysFeed = forwardRef< const RelaysFeed = forwardRef<
TNoteListRef, TNoteListRef,
@ -19,48 +20,46 @@ const RelaysFeed = forwardRef<
const { feedInfo, relayUrls } = useFeed() const { feedInfo, relayUrls } = useFeed()
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
const [areAlgoRelays, setAreAlgoRelays] = useState(false) const [areAlgoRelays, setAreAlgoRelays] = useState(false)
const relayInfoFetchedRef = useRef(false)
// Fetch relay info in background (non-blocking) - don't wait for it to render const relayUrlsKey = useMemo(
() =>
[...relayUrls]
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
.sort()
.join('|'),
[relayUrls]
)
useEffect(() => { useEffect(() => {
// Only fetch once per relayUrls change if (relayUrls.length === 0) return
if (relayInfoFetchedRef.current || relayUrls.length === 0) { let cancelled = false
return
}
const init = async () => { const init = async () => {
relayInfoFetchedRef.current = true
// Add aggressive timeout to prevent hanging (reduced from 5s to 2s)
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => { setTimeout(() => {
reject(new Error('getRelayInfos timeout after 2 seconds')) reject(new Error('getRelayInfos timeout after 8 seconds'))
}, 2000) }, 8000)
}) })
try { try {
const relayInfos = await Promise.race([ const relayInfos = await Promise.race([
relayInfoService.getRelayInfos(relayUrls), relayInfoService.getRelayInfos(relayUrls),
timeoutPromise timeoutPromise
]) ])
if (cancelled) return
const areAlgo = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo)) const areAlgo = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
setAreAlgoRelays(areAlgo) setAreAlgoRelays(areAlgo)
} catch (_error) { } catch (_error) {
// Default to false - feed will work without this info if (!cancelled) setAreAlgoRelays(false)
setAreAlgoRelays(false)
} }
} }
// Don't await - let it run in background
init().catch(() => {
setAreAlgoRelays(false)
})
}, [relayUrls])
// Reset fetch flag when relayUrls change void init()
useEffect(() => { return () => {
relayInfoFetchedRef.current = false cancelled = true
}, [relayUrls]) }
}, [relayUrlsKey, relayUrls.length])
const defaultKinds = const defaultKinds =
kindsOverride && kindsOverride.length > 0 kindsOverride && kindsOverride.length > 0

20
src/pages/secondary/ProfileEditorPage/index.tsx

@ -22,6 +22,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event' import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event'
import { generateImageByPubkey } from '@/lib/pubkey' import { generateImageByPubkey } from '@/lib/pubkey'
import { isEmail } from '@/lib/utils' import { isEmail } from '@/lib/utils'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -34,7 +35,15 @@ import { toast } from 'sonner'
const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pop } = useSecondaryPage() const { pop } = useSecondaryPage()
const { account, profile, profileEvent, publish, updateProfileEvent } = useNostr() const {
account,
profile,
profileEvent,
publish,
updateProfileEvent,
relayList,
requestAccountNetworkHydrate
} = useNostr()
const [banner, setBanner] = useState<string>('') const [banner, setBanner] = useState<string>('')
const [avatar, setAvatar] = useState<string>('') const [avatar, setAvatar] = useState<string>('')
const [username, setUsername] = useState<string>('') const [username, setUsername] = useState<string>('')
@ -239,6 +248,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
if (!account?.pubkey) return if (!account?.pubkey) return
setRefreshingCache(true) setRefreshingCache(true)
try { try {
await requestAccountNetworkHydrate()
await syncUserDeletionTombstones(account.pubkey, relayList)
await client.forceRefreshProfileAndPaymentInfoCache(account.pubkey) await client.forceRefreshProfileAndPaymentInfoCache(account.pubkey)
const [profileEvt, paymentEvt] = await Promise.all([ const [profileEvt, paymentEvt] = await Promise.all([
client.fetchProfileEvent(account.pubkey), client.fetchProfileEvent(account.pubkey),
@ -252,7 +263,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} finally { } finally {
setRefreshingCache(false) setRefreshingCache(false)
} }
}, [account?.pubkey, updateProfileEvent, t]) }, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t])
if (!account || !profile) return null if (!account || !profile) return null
@ -298,7 +309,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
onClick={forceRefreshProfileAndPaymentCache} onClick={forceRefreshProfileAndPaymentCache}
disabled={refreshingCache} disabled={refreshingCache}
className="gap-1.5" className="gap-1.5"
title={t('Force-refresh profile and payment info from relays')} title={t('profileEditorRefreshCacheHint', {
defaultValue:
'Full account sync from relays (like Settings → Cache), deletion tombstones, then profile and payment info.'
})}
> >
{refreshingCache ? <Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden /> : <RefreshCw className="h-3.5 w-3.5" />} {refreshingCache ? <Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden /> : <RefreshCw className="h-3.5 w-3.5" />}
{t('Refresh cache')} {t('Refresh cache')}

270
src/providers/NostrProvider/index.tsx

@ -1,5 +1,6 @@
import LoginDialog from '@/components/LoginDialog' import LoginDialog from '@/components/LoginDialog'
import { import {
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS, DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
ExtendedKind, ExtendedKind,
@ -43,7 +44,7 @@ import { Event, kinds, VerifiedEvent, validateEvent } from 'nostr-tools'
import * as nip19 from 'nostr-tools/nip19' import * as nip19 from 'nostr-tools/nip19'
import * as nip49 from 'nostr-tools/nip49' import * as nip49 from 'nostr-tools/nip49'
import { NostrContext } from '@/providers/nostr-context' import { NostrContext } from '@/providers/nostr-context'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { BunkerSigner } from './bunker.signer' import { BunkerSigner } from './bunker.signer'
@ -145,6 +146,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [isAccountSessionHydrating, setIsAccountSessionHydrating] = useState(false) const [isAccountSessionHydrating, setIsAccountSessionHydrating] = useState(false)
/** Bumps on each account hydration run so stale async completions cannot clear {@link isAccountSessionHydrating}. */ /** Bumps on each account hydration run so stale async completions cannot clear {@link isAccountSessionHydrating}. */
const accountHydrationGenerationRef = useRef(0) const accountHydrationGenerationRef = useRef(0)
/** When true, next hydrate run performs a full network merge without clearing UI state from IndexedDB first. */
const forceNextAccountNetworkHydrateRef = useRef(false)
const manualNetworkHydrateResolveRef = useRef<(() => void) | null>(null)
const [accountNetworkHydrateBump, setAccountNetworkHydrateBump] = useState(0)
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -191,21 +196,39 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
let hydrationGenForThisRun = -1 let hydrationGenForThisRun = -1
const init = async () => { const init = async () => {
setRelayList(null)
setProfile(null)
setProfileEvent(null)
setNsec(null)
setFavoriteRelaysEvent(null)
setFollowListEvent(null)
setMuteListEvent(null)
setBookmarkListEvent(null)
setRssFeedListEvent(null)
if (!account) { if (!account) {
accountHydrationGenerationRef.current += 1 accountHydrationGenerationRef.current += 1
setIsAccountSessionHydrating(false) setIsAccountSessionHydrating(false)
forceNextAccountNetworkHydrateRef.current = false
setRelayList(null)
setProfile(null)
setProfileEvent(null)
setNsec(null)
setFavoriteRelaysEvent(null)
setFollowListEvent(null)
setMuteListEvent(null)
setBookmarkListEvent(null)
setRssFeedListEvent(null)
return undefined return undefined
} }
const userForcedAccountNetworkHydrate = forceNextAccountNetworkHydrateRef.current
if (userForcedAccountNetworkHydrate) {
forceNextAccountNetworkHydrateRef.current = false
}
if (!userForcedAccountNetworkHydrate) {
setRelayList(null)
setProfile(null)
setProfileEvent(null)
setNsec(null)
setFavoriteRelaysEvent(null)
setFollowListEvent(null)
setMuteListEvent(null)
setBookmarkListEvent(null)
setRssFeedListEvent(null)
}
hydrationGenForThisRun = accountHydrationGenerationRef.current += 1 hydrationGenForThisRun = accountHydrationGenerationRef.current += 1
setIsAccountSessionHydrating(true) setIsAccountSessionHydrating(true)
logger.info('[NostrProvider] Account session hydrate: loading cache and relays…', { logger.info('[NostrProvider] Account session hydrate: loading cache and relays…', {
@ -226,6 +249,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setNcryptsec(null) setNcryptsec(null)
} }
const INTEREST_LIST_KIND = 10015
const [ const [
storedRelayListEvent, storedRelayListEvent,
storedCacheRelayListEvent, storedCacheRelayListEvent,
@ -236,7 +261,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
storedFavoriteRelaysEvent, storedFavoriteRelaysEvent,
storedBlockedRelaysEvent, storedBlockedRelaysEvent,
storedUserEmojiListEvent, storedUserEmojiListEvent,
storedRssFeedListEvent storedRssFeedListEvent,
storedInterestListEvent,
storedBlossomServerListEvent
] = await Promise.all([ ] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.CACHE_RELAYS), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.CACHE_RELAYS),
@ -247,7 +274,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.BLOCKED_RELAYS), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.BLOCKED_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList), indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.RSS_FEED_LIST) indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.RSS_FEED_LIST),
indexedDb.getReplaceableEvent(account.pubkey, INTEREST_LIST_KIND),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.BLOSSOM_SERVER_LIST)
]) ])
// Extract blocked relays from event // Extract blocked relays from event
@ -261,12 +290,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
}) })
setBlockedRelaysEvent(storedBlockedRelaysEvent) if (!userForcedAccountNetworkHydrate) {
setBlockedRelaysEvent(storedBlockedRelaysEvent)
}
} }
// Set initial relay list from stored events (will be updated with merged list later) // Set initial relay list from stored events (will be updated with merged list later)
// Merge cache relays even at initial load so cache relays are available immediately // Merge cache relays even at initial load so cache relays are available immediately
if (storedRelayListEvent || storedCacheRelayListEvent) { if (!userForcedAccountNetworkHydrate && (storedRelayListEvent || storedCacheRelayListEvent)) {
const baseRelayList = storedRelayListEvent const baseRelayList = storedRelayListEvent
? getRelayListFromEvent(storedRelayListEvent, blockedRelays) ? getRelayListFromEvent(storedRelayListEvent, blockedRelays)
: { write: [], read: [], originalRelays: [] } : { write: [], read: [], originalRelays: [] }
@ -300,82 +331,105 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setRelayList(baseRelayList) setRelayList(baseRelayList)
} }
} }
if (storedProfileEvent) { if (!userForcedAccountNetworkHydrate) {
setProfileEvent(storedProfileEvent) if (storedProfileEvent) {
setProfile(getProfileFromEvent(storedProfileEvent)) setProfileEvent(storedProfileEvent)
} setProfile(getProfileFromEvent(storedProfileEvent))
if (storedFollowListEvent) { }
setFollowListEvent(storedFollowListEvent) if (storedFollowListEvent) {
} setFollowListEvent(storedFollowListEvent)
if (storedMuteListEvent) { }
setMuteListEvent(storedMuteListEvent) if (storedMuteListEvent) {
} setMuteListEvent(storedMuteListEvent)
if (storedBookmarkListEvent) { }
setBookmarkListEvent(storedBookmarkListEvent) if (storedBookmarkListEvent) {
} setBookmarkListEvent(storedBookmarkListEvent)
if (storedFavoriteRelaysEvent) { }
setFavoriteRelaysEvent(storedFavoriteRelaysEvent) if (storedFavoriteRelaysEvent) {
} setFavoriteRelaysEvent(storedFavoriteRelaysEvent)
if (storedUserEmojiListEvent) { }
setUserEmojiListEvent(storedUserEmojiListEvent) if (storedUserEmojiListEvent) {
} setUserEmojiListEvent(storedUserEmojiListEvent)
if (storedRssFeedListEvent) { }
setRssFeedListEvent(storedRssFeedListEvent) if (storedRssFeedListEvent) {
logger.debug('[NostrProvider] Loaded RSS feed list event from cache', { setRssFeedListEvent(storedRssFeedListEvent)
eventId: storedRssFeedListEvent.id, logger.debug('[NostrProvider] Loaded RSS feed list event from cache', {
created_at: storedRssFeedListEvent.created_at eventId: storedRssFeedListEvent.id,
}) created_at: storedRssFeedListEvent.created_at
})
}
if (storedInterestListEvent) {
setInterestListEvent(storedInterestListEvent)
}
if (storedBlossomServerListEvent) {
void client.updateBlossomServerListEventCache(storedBlossomServerListEvent)
}
} }
// Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour) const lastNetworkHydrateAt = storage.getAccountNetworkHydrateAt(account.pubkey)
const rssFeedListStale = !storedRssFeedListEvent || const hasLocalRelayAndProfile = !!storedRelayListEvent && !!storedProfileEvent
(dayjs().unix() - storedRssFeedListEvent.created_at > 3600) // 1 hour const skipNetworkHydrate =
!userForcedAccountNetworkHydrate &&
if (rssFeedListStale) { hasLocalRelayAndProfile &&
logger.debug('[NostrProvider] RSS feed list cache is missing or stale, fetching from relays', { typeof lastNetworkHydrateAt === 'number' &&
hasCache: !!storedRssFeedListEvent, Date.now() - lastNetworkHydrateAt < ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS
cacheAge: storedRssFeedListEvent ? dayjs().unix() - storedRssFeedListEvent.created_at : 'N/A'
}) if (!skipNetworkHydrate) {
// Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour)
// Fetch in background - don't block initialization const rssFeedListStale =
queryService.fetchEvents(FAST_WRITE_RELAY_URLS.concat(PROFILE_RELAY_URLS), { !storedRssFeedListEvent ||
kinds: [ExtendedKind.RSS_FEED_LIST], dayjs().unix() - storedRssFeedListEvent.created_at > 3600 // 1 hour
authors: [account.pubkey],
limit: 1 if (rssFeedListStale) {
}).then(events => { logger.debug('[NostrProvider] RSS feed list cache is missing or stale, fetching from relays', {
const latestEvent = getLatestEvent(events) hasCache: !!storedRssFeedListEvent,
if (latestEvent) { cacheAge: storedRssFeedListEvent ? dayjs().unix() - storedRssFeedListEvent.created_at : 'N/A'
// Only update if the fetched event is newer than cached })
if (!storedRssFeedListEvent || latestEvent.created_at > storedRssFeedListEvent.created_at) {
logger.debug('[NostrProvider] Found newer RSS feed list event from relays', { queryService
eventId: latestEvent.id, .fetchEvents(FAST_WRITE_RELAY_URLS.concat(PROFILE_RELAY_URLS), {
created_at: latestEvent.created_at, kinds: [ExtendedKind.RSS_FEED_LIST],
wasCached: !!storedRssFeedListEvent authors: [account.pubkey],
}) limit: 1
indexedDb.putReplaceableEvent(latestEvent).then(() => { })
setRssFeedListEvent(latestEvent) .then((events) => {
logger.debug('[NostrProvider] Updated RSS feed list event in cache and state') const latestEvent = getLatestEvent(events)
}).catch(err => { if (latestEvent) {
logger.error('[NostrProvider] Failed to cache RSS feed list event', { error: err }) if (!storedRssFeedListEvent || latestEvent.created_at > storedRssFeedListEvent.created_at) {
}) logger.debug('[NostrProvider] Found newer RSS feed list event from relays', {
} else { eventId: latestEvent.id,
logger.debug('[NostrProvider] Cached RSS feed list event is up to date', { created_at: latestEvent.created_at,
cachedCreatedAt: storedRssFeedListEvent.created_at, wasCached: !!storedRssFeedListEvent
fetchedCreatedAt: latestEvent.created_at })
}) indexedDb
} .putReplaceableEvent(latestEvent)
} else if (!storedRssFeedListEvent) { .then(() => {
logger.debug('[NostrProvider] No RSS feed list event found on relays (user may not have created one yet)') setRssFeedListEvent(latestEvent)
} logger.debug('[NostrProvider] Updated RSS feed list event in cache and state')
}).catch(err => { })
logger.error('[NostrProvider] Failed to fetch RSS feed list from relays', { error: err }) .catch((err) => {
// Don't clear cache on fetch error - use cached value logger.error('[NostrProvider] Failed to cache RSS feed list event', { error: err })
}) })
} else { } else {
logger.debug('[NostrProvider] RSS feed list cache is fresh, using cached value') logger.debug('[NostrProvider] Cached RSS feed list event is up to date', {
} cachedCreatedAt: storedRssFeedListEvent.created_at,
fetchedCreatedAt: latestEvent.created_at
})
}
} else if (!storedRssFeedListEvent) {
logger.debug(
'[NostrProvider] No RSS feed list event found on relays (user may not have created one yet)'
)
}
})
.catch((err) => {
logger.error('[NostrProvider] Failed to fetch RSS feed list from relays', { error: err })
})
} else {
logger.debug('[NostrProvider] RSS feed list cache is fresh, using cached value')
}
const [relayListEvents, cacheRelayListEvents] = await Promise.all([ const [relayListEvents, cacheRelayListEvents] = await Promise.all([
queryService.fetchEvents(FAST_READ_RELAY_URLS, { queryService.fetchEvents(FAST_READ_RELAY_URLS, {
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
authors: [account.pubkey] authors: [account.pubkey]
@ -414,7 +468,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
kinds.Contacts, kinds.Contacts,
kinds.Mutelist, kinds.Mutelist,
kinds.BookmarkList, kinds.BookmarkList,
10015, // Interest list INTEREST_LIST_KIND,
ExtendedKind.FAVORITE_RELAYS, ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS, ExtendedKind.BLOCKED_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST, ExtendedKind.BLOSSOM_SERVER_LIST,
@ -428,7 +482,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts) const followListEvent = sortedEvents.find((e) => e.kind === kinds.Contacts)
const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist) const muteListEvent = sortedEvents.find((e) => e.kind === kinds.Mutelist)
const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList) const bookmarkListEvent = sortedEvents.find((e) => e.kind === kinds.BookmarkList)
const interestListEvent = sortedEvents.find((e) => e.kind === 10015) const interestListEvent = sortedEvents.find((e) => e.kind === INTEREST_LIST_KIND)
const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS) const favoriteRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.FAVORITE_RELAYS)
const blockedRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.BLOCKED_RELAYS) const blockedRelaysEvent = sortedEvents.find((e) => e.kind === ExtendedKind.BLOCKED_RELAYS)
const blossomServerListEvent = sortedEvents.find( const blossomServerListEvent = sortedEvents.find(
@ -513,13 +567,29 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
} }
void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal }) storage.setAccountNetworkHydrateAt(account.pubkey, Date.now())
logger.info('[NostrProvider] Account session hydrate: core relay/profile merge finished; client prewarm started (parallel)', { void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal })
pubkeySlice: account.pubkey.slice(0, 12) logger.info('[NostrProvider] Account session hydrate: core relay/profile merge finished; client prewarm started (parallel)', {
}) pubkeySlice: account.pubkey.slice(0, 12)
})
} else {
logger.info('[NostrProvider] Skipped network hydrate (within min interval); IndexedDB cache only', {
pubkeySlice: account.pubkey.slice(0, 12),
lastNetworkHydrateAt,
ageMs: Date.now() - (lastNetworkHydrateAt ?? 0)
})
if (storedRelayListEvent) {
client.updateRelayListCache(storedRelayListEvent)
}
}
return controller return controller
} }
const promise = init() const promise = init()
void promise.finally(() => {
const r = manualNetworkHydrateResolveRef.current
manualNetworkHydrateResolveRef.current = null
r?.()
})
const finishHydration = () => { const finishHydration = () => {
if ( if (
hydrationGenForThisRun >= 0 && hydrationGenForThisRun >= 0 &&
@ -539,7 +609,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}) })
.catch(() => {}) .catch(() => {})
} }
}, [account]) }, [account, accountNetworkHydrateBump])
useEffect(() => { useEffect(() => {
if (!account) return if (!account) return
@ -1133,6 +1203,15 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setRssFeedListEvent(newRssFeedListEvent) setRssFeedListEvent(newRssFeedListEvent)
} }
const requestAccountNetworkHydrate = useCallback(() => {
if (!account) return Promise.resolve()
forceNextAccountNetworkHydrateRef.current = true
return new Promise<void>((resolve) => {
manualNetworkHydrateResolveRef.current = resolve
setAccountNetworkHydrateBump((n) => n + 1)
})
}, [account])
return ( return (
<NostrContext.Provider <NostrContext.Provider
value={{ value={{
@ -1180,7 +1259,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
updateInterestListEvent, updateInterestListEvent,
updateFavoriteRelaysEvent, updateFavoriteRelaysEvent,
updateBlockedRelaysEvent, updateBlockedRelaysEvent,
updateRssFeedListEvent updateRssFeedListEvent,
requestAccountNetworkHydrate
}} }}
> >
{children} {children}

5
src/providers/nostr-context.tsx

@ -59,6 +59,11 @@ export type TNostrContext = {
updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void> updateFavoriteRelaysEvent: (favoriteRelaysEvent: Event) => Promise<void>
updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void> updateBlockedRelaysEvent: (blockedRelaysEvent: Event) => Promise<void>
updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void> updateRssFeedListEvent: (rssFeedListEvent: Event) => Promise<void>
/**
* Re-run the full account network hydrate (relay lists + replaceable merge + prewarm), bypassing the
* 24h throttle. Resolves when the hydrate pass finishes. No-op when logged out.
*/
requestAccountNetworkHydrate: () => Promise<void>
} }
export const NostrContext = createContext<TNostrContext | undefined>(undefined) export const NostrContext = createContext<TNostrContext | undefined>(undefined)

139
src/services/client.service.ts

@ -20,7 +20,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter {
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { buildDeletionRelayUrls, dispatchTombstonesUpdated } from '@/lib/tombstone-events'
import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { import {
@ -52,7 +52,8 @@ import {
Event as NEvent, Event as NEvent,
Relay, Relay,
SimplePool, SimplePool,
VerifiedEvent VerifiedEvent,
verifyEvent
} from 'nostr-tools' } from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay' import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service' import indexedDb from './indexed-db.service'
@ -63,6 +64,13 @@ import { QueryService } from './client-query.service'
const SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS = 2800 const SUBSCRIBE_RELAY_CONNECTION_TIMEOUT_MS = 2800
const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800 const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800
/**
* After initial timeline EOSE (incl. grace), events with `created_at` older than this many seconds
* (relative to wall clock at EOSE) are treated as backlog stragglers and merged into the feed;
* fresher timestamps go to `onNew` (live / new notes UX).
*/
const TIMELINE_STRAGGLER_MAX_AGE_SEC = 600
function summarizeFiltersForRelayLog(filters: Filter[]): Record<string, unknown> { function summarizeFiltersForRelayLog(filters: Filter[]): Record<string, unknown> {
const f = filters[0] const f = filters[0]
if (!f) return {} if (!f) return {}
@ -1608,7 +1616,9 @@ class ClientService extends EventTarget {
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this const that = this
let events: NEvent[] = [] let events: NEvent[] = []
/** `null` until initial backlog is considered complete; then wall-clock unix at completion (for straggler vs live). */
let eosedAt: number | null = null let eosedAt: number | null = null
let eventIds = new Set<string>()
let firstResultGraceTimer: ReturnType<typeof setTimeout> | null = null let firstResultGraceTimer: ReturnType<typeof setTimeout> | null = null
const clearFirstResultGraceTimer = () => { const clearFirstResultGraceTimer = () => {
@ -1670,6 +1680,7 @@ class ClientService extends EventTarget {
} }
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit) events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
eventIds = new Set(events.map((e) => e.id))
const tl = that.timelines[key] const tl = that.timelines[key]
if (!tl || Array.isArray(tl)) { if (!tl || Array.isArray(tl)) {
@ -1678,8 +1689,10 @@ class ClientService extends EventTarget {
filter, filter,
urls urls
} }
} else if (tl.refs.length === 0) {
tl.refs = events.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
} else { } else {
const firstRefCreatedAt = tl.refs.length > 0 ? tl.refs[0][1] : dayjs().unix() const firstRefCreatedAt = tl.refs[0]![1]
const newRefs = events const newRefs = events
.filter((evt) => evt.created_at > firstRefCreatedAt) .filter((evt) => evt.created_at > firstRefCreatedAt)
.map((evt) => [evt.id, evt.created_at] as TTimelineRef) .map((evt) => [evt.id, evt.created_at] as TTimelineRef)
@ -1698,44 +1711,63 @@ class ClientService extends EventTarget {
that.addEventToCache(evt) that.addEventToCache(evt)
// not eosed yet, push to events // not eosed yet, push to events
if (!eosedAt) { if (!eosedAt) {
if (eventIds.has(evt.id)) return
eventIds.add(evt.id)
events.push(evt) events.push(evt)
flushStreamingSnapshot() flushStreamingSnapshot()
armFirstResultGraceAfterFirstEvent() armFirstResultGraceAfterFirstEvent()
return return
} }
// new event
if (evt.created_at > eosedAt) { if (eventIds.has(evt.id)) return
onNew(evt)
const wallClockAtEose = eosedAt
const isBacklogStraggler =
evt.created_at + TIMELINE_STRAGGLER_MAX_AGE_SEC < wallClockAtEose
if (isBacklogStraggler) {
eventIds.add(evt.id)
events.push(evt)
if (needSort) {
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
}
eventIds = new Set(events.map((e) => e.id))
onEvents([...events], false)
const timeline = that.timelines[key]
if (timeline && !Array.isArray(timeline)) {
timeline.refs = events
.map((e) => [e.id, e.created_at] as TTimelineRef)
.sort((a, b) => b[1] - a[1])
}
return
} }
// Update timeline refs for pagination tracking eventIds.add(evt.id)
// This is needed for loadMoreTimeline to know what events have been loaded onNew(evt)
const timeline = that.timelines[key] const timeline = that.timelines[key]
if (!timeline || Array.isArray(timeline)) { if (!timeline || Array.isArray(timeline)) {
return return
} }
// Initialize refs if empty (needed for pagination even when not using cache) if (timeline.refs.length === 0) {
if (!timeline.refs || timeline.refs.length === 0) { timeline.refs = events.map((e) => [e.id, e.created_at] as TTimelineRef).sort((a, b) => b[1] - a[1])
timeline.refs = [] return
} }
// find the right position to insert
let idx = 0 let idx = 0
for (const ref of timeline.refs) { for (const ref of timeline.refs) {
if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) { if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) {
break break
} }
// the event is already in the cache
if (evt.created_at === ref[1] && evt.id === ref[0]) { if (evt.created_at === ref[1] && evt.id === ref[0]) {
return return
} }
idx++ idx++
} }
// the event is too old, ignore it
if (idx >= timeline.refs.length) return if (idx >= timeline.refs.length) return
// insert the event to the right position
timeline.refs.splice(idx, 0, [evt.id, evt.created_at]) timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
}, },
oneose: handleTimelineEose, oneose: handleTimelineEose,
@ -1760,7 +1792,11 @@ class ClientService extends EventTarget {
const { filter, urls } = timeline const { filter, urls } = timeline
let events = await this.query(urls, { ...filter, until, limit }) let events = await this.query(urls, { ...filter, until, limit }, undefined, {
firstRelayResultGraceMs: false,
globalTimeout: 25_000,
eoseTimeout: 2500
})
events.forEach((evt) => { events.forEach((evt) => {
this.addEventToCache(evt) this.addEventToCache(evt)
}) })
@ -2071,19 +2107,68 @@ class ClientService extends EventTarget {
} }
/** /**
* Fetch deletion events (kind 5) and update the tombstone list. * Fetch kind-5 deletion events for an author, merge tombstones, remove matching rows from IndexedDB,
* Network sync is intentionally disabled: it queried many relays on every refresh/login and saturated * and notify the UI. Intended for **manual** refresh (e.g. cache settings); not run on every login.
* the connection pool. Tombstones still update via {@link applyDeletionRequestToLocalCache} when the user deletes from this client.
*/ */
async fetchDeletionEvents(_relayUrls: string[] = [], _authorPubkey?: string): Promise<void> { async fetchDeletionEvents(relayUrls: string[] = [], authorPubkey?: string): Promise<void> {
return const pk = authorPubkey?.trim().toLowerCase()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return
const urls = (relayUrls.length > 0 ? relayUrls : [...PROFILE_FETCH_RELAY_URLS])
.map((u) => normalizeUrl(u) || u)
.filter(Boolean)
const capped = Array.from(new Set(urls)).slice(0, 16)
if (capped.length === 0) return
try {
const events = await this.queryService.fetchEvents(
capped,
{
kinds: [kinds.EventDeletion],
authors: [pk],
limit: 500
},
{
firstRelayResultGraceMs: false,
globalTimeout: 22_000,
eoseTimeout: 2500
}
)
let any = false
for (const e of events) {
if (e.kind !== kinds.EventDeletion || e.pubkey.toLowerCase() !== pk) continue
if (!verifyEvent(e)) continue
if (shouldDropEventOnIngest(e)) continue
await this.addTombstoneEntriesFromDeletionEvent(e)
any = true
}
if (any) {
const removed = await indexedDb.removeTombstonedFromCache()
if (removed > 0) {
logger.info('[ClientService] Removed tombstoned events from cache after deletion sync', {
count: removed
})
}
dispatchTombstonesUpdated()
}
} catch (e) {
logger.warn('[ClientService] fetchDeletionEvents failed', { error: e })
}
} }
/** /** Fetch deletions for a profile pubkey using that user’s NIP-65 read stack when possible. */
* @deprecated No-op see {@link fetchDeletionEvents}. async fetchDeletionEventsForPubkey(profilePubkey: string): Promise<void> {
*/ const pk = profilePubkey.trim().toLowerCase()
async fetchDeletionEventsForPubkey(_profilePubkey: string): Promise<void> { if (!/^[0-9a-f]{64}$/.test(pk)) return
return try {
const rl = await this.fetchRelayList(pk)
const urls = buildDeletionRelayUrls(rl)
await this.fetchDeletionEvents(urls, pk)
} catch {
await this.fetchDeletionEvents(buildDeletionRelayUrls(null), pk)
}
} }
async searchNpubsForMention( async searchNpubsForMention(

24
src/services/local-storage.service.ts

@ -934,6 +934,30 @@ class LocalStorageService {
this.panelMode = mode this.panelMode = mode
this.persistSetting(StorageKey.PANE_MODE, mode) this.persistSetting(StorageKey.PANE_MODE, mode)
} }
getAccountNetworkHydrateAt(pubkey: string): number | undefined {
try {
const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP)
if (!raw) return undefined
const map = JSON.parse(raw) as Record<string, unknown>
const pk = pubkey.trim().toLowerCase()
const v = map[pk]
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
} catch {
return undefined
}
}
setAccountNetworkHydrateAt(pubkey: string, atMs: number): void {
try {
const raw = window.localStorage.getItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP)
const map: Record<string, number> = raw ? (JSON.parse(raw) as Record<string, number>) : {}
map[pubkey.trim().toLowerCase()] = atMs
window.localStorage.setItem(StorageKey.ACCOUNT_NETWORK_HYDRATE_AT_MAP, JSON.stringify(map))
} catch {
/* ignore quota / privacy mode */
}
}
} }
const instance = new LocalStorageService() const instance = new LocalStorageService()

Loading…
Cancel
Save