0) {
- const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
+ const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
events.forEach((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return
@@ -35,11 +35,11 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
- const repliesForThisReply = repliesMap.get(replyKey)
+ const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
- repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
+ repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return
}
@@ -53,7 +53,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [
parentKey,
- repliesMap,
+ allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
hideUntrustedInteractions
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index ac7264d..6cb19b7 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -1,53 +1,34 @@
-import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
+import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
import { useStuff } from '@/hooks/useStuff'
-import {
- getEventKey,
- getReplaceableCoordinateFromEvent,
- getRootTag,
- isMentioningMutedUsers,
- isProtectedEvent,
- isReplaceableEvent
-} from '@/lib/event'
-import { generateBech32IdFromETag } from '@/lib/tag'
-import { useSecondaryPage } from '@/PageManager'
+import { useAllDescendantThreads } from '@/hooks/useThread'
+import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
-import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
-import client from '@/services/client.service'
-import { Filter, Event as NEvent, kinds } from 'nostr-tools'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import threadService from '@/services/thread.service'
+import { Event as NEvent } from 'nostr-tools'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import SubReplies from './SubReplies'
-type TRootInfo =
- | { type: 'E'; id: string; pubkey: string }
- | { type: 'A'; id: string; pubkey: string; relay?: string }
- | { type: 'I'; id: string }
-
const LIMIT = 100
const SHOW_COUNT = 10
-export default function ReplyNoteList({
- stuff,
- index
-}: {
- stuff: NEvent | string
- index?: number
-}) {
+export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
const { t } = useTranslation()
- const { currentIndex } = useSecondaryPage()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
- const [rootInfo, setRootInfo] = useState
(undefined)
- const { repliesMap, addReplies } = useReply()
- const { event, externalContent, stuffKey } = useStuff(stuff)
+ const { stuffKey } = useStuff(stuff)
+ const allThreads = useAllDescendantThreads(stuffKey)
+ const [initialLoading, setInitialLoading] = useState(false)
+
const replies = useMemo(() => {
const replyKeySet = new Set()
- const replyEvents = (repliesMap.get(stuffKey)?.events || []).filter((evt) => {
+ const thread = allThreads.get(stuffKey) || []
+ const replyEvents = thread.filter((evt) => {
const key = getEventKey(evt)
if (replyKeySet.has(key)) return false
if (mutePubkeySet.has(evt.pubkey)) return false
@@ -56,11 +37,11 @@ export default function ReplyNoteList({
}
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
const replyKey = getEventKey(evt)
- const repliesForThisReply = repliesMap.get(replyKey)
+ const repliesForThisReply = allThreads.get(replyKey)
// If the reply is not trusted and there are no trusted replies for this reply, skip rendering
if (
!repliesForThisReply ||
- repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
+ repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
) {
return false
}
@@ -72,222 +53,55 @@ export default function ReplyNoteList({
return replyEvents.sort((a, b) => b.created_at - a.created_at)
}, [
stuffKey,
- repliesMap,
+ allThreads,
mutePubkeySet,
hideContentMentioningMutedUsers,
- hideUntrustedInteractions
+ hideUntrustedInteractions,
+ isUserTrusted
])
- const [timelineKey, setTimelineKey] = useState(undefined)
- const [until, setUntil] = useState(undefined)
- const [showCount, setShowCount] = useState(SHOW_COUNT)
- const [loading, setLoading] = useState(false)
- const loadingRef = useRef(false)
- const bottomRef = useRef(null)
+ // Initial subscription
useEffect(() => {
- const fetchRootEvent = async () => {
- if (!event && !externalContent) return
-
- let root: TRootInfo = event
- ? isReplaceableEvent(event.kind)
- ? {
- type: 'A',
- id: getReplaceableCoordinateFromEvent(event),
- pubkey: event.pubkey,
- relay: client.getEventHint(event.id)
- }
- : { type: 'E', id: event.id, pubkey: event.pubkey }
- : { type: 'I', id: externalContent! }
-
- const rootTag = getRootTag(event)
- if (rootTag?.type === 'e') {
- const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
- if (rootEventHexId && rootEventPubkey) {
- root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
- } else {
- const rootEventId = generateBech32IdFromETag(rootTag.tag)
- if (rootEventId) {
- const rootEvent = await client.fetchEvent(rootEventId)
- if (rootEvent) {
- root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
- }
- }
- }
- } else if (rootTag?.type === 'a') {
- const [, coordinate, relay] = rootTag.tag
- const [, pubkey] = coordinate.split(':')
- root = { type: 'A', id: coordinate, pubkey, relay }
- } else if (rootTag?.type === 'i') {
- root = { type: 'I', id: rootTag.tag[1] }
- }
- setRootInfo(root)
- }
- fetchRootEvent()
- }, [event])
-
- useEffect(() => {
- if (loadingRef.current || !rootInfo || currentIndex !== index) return
-
- const init = async () => {
- loadingRef.current = true
- setLoading(true)
-
- try {
- 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 & {
- limit: number
- })[] = []
- if (rootInfo.type === 'E') {
- filters.push({
- '#e': [rootInfo.id],
- kinds: [kinds.ShortTextNote],
- limit: LIMIT
- })
- if (event?.kind !== kinds.ShortTextNote) {
- filters.push({
- '#E': [rootInfo.id],
- kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
- limit: LIMIT
- })
- }
- } else if (rootInfo.type === 'A') {
- filters.push(
- {
- '#a': [rootInfo.id],
- kinds: [kinds.ShortTextNote],
- limit: LIMIT
- },
- {
- '#A': [rootInfo.id],
- kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
- limit: LIMIT
- }
- )
- if (rootInfo.relay) {
- relayUrls.push(rootInfo.relay)
- }
- } else {
- filters.push({
- '#I': [rootInfo.id],
- kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
- limit: LIMIT
- })
- }
- const { closer, timelineKey } = await client.subscribeTimeline(
- filters.map((filter) => ({
- urls: relayUrls.slice(0, 8),
- filter
- })),
- {
- onEvents: (evts, eosed) => {
- if (evts.length > 0) {
- addReplies(evts)
- }
- if (eosed) {
- loadingRef.current = false
- setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
- setLoading(false)
- }
- },
- onNew: (evt) => {
- addReplies([evt])
- }
- }
- )
- setTimelineKey(timelineKey)
- return closer
- } catch {
- loadingRef.current = false
- setLoading(false)
- }
- return
+ const loadInitial = async () => {
+ setInitialLoading(true)
+ await threadService.subscribe(stuff, LIMIT)
+ setInitialLoading(false)
}
- const promise = init()
- return () => {
- promise.then((closer) => closer?.())
- }
- }, [rootInfo, currentIndex, index])
-
- 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 || !until || !timelineKey) return
- loadingRef.current = true
- setLoading(true)
- const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
- addReplies(events)
-
- let newUntil = events.length ? events[events.length - 1].created_at - 1 : undefined
- if (newUntil && event && newUntil < event.created_at) {
- newUntil = undefined
- }
- setUntil(newUntil)
- loadingRef.current = false
- setLoading(false)
- }
-
- const observerInstance = new IntersectionObserver((entries) => {
- if (entries[0].isIntersecting && !!until) {
- loadMore()
- }
- }, options)
-
- const currentBottomRef = bottomRef.current
-
- if (currentBottomRef) {
- observerInstance.observe(currentBottomRef)
- }
+ loadInitial()
return () => {
- if (observerInstance && currentBottomRef) {
- observerInstance.unobserve(currentBottomRef)
- }
+ threadService.unsubscribe(stuff)
}
- }, [replies, showCount, until, timelineKey, loading, event])
+ }, [stuff])
+
+ const handleLoadMore = useCallback(async () => {
+ return await threadService.loadMore(stuff, LIMIT)
+ }, [stuff])
+
+ const { visibleItems, loading, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({
+ items: replies,
+ showCount: SHOW_COUNT,
+ onLoadMore: handleLoadMore,
+ initialLoading
+ })
return (
- {loading &&
}
+ {(loading || initialLoading) &&
}
- {replies.slice(0, showCount).map((reply) => (
+ {visibleItems.map((reply) => (
))}
- {!!until || showCount < replies.length || loading ? (
+
+ {shouldShowLoadingIndicator ? (
) : (
{replies.length > 0 ? t('no more replies') : t('no replies')}
)}
-
)
}
diff --git a/src/components/StuffStats/ReplyButton.tsx b/src/components/StuffStats/ReplyButton.tsx
index e39be9b..53e8556 100644
--- a/src/components/StuffStats/ReplyButton.tsx
+++ b/src/components/StuffStats/ReplyButton.tsx
@@ -1,10 +1,10 @@
import { useStuff } from '@/hooks/useStuff'
+import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
-import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { MessageCircle } from 'lucide-react'
import { Event } from 'nostr-tools'
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { event, stuffKey } = useStuff(stuff)
- const { repliesMap } = useReply()
+ const allThreads = useAllDescendantThreads(stuffKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => {
const hasReplied = pubkey
- ? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
+ ? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
: false
let replyCount = 0
- const replies = [...(repliesMap.get(stuffKey)?.events || [])]
+ const replies = [...(allThreads.get(stuffKey) ?? [])]
while (replies.length > 0) {
const reply = replies.pop()
if (!reply) break
const replyKey = getEventKey(reply)
- const nestedReplies = repliesMap.get(replyKey)?.events ?? []
+ const nestedReplies = allThreads.get(replyKey) ?? []
replies.push(...nestedReplies)
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
}
return { replyCount, hasReplied }
- }, [repliesMap, event, stuffKey, hideUntrustedInteractions])
+ }, [allThreads, event, stuffKey, hideUntrustedInteractions])
const [open, setOpen] = useState(false)
return (
diff --git a/src/components/UserAggregationList/index.tsx b/src/components/UserAggregationList/index.tsx
index baadd64..50d053b 100644
--- a/src/components/UserAggregationList/index.tsx
+++ b/src/components/UserAggregationList/index.tsx
@@ -12,9 +12,9 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
-import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
+import threadService from '@/services/thread.service'
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
import { TFeedSubRequest } from '@/types'
import dayjs from 'dayjs'
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
const { pinnedPubkeySet } = usePinnedUsers()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
- const { addReplies } = useReply()
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
const [events, setEvents] = useState([])
const [newEvents, setNewEvents] = useState([])
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
if (eosed) {
setLoading(false)
setHasMore(events.length > 0)
- addReplies(events)
+ threadService.addRepliesToThread(events)
}
},
onNew: (event) => {
setNewEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
- addReplies([event])
+ threadService.addRepliesToThread([event])
},
onClose: (url, reason) => {
if (!showRelayCloseReason) return
diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx
index 2bf759d..b5aff83 100644
--- a/src/hooks/index.tsx
+++ b/src/hooks/index.tsx
@@ -5,5 +5,6 @@ export * from './useFetchProfile'
export * from './useFetchRelayInfo'
export * from './useFetchRelayInfos'
export * from './useFetchRelayList'
+export * from './useInfiniteScroll'
export * from './useSearchProfiles'
export * from './useTranslatedEvent'
diff --git a/src/hooks/useInfiniteScroll.tsx b/src/hooks/useInfiniteScroll.tsx
new file mode 100644
index 0000000..5685c32
--- /dev/null
+++ b/src/hooks/useInfiniteScroll.tsx
@@ -0,0 +1,119 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+export interface UseInfiniteScrollOptions {
+ /**
+ * The initial data items
+ */
+ items: T[]
+ /**
+ * Whether to initially show all items or use pagination
+ * @default false
+ */
+ showAllInitially?: boolean
+ /**
+ * Number of items to show initially and load per batch
+ * @default 10
+ */
+ showCount?: number
+ /**
+ * Initial loading state, which can be used to prevent loading more data until initial load is complete
+ */
+ initialLoading?: boolean
+ /**
+ * The function to load more data
+ * Returns true if there are more items to load, false otherwise
+ */
+ onLoadMore: () => Promise
+ /**
+ * IntersectionObserver options
+ */
+ observerOptions?: IntersectionObserverInit
+}
+
+export function useInfiniteScroll({
+ items,
+ showAllInitially = false,
+ showCount: initialShowCount = 10,
+ onLoadMore,
+ initialLoading = false,
+ observerOptions = {
+ root: null,
+ rootMargin: '100px',
+ threshold: 0
+ }
+}: UseInfiniteScrollOptions) {
+ const [hasMore, setHasMore] = useState(true)
+ const [showCount, setShowCount] = useState(showAllInitially ? Infinity : initialShowCount)
+ const [loading, setLoading] = useState(false)
+ const bottomRef = useRef(null)
+ const stateRef = useRef({
+ loading,
+ hasMore,
+ showCount,
+ itemsLength: items.length,
+ initialLoading
+ })
+
+ stateRef.current = {
+ loading,
+ hasMore,
+ showCount,
+ itemsLength: items.length,
+ initialLoading
+ }
+
+ const loadMore = useCallback(async () => {
+ const { loading, hasMore, showCount, itemsLength, initialLoading } = stateRef.current
+
+ if (initialLoading || loading) return
+
+ // If there are more items to show, increase showCount first
+ if (showCount < itemsLength) {
+ setShowCount((prev) => prev + initialShowCount)
+ // Only fetch more data when remaining items are running low
+ if (itemsLength - showCount > initialShowCount * 2) {
+ return
+ }
+ }
+
+ if (!hasMore) return
+ setLoading(true)
+ const newHasMore = await onLoadMore()
+ setHasMore(newHasMore)
+ setLoading(false)
+ }, [onLoadMore, initialShowCount])
+
+ // IntersectionObserver setup
+ useEffect(() => {
+ const currentBottomRef = bottomRef.current
+ if (!currentBottomRef) return
+
+ const observer = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) {
+ loadMore()
+ }
+ }, observerOptions)
+
+ observer.observe(currentBottomRef)
+
+ return () => {
+ observer.disconnect()
+ }
+ }, [loadMore, observerOptions])
+
+ const visibleItems = useMemo(() => {
+ return showAllInitially ? items : items.slice(0, showCount)
+ }, [items, showAllInitially, showCount])
+
+ const shouldShowLoadingIndicator = hasMore || showCount < items.length || loading
+
+ return {
+ visibleItems,
+ loading,
+ hasMore,
+ shouldShowLoadingIndicator,
+ bottomRef,
+ setHasMore,
+ setLoading
+ }
+}
diff --git a/src/hooks/useThread.tsx b/src/hooks/useThread.tsx
new file mode 100644
index 0000000..d2e2826
--- /dev/null
+++ b/src/hooks/useThread.tsx
@@ -0,0 +1,16 @@
+import threadService from '@/services/thread.service'
+import { useSyncExternalStore } from 'react'
+
+export function useThread(stuffKey: string) {
+ return useSyncExternalStore(
+ (cb) => threadService.listenThread(stuffKey, cb),
+ () => threadService.getThread(stuffKey)
+ )
+}
+
+export function useAllDescendantThreads(stuffKey: string) {
+ return useSyncExternalStore(
+ (cb) => threadService.listenAllDescendantThreads(stuffKey, cb),
+ () => threadService.getAllDescendantThreads(stuffKey)
+ )
+}
diff --git a/src/pages/secondary/ExternalContentPage/index.tsx b/src/pages/secondary/ExternalContentPage/index.tsx
index 7c4ff9e..e87e9df 100644
--- a/src/pages/secondary/ExternalContentPage/index.tsx
+++ b/src/pages/secondary/ExternalContentPage/index.tsx
@@ -33,7 +33,7 @@ const ExternalContentPage = forwardRef(({ index }: { index?: number }, ref) => {