feat: add persistent cache for following feed and notifications

This commit is contained in:
codytseng 2026-01-02 00:39:10 +08:00
parent 7a9c777744
commit fd9f41c8f4
10 changed files with 268 additions and 102 deletions

View file

@ -34,11 +34,11 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
{nip05IsVerified ? ( {nip05IsVerified ? (
<Favicon <Favicon
domain={nip05Domain} domain={nip05Domain}
className="w-3.5 h-3.5 rounded-full" className="w-3.5 h-3.5 rounded-full shrink-0"
fallback={<BadgeCheck className="text-primary" />} fallback={<BadgeCheck className="text-primary shrink-0" />}
/> />
) : ( ) : (
<BadgeAlert className="text-muted-foreground" /> <BadgeAlert className="text-muted-foreground shrink-0" />
)} )}
<SecondaryPageLink <SecondaryPageLink
to={toNoteList({ domain: nip05Domain })} to={toNoteList({ domain: nip05Domain })}

View file

@ -16,7 +16,8 @@ export default function NormalFeed({
isMainFeed = false, isMainFeed = false,
showRelayCloseReason = false, showRelayCloseReason = false,
disable24hMode = false, disable24hMode = false,
onRefresh onRefresh,
isPubkeyFeed = false
}: { }: {
subRequests: TFeedSubRequest[] subRequests: TFeedSubRequest[]
areAlgoRelays?: boolean areAlgoRelays?: boolean
@ -24,6 +25,7 @@ export default function NormalFeed({
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
disable24hMode?: boolean disable24hMode?: boolean
onRefresh?: () => void onRefresh?: () => void
isPubkeyFeed?: boolean
}) { }) {
const { showKinds } = useKindFilter() const { showKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
@ -102,6 +104,7 @@ export default function NormalFeed({
subRequests={subRequests} subRequests={subRequests}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
isPubkeyFeed={isPubkeyFeed}
/> />
) : ( ) : (
<NoteList <NoteList
@ -111,6 +114,7 @@ export default function NormalFeed({
hideReplies={listMode === 'posts'} hideReplies={listMode === 'posts'}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason} showRelayCloseReason={showRelayCloseReason}
isPubkeyFeed={isPubkeyFeed}
/> />
)} )}
</> </>

View file

@ -28,6 +28,7 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { toast } from 'sonner' import { toast } from 'sonner'
import { LoadingBar } from '../LoadingBar'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import PinnedNoteCard from '../PinnedNoteCard' import PinnedNoteCard from '../PinnedNoteCard'
@ -53,6 +54,7 @@ const NoteList = forwardRef<
pinnedEventIds?: string[] pinnedEventIds?: string[]
filterFn?: (event: Event) => boolean filterFn?: (event: Event) => boolean
showNewNotesDirectly?: boolean showNewNotesDirectly?: boolean
isPubkeyFeed?: boolean
} }
>( >(
( (
@ -66,7 +68,8 @@ const NoteList = forwardRef<
showRelayCloseReason = false, showRelayCloseReason = false,
pinnedEventIds, pinnedEventIds,
filterFn, filterFn,
showNewNotesDirectly = false showNewNotesDirectly = false,
isPubkeyFeed = false
}, },
ref ref
) => { ) => {
@ -78,7 +81,7 @@ const NoteList = forwardRef<
const { isEventDeleted } = useDeletedEvent() const { isEventDeleted } = useDeletedEvent()
const [events, setEvents] = useState<Event[]>([]) const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([]) const [newEvents, setNewEvents] = useState<Event[]>([])
const [initialLoading, setInitialLoading] = useState(false) const [initialLoading, setInitialLoading] = useState(true)
const [filtering, setFiltering] = useState(false) const [filtering, setFiltering] = useState(false)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [filteredNotes, setFilteredNotes] = useState< const [filteredNotes, setFilteredNotes] = useState<
@ -340,7 +343,8 @@ const NoteList = forwardRef<
}, },
{ {
startLogin, startLogin,
needSort: !areAlgoRelays needSort: !areAlgoRelays,
needSaveToDb: isPubkeyFeed
} }
) )
setTimelineKey(timelineKey) setTimelineKey(timelineKey)
@ -384,6 +388,7 @@ const NoteList = forwardRef<
const list = ( const list = (
<div className="min-h-screen"> <div className="min-h-screen">
{initialLoading && shouldShowLoadingIndicator && <LoadingBar />}
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)} {pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
{visibleItems.map(({ key, event, reposters }) => ( {visibleItems.map(({ key, event, reposters }) => (
<NoteCard <NoteCard

View file

@ -1,4 +1,5 @@
import { BIG_RELAY_URLS, ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind, NOTIFICATION_LIST_STYLE } from '@/constants'
import { useInfiniteScroll } from '@/hooks'
import { compareEvents } from '@/lib/event' import { compareEvents } from '@/lib/event'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
@ -22,6 +23,7 @@ import {
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PullToRefresh from 'react-simple-pull-to-refresh' import PullToRefresh from 'react-simple-pull-to-refresh'
import { LoadingBar } from '../LoadingBar'
import { RefreshButton } from '../RefreshButton' import { RefreshButton } from '../RefreshButton'
import Tabs from '../Tabs' import Tabs from '../Tabs'
import { NotificationItem } from './NotificationItem' import { NotificationItem } from './NotificationItem'
@ -41,14 +43,11 @@ const NotificationList = forwardRef((_, ref) => {
const [lastReadTime, setLastReadTime] = useState(0) const [lastReadTime, setLastReadTime] = useState(0)
const [refreshCount, setRefreshCount] = useState(0) const [refreshCount, setRefreshCount] = useState(0)
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined) const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true) const [initialLoading, setInitialLoading] = useState(true)
const [notifications, setNotifications] = useState<NostrEvent[]>([]) const [notifications, setNotifications] = useState<NostrEvent[]>([])
const [visibleNotifications, setVisibleNotifications] = useState<NostrEvent[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [until, setUntil] = useState<number | undefined>(dayjs().unix()) const [until, setUntil] = useState<number | undefined>(dayjs().unix())
const supportTouch = useMemo(() => isTouchDevice(), []) const supportTouch = useMemo(() => isTouchDevice(), [])
const topRef = useRef<HTMLDivElement | null>(null) const topRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const filterKinds = useMemo(() => { const filterKinds = useMemo(() => {
switch (notificationType) { switch (notificationType) {
case 'mentions': case 'mentions':
@ -82,11 +81,11 @@ const NotificationList = forwardRef((_, ref) => {
ref, ref,
() => ({ () => ({
refresh: () => { refresh: () => {
if (loading) return if (initialLoading) return
setRefreshCount((count) => count + 1) setRefreshCount((count) => count + 1)
} }
}), }),
[loading] [initialLoading]
) )
const handleNewEvent = useCallback( const handleNewEvent = useCallback(
@ -117,9 +116,9 @@ const NotificationList = forwardRef((_, ref) => {
} }
const init = async () => { const init = async () => {
setLoading(true) setInitialLoading(true)
setNotifications([]) setNotifications([])
setShowCount(SHOW_COUNT) setRefreshCount(SHOW_COUNT)
setLastReadTime(getNotificationsSeenAt()) setLastReadTime(getNotificationsSeenAt())
const relayList = await client.fetchRelayList(pubkey) const relayList = await client.fetchRelayList(pubkey)
@ -140,7 +139,7 @@ const NotificationList = forwardRef((_, ref) => {
setNotifications(events.filter((event) => event.pubkey !== pubkey)) setNotifications(events.filter((event) => event.pubkey !== pubkey))
} }
if (eosed) { if (eosed) {
setLoading(false) setInitialLoading(false)
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
threadService.addRepliesToThread(events) threadService.addRepliesToThread(events)
stuffStatsService.updateStuffStatsByEvents(events) stuffStatsService.updateStuffStatsByEvents(events)
@ -150,7 +149,8 @@ const NotificationList = forwardRef((_, ref) => {
handleNewEvent(event) handleNewEvent(event)
threadService.addRepliesToThread([event]) threadService.addRepliesToThread([event])
} }
} },
{ needSaveToDb: true }
) )
setTimelineKey(timelineKey) setTimelineKey(timelineKey)
return closer return closer
@ -187,63 +187,27 @@ const NotificationList = forwardRef((_, ref) => {
} }
}, [pubkey, active, filterKinds, handleNewEvent]) }, [pubkey, active, filterKinds, handleNewEvent])
useEffect(() => { const handleLoadMore = useCallback(async () => {
setVisibleNotifications(notifications.slice(0, showCount)) if (!timelineKey || !until) return false
}, [notifications, showCount]) const newEvents = await client.loadMoreTimeline(timelineKey, until, LIMIT)
if (newEvents.length === 0) {
useEffect(() => { return false
const options = {
root: null,
rootMargin: '10px',
threshold: 1
} }
const loadMore = async () => {
if (showCount < notifications.length) {
setShowCount((count) => count + SHOW_COUNT)
// preload more
if (notifications.length - showCount > LIMIT / 2) {
return
}
}
if (!pubkey || !timelineKey || !until || loading) return
setLoading(true)
const newNotifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
setLoading(false)
if (newNotifications.length === 0) {
setUntil(undefined)
return
}
if (newNotifications.length > 0) {
setNotifications((oldNotifications) => [ setNotifications((oldNotifications) => [
...oldNotifications, ...oldNotifications,
...newNotifications.filter((event) => event.pubkey !== pubkey) ...newEvents.filter((event) => event.pubkey !== pubkey)
]) ])
} setUntil(newEvents[newEvents.length - 1].created_at - 1)
return true
}, [timelineKey, until, pubkey, setNotifications, setUntil])
setUntil(newNotifications[newNotifications.length - 1].created_at - 1) const { visibleItems, shouldShowLoadingIndicator, bottomRef, setShowCount } = useInfiniteScroll({
} items: notifications,
showCount: SHOW_COUNT,
const observerInstance = new IntersectionObserver((entries) => { onLoadMore: handleLoadMore,
if (entries[0].isIntersecting) { initialLoading
loadMore() })
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [pubkey, timelineKey, until, loading, showCount, notifications])
const refresh = () => { const refresh = () => {
topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' }) topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' })
@ -253,19 +217,20 @@ const NotificationList = forwardRef((_, ref) => {
} }
const list = ( const list = (
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}> <div>
{visibleNotifications.map((notification) => ( {initialLoading && shouldShowLoadingIndicator && <LoadingBar />}
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'mb-2' : ''} />
{visibleItems.map((notification) => (
<NotificationItem <NotificationItem
key={notification.id} key={notification.id}
notification={notification} notification={notification}
isNew={notification.created_at > lastReadTime} isNew={notification.created_at > lastReadTime}
/> />
))} ))}
<div ref={bottomRef} />
<div className="text-center text-sm text-muted-foreground"> <div className="text-center text-sm text-muted-foreground">
{until || loading ? ( {!!until || shouldShowLoadingIndicator ? (
<div ref={bottomRef}>
<NotificationSkeleton /> <NotificationSkeleton />
</div>
) : ( ) : (
t('no more notifications') t('no more notifications')
)} )}

View file

@ -52,6 +52,7 @@ const UserAggregationList = forwardRef<
filterMutedNotes?: boolean filterMutedNotes?: boolean
areAlgoRelays?: boolean areAlgoRelays?: boolean
showRelayCloseReason?: boolean showRelayCloseReason?: boolean
isPubkeyFeed?: boolean
} }
>( >(
( (
@ -60,7 +61,8 @@ const UserAggregationList = forwardRef<
showKinds, showKinds,
filterMutedNotes = true, filterMutedNotes = true,
areAlgoRelays = false, areAlgoRelays = false,
showRelayCloseReason = false showRelayCloseReason = false,
isPubkeyFeed = false
}, },
ref ref
) => { ) => {
@ -187,7 +189,8 @@ const UserAggregationList = forwardRef<
}, },
{ {
startLogin, startLogin,
needSort: !areAlgoRelays needSort: !areAlgoRelays,
needSaveToDb: isPubkeyFeed
} }
) )
setTimelineKey(timelineKey) setTimelineKey(timelineKey)

View file

@ -65,8 +65,6 @@ export function useInfiniteScroll<T>({
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current
if (initialLoading || loading) return
// If there are more items to show, increase showCount first // If there are more items to show, increase showCount first
if (showCount < itemsLength) { if (showCount < itemsLength) {
setShowCount((prev) => prev + initialShowCount) setShowCount((prev) => prev + initialShowCount)
@ -76,6 +74,8 @@ export function useInfiniteScroll<T>({
} }
} }
if (initialLoading || loading) return
if (!hasMore) return if (!hasMore) return
setLoading(true) setLoading(true)
const newHasMore = await onLoadMore() const newHasMore = await onLoadMore()
@ -114,6 +114,7 @@ export function useInfiniteScroll<T>({
shouldShowLoadingIndicator, shouldShowLoadingIndicator,
bottomRef, bottomRef,
setHasMore, setHasMore,
setLoading setLoading,
setShowCount
} }
} }

View file

@ -74,6 +74,7 @@ export default function FollowingFeed() {
setRefreshCount((count) => count + 1) setRefreshCount((count) => count + 1)
}} }}
isMainFeed isMainFeed
isPubkeyFeed
/> />
) )
} }

View file

@ -28,5 +28,5 @@ export default function PinnedFeed() {
init() init()
}, [pubkey, pinnedPubkeySet]) }, [pubkey, pinnedPubkeySet])
return <NormalFeed subRequests={subRequests} isMainFeed /> return <NormalFeed subRequests={subRequests} isMainFeed isPubkeyFeed />
} }

View file

@ -40,6 +40,7 @@ class ClientService extends EventTarget {
pubkey?: string pubkey?: string
currentRelays: string[] = [] currentRelays: string[] = []
private pool: SimplePool private pool: SimplePool
private externalSeenOn = new Map<string, Set<string>>()
private timelines: Record< private timelines: Record<
string, string,
@ -306,20 +307,21 @@ class ClientService extends EventTarget {
}, },
{ {
startLogin, startLogin,
needSort = true needSort = true,
needSaveToDb = false
}: { }: {
startLogin?: () => void startLogin?: () => void
needSort?: boolean needSort?: boolean
needSaveToDb?: boolean
} = {} } = {}
) { ) {
const newEventIdSet = new Set<string>() const newEventIdSet = new Set<string>()
const requestCount = subRequests.length const requestCount = subRequests.length
const threshold = Math.floor(requestCount / 2) const timelines: NEvent[][] = new Array(requestCount).fill(0).map(() => [])
let events: NEvent[] = []
let eosedCount = 0 let eosedCount = 0
const subs = await Promise.all( const subs = await Promise.all(
subRequests.map(({ urls, filter }) => { subRequests.map(({ urls, filter }, i) => {
return this._subscribeTimeline( return this._subscribeTimeline(
urls, urls,
filter, filter,
@ -329,11 +331,9 @@ class ClientService extends EventTarget {
eosedCount++ eosedCount++
} }
events = this.mergeTimelines(events, _events) timelines[i] = _events
const events = this.mergeTimelines(timelines, filter.limit)
if (eosedCount >= threshold) {
onEvents(events, eosedCount >= requestCount) onEvents(events, eosedCount >= requestCount)
}
}, },
onNew: (evt) => { onNew: (evt) => {
if (newEventIdSet.has(evt.id)) return if (newEventIdSet.has(evt.id)) return
@ -342,7 +342,7 @@ class ClientService extends EventTarget {
}, },
onClose onClose
}, },
{ startLogin, needSort } { startLogin, needSort, needSaveToDb }
) )
}) })
) )
@ -362,14 +362,20 @@ class ClientService extends EventTarget {
} }
} }
private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] { private mergeTimelines(timelines: NEvent[][], limit: number) {
if (a.length === 0) return [...b] if (timelines.length === 0) return []
if (b.length === 0) return [...a] if (timelines.length === 1) return timelines[0].slice(0, limit)
return timelines.reduce((merged, current) => this._mergeTimelines(merged, current, limit), [])
}
private _mergeTimelines(a: NEvent[], b: NEvent[], limit: number): NEvent[] {
if (a.length === 0) return b.slice(0, limit)
if (b.length === 0) return a.slice(0, limit)
const result: NEvent[] = [] const result: NEvent[] = []
let i = 0 let i = 0
let j = 0 let j = 0
while (i < a.length && j < b.length) { while (i < a.length && j < b.length && result.length < limit) {
const cmp = compareEvents(a[i], b[j]) const cmp = compareEvents(a[i], b[j])
if (cmp > 0) { if (cmp > 0) {
result.push(a[i]) result.push(a[i])
@ -384,6 +390,20 @@ class ClientService extends EventTarget {
} }
} }
if (result.length >= limit) {
return result
}
while (i < a.length) {
result.push(a[i])
i++
}
while (j < b.length) {
result.push(b[j])
j++
}
return result return result
} }
@ -579,10 +599,12 @@ class ClientService extends EventTarget {
}, },
{ {
startLogin, startLogin,
needSort = true needSort = true,
needSaveToDb = false
}: { }: {
startLogin?: () => void startLogin?: () => void
needSort?: boolean needSort?: boolean
needSaveToDb?: boolean
} = {} } = {}
) { ) {
const relays = Array.from(new Set(urls)) const relays = Array.from(new Set(urls))
@ -598,6 +620,15 @@ class ClientService extends EventTarget {
onEvents([...cachedEvents], false) onEvents([...cachedEvents], false)
since = cachedEvents[0].created_at + 1 since = cachedEvents[0].created_at + 1
} }
} else if (needSaveToDb) {
const storedEvents: NEvent[] = []
const items = await indexedDb.getEvents(filter)
items.forEach((item) => {
this.trackEventExternalSeenOn(item.event.id, item.relays)
storedEvents.push(item.event)
this.addEventToCache(item.event)
})
onEvents([...storedEvents], false)
} }
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
@ -615,6 +646,9 @@ class ClientService extends EventTarget {
// new event // new event
if (evt.created_at > eosedAt) { if (evt.created_at > eosedAt) {
onNew(evt) onNew(evt)
if (needSaveToDb) {
indexedDb.putEvents([{ event: evt, relays: that.getEventHints(evt.id) }])
}
} }
const timeline = that.timelines[key] const timeline = that.timelines[key]
@ -654,6 +688,11 @@ 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)
if (needSaveToDb) {
indexedDb.putEvents(
events.map((evt) => ({ event: evt, relays: this.getEventHints(evt.id) }))
)
}
const timeline = that.timelines[key] const timeline = that.timelines[key]
// no cache yet // no cache yet
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) { if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
@ -675,6 +714,9 @@ class ClientService extends EventTarget {
// if new refs are more than limit, means old refs are too old, replace them // if new refs are more than limit, means old refs are too old, replace them
timeline.refs = newRefs timeline.refs = newRefs
onEvents([...events], true) onEvents([...events], true)
if (needSaveToDb) {
indexedDb.deleteEvents({ ...filter, until: events[events.length - 1].created_at })
}
} else { } else {
// merge new refs with old refs // merge new refs with old refs
timeline.refs = newRefs.concat(timeline.refs) timeline.refs = newRefs.concat(timeline.refs)
@ -737,7 +779,12 @@ class ClientService extends EventTarget {
} }
getSeenEventRelayUrls(eventId: string) { getSeenEventRelayUrls(eventId: string) {
return this.getSeenEventRelays(eventId).map((relay) => relay.url) return Array.from(
new Set([
...this.getSeenEventRelays(eventId).map((relay) => relay.url),
...(this.externalSeenOn.get(eventId) || [])
])
)
} }
getEventHints(eventId: string) { getEventHints(eventId: string) {
@ -757,6 +804,15 @@ class ClientService extends EventTarget {
set.add(relay) set.add(relay)
} }
trackEventExternalSeenOn(eventId: string, relayUrls: string[]) {
let set = this.externalSeenOn.get(eventId)
if (!set) {
set = new Set()
this.externalSeenOn.set(eventId, set)
}
relayUrls.forEach((url) => set.add(url))
}
private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) { private async query(urls: string[], filter: Filter | Filter[], onevent?: (evt: NEvent) => void) {
return await new Promise<NEvent[]>((resolve) => { return await new Promise<NEvent[]>((resolve) => {
const events: NEvent[] = [] const events: NEvent[] = []

View file

@ -1,7 +1,8 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { TRelayInfo } from '@/types' import { TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools' import dayjs from 'dayjs'
import { Event, Filter, kinds, matchFilter } from 'nostr-tools'
type TValue<T = any> = { type TValue<T = any> = {
key: string key: string
@ -25,6 +26,7 @@ const StoreNames = {
RELAY_INFOS: 'relayInfos', RELAY_INFOS: 'relayInfos',
DECRYPTED_CONTENTS: 'decryptedContents', DECRYPTED_CONTENTS: 'decryptedContents',
PINNED_USERS_EVENTS: 'pinnedUsersEvents', PINNED_USERS_EVENTS: 'pinnedUsersEvents',
EVENTS: 'events',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated
RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated
} }
@ -45,7 +47,7 @@ class IndexedDbService {
init(): Promise<void> { init(): Promise<void> {
if (!this.initPromise) { if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => { this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 10) const request = window.indexedDB.open('jumble', 11)
request.onerror = (event) => { request.onerror = (event) => {
reject(event) reject(event)
@ -103,6 +105,12 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) {
db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.PINNED_USERS_EVENTS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.EVENTS)) {
const feedEventsStore = db.createObjectStore(StoreNames.EVENTS, {
keyPath: 'event.id'
})
feedEventsStore.createIndex('createdAtIndex', 'event.created_at')
}
if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) { if (db.objectStoreNames.contains(StoreNames.RELAY_INFO_EVENTS)) {
db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS) db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS)
@ -113,7 +121,10 @@ class IndexedDbService {
this.db = db this.db = db
} }
}) })
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute setTimeout(() => {
this.cleanUpOldEvents()
this.cleanUp()
}, 1000 * 30) // 30 seconds after initialization
} }
return this.initPromise return this.initPromise
} }
@ -440,6 +451,99 @@ class IndexedDbService {
}) })
} }
async putEvents(items: { event: Event; relays: string[] }[]): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.EVENTS)
let completed = 0
items.forEach((item) => {
const putRequest = store.put(item)
putRequest.onsuccess = () => {
completed++
if (completed === items.length) {
transaction.commit()
resolve()
}
}
putRequest.onerror = (event) => {
transaction.commit()
reject(event)
}
})
})
}
async getEvents({ limit, ...filter }: Filter): Promise<{ event: Event; relays: string[] }[]> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.EVENTS)
const index = store.index('createdAtIndex')
const request = index.openCursor(null, 'prev')
const results: { event: Event; relays: string[] }[] = []
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor && (!limit || results.length < limit)) {
const item = cursor.value as { event: Event; relays: string[] }
if (matchFilter(filter, item.event)) {
results.push(item)
}
cursor.continue()
} else {
transaction.commit()
resolve(results)
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async deleteEvents(filter: Filter & { until: number }): Promise<void> {
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
const transaction = this.db.transaction(StoreNames.EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.EVENTS)
const index = store.index('createdAtIndex')
const request = index.openCursor(IDBKeyRange.upperBound(filter.until, true))
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const item = cursor.value as { event: Event; relays: string[] }
if (matchFilter(filter, item.event)) {
cursor.delete()
}
cursor.continue()
} else {
transaction.commit()
resolve()
}
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
private getReplaceableEventKeyFromEvent(event: Event): string { private getReplaceableEventKeyFromEvent(event: Event): string {
if ( if (
[kinds.Metadata, kinds.Contacts].includes(event.kind) || [kinds.Metadata, kinds.Contacts].includes(event.kind) ||
@ -559,6 +663,33 @@ class IndexedDbService {
}) })
) )
} }
private async cleanUpOldEvents() {
await this.initPromise
if (!this.db) {
return
}
const transaction = this.db!.transaction(StoreNames.EVENTS, 'readwrite')
const store = transaction.objectStore(StoreNames.EVENTS)
const index = store.index('createdAtIndex')
const request = index.openCursor(IDBKeyRange.upperBound(dayjs().subtract(5, 'days').unix()))
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
cursor.delete()
cursor.continue()
} else {
transaction.commit()
}
}
request.onerror = (event) => {
transaction.commit()
console.error('Failed to clean up old events:', event)
}
}
} }
const instance = IndexedDbService.getInstance() const instance = IndexedDbService.getInstance()