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
}
}