From fd9f41c8f485b180bcf2b8a4602f40c62147afbd Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 2 Jan 2026 00:39:10 +0800 Subject: [PATCH] feat: add persistent cache for following feed and notifications --- src/components/Nip05/index.tsx | 6 +- src/components/NormalFeed/index.tsx | 6 +- src/components/NoteList/index.tsx | 11 +- src/components/NotificationList/index.tsx | 105 +++++--------- src/components/UserAggregationList/index.tsx | 7 +- src/hooks/useInfiniteScroll.tsx | 7 +- .../primary/NoteListPage/FollowingFeed.tsx | 1 + src/pages/primary/NoteListPage/PinnedFeed.tsx | 2 +- src/services/client.service.ts | 88 +++++++++-- src/services/indexed-db.service.ts | 137 +++++++++++++++++- 10 files changed, 268 insertions(+), 102 deletions(-) diff --git a/src/components/Nip05/index.tsx b/src/components/Nip05/index.tsx index 78c6c56..9b4e4f6 100644 --- a/src/components/Nip05/index.tsx +++ b/src/components/Nip05/index.tsx @@ -34,11 +34,11 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str {nip05IsVerified ? ( } + className="w-3.5 h-3.5 rounded-full shrink-0" + fallback={} /> ) : ( - + )} void + isPubkeyFeed?: boolean }) { const { showKinds } = useKindFilter() const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds) @@ -102,6 +104,7 @@ export default function NormalFeed({ subRequests={subRequests} areAlgoRelays={areAlgoRelays} showRelayCloseReason={showRelayCloseReason} + isPubkeyFeed={isPubkeyFeed} /> ) : ( )} diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index e0f98e0..f58e872 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -28,6 +28,7 @@ import { import { useTranslation } from 'react-i18next' import PullToRefresh from 'react-simple-pull-to-refresh' import { toast } from 'sonner' +import { LoadingBar } from '../LoadingBar' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' import PinnedNoteCard from '../PinnedNoteCard' @@ -53,6 +54,7 @@ const NoteList = forwardRef< pinnedEventIds?: string[] filterFn?: (event: Event) => boolean showNewNotesDirectly?: boolean + isPubkeyFeed?: boolean } >( ( @@ -66,7 +68,8 @@ const NoteList = forwardRef< showRelayCloseReason = false, pinnedEventIds, filterFn, - showNewNotesDirectly = false + showNewNotesDirectly = false, + isPubkeyFeed = false }, ref ) => { @@ -78,7 +81,7 @@ const NoteList = forwardRef< const { isEventDeleted } = useDeletedEvent() const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) - const [initialLoading, setInitialLoading] = useState(false) + const [initialLoading, setInitialLoading] = useState(true) const [filtering, setFiltering] = useState(false) const [timelineKey, setTimelineKey] = useState(undefined) const [filteredNotes, setFilteredNotes] = useState< @@ -340,7 +343,8 @@ const NoteList = forwardRef< }, { startLogin, - needSort: !areAlgoRelays + needSort: !areAlgoRelays, + needSaveToDb: isPubkeyFeed } ) setTimelineKey(timelineKey) @@ -384,6 +388,7 @@ const NoteList = forwardRef< const list = (
+ {initialLoading && shouldShowLoadingIndicator && } {pinnedEventIds?.map((id) => )} {visibleItems.map(({ key, event, reposters }) => ( { const [lastReadTime, setLastReadTime] = useState(0) const [refreshCount, setRefreshCount] = useState(0) const [timelineKey, setTimelineKey] = useState(undefined) - const [loading, setLoading] = useState(true) + const [initialLoading, setInitialLoading] = useState(true) const [notifications, setNotifications] = useState([]) - const [visibleNotifications, setVisibleNotifications] = useState([]) - const [showCount, setShowCount] = useState(SHOW_COUNT) const [until, setUntil] = useState(dayjs().unix()) const supportTouch = useMemo(() => isTouchDevice(), []) const topRef = useRef(null) - const bottomRef = useRef(null) const filterKinds = useMemo(() => { switch (notificationType) { case 'mentions': @@ -82,11 +81,11 @@ const NotificationList = forwardRef((_, ref) => { ref, () => ({ refresh: () => { - if (loading) return + if (initialLoading) return setRefreshCount((count) => count + 1) } }), - [loading] + [initialLoading] ) const handleNewEvent = useCallback( @@ -117,9 +116,9 @@ const NotificationList = forwardRef((_, ref) => { } const init = async () => { - setLoading(true) + setInitialLoading(true) setNotifications([]) - setShowCount(SHOW_COUNT) + setRefreshCount(SHOW_COUNT) setLastReadTime(getNotificationsSeenAt()) const relayList = await client.fetchRelayList(pubkey) @@ -140,7 +139,7 @@ const NotificationList = forwardRef((_, ref) => { setNotifications(events.filter((event) => event.pubkey !== pubkey)) } if (eosed) { - setLoading(false) + setInitialLoading(false) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) threadService.addRepliesToThread(events) stuffStatsService.updateStuffStatsByEvents(events) @@ -150,7 +149,8 @@ const NotificationList = forwardRef((_, ref) => { handleNewEvent(event) threadService.addRepliesToThread([event]) } - } + }, + { needSaveToDb: true } ) setTimelineKey(timelineKey) return closer @@ -187,63 +187,27 @@ const NotificationList = forwardRef((_, ref) => { } }, [pubkey, active, filterKinds, handleNewEvent]) - useEffect(() => { - setVisibleNotifications(notifications.slice(0, showCount)) - }, [notifications, showCount]) - - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 1 + const handleLoadMore = useCallback(async () => { + if (!timelineKey || !until) return false + const newEvents = await client.loadMoreTimeline(timelineKey, until, LIMIT) + if (newEvents.length === 0) { + return false } - const loadMore = async () => { - if (showCount < notifications.length) { - setShowCount((count) => count + SHOW_COUNT) - // preload more - if (notifications.length - showCount > LIMIT / 2) { - return - } - } + setNotifications((oldNotifications) => [ + ...oldNotifications, + ...newEvents.filter((event) => event.pubkey !== pubkey) + ]) + setUntil(newEvents[newEvents.length - 1].created_at - 1) + return true + }, [timelineKey, until, pubkey, setNotifications, setUntil]) - 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) => [ - ...oldNotifications, - ...newNotifications.filter((event) => event.pubkey !== pubkey) - ]) - } - - setUntil(newNotifications[newNotifications.length - 1].created_at - 1) - } - - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting) { - 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 { visibleItems, shouldShowLoadingIndicator, bottomRef, setShowCount } = useInfiniteScroll({ + items: notifications, + showCount: SHOW_COUNT, + onLoadMore: handleLoadMore, + initialLoading + }) const refresh = () => { topRef.current?.scrollIntoView({ behavior: 'instant', block: 'start' }) @@ -253,19 +217,20 @@ const NotificationList = forwardRef((_, ref) => { } const list = ( -
- {visibleNotifications.map((notification) => ( +
+ {initialLoading && shouldShowLoadingIndicator && } +
+ {visibleItems.map((notification) => ( lastReadTime} /> ))} +
- {until || loading ? ( -
- -
+ {!!until || shouldShowLoadingIndicator ? ( + ) : ( t('no more notifications') )} diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx index c0954b3..90f0773 100644 --- a/src/components/UserAggregationList/index.tsx +++ b/src/components/UserAggregationList/index.tsx @@ -52,6 +52,7 @@ const UserAggregationList = forwardRef< filterMutedNotes?: boolean areAlgoRelays?: boolean showRelayCloseReason?: boolean + isPubkeyFeed?: boolean } >( ( @@ -60,7 +61,8 @@ const UserAggregationList = forwardRef< showKinds, filterMutedNotes = true, areAlgoRelays = false, - showRelayCloseReason = false + showRelayCloseReason = false, + isPubkeyFeed = false }, ref ) => { @@ -187,7 +189,8 @@ const UserAggregationList = forwardRef< }, { startLogin, - needSort: !areAlgoRelays + needSort: !areAlgoRelays, + needSaveToDb: isPubkeyFeed } ) setTimelineKey(timelineKey) diff --git a/src/hooks/useInfiniteScroll.tsx b/src/hooks/useInfiniteScroll.tsx index 5685c32..bf9678d 100644 --- a/src/hooks/useInfiniteScroll.tsx +++ b/src/hooks/useInfiniteScroll.tsx @@ -65,8 +65,6 @@ export function useInfiniteScroll({ const loadMore = useCallback(async () => { const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current - if (initialLoading || loading) return - // If there are more items to show, increase showCount first if (showCount < itemsLength) { setShowCount((prev) => prev + initialShowCount) @@ -76,6 +74,8 @@ export function useInfiniteScroll({ } } + if (initialLoading || loading) return + if (!hasMore) return setLoading(true) const newHasMore = await onLoadMore() @@ -114,6 +114,7 @@ export function useInfiniteScroll({ shouldShowLoadingIndicator, bottomRef, setHasMore, - setLoading + setLoading, + setShowCount } } diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index a04f3cc..211ee3a 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -74,6 +74,7 @@ export default function FollowingFeed() { setRefreshCount((count) => count + 1) }} isMainFeed + isPubkeyFeed /> ) } diff --git a/src/pages/primary/NoteListPage/PinnedFeed.tsx b/src/pages/primary/NoteListPage/PinnedFeed.tsx index 2238553..7bc2892 100644 --- a/src/pages/primary/NoteListPage/PinnedFeed.tsx +++ b/src/pages/primary/NoteListPage/PinnedFeed.tsx @@ -28,5 +28,5 @@ export default function PinnedFeed() { init() }, [pubkey, pinnedPubkeySet]) - return + return } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b728a5c..057df4d 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -40,6 +40,7 @@ class ClientService extends EventTarget { pubkey?: string currentRelays: string[] = [] private pool: SimplePool + private externalSeenOn = new Map>() private timelines: Record< string, @@ -306,20 +307,21 @@ class ClientService extends EventTarget { }, { startLogin, - needSort = true + needSort = true, + needSaveToDb = false }: { startLogin?: () => void needSort?: boolean + needSaveToDb?: boolean } = {} ) { const newEventIdSet = new Set() const requestCount = subRequests.length - const threshold = Math.floor(requestCount / 2) - let events: NEvent[] = [] + const timelines: NEvent[][] = new Array(requestCount).fill(0).map(() => []) let eosedCount = 0 const subs = await Promise.all( - subRequests.map(({ urls, filter }) => { + subRequests.map(({ urls, filter }, i) => { return this._subscribeTimeline( urls, filter, @@ -329,11 +331,9 @@ class ClientService extends EventTarget { eosedCount++ } - events = this.mergeTimelines(events, _events) - - if (eosedCount >= threshold) { - onEvents(events, eosedCount >= requestCount) - } + timelines[i] = _events + const events = this.mergeTimelines(timelines, filter.limit) + onEvents(events, eosedCount >= requestCount) }, onNew: (evt) => { if (newEventIdSet.has(evt.id)) return @@ -342,7 +342,7 @@ class ClientService extends EventTarget { }, onClose }, - { startLogin, needSort } + { startLogin, needSort, needSaveToDb } ) }) ) @@ -362,14 +362,20 @@ class ClientService extends EventTarget { } } - private mergeTimelines(a: NEvent[], b: NEvent[]): NEvent[] { - if (a.length === 0) return [...b] - if (b.length === 0) return [...a] + private mergeTimelines(timelines: NEvent[][], limit: number) { + if (timelines.length === 0) return [] + 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[] = [] let i = 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]) if (cmp > 0) { 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 } @@ -579,10 +599,12 @@ class ClientService extends EventTarget { }, { startLogin, - needSort = true + needSort = true, + needSaveToDb = false }: { startLogin?: () => void needSort?: boolean + needSaveToDb?: boolean } = {} ) { const relays = Array.from(new Set(urls)) @@ -598,6 +620,15 @@ class ClientService extends EventTarget { onEvents([...cachedEvents], false) 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 @@ -615,6 +646,9 @@ class ClientService extends EventTarget { // new event if (evt.created_at > eosedAt) { onNew(evt) + if (needSaveToDb) { + indexedDb.putEvents([{ event: evt, relays: that.getEventHints(evt.id) }]) + } } 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) + if (needSaveToDb) { + indexedDb.putEvents( + events.map((evt) => ({ event: evt, relays: this.getEventHints(evt.id) })) + ) + } const timeline = that.timelines[key] // no cache yet 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 timeline.refs = newRefs onEvents([...events], true) + if (needSaveToDb) { + indexedDb.deleteEvents({ ...filter, until: events[events.length - 1].created_at }) + } } else { // merge new refs with old refs timeline.refs = newRefs.concat(timeline.refs) @@ -737,7 +779,12 @@ class ClientService extends EventTarget { } 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) { @@ -757,6 +804,15 @@ class ClientService extends EventTarget { 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) { return await new Promise((resolve) => { const events: NEvent[] = [] diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index ba8f2cd..886fdd7 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -1,7 +1,8 @@ import { ExtendedKind } from '@/constants' import { tagNameEquals } from '@/lib/tag' import { TRelayInfo } from '@/types' -import { Event, kinds } from 'nostr-tools' +import dayjs from 'dayjs' +import { Event, Filter, kinds, matchFilter } from 'nostr-tools' type TValue = { key: string @@ -25,6 +26,7 @@ const StoreNames = { RELAY_INFOS: 'relayInfos', DECRYPTED_CONTENTS: 'decryptedContents', PINNED_USERS_EVENTS: 'pinnedUsersEvents', + EVENTS: 'events', MUTE_DECRYPTED_TAGS: 'muteDecryptedTags', // deprecated RELAY_INFO_EVENTS: 'relayInfoEvents' // deprecated } @@ -45,7 +47,7 @@ class IndexedDbService { init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { - const request = window.indexedDB.open('jumble', 10) + const request = window.indexedDB.open('jumble', 11) request.onerror = (event) => { reject(event) @@ -103,6 +105,12 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.PINNED_USERS_EVENTS)) { 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)) { db.deleteObjectStore(StoreNames.RELAY_INFO_EVENTS) @@ -113,7 +121,10 @@ class IndexedDbService { this.db = db } }) - setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute + setTimeout(() => { + this.cleanUpOldEvents() + this.cleanUp() + }, 1000 * 30) // 30 seconds after initialization } return this.initPromise } @@ -440,6 +451,99 @@ class IndexedDbService { }) } + async putEvents(items: { event: Event; relays: string[] }[]): Promise { + 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 { + 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 { if ( [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()