From 6d4bb00f8b6c09c3a8f38b5835036ced2e5ce146 Mon Sep 17 00:00:00 2001 From: codytseng Date: Sun, 24 Nov 2024 16:44:01 +0800 Subject: [PATCH] feat: cache replies --- src/renderer/src/App.tsx | 12 +- .../src/components/NoteList/index.tsx | 8 +- .../src/components/ReplyNoteList/index.tsx | 92 +++++++---- src/renderer/src/providers/NostrProvider.tsx | 7 +- src/renderer/src/services/client.service.ts | 155 ++++++++++++++---- .../src/services/event-bus.service.ts | 33 ---- src/renderer/src/services/storage.service.ts | 11 -- 7 files changed, 199 insertions(+), 119 deletions(-) delete mode 100644 src/renderer/src/services/event-bus.service.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a0aad9dd..e0da7cf2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,18 +14,18 @@ export default function App(): JSX.Element { return (
- - - + + + - - - + + +
) diff --git a/src/renderer/src/components/NoteList/index.tsx b/src/renderer/src/components/NoteList/index.tsx index 51605224..611efe7d 100644 --- a/src/renderer/src/components/NoteList/index.tsx +++ b/src/renderer/src/components/NoteList/index.tsx @@ -56,9 +56,6 @@ export default function NoteList({ setUntil(events[events.length - 1].created_at - 1) } setInitialized(true) - processedEvents.forEach((e) => { - client.addEventToCache(e) - }) }, onNew: (event) => { if (!isReplyNoteEvent(event)) { @@ -101,7 +98,7 @@ export default function NoteList({ }, [until, initialized, hasMore]) const loadMore = async () => { - const events = await client.fetchEvents(relayUrls, { ...noteFilter, until }) + const events = await client.fetchEvents(relayUrls, { ...noteFilter, until }, true) const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) if (sortedEvents.length === 0) { setHasMore(false) @@ -114,9 +111,6 @@ export default function NoteList({ } setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) - processedEvents.forEach((e) => { - client.addEventToCache(e) - }) } const showNewEvents = () => { diff --git a/src/renderer/src/components/ReplyNoteList/index.tsx b/src/renderer/src/components/ReplyNoteList/index.tsx index 5b554d64..02ec7f9b 100644 --- a/src/renderer/src/components/ReplyNoteList/index.tsx +++ b/src/renderer/src/components/ReplyNoteList/index.tsx @@ -1,52 +1,62 @@ import { Separator } from '@renderer/components/ui/separator' -import { isReplyNoteEvent } from '@renderer/lib/event' import { isReplyETag, isRootETag } from '@renderer/lib/tag' import { cn } from '@renderer/lib/utils' +import { useNostr } from '@renderer/providers/NostrProvider' import { useNoteStats } from '@renderer/providers/NoteStatsProvider' import client from '@renderer/services/client.service' -import dayjs from 'dayjs' import { Event } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' -import ReplyNote from '../ReplyNote' import { useTranslation } from 'react-i18next' +import ReplyNote from '../ReplyNote' export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() + const { isReady, pubkey } = useNostr() const [replies, setReplies] = useState([]) const [replyMap, setReplyMap] = useState< Record >({}) - const [until, setUntil] = useState(() => dayjs().unix()) + const [until, setUntil] = useState() const [loading, setLoading] = useState(false) - const [hasMore, setHasMore] = useState(false) const [highlightReplyId, setHighlightReplyId] = useState(undefined) const { updateNoteReplyCount } = useNoteStats() const replyRefs = useRef>({}) - const loadMore = async () => { - setLoading(true) - const relayList = await client.fetchRelayList(event.pubkey) - const events = await client.fetchEvents(relayList.read.slice(0, 5), { - '#e': [event.id], - kinds: [1], - limit: 100, - until - }) - const sortedEvents = events.sort((a, b) => a.created_at - b.created_at) - const processedEvents = events.filter((e) => isReplyNoteEvent(e)) - if (processedEvents.length > 0) { - setReplies((pre) => [...processedEvents, ...pre]) - } - if (sortedEvents.length > 0) { - setUntil(sortedEvents[0].created_at - 1) + useEffect(() => { + if (!isReady || loading) return + + const init = async () => { + setLoading(true) + setReplies([]) + setUntil(undefined) + + try { + const relayList = await client.fetchRelayList(event.pubkey) + const closer = await client.subscribeReplies(relayList.read.slice(0, 5), event.id, 100, { + onReplies: (evts, until) => { + setReplies(evts) + setUntil(until) + setLoading(false) + }, + onNew: (evt) => { + setReplies((pre) => [...pre, evt]) + if (evt.pubkey === pubkey) { + highlightReply(evt.id) + } + } + }) + return closer + } catch { + setLoading(false) + } + return } - setHasMore(sortedEvents.length >= 100) - setLoading(false) - } - useEffect(() => { - loadMore() - }, []) + const promise = init() + return () => { + promise.then((closer) => closer?.()) + } + }, [isReady]) useEffect(() => { updateNoteReplyCount(event.id, replies.length) @@ -83,7 +93,25 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas setReplyMap(replyMap) }, [replies]) - const onClickParent = (eventId: string) => { + const loadMore = async () => { + if (loading || !until) return + + setLoading(true) + const relayList = await client.fetchRelayList(event.pubkey) + const { replies: olderReplies, until: newUntil } = await client.fetchMoreReplies( + relayList.read.slice(0, 5), + event.id, + until, + 100 + ) + if (olderReplies.length > 0) { + setReplies((pre) => [...olderReplies, ...pre]) + } + setUntil(newUntil) + setLoading(false) + } + + const highlightReply = (eventId: string) => { const ref = replyRefs.current[eventId] if (ref) { ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) @@ -100,9 +128,9 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`} onClick={loadMore} > - {loading ? t('loading...') : hasMore ? t('load more older replies') : null} + {loading ? t('loading...') : until ? t('load more older replies') : null} - {replies.length > 0 && (loading || hasMore) && } + {replies.length > 0 && (loading || until) && }
{replies.map((reply, index) => { const info = replyMap[reply.id] @@ -111,14 +139,14 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
) })} - {replies.length === 0 && !loading && !hasMore && ( + {replies.length === 0 && !loading && !until && (
{t('no replies')}
)} diff --git a/src/renderer/src/providers/NostrProvider.tsx b/src/renderer/src/providers/NostrProvider.tsx index e4167fcb..1bb29acf 100644 --- a/src/renderer/src/providers/NostrProvider.tsx +++ b/src/renderer/src/providers/NostrProvider.tsx @@ -7,6 +7,7 @@ import client from '@renderer/services/client.service' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' import { createContext, useContext, useEffect, useState } from 'react' +import { useRelaySettings } from './RelaySettingsProvider' type TNostrContext = { isReady: boolean @@ -40,6 +41,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [pubkey, setPubkey] = useState(null) const [canLogin, setCanLogin] = useState(false) const [openLoginDialog, setOpenLoginDialog] = useState(false) + const { relayUrls: currentRelayUrls } = useRelaySettings() const relayList = useFetchRelayList(pubkey) useEffect(() => { @@ -108,7 +110,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (!event) { throw new Error('sign event failed') } - await client.publishEvent(relayList.write.concat(additionalRelayUrls), event) + await client.publishEvent( + relayList.write.concat(additionalRelayUrls).concat(currentRelayUrls), + event + ) return event } diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts index d6cf852a..c580c3b4 100644 --- a/src/renderer/src/services/client.service.ts +++ b/src/renderer/src/services/client.service.ts @@ -1,4 +1,5 @@ -import { TDraftEvent, TRelayGroup } from '@common/types' +import { TDraftEvent } from '@common/types' +import { isReplyNoteEvent } from '@renderer/lib/event' import { formatPubkey } from '@renderer/lib/pubkey' import { tagNameEquals } from '@renderer/lib/tag' import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url' @@ -14,8 +15,6 @@ import { SimplePool, VerifiedEvent } from 'nostr-tools' -import { EVENT_TYPES, eventBus } from './event-bus.service' -import storage from './storage.service' const BIG_RELAY_URLS = [ 'wss://relay.damus.io/', @@ -28,8 +27,6 @@ class ClientService { static instance: ClientService private pool = new SimplePool() - private relayUrls: string[] = BIG_RELAY_URLS - private initPromise!: Promise private eventCache = new LRUCache>({ max: 10000 }) private eventDataLoader = new DataLoader( @@ -40,6 +37,9 @@ class ClientService { this.eventBatchLoadFn.bind(this), { cache: false } ) + private repliesCache = new LRUCache({ + max: 1000 + }) private profileCache = new LRUCache>({ max: 10000 }) private profileDataloader = new DataLoader( (ids) => Promise.all(ids.map((id) => this._fetchProfileByBench32Id(id))), @@ -62,31 +62,17 @@ class ClientService { constructor() { if (!ClientService.instance) { - this.initPromise = this.init() ClientService.instance = this } return ClientService.instance } - async init() { - const relayGroups = await storage.getRelayGroups() - this.relayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] - eventBus.on(EVENT_TYPES.RELAY_GROUPS_CHANGED, (event) => { - this.onRelayGroupsChange(event.detail) - }) - } - - onRelayGroupsChange(relayGroups: TRelayGroup[]) { - const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] - this.relayUrls = newRelayUrls - } - listConnectionStatus() { return this.pool.listConnectionStatus() } async publishEvent(relayUrls: string[], event: NEvent) { - return await Promise.any(this.pool.publish(this.relayUrls.concat(relayUrls), event)) + return await Promise.any(this.pool.publish(relayUrls, event)) } subscribeEventsWithAuth( @@ -165,10 +151,121 @@ class ClientService { } } - async fetchEvents(relayUrls: string[], filter: Filter) { - await this.initPromise - // If relayUrls is empty, use this.relayUrls - return await this.pool.querySync(relayUrls.length > 0 ? relayUrls : BIG_RELAY_URLS, filter) + async subscribeReplies( + relayUrls: string[], + parentEventId: string, + limit: number, + { + onReplies, + onNew + }: { + onReplies: (events: NEvent[], until?: number) => void + onNew: (evt: NEvent) => void + } + ) { + let cache = this.repliesCache.get(parentEventId) + const refs = cache?.refs ?? [] + let replies: NEvent[] = [] + if (cache) { + replies = (await Promise.all(cache.refs.map(([id]) => this.eventCache.get(id)))).filter( + Boolean + ) as NEvent[] + onReplies(replies, cache.until) + } else { + cache = { refs } + this.repliesCache.set(parentEventId, cache) + } + const since = replies.length ? replies[replies.length - 1].created_at + 1 : undefined + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this + const events: NEvent[] = [] + let hasEosed = false + const closer = this.pool.subscribeMany( + relayUrls.length > 0 ? relayUrls : BIG_RELAY_URLS, + [ + { + '#e': [parentEventId], + kinds: [kinds.ShortTextNote], + limit, + since + } + ], + { + onevent(evt: NEvent) { + if (!isReplyNoteEvent(evt)) return + + if (hasEosed) { + onNew(evt) + } else { + events.push(evt) + } + that.eventDataLoader.prime(evt.id, Promise.resolve(evt)) + }, + oneose() { + hasEosed = true + const newReplies = events.sort((a, b) => a.created_at - b.created_at) + replies = replies.concat(newReplies) + refs.push(...newReplies.map((evt) => [evt.id, evt.created_at] as [string, number])) + // first fetch + if (!since) { + cache.until = events.length >= limit ? events[0].created_at - 1 : undefined + } + onReplies(replies, cache.until) + } + } + ) + + return () => { + onReplies = () => {} + onNew = () => {} + closer.close() + } + } + + async fetchMoreReplies(relayUrls: string[], parentEventId: string, until: number, limit: number) { + const events = await this.pool.querySync(relayUrls, { + '#e': [parentEventId], + kinds: [kinds.ShortTextNote], + limit, + until + }) + events.forEach((evt) => { + this.eventDataLoader.prime(evt.id, Promise.resolve(evt)) + }) + events.sort((a, b) => a.created_at - b.created_at) + const replies = events.filter((evt) => isReplyNoteEvent(evt)) + let cache = this.repliesCache.get(parentEventId) + if (!cache) { + cache = { refs: [] } + this.repliesCache.set(parentEventId, cache) + } + const refs = cache.refs + const firstRefCreatedAt = refs.length ? refs[0][1] : undefined + const newRefs = firstRefCreatedAt + ? replies + .filter((evt) => evt.created_at < firstRefCreatedAt) + .map((evt) => [evt.id, evt.created_at] as [string, number]) + : replies.map((evt) => [evt.id, evt.created_at] as [string, number]) + + if (newRefs.length) { + refs.unshift(...newRefs) + } + cache.until = events.length >= limit ? events[0].created_at - 1 : undefined + return { replies, until: cache.until } + } + + async fetchEvents(relayUrls: string[], filter: Filter, cache = false) { + const events = await this.pool.querySync( + relayUrls.length > 0 ? relayUrls : BIG_RELAY_URLS, + filter + ) + if (cache) { + events.forEach((evt) => { + this.eventDataLoader.prime(evt.id, Promise.resolve(evt)) + }) + } + return events } async fetchEventByBench32Id(id: string): Promise { @@ -351,12 +448,12 @@ class ClientService { } if (!relayUrls.length) return - const events = await this.fetchEvents(relayUrls, filter) + const events = await this.pool.querySync(relayUrls, filter) return events.sort((a, b) => b.created_at - a.created_at)[0] } private async eventBatchLoadFn(ids: readonly string[]) { - const events = await this.fetchEvents(BIG_RELAY_URLS, { + const events = await this.pool.querySync(BIG_RELAY_URLS, { ids: Array.from(new Set(ids)), limit: ids.length }) @@ -369,7 +466,7 @@ class ClientService { } private async profileBatchLoadFn(pubkeys: readonly string[]) { - const events = await this.fetchEvents(BIG_RELAY_URLS, { + const events = await this.pool.querySync(BIG_RELAY_URLS, { authors: Array.from(new Set(pubkeys)), kinds: [kinds.Metadata], limit: pubkeys.length @@ -390,7 +487,7 @@ class ClientService { } private async relayListBatchLoadFn(pubkeys: readonly string[]) { - const events = await this.fetchEvents(BIG_RELAY_URLS, { + const events = await this.pool.querySync(BIG_RELAY_URLS, { authors: pubkeys as string[], kinds: [kinds.RelayList], limit: pubkeys.length @@ -434,7 +531,7 @@ class ClientService { private async _fetchFollowListEvent(pubkey: string) { const relayList = await this.fetchRelayList(pubkey) - const followListEvents = await this.fetchEvents(relayList.write.concat(BIG_RELAY_URLS), { + const followListEvents = await this.pool.querySync(relayList.write.concat(BIG_RELAY_URLS), { authors: [pubkey], kinds: [kinds.Contacts] }) diff --git a/src/renderer/src/services/event-bus.service.ts b/src/renderer/src/services/event-bus.service.ts deleted file mode 100644 index c315f3bf..00000000 --- a/src/renderer/src/services/event-bus.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { TRelayGroup } from '@common/types' - -export const EVENT_TYPES = { - RELAY_GROUPS_CHANGED: 'relay-groups-changed' -} as const - -type TEventMap = { - [EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[] -} - -type TCustomEventMap = { - [K in keyof TEventMap]: CustomEvent -} - -export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => { - return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups }) -} - -class EventBus extends EventTarget { - emit(event: TCustomEventMap[K]): boolean { - return super.dispatchEvent(event) - } - - on(type: K, listener: (event: TCustomEventMap[K]) => void): void { - super.addEventListener(type, listener as EventListener) - } - - remove(type: K, listener: (event: TCustomEventMap[K]) => void): void { - super.removeEventListener(type, listener as EventListener) - } -} - -export const eventBus = new EventBus() diff --git a/src/renderer/src/services/storage.service.ts b/src/renderer/src/services/storage.service.ts index 9a099424..f10c6250 100644 --- a/src/renderer/src/services/storage.service.ts +++ b/src/renderer/src/services/storage.service.ts @@ -1,5 +1,4 @@ import { TRelayGroup } from '@common/types' -import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service' import { isElectron } from '@renderer/lib/env' const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [ @@ -40,7 +39,6 @@ class StorageService { private initPromise!: Promise private relayGroups: TRelayGroup[] = [] - private activeRelayUrls: string[] = [] private storage: Storage = new Storage() constructor() { @@ -53,7 +51,6 @@ class StorageService { async init() { this.relayGroups = await this.storage.getRelayGroups() - this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? [] } async getRelayGroups() { @@ -65,14 +62,6 @@ class StorageService { await this.initPromise await this.storage.setRelayGroups(relayGroups) this.relayGroups = relayGroups - const newActiveRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] - if ( - this.activeRelayUrls.length !== newActiveRelayUrls.length || - this.activeRelayUrls.some((url) => !newActiveRelayUrls.includes(url)) - ) { - eventBus.emit(createRelayGroupsChangedEvent(relayGroups)) - } - this.activeRelayUrls = newActiveRelayUrls } }