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 threadService from '@/services/thread.service'
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 { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
@@ -57,15 +57,15 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
])
const [hasMore, setHasMore] = useState(true)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [loading, setLoading] = useState<boolean>(false)
const loadingRef = useRef(false)
const [loading, setLoading] = useState(false)
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(() => {
loadingRef.current = true
setLoading(true)
threadService.subscribe(stuff, LIMIT).finally(() => {
loadingRef.current = false
setLoading(false)
})
@@ -74,52 +74,50 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
}
}, [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(() => {
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
if (!currentBottomRef) return
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{
root: null,
rootMargin: '100px',
threshold: 0
}
)
observer.observe(currentBottomRef)
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
observer.disconnect()
}
}, [replies, showCount, loading, stuff, hasMore])
}, [loadMore])
return (
<div className="min-h-[80vh]">

View File

@@ -26,8 +26,10 @@ class ThreadService {
private subscriptions = new Map<
string,
{
closer?: () => void
timelineKey?: string
promise: Promise<{
closer: () => void
timelineKey: string
}>
count: number
until?: number
}
@@ -54,101 +56,99 @@ class ThreadService {
const rootInfo = await this.parseRootInfo(stuff)
if (!rootInfo) return
let subscription = this.subscriptions.get(rootInfo.id)
const subscription = this.subscriptions.get(rootInfo.id)
if (subscription) {
subscription.count += 1
return
}
subscription = { count: 1, until: dayjs().unix() }
this.subscriptions.set(rootInfo.id, subscription)
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
if (rootPubkey) {
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
})
const _subscribe = async () => {
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
if (rootPubkey) {
const relayList = await client.fetchRelayList(rootPubkey)
relayUrls = relayList.read
}
} else if (rootInfo.type === 'A') {
filters.push(
{
'#a': [rootInfo.id],
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
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
)
if (rootInfo.relay) {
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
})),
} else if (rootInfo.type === 'A') {
filters.push(
{
onEvents: (events, eosed) => {
if (events.length > 0) {
this.addRepliesToThread(events)
}
if (eosed) {
subscription.until =
events.length >= limit ? events[events.length - 1].created_at - 1 : undefined
resolve()
}
},
onNew: (evt) => {
this.addRepliesToThread([evt])
}
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
}
)
.then(({ closer, timelineKey }) => {
subscription.closer = closer
subscription.timelineKey = timelineKey
})
.catch(() => {
this.subscriptions.delete(rootInfo.id)
resolve()
if (rootInfo.relay) {
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
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) {
@@ -162,7 +162,9 @@ class ThreadService {
subscription.count -= 1
if (subscription.count <= 0) {
this.subscriptions.delete(rootInfo.id)
subscription.closer?.()
subscription.promise.then(({ closer }) => {
closer()
})
}
}, 2000)
}
@@ -172,12 +174,13 @@ class ThreadService {
if (!rootInfo) return false
const subscription = this.subscriptions.get(rootInfo.id)
if (!subscription) return false
if (!subscription || !subscription.until) return false
const { timelineKey, until } = subscription
if (!timelineKey || !until) return false
const { timelineKey } = await subscription.promise
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)
const { event } = this.resolveStuff(stuff)