refactor: 🏗️
This commit is contained in:
@@ -12,6 +12,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NoteCard from '../NoteCard'
|
import NoteCard from '../NoteCard'
|
||||||
|
|
||||||
|
const NORMAL_RELAY_LIMIT = 100
|
||||||
|
const ALGO_RELAY_LIMIT = 500
|
||||||
|
|
||||||
export default function NoteList({
|
export default function NoteList({
|
||||||
relayUrls,
|
relayUrls,
|
||||||
filter = {},
|
filter = {},
|
||||||
@@ -24,9 +27,9 @@ export default function NoteList({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isReady, signEvent } = useNostr()
|
const { isReady, signEvent } = useNostr()
|
||||||
const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos(relayUrls)
|
const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos(relayUrls)
|
||||||
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [until, setUntil] = useState<number>(() => dayjs().unix())
|
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
const [displayReplies, setDisplayReplies] = useState(false)
|
const [displayReplies, setDisplayReplies] = useState(false)
|
||||||
@@ -35,7 +38,7 @@ export default function NoteList({
|
|||||||
const noteFilter = useMemo(() => {
|
const noteFilter = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
kinds: [kinds.ShortTextNote, kinds.Repost],
|
kinds: [kinds.ShortTextNote, kinds.Repost],
|
||||||
limit: areAlgoRelays ? 500 : 50,
|
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
|
||||||
...filter
|
...filter
|
||||||
}
|
}
|
||||||
}, [JSON.stringify(filter), areAlgoRelays])
|
}, [JSON.stringify(filter), areAlgoRelays])
|
||||||
@@ -43,40 +46,44 @@ export default function NoteList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReady || isFetchingRelayInfo) return
|
if (!isReady || isFetchingRelayInfo) return
|
||||||
|
|
||||||
|
async function init() {
|
||||||
setInitialized(false)
|
setInitialized(false)
|
||||||
setEvents([])
|
setEvents([])
|
||||||
setNewEvents([])
|
setNewEvents([])
|
||||||
setHasMore(true)
|
setHasMore(true)
|
||||||
|
|
||||||
const subCloser = client.subscribeEventsWithAuth(
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
relayUrls,
|
relayUrls,
|
||||||
noteFilter,
|
noteFilter,
|
||||||
{
|
{
|
||||||
onEose: (events) => {
|
onEvents: (events, eosed) => {
|
||||||
if (!areAlgoRelays) {
|
|
||||||
events.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
}
|
|
||||||
events = events.slice(0, noteFilter.limit)
|
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
setEvents((pre) => [...pre, ...events])
|
setEvents(events)
|
||||||
setUntil(events[events.length - 1].created_at - 1)
|
|
||||||
} else {
|
} else {
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
}
|
}
|
||||||
if (areAlgoRelays) {
|
if (areAlgoRelays) {
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
}
|
}
|
||||||
|
if (eosed) {
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
},
|
|
||||||
onNew: (event) => {
|
|
||||||
setNewEvents((oldEvents) => [event, ...oldEvents])
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
signEvent
|
onNew: (event) => {
|
||||||
|
setNewEvents((oldEvents) =>
|
||||||
|
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ signer: signEvent, needSort: !areAlgoRelays }
|
||||||
|
)
|
||||||
|
setTimelineKey(timelineKey)
|
||||||
|
return closer
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = init()
|
||||||
return () => {
|
return () => {
|
||||||
subCloser()
|
promise.then((closer) => closer())
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
JSON.stringify(relayUrls),
|
JSON.stringify(relayUrls),
|
||||||
@@ -110,23 +117,21 @@ export default function NoteList({
|
|||||||
observer.current.unobserve(bottomRef.current)
|
observer.current.unobserve(bottomRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [until, initialized, hasMore])
|
}, [initialized, hasMore, events, timelineKey])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
const events = await client.fetchEvents(relayUrls, { ...noteFilter, until }, true)
|
if (!timelineKey) return
|
||||||
const sortedEvents = events
|
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
const newEvents = await client.loadMoreTimeline(
|
||||||
.slice(0, noteFilter.limit)
|
timelineKey,
|
||||||
if (sortedEvents.length === 0) {
|
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
|
||||||
|
noteFilter.limit
|
||||||
|
)
|
||||||
|
if (newEvents.length === 0) {
|
||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setEvents((oldEvents) => [...oldEvents, ...newEvents])
|
||||||
if (sortedEvents.length > 0) {
|
|
||||||
setEvents((oldEvents) => [...oldEvents, ...sortedEvents])
|
|
||||||
}
|
|
||||||
|
|
||||||
setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showNewEvents = () => {
|
const showNewEvents = () => {
|
||||||
|
|||||||
@@ -17,35 +17,46 @@ const LIMIT = 50
|
|||||||
export default function NotificationList() {
|
export default function NotificationList() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [initialized, setInitialized] = useState(false)
|
||||||
const [notifications, setNotifications] = useState<Event[]>([])
|
const [notifications, setNotifications] = useState<Event[]>([])
|
||||||
const [until, setUntil] = useState<number>(dayjs().unix())
|
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
const observer = useRef<IntersectionObserver | null>(null)
|
const observer = useRef<IntersectionObserver | null>(null)
|
||||||
const [hasMore, setHasMore] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
setHasMore(false)
|
setUntil(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
setHasMore(true)
|
const relayList = await client.fetchRelayList(pubkey)
|
||||||
const subCloser = await client.subscribeNotifications(pubkey, LIMIT, {
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
onNotifications: (events, isCache) => {
|
relayList.read.length >= 4
|
||||||
setNotifications(events)
|
? relayList.read
|
||||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : dayjs().unix())
|
: relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4),
|
||||||
if (!isCache) {
|
{
|
||||||
|
'#p': [pubkey],
|
||||||
|
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
|
||||||
|
limit: LIMIT
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onEvents: (events, eosed) => {
|
||||||
|
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||||
|
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
|
if (eosed) {
|
||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
|
if (event.pubkey === pubkey) return
|
||||||
setNotifications((oldEvents) => [event, ...oldEvents])
|
setNotifications((oldEvents) => [event, ...oldEvents])
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return subCloser
|
setTimelineKey(timelineKey)
|
||||||
|
return closer
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = init()
|
const promise = init()
|
||||||
@@ -64,7 +75,7 @@ export default function NotificationList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
observer.current = new IntersectionObserver((entries) => {
|
observer.current = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore) {
|
if (entries[0].isIntersecting) {
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}, options)
|
}, options)
|
||||||
@@ -78,13 +89,13 @@ export default function NotificationList() {
|
|||||||
observer.current.unobserve(bottomRef.current)
|
observer.current.unobserve(bottomRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [until, initialized, hasMore])
|
}, [until, initialized, timelineKey])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (!pubkey) return
|
if (!pubkey || !timelineKey || !until) return
|
||||||
const notifications = await client.fetchMoreNotifications(pubkey, until, LIMIT)
|
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||||
if (notifications.length === 0) {
|
if (notifications.length === 0) {
|
||||||
setHasMore(false)
|
setUntil(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +112,7 @@ export default function NotificationList() {
|
|||||||
<NotificationItem key={index} notification={notification} />
|
<NotificationItem key={index} notification={notification} />
|
||||||
))}
|
))}
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{hasMore ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
|
{until ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
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 { 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 { Event } from 'nostr-tools'
|
import dayjs from 'dayjs'
|
||||||
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReplyNote from '../ReplyNote'
|
import ReplyNote from '../ReplyNote'
|
||||||
|
|
||||||
|
const LIMIT = 100
|
||||||
|
|
||||||
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 { isReady, pubkey } = useNostr()
|
||||||
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
|
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
|
||||||
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 | undefined>()
|
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||||
const { updateNoteReplyCount } = useNoteStats()
|
const { updateNoteReplyCount } = useNoteStats()
|
||||||
@@ -28,25 +33,35 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
|||||||
const init = async () => {
|
const init = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setReplies([])
|
setReplies([])
|
||||||
setUntil(undefined)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayList = await client.fetchRelayList(event.pubkey)
|
const relayList = await client.fetchRelayList(event.pubkey)
|
||||||
const closer = await client.subscribeReplies(relayList.read.slice(0, 5), event.id, 100, {
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
onReplies: (evts, isCache, until) => {
|
relayList.read.slice(0, 5),
|
||||||
setReplies(evts)
|
{
|
||||||
setUntil(until)
|
'#e': [event.id],
|
||||||
if (!isCache) {
|
kinds: [kinds.ShortTextNote],
|
||||||
|
limit: LIMIT
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onEvents: (evts, eosed) => {
|
||||||
|
setReplies(evts.filter((evt) => isReplyNoteEvent(evt)).reverse())
|
||||||
|
if (eosed) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (evt) => {
|
onNew: (evt) => {
|
||||||
|
if (!isReplyNoteEvent(evt)) return
|
||||||
|
|
||||||
setReplies((pre) => [...pre, evt])
|
setReplies((pre) => [...pre, evt])
|
||||||
if (evt.pubkey === pubkey) {
|
if (evt.pubkey === pubkey) {
|
||||||
highlightReply(evt.id)
|
highlightReply(evt.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
setTimelineKey(timelineKey)
|
||||||
return closer
|
return closer
|
||||||
} catch {
|
} catch {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -96,20 +111,15 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
|
|||||||
}, [replies])
|
}, [replies])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (loading || !until) return
|
if (loading || !until || !timelineKey) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const relayList = await client.fetchRelayList(event.pubkey)
|
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||||
const { replies: olderReplies, until: newUntil } = await client.fetchMoreReplies(
|
const olderReplies = events.filter((evt) => isReplyNoteEvent(evt)).reverse()
|
||||||
relayList.read.slice(0, 5),
|
|
||||||
event.id,
|
|
||||||
until,
|
|
||||||
100
|
|
||||||
)
|
|
||||||
if (olderReplies.length > 0) {
|
if (olderReplies.length > 0) {
|
||||||
setReplies((pre) => [...olderReplies, ...pre])
|
setReplies((pre) => [...olderReplies, ...pre])
|
||||||
}
|
}
|
||||||
setUntil(newUntil)
|
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,26 +14,26 @@ export default function NoteListPage() {
|
|||||||
const {
|
const {
|
||||||
title = '',
|
title = '',
|
||||||
filter,
|
filter,
|
||||||
specificRelayUrl
|
urls
|
||||||
} = useMemo<{
|
} = useMemo<{
|
||||||
title?: string
|
title?: string
|
||||||
filter?: Filter
|
filter?: Filter
|
||||||
specificRelayUrl?: string
|
urls: string[]
|
||||||
}>(() => {
|
}>(() => {
|
||||||
const hashtag = searchParams.get('t')
|
const hashtag = searchParams.get('t')
|
||||||
if (hashtag) {
|
if (hashtag) {
|
||||||
return { title: `# ${hashtag}`, filter: { '#t': [hashtag] } }
|
return { title: `# ${hashtag}`, filter: { '#t': [hashtag] }, urls: relayUrls }
|
||||||
}
|
}
|
||||||
const search = searchParams.get('s')
|
const search = searchParams.get('s')
|
||||||
if (search) {
|
if (search) {
|
||||||
return { title: `${t('search')}: ${search}`, filter: { search } }
|
return { title: `${t('search')}: ${search}`, filter: { search }, urls: relayUrls }
|
||||||
}
|
}
|
||||||
const relayUrl = searchParams.get('relay')
|
const relayUrl = searchParams.get('relay')
|
||||||
if (relayUrl && isWebsocketUrl(relayUrl)) {
|
if (relayUrl && isWebsocketUrl(relayUrl)) {
|
||||||
return { title: relayUrl, specificRelayUrl: relayUrl }
|
return { title: relayUrl, urls: [relayUrl] }
|
||||||
}
|
}
|
||||||
return {}
|
return { urls: relayUrls }
|
||||||
}, [searchParams])
|
}, [searchParams, JSON.stringify(relayUrls)])
|
||||||
|
|
||||||
if (filter?.search && searchableRelayUrls.length === 0) {
|
if (filter?.search && searchableRelayUrls.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -47,11 +47,7 @@ export default function NoteListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={title}>
|
<SecondaryPageLayout titlebarContent={title}>
|
||||||
<NoteList
|
<NoteList key={title} filter={filter} relayUrls={urls} />
|
||||||
key={title}
|
|
||||||
filter={filter}
|
|
||||||
relayUrls={specificRelayUrl ? [specificRelayUrl] : relayUrls}
|
|
||||||
/>
|
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setPubkey(null)
|
setPubkey(null)
|
||||||
await storage.setAccountInfo(null)
|
await storage.setAccountInfo(null)
|
||||||
client.clearNotificationsCache()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const signEvent = async (draftEvent: TDraftEvent) => {
|
const signEvent = async (draftEvent: TDraftEvent) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { TDraftEvent } 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'
|
||||||
@@ -23,12 +22,23 @@ const BIG_RELAY_URLS = [
|
|||||||
'wss://relay.noswhere.com/'
|
'wss://relay.noswhere.com/'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type TTimelineRef = [string, number]
|
||||||
|
|
||||||
class ClientService {
|
class ClientService {
|
||||||
static instance: ClientService
|
static instance: ClientService
|
||||||
|
|
||||||
private defaultRelayUrls: string[] = BIG_RELAY_URLS
|
private defaultRelayUrls: string[] = BIG_RELAY_URLS
|
||||||
private pool = new SimplePool()
|
private pool = new SimplePool()
|
||||||
|
|
||||||
|
private timelines: Record<
|
||||||
|
string,
|
||||||
|
| {
|
||||||
|
refs: TTimelineRef[]
|
||||||
|
filter: Omit<Filter, 'since' | 'until'> & { limit: number }
|
||||||
|
urls: string[]
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
> = {}
|
||||||
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>(
|
||||||
(ids) => Promise.all(ids.map((id) => this._fetchEvent(id))),
|
(ids) => Promise.all(ids.map((id) => this._fetchEvent(id))),
|
||||||
@@ -38,10 +48,6 @@ 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 notificationsCache: [string, number][] = []
|
|
||||||
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._fetchProfile(id))),
|
(ids) => Promise.all(ids.map((id) => this._fetchProfile(id))),
|
||||||
@@ -91,26 +97,65 @@ class ClientService {
|
|||||||
this.defaultRelayUrls = Array.from(new Set(urls.concat(BIG_RELAY_URLS)))
|
this.defaultRelayUrls = Array.from(new Set(urls.concat(BIG_RELAY_URLS)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDefaultRelayUrls() {
|
||||||
|
return this.defaultRelayUrls
|
||||||
|
}
|
||||||
|
|
||||||
async publishEvent(relayUrls: string[], event: NEvent) {
|
async publishEvent(relayUrls: string[], event: NEvent) {
|
||||||
return await Promise.any(this.pool.publish(relayUrls, event))
|
return await Promise.any(this.pool.publish(relayUrls, event))
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribeEventsWithAuth(
|
private async generateTimelineKey(urls: string[], filter: Filter): Promise<string> {
|
||||||
|
const paramsStr = JSON.stringify({ urls: urls.sort(), filter })
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(paramsStr)
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||||
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribeTimeline(
|
||||||
urls: string[],
|
urls: string[],
|
||||||
filter: Filter,
|
filter: Omit<Filter, 'since' | 'until'> & { limit: number }, // filter with limit,
|
||||||
{
|
{
|
||||||
onEose,
|
onEvents,
|
||||||
onNew
|
onNew
|
||||||
}: {
|
}: {
|
||||||
onEose: (events: NEvent[]) => void
|
onEvents: (events: NEvent[], eosed: boolean) => void
|
||||||
onNew: (evt: NEvent) => void
|
onNew: (evt: NEvent) => void
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
signer,
|
||||||
|
needSort = true
|
||||||
|
}: {
|
||||||
signer?: (evt: TDraftEvent) => Promise<NEvent>
|
signer?: (evt: TDraftEvent) => Promise<NEvent>
|
||||||
|
needSort?: boolean
|
||||||
|
} = {}
|
||||||
) {
|
) {
|
||||||
|
const key = await this.generateTimelineKey(urls, filter)
|
||||||
|
const timeline = this.timelines[key]
|
||||||
|
let cachedEvents: NEvent[] = []
|
||||||
|
let since: number | undefined
|
||||||
|
if (timeline && timeline.refs.length) {
|
||||||
|
cachedEvents = (
|
||||||
|
await Promise.all(
|
||||||
|
timeline.refs.slice(0, filter.limit).map(([id]) => this.eventCache.get(id))
|
||||||
|
)
|
||||||
|
).filter(Boolean) as NEvent[]
|
||||||
|
if (cachedEvents.length) {
|
||||||
|
onEvents(cachedEvents, false)
|
||||||
|
since = cachedEvents[0].created_at + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeline && needSort) {
|
||||||
|
this.timelines[key] = { refs: [], filter, urls }
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
const that = this
|
const that = this
|
||||||
const _knownIds = new Set<string>()
|
const _knownIds = new Set<string>()
|
||||||
const events: NEvent[] = []
|
let events: NEvent[] = []
|
||||||
let started = 0
|
let started = 0
|
||||||
let eosed = 0
|
let eosed = 0
|
||||||
const subPromises = urls.map(async (url) => {
|
const subPromises = urls.map(async (url) => {
|
||||||
@@ -121,19 +166,52 @@ class ClientService {
|
|||||||
|
|
||||||
function startSub() {
|
function startSub() {
|
||||||
started++
|
started++
|
||||||
return relay.subscribe([filter], {
|
return relay.subscribe([since ? { ...filter, since } : filter], {
|
||||||
alreadyHaveEvent: (id: string) => {
|
alreadyHaveEvent: (id: string) => {
|
||||||
const have = _knownIds.has(id)
|
const have = _knownIds.has(id)
|
||||||
|
if (have) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
_knownIds.add(id)
|
_knownIds.add(id)
|
||||||
return have
|
return false
|
||||||
},
|
},
|
||||||
onevent(evt: NEvent) {
|
onevent(evt: NEvent) {
|
||||||
if (eosed === started) {
|
|
||||||
onNew(evt)
|
|
||||||
} else {
|
|
||||||
events.push(evt)
|
|
||||||
}
|
|
||||||
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||||
|
// not eosed yet, push to events
|
||||||
|
if (eosed < started) {
|
||||||
|
return events.push(evt)
|
||||||
|
}
|
||||||
|
// eosed, (algo relay feeds) no need to sort and cache
|
||||||
|
if (!needSort) {
|
||||||
|
return onNew(evt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = that.timelines[key]
|
||||||
|
if (!timeline || !timeline.refs.length) {
|
||||||
|
return onNew(evt)
|
||||||
|
}
|
||||||
|
// the event is newer than the first ref, insert it to the front
|
||||||
|
if (evt.created_at > timeline.refs[0][1]) {
|
||||||
|
onNew(evt)
|
||||||
|
return timeline.refs.unshift([evt.id, evt.created_at])
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = 0
|
||||||
|
for (const ref of timeline.refs) {
|
||||||
|
if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// the event is already in the cache
|
||||||
|
if (evt.created_at === ref[1] && evt.id === ref[0]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
// the event is too old, ignore it
|
||||||
|
if (idx >= timeline.refs.length) return
|
||||||
|
|
||||||
|
// insert the event to the right position
|
||||||
|
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
|
||||||
},
|
},
|
||||||
onclose(reason: string) {
|
onclose(reason: string) {
|
||||||
if (reason.startsWith('auth-required:')) {
|
if (reason.startsWith('auth-required:')) {
|
||||||
@@ -151,16 +229,52 @@ class ClientService {
|
|||||||
},
|
},
|
||||||
oneose() {
|
oneose() {
|
||||||
eosed++
|
eosed++
|
||||||
if (eosed === started) {
|
if (eosed < started) return
|
||||||
onEose(events)
|
|
||||||
|
// (algo feeds) no need to sort and cache
|
||||||
|
if (!needSort) {
|
||||||
|
return onEvents(events, true)
|
||||||
|
}
|
||||||
|
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||||
|
|
||||||
|
const timeline = that.timelines[key]
|
||||||
|
// no cache yet
|
||||||
|
if (!timeline || !timeline.refs.length) {
|
||||||
|
that.timelines[key] = {
|
||||||
|
refs: events.map((evt) => [evt.id, evt.created_at]),
|
||||||
|
filter,
|
||||||
|
urls
|
||||||
|
}
|
||||||
|
return onEvents(events, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEvents = events.filter((evt) => {
|
||||||
|
const firstRef = timeline.refs[0]
|
||||||
|
return (
|
||||||
|
evt.created_at > firstRef[1] ||
|
||||||
|
(evt.created_at === firstRef[1] && evt.id < firstRef[0])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
|
||||||
|
|
||||||
|
if (newRefs.length >= filter.limit) {
|
||||||
|
// if new refs are more than limit, means old refs are too old, replace them
|
||||||
|
timeline.refs = newRefs
|
||||||
|
onEvents(newEvents, true)
|
||||||
|
} else {
|
||||||
|
// merge new refs with old refs
|
||||||
|
timeline.refs = newRefs.concat(timeline.refs)
|
||||||
|
onEvents(newEvents.concat(cachedEvents), true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return {
|
||||||
onEose = () => {}
|
timelineKey: key,
|
||||||
|
closer: () => {
|
||||||
|
onEvents = () => {}
|
||||||
onNew = () => {}
|
onNew = () => {}
|
||||||
subPromises.forEach((subPromise) => {
|
subPromises.forEach((subPromise) => {
|
||||||
subPromise.then((sub) => {
|
subPromise.then((sub) => {
|
||||||
@@ -169,237 +283,37 @@ class ClientService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async subscribeReplies(
|
async loadMoreTimeline(key: string, until: number, limit: number) {
|
||||||
relayUrls: string[],
|
const timeline = this.timelines[key]
|
||||||
parentEventId: string,
|
if (!timeline) return []
|
||||||
limit: number,
|
|
||||||
{
|
|
||||||
onReplies,
|
|
||||||
onNew
|
|
||||||
}: {
|
|
||||||
onReplies: (events: NEvent[], isCache: boolean, 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, true, 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 { filter, urls, refs } = timeline
|
||||||
const that = this
|
const startIdx = refs.findIndex(([, createdAt]) => createdAt < until)
|
||||||
const events: NEvent[] = []
|
const cachedEvents =
|
||||||
let hasEosed = false
|
startIdx >= 0
|
||||||
const closer = this.pool.subscribeMany(
|
? ((
|
||||||
relayUrls.length > 0 ? relayUrls : this.defaultRelayUrls,
|
await Promise.all(
|
||||||
[
|
refs.slice(startIdx, startIdx + limit).map(([id]) => this.eventCache.get(id))
|
||||||
{
|
|
||||||
'#e': [parentEventId],
|
|
||||||
kinds: [kinds.ShortTextNote],
|
|
||||||
limit,
|
|
||||||
since
|
|
||||||
}
|
|
||||||
],
|
|
||||||
{
|
|
||||||
onevent(evt: NEvent) {
|
|
||||||
if (hasEosed) {
|
|
||||||
if (!isReplyNoteEvent(evt)) return
|
|
||||||
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)
|
|
||||||
.slice(0, limit)
|
|
||||||
.filter(isReplyNoteEvent)
|
|
||||||
replies = replies.concat(newReplies)
|
|
||||||
// first fetch
|
|
||||||
if (!since) {
|
|
||||||
cache.until = events.length >= limit ? events[0].created_at - 1 : undefined
|
|
||||||
}
|
|
||||||
onReplies(replies, false, cache.until)
|
|
||||||
const lastRefCreatedAt = refs.length ? refs[refs.length - 1][1] : undefined
|
|
||||||
if (lastRefCreatedAt) {
|
|
||||||
refs.push(
|
|
||||||
...newReplies
|
|
||||||
.filter((reply) => reply.created_at > lastRefCreatedAt)
|
|
||||||
.map((evt) => [evt.id, evt.created_at] as [string, number])
|
|
||||||
)
|
)
|
||||||
} else {
|
).filter(Boolean) as NEvent[])
|
||||||
refs.push(...newReplies.map((evt) => [evt.id, evt.created_at] as [string, number]))
|
: []
|
||||||
|
if (cachedEvents.length >= limit) {
|
||||||
|
return cachedEvents
|
||||||
}
|
}
|
||||||
}
|
const restLimit = limit - cachedEvents.length
|
||||||
}
|
const restUntil = cachedEvents.length
|
||||||
)
|
? cachedEvents[cachedEvents.length - 1].created_at - 1
|
||||||
|
: until
|
||||||
|
|
||||||
return () => {
|
let events = await this.pool.querySync(urls, { ...filter, until: restUntil, limit: restLimit })
|
||||||
onReplies = () => {}
|
|
||||||
onNew = () => {}
|
|
||||||
closer.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribeNotifications(
|
|
||||||
pubkey: string,
|
|
||||||
limit: number,
|
|
||||||
{
|
|
||||||
onNotifications,
|
|
||||||
onNew
|
|
||||||
}: {
|
|
||||||
onNotifications: (events: NEvent[], isCache: boolean) => void
|
|
||||||
onNew: (evt: NEvent) => void
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
let cachedNotifications: NEvent[] = []
|
|
||||||
if (this.notificationsCache.length) {
|
|
||||||
cachedNotifications = (
|
|
||||||
await Promise.all(this.notificationsCache.map(([id]) => this.eventCache.get(id)))
|
|
||||||
).filter(Boolean) as NEvent[]
|
|
||||||
onNotifications(cachedNotifications, true)
|
|
||||||
}
|
|
||||||
const since = this.notificationsCache.length ? this.notificationsCache[0][1] + 1 : undefined
|
|
||||||
|
|
||||||
const relayList = await this.fetchRelayList(pubkey)
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
||||||
const that = this
|
|
||||||
const events: NEvent[] = []
|
|
||||||
let hasEosed = false
|
|
||||||
let count = 0
|
|
||||||
const closer = this.pool.subscribeMany(
|
|
||||||
relayList.read.length >= 4
|
|
||||||
? relayList.read
|
|
||||||
: relayList.read.concat(this.defaultRelayUrls).slice(0, 4),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
|
|
||||||
'#p': [pubkey],
|
|
||||||
limit,
|
|
||||||
since
|
|
||||||
}
|
|
||||||
],
|
|
||||||
{
|
|
||||||
onevent(evt: NEvent) {
|
|
||||||
count++
|
|
||||||
if (hasEosed) {
|
|
||||||
if (evt.pubkey === pubkey) return
|
|
||||||
onNew(evt)
|
|
||||||
} else {
|
|
||||||
events.push(evt)
|
|
||||||
}
|
|
||||||
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
hasEosed = true
|
|
||||||
const newNotifications = events
|
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
.slice(0, limit)
|
|
||||||
.filter((evt) => evt.pubkey !== pubkey)
|
|
||||||
if (count >= limit) {
|
|
||||||
that.notificationsCache = newNotifications.map(
|
|
||||||
(evt) => [evt.id, evt.created_at] as [string, number]
|
|
||||||
)
|
|
||||||
onNotifications(newNotifications, false)
|
|
||||||
} else {
|
|
||||||
that.notificationsCache = [
|
|
||||||
...newNotifications.map((evt) => [evt.id, evt.created_at] as [string, number]),
|
|
||||||
...that.notificationsCache
|
|
||||||
]
|
|
||||||
onNotifications(newNotifications.concat(cachedNotifications), false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
onNotifications = () => {}
|
|
||||||
onNew = () => {}
|
|
||||||
closer.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchMoreReplies(relayUrls: string[], parentEventId: string, until: number, limit: number) {
|
|
||||||
let events = await this.pool.querySync(relayUrls, {
|
|
||||||
'#e': [parentEventId],
|
|
||||||
kinds: [kinds.ShortTextNote],
|
|
||||||
limit,
|
|
||||||
until
|
|
||||||
})
|
|
||||||
events.forEach((evt) => {
|
events.forEach((evt) => {
|
||||||
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||||
})
|
})
|
||||||
events = events.sort((a, b) => a.created_at - b.created_at).slice(0, limit)
|
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, restLimit)
|
||||||
const replies = events.filter((evt) => isReplyNoteEvent(evt))
|
timeline.refs.push(...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef))
|
||||||
let cache = this.repliesCache.get(parentEventId)
|
return cachedEvents.concat(events)
|
||||||
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 fetchMoreNotifications(pubkey: string, until: number, limit: number) {
|
|
||||||
const relayList = await this.fetchRelayList(pubkey)
|
|
||||||
const events = await this.pool.querySync(
|
|
||||||
relayList.read.length >= 4
|
|
||||||
? relayList.read
|
|
||||||
: relayList.read.concat(this.defaultRelayUrls).slice(0, 4),
|
|
||||||
{
|
|
||||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
|
|
||||||
'#p': [pubkey],
|
|
||||||
limit,
|
|
||||||
until
|
|
||||||
}
|
|
||||||
)
|
|
||||||
events.forEach((evt) => {
|
|
||||||
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
|
||||||
})
|
|
||||||
const notifications = events
|
|
||||||
.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
.slice(0, limit)
|
|
||||||
.filter((evt) => evt.pubkey !== pubkey)
|
|
||||||
|
|
||||||
const cacheLastCreatedAt = this.notificationsCache.length
|
|
||||||
? this.notificationsCache[this.notificationsCache.length - 1][1]
|
|
||||||
: undefined
|
|
||||||
this.notificationsCache = this.notificationsCache.concat(
|
|
||||||
(cacheLastCreatedAt
|
|
||||||
? notifications.filter((evt) => evt.created_at < cacheLastCreatedAt)
|
|
||||||
: notifications
|
|
||||||
).map((evt) => [evt.id, evt.created_at] as [string, number])
|
|
||||||
)
|
|
||||||
|
|
||||||
return notifications
|
|
||||||
}
|
|
||||||
|
|
||||||
clearNotificationsCache() {
|
|
||||||
this.notificationsCache = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchEvents(relayUrls: string[], filter: Filter, cache = false) {
|
async fetchEvents(relayUrls: string[], filter: Filter, cache = false) {
|
||||||
|
|||||||
Reference in New Issue
Block a user