This commit is contained in:
codytseng
2025-12-25 18:07:22 +08:00
parent 17d90a298a
commit 078a8fd348
2 changed files with 134 additions and 133 deletions

View File

@@ -6,7 +6,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
import { useUserTrust } from '@/providers/UserTrustProvider' import { useUserTrust } from '@/providers/UserTrustProvider'
import threadService from '@/services/thread.service' import threadService from '@/services/thread.service'
import { Event as NEvent } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
@@ -57,15 +57,15 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
]) ])
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState(false)
const loadingRef = useRef(false)
const bottomRef = useRef<HTMLDivElement | null>(null) const bottomRef = useRef<HTMLDivElement | null>(null)
const stateRef = useRef({ loading, hasMore, showCount, repliesLength: replies.length })
stateRef.current = { loading, hasMore, showCount, repliesLength: replies.length }
// Initial subscription
useEffect(() => { useEffect(() => {
loadingRef.current = true
setLoading(true) setLoading(true)
threadService.subscribe(stuff, LIMIT).finally(() => { threadService.subscribe(stuff, LIMIT).finally(() => {
loadingRef.current = false
setLoading(false) setLoading(false)
}) })
@@ -74,52 +74,50 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
} }
}, [stuff]) }, [stuff])
const loadMore = useCallback(async () => {
const { loading, hasMore, showCount, repliesLength } = stateRef.current
if (loading || !hasMore) return
// If there are more items to show, increase showCount first
if (showCount < repliesLength) {
setShowCount((prev) => prev + SHOW_COUNT)
// Only fetch more data when remaining items are running low
if (repliesLength - showCount > LIMIT / 2) {
return
}
}
setLoading(true)
const newHasMore = await threadService.loadMore(stuff, LIMIT)
setHasMore(newHasMore)
setLoading(false)
}, [stuff])
// IntersectionObserver setup
useEffect(() => { useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = async () => {
if (showCount < replies.length) {
setShowCount((prev) => prev + SHOW_COUNT)
// preload more
if (replies.length - showCount > LIMIT / 2) {
return
}
}
if (loadingRef.current) return
loadingRef.current = true
setLoading(true)
const newHasMore = await threadService.loadMore(stuff, LIMIT)
setHasMore(newHasMore)
loadingRef.current = false
setLoading(false)
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current const currentBottomRef = bottomRef.current
if (!currentBottomRef) return
if (currentBottomRef) { const observer = new IntersectionObserver(
observerInstance.observe(currentBottomRef) (entries) => {
} if (entries[0].isIntersecting) {
loadMore()
}
},
{
root: null,
rootMargin: '100px',
threshold: 0
}
)
observer.observe(currentBottomRef)
return () => { return () => {
if (observerInstance && currentBottomRef) { observer.disconnect()
observerInstance.unobserve(currentBottomRef)
}
} }
}, [replies, showCount, loading, stuff, hasMore]) }, [loadMore])
return ( return (
<div className="min-h-[80vh]"> <div className="min-h-[80vh]">

View File

@@ -26,8 +26,10 @@ class ThreadService {
private subscriptions = new Map< private subscriptions = new Map<
string, string,
{ {
closer?: () => void promise: Promise<{
timelineKey?: string closer: () => void
timelineKey: string
}>
count: number count: number
until?: number until?: number
} }
@@ -54,101 +56,99 @@ class ThreadService {
const rootInfo = await this.parseRootInfo(stuff) const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return if (!rootInfo) return
let subscription = this.subscriptions.get(rootInfo.id) const subscription = this.subscriptions.get(rootInfo.id)
if (subscription) { if (subscription) {
subscription.count += 1 subscription.count += 1
return return
} }
subscription = { count: 1, until: dayjs().unix() } const _subscribe = async () => {
this.subscriptions.set(rootInfo.id, subscription) let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
let relayUrls: string[] = [] if (rootPubkey) {
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey const relayList = await client.fetchRelayList(rootPubkey)
if (rootPubkey) { relayUrls = relayList.read
const relayList = await client.fetchRelayList(rootPubkey)
relayUrls = relayList.read
}
relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
// If current event is protected, we can assume its replies are also protected and stored on the same relays
if (event && isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn)
}
const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
} }
} else if (rootInfo.type === 'A') { relayUrls = relayUrls.concat(BIG_RELAY_URLS).slice(0, 4)
filters.push(
{ // If current event is protected, we can assume its replies are also protected and stored on the same relays
'#a': [rootInfo.id], if (event && isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.concat(...seenOn)
}
const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote], kinds: [kinds.ShortTextNote],
limit limit
}, })
{ if (event?.kind !== kinds.ShortTextNote) {
'#A': [rootInfo.id], filters.push({
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], '#E': [rootInfo.id],
limit kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
} }
) } else if (rootInfo.type === 'A') {
if (rootInfo.relay) { filters.push(
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
return new Promise<void>((resolve) => {
client
.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 8),
filter
})),
{ {
onEvents: (events, eosed) => { '#a': [rootInfo.id],
if (events.length > 0) { kinds: [kinds.ShortTextNote],
this.addRepliesToThread(events) limit
} },
if (eosed) { {
subscription.until = '#A': [rootInfo.id],
events.length >= limit ? events[events.length - 1].created_at - 1 : undefined kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
resolve() limit
}
},
onNew: (evt) => {
this.addRepliesToThread([evt])
}
} }
) )
.then(({ closer, timelineKey }) => { if (rootInfo.relay) {
subscription.closer = closer relayUrls.push(rootInfo.relay)
subscription.timelineKey = timelineKey }
}) } else {
.catch(() => { filters.push({
this.subscriptions.delete(rootInfo.id) '#I': [rootInfo.id],
resolve() kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
}) })
}
const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 8),
filter
})),
{
onEvents: (events, eosed) => {
if (events.length > 0) {
this.addRepliesToThread(events)
}
if (eosed) {
const subscription = this.subscriptions.get(rootInfo.id)
if (subscription) {
subscription.until =
events.length >= limit ? events[events.length - 1].created_at - 1 : undefined
}
}
},
onNew: (evt) => {
this.addRepliesToThread([evt])
}
}
)
return { closer, timelineKey }
}
const promise = _subscribe()
this.subscriptions.set(rootInfo.id, {
promise,
count: 1,
until: dayjs().unix()
}) })
await promise
} }
async unsubscribe(stuff: NostrEvent | string) { async unsubscribe(stuff: NostrEvent | string) {
@@ -162,7 +162,9 @@ class ThreadService {
subscription.count -= 1 subscription.count -= 1
if (subscription.count <= 0) { if (subscription.count <= 0) {
this.subscriptions.delete(rootInfo.id) this.subscriptions.delete(rootInfo.id)
subscription.closer?.() subscription.promise.then(({ closer }) => {
closer()
})
} }
}, 2000) }, 2000)
} }
@@ -172,12 +174,13 @@ class ThreadService {
if (!rootInfo) return false if (!rootInfo) return false
const subscription = this.subscriptions.get(rootInfo.id) const subscription = this.subscriptions.get(rootInfo.id)
if (!subscription) return false if (!subscription || !subscription.until) return false
const { timelineKey, until } = subscription const { timelineKey } = await subscription.promise
if (!timelineKey || !until) return false console.log('loadMore', { timelineKey, until: subscription.until })
if (!timelineKey) return false
const events = await client.loadMoreTimeline(timelineKey, until, limit) const events = await client.loadMoreTimeline(timelineKey, subscription.until, limit)
this.addRepliesToThread(events) this.addRepliesToThread(events)
const { event } = this.resolveStuff(stuff) const { event } = this.resolveStuff(stuff)