Browse Source

feat: cache replies

imwald
codytseng 1 year ago
parent
commit
6d4bb00f8b
  1. 12
      src/renderer/src/App.tsx
  2. 8
      src/renderer/src/components/NoteList/index.tsx
  3. 92
      src/renderer/src/components/ReplyNoteList/index.tsx
  4. 7
      src/renderer/src/providers/NostrProvider.tsx
  5. 155
      src/renderer/src/services/client.service.ts
  6. 33
      src/renderer/src/services/event-bus.service.ts
  7. 11
      src/renderer/src/services/storage.service.ts

12
src/renderer/src/App.tsx

@ -14,18 +14,18 @@ export default function App(): JSX.Element {
return ( return (
<div className="h-screen"> <div className="h-screen">
<ThemeProvider> <ThemeProvider>
<NostrProvider> <RelaySettingsProvider>
<FollowListProvider> <NostrProvider>
<RelaySettingsProvider> <FollowListProvider>
<NoteStatsProvider> <NoteStatsProvider>
<PageManager> <PageManager>
<NoteListPage /> <NoteListPage />
</PageManager> </PageManager>
<Toaster /> <Toaster />
</NoteStatsProvider> </NoteStatsProvider>
</RelaySettingsProvider> </FollowListProvider>
</FollowListProvider> </NostrProvider>
</NostrProvider> </RelaySettingsProvider>
</ThemeProvider> </ThemeProvider>
</div> </div>
) )

8
src/renderer/src/components/NoteList/index.tsx

@ -56,9 +56,6 @@ export default function NoteList({
setUntil(events[events.length - 1].created_at - 1) setUntil(events[events.length - 1].created_at - 1)
} }
setInitialized(true) setInitialized(true)
processedEvents.forEach((e) => {
client.addEventToCache(e)
})
}, },
onNew: (event) => { onNew: (event) => {
if (!isReplyNoteEvent(event)) { if (!isReplyNoteEvent(event)) {
@ -101,7 +98,7 @@ export default function NoteList({
}, [until, initialized, hasMore]) }, [until, initialized, hasMore])
const loadMore = async () => { 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) const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
if (sortedEvents.length === 0) { if (sortedEvents.length === 0) {
setHasMore(false) setHasMore(false)
@ -114,9 +111,6 @@ export default function NoteList({
} }
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
processedEvents.forEach((e) => {
client.addEventToCache(e)
})
} }
const showNewEvents = () => { const showNewEvents = () => {

92
src/renderer/src/components/ReplyNoteList/index.tsx

@ -1,52 +1,62 @@
import { Separator } from '@renderer/components/ui/separator' import { Separator } from '@renderer/components/ui/separator'
import { isReplyNoteEvent } from '@renderer/lib/event'
import { isReplyETag, isRootETag } from '@renderer/lib/tag' import { isReplyETag, isRootETag } from '@renderer/lib/tag'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import { useNostr } from '@renderer/providers/NostrProvider'
import { useNoteStats } from '@renderer/providers/NoteStatsProvider' import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import dayjs from 'dayjs'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import ReplyNote from '../ReplyNote'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) { export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const { isReady, pubkey } = useNostr()
const [replies, setReplies] = useState<Event[]>([]) const [replies, setReplies] = useState<Event[]>([])
const [replyMap, setReplyMap] = useState< const [replyMap, setReplyMap] = useState<
Record<string, { event: Event; level: number; parent?: Event } | undefined> Record<string, { event: Event; level: number; parent?: Event } | undefined>
>({}) >({})
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [until, setUntil] = useState<number | undefined>()
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [hasMore, setHasMore] = useState<boolean>(false)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined) const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const { updateNoteReplyCount } = useNoteStats() const { updateNoteReplyCount } = useNoteStats()
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({}) const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const loadMore = async () => { useEffect(() => {
setLoading(true) if (!isReady || loading) return
const relayList = await client.fetchRelayList(event.pubkey)
const events = await client.fetchEvents(relayList.read.slice(0, 5), { const init = async () => {
'#e': [event.id], setLoading(true)
kinds: [1], setReplies([])
limit: 100, setUntil(undefined)
until
}) try {
const sortedEvents = events.sort((a, b) => a.created_at - b.created_at) const relayList = await client.fetchRelayList(event.pubkey)
const processedEvents = events.filter((e) => isReplyNoteEvent(e)) const closer = await client.subscribeReplies(relayList.read.slice(0, 5), event.id, 100, {
if (processedEvents.length > 0) { onReplies: (evts, until) => {
setReplies((pre) => [...processedEvents, ...pre]) setReplies(evts)
} setUntil(until)
if (sortedEvents.length > 0) { setLoading(false)
setUntil(sortedEvents[0].created_at - 1) },
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(() => { const promise = init()
loadMore() return () => {
}, []) promise.then((closer) => closer?.())
}
}, [isReady])
useEffect(() => { useEffect(() => {
updateNoteReplyCount(event.id, replies.length) updateNoteReplyCount(event.id, replies.length)
@ -83,7 +93,25 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
setReplyMap(replyMap) setReplyMap(replyMap)
}, [replies]) }, [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] const ref = replyRefs.current[eventId]
if (ref) { if (ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) 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' : ''}`} className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore} onClick={loadMore}
> >
{loading ? t('loading...') : hasMore ? t('load more older replies') : null} {loading ? t('loading...') : until ? t('load more older replies') : null}
</div> </div>
{replies.length > 0 && (loading || hasMore) && <Separator className="my-4" />} {replies.length > 0 && (loading || until) && <Separator className="my-4" />}
<div className={cn('mb-4', className)}> <div className={cn('mb-4', className)}>
{replies.map((reply, index) => { {replies.map((reply, index) => {
const info = replyMap[reply.id] const info = replyMap[reply.id]
@ -111,14 +139,14 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
<ReplyNote <ReplyNote
event={reply} event={reply}
parentEvent={info?.parent} parentEvent={info?.parent}
onClickParent={onClickParent} onClickParent={highlightReply}
highlight={highlightReplyId === reply.id} highlight={highlightReplyId === reply.id}
/> />
</div> </div>
) )
})} })}
</div> </div>
{replies.length === 0 && !loading && !hasMore && ( {replies.length === 0 && !loading && !until && (
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div> <div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
)} )}
</> </>

7
src/renderer/src/providers/NostrProvider.tsx

@ -7,6 +7,7 @@ import client from '@renderer/services/client.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { useRelaySettings } from './RelaySettingsProvider'
type TNostrContext = { type TNostrContext = {
isReady: boolean isReady: boolean
@ -40,6 +41,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [pubkey, setPubkey] = useState<string | null>(null) const [pubkey, setPubkey] = useState<string | null>(null)
const [canLogin, setCanLogin] = useState(false) const [canLogin, setCanLogin] = useState(false)
const [openLoginDialog, setOpenLoginDialog] = useState(false) const [openLoginDialog, setOpenLoginDialog] = useState(false)
const { relayUrls: currentRelayUrls } = useRelaySettings()
const relayList = useFetchRelayList(pubkey) const relayList = useFetchRelayList(pubkey)
useEffect(() => { useEffect(() => {
@ -108,7 +110,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
if (!event) { if (!event) {
throw new Error('sign event failed') 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 return event
} }

155
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 { formatPubkey } from '@renderer/lib/pubkey'
import { tagNameEquals } from '@renderer/lib/tag' import { tagNameEquals } from '@renderer/lib/tag'
import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url' import { isWebsocketUrl, normalizeUrl } from '@renderer/lib/url'
@ -14,8 +15,6 @@ import {
SimplePool, SimplePool,
VerifiedEvent VerifiedEvent
} from 'nostr-tools' } from 'nostr-tools'
import { EVENT_TYPES, eventBus } from './event-bus.service'
import storage from './storage.service'
const BIG_RELAY_URLS = [ const BIG_RELAY_URLS = [
'wss://relay.damus.io/', 'wss://relay.damus.io/',
@ -28,8 +27,6 @@ class ClientService {
static instance: ClientService static instance: ClientService
private pool = new SimplePool() private pool = new SimplePool()
private relayUrls: string[] = BIG_RELAY_URLS
private initPromise!: Promise<void>
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 }) private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
private eventDataLoader = new DataLoader<string, NEvent | undefined>( private eventDataLoader = new DataLoader<string, NEvent | undefined>(
@ -40,6 +37,9 @@ class ClientService {
this.eventBatchLoadFn.bind(this), this.eventBatchLoadFn.bind(this),
{ cache: false } { cache: false }
) )
private repliesCache = new LRUCache<string, { refs: [string, number][]; until?: number }>({
max: 1000
})
private profileCache = new LRUCache<string, Promise<TProfile>>({ max: 10000 }) private profileCache = new LRUCache<string, Promise<TProfile>>({ max: 10000 })
private profileDataloader = new DataLoader<string, TProfile>( private profileDataloader = new DataLoader<string, TProfile>(
(ids) => Promise.all(ids.map((id) => this._fetchProfileByBench32Id(id))), (ids) => Promise.all(ids.map((id) => this._fetchProfileByBench32Id(id))),
@ -62,31 +62,17 @@ class ClientService {
constructor() { constructor() {
if (!ClientService.instance) { if (!ClientService.instance) {
this.initPromise = this.init()
ClientService.instance = this ClientService.instance = this
} }
return ClientService.instance 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() { listConnectionStatus() {
return this.pool.listConnectionStatus() return this.pool.listConnectionStatus()
} }
async publishEvent(relayUrls: string[], event: NEvent) { 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( subscribeEventsWithAuth(
@ -165,10 +151,121 @@ class ClientService {
} }
} }
async fetchEvents(relayUrls: string[], filter: Filter) { async subscribeReplies(
await this.initPromise relayUrls: string[],
// If relayUrls is empty, use this.relayUrls parentEventId: string,
return await this.pool.querySync(relayUrls.length > 0 ? relayUrls : BIG_RELAY_URLS, filter) 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<NEvent | undefined> { async fetchEventByBench32Id(id: string): Promise<NEvent | undefined> {
@ -351,12 +448,12 @@ class ClientService {
} }
if (!relayUrls.length) return 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] return events.sort((a, b) => b.created_at - a.created_at)[0]
} }
private async eventBatchLoadFn(ids: readonly string[]) { 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)), ids: Array.from(new Set(ids)),
limit: ids.length limit: ids.length
}) })
@ -369,7 +466,7 @@ class ClientService {
} }
private async profileBatchLoadFn(pubkeys: readonly string[]) { 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)), authors: Array.from(new Set(pubkeys)),
kinds: [kinds.Metadata], kinds: [kinds.Metadata],
limit: pubkeys.length limit: pubkeys.length
@ -390,7 +487,7 @@ class ClientService {
} }
private async relayListBatchLoadFn(pubkeys: readonly string[]) { 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[], authors: pubkeys as string[],
kinds: [kinds.RelayList], kinds: [kinds.RelayList],
limit: pubkeys.length limit: pubkeys.length
@ -434,7 +531,7 @@ class ClientService {
private async _fetchFollowListEvent(pubkey: string) { private async _fetchFollowListEvent(pubkey: string) {
const relayList = await this.fetchRelayList(pubkey) 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], authors: [pubkey],
kinds: [kinds.Contacts] kinds: [kinds.Contacts]
}) })

33
src/renderer/src/services/event-bus.service.ts

@ -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<TEventMap[K]>
}
export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => {
return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups })
}
class EventBus extends EventTarget {
emit<K extends keyof TEventMap>(event: TCustomEventMap[K]): boolean {
return super.dispatchEvent(event)
}
on<K extends keyof TEventMap>(type: K, listener: (event: TCustomEventMap[K]) => void): void {
super.addEventListener(type, listener as EventListener)
}
remove<K extends keyof TEventMap>(type: K, listener: (event: TCustomEventMap[K]) => void): void {
super.removeEventListener(type, listener as EventListener)
}
}
export const eventBus = new EventBus()

11
src/renderer/src/services/storage.service.ts

@ -1,5 +1,4 @@
import { TRelayGroup } from '@common/types' import { TRelayGroup } from '@common/types'
import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service'
import { isElectron } from '@renderer/lib/env' import { isElectron } from '@renderer/lib/env'
const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [ const DEFAULT_RELAY_GROUPS: TRelayGroup[] = [
@ -40,7 +39,6 @@ class StorageService {
private initPromise!: Promise<void> private initPromise!: Promise<void>
private relayGroups: TRelayGroup[] = [] private relayGroups: TRelayGroup[] = []
private activeRelayUrls: string[] = []
private storage: Storage = new Storage() private storage: Storage = new Storage()
constructor() { constructor() {
@ -53,7 +51,6 @@ class StorageService {
async init() { async init() {
this.relayGroups = await this.storage.getRelayGroups() this.relayGroups = await this.storage.getRelayGroups()
this.activeRelayUrls = this.relayGroups.find((group) => group.isActive)?.relayUrls ?? []
} }
async getRelayGroups() { async getRelayGroups() {
@ -65,14 +62,6 @@ class StorageService {
await this.initPromise await this.initPromise
await this.storage.setRelayGroups(relayGroups) await this.storage.setRelayGroups(relayGroups)
this.relayGroups = 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
} }
} }

Loading…
Cancel
Save