diff --git a/src/App.tsx b/src/App.tsx index 8c98026..b34ce2b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,7 +15,6 @@ import { MuteListProvider } from '@/providers/MuteListProvider' import { NostrProvider } from '@/providers/NostrProvider' import { PinListProvider } from '@/providers/PinListProvider' import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider' -import { ReplyProvider } from '@/providers/ReplyProvider' import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider' import { ThemeProvider } from '@/providers/ThemeProvider' import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider' @@ -43,14 +42,12 @@ export default function App(): JSX.Element { - - - - - - - - + + + + + + diff --git a/src/components/ExternalContentInteractions/index.tsx b/src/components/ExternalContentInteractions/index.tsx index 023df2e..a5cc483 100644 --- a/src/components/ExternalContentInteractions/index.tsx +++ b/src/components/ExternalContentInteractions/index.tsx @@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList' import { Tabs, TTabValue } from './Tabs' export default function ExternalContentInteractions({ - pageIndex, externalContent }: { - pageIndex?: number externalContent: string }) { const [type, setType] = useState('replies') let list switch (type) { case 'replies': - list = + list = break case 'reactions': list = diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index 385899a..8fd123d 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -10,18 +10,12 @@ import RepostList from '../RepostList' import ZapList from '../ZapList' import { Tabs, TTabValue } from './Tabs' -export default function NoteInteractions({ - pageIndex, - event -}: { - pageIndex?: number - event: Event -}) { +export default function NoteInteractions({ event }: { event: Event }) { const [type, setType] = useState('replies') let list switch (type) { case 'replies': - list = + list = break case 'quotes': list = diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 74d7315..79c04a2 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,5 +1,6 @@ import NewNotesButton from '@/components/NewNotesButton' import { Button } from '@/components/ui/button' +import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' import { getEventKey, getKeyFromTag, isMentioningMutedUsers, isReplyNoteEvent } from '@/lib/event' import { tagNameEquals } from '@/lib/tag' import { isTouchDevice } from '@/lib/utils' @@ -7,9 +8,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' -import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' +import threadService from '@/services/thread.service' import { TFeedSubRequest } from '@/types' import dayjs from 'dayjs' import { Event, kinds } from 'nostr-tools' @@ -76,11 +77,9 @@ const NoteList = forwardRef< const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() - const { addReplies } = useReply() const [events, setEvents] = useState([]) const [newEvents, setNewEvents] = useState([]) - const [hasMore, setHasMore] = useState(true) - const [loading, setLoading] = useState(true) + const [initialLoading, setInitialLoading] = useState(false) const [filtering, setFiltering] = useState(false) const [timelineKey, setTimelineKey] = useState(undefined) const [filteredNotes, setFilteredNotes] = useState< @@ -88,11 +87,8 @@ const NoteList = forwardRef< >([]) const [filteredNewEvents, setFilteredNewEvents] = useState([]) const [refreshCount, setRefreshCount] = useState(0) - const [showCount, setShowCount] = useState(SHOW_COUNT) const supportTouch = useMemo(() => isTouchDevice(), []) - const bottomRef = useRef(null) const topRef = useRef(null) - const loadingRef = useRef(false) const shouldHideEvent = useCallback( (evt: Event) => { @@ -219,10 +215,6 @@ const NoteList = forwardRef< processEvents().finally(() => setFiltering(false)) }, [events, shouldHideEvent, hideReplies, isSpammer, hideSpam]) - const slicedNotes = useMemo(() => { - return filteredNotes.slice(0, showCount) - }, [filteredNotes, showCount]) - useEffect(() => { const processNewEvents = async () => { const keySet = new Set() @@ -274,16 +266,11 @@ const NoteList = forwardRef< if (!subRequests.length) return async function init() { - loadingRef.current = true - setLoading(true) + setInitialLoading(true) setEvents([]) setNewEvents([]) - setHasMore(true) if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) { - loadingRef.current = false - setLoading(false) - setHasMore(false) return () => {} } @@ -308,13 +295,9 @@ const NoteList = forwardRef< if (events.length > 0) { setEvents(events) } - if (areAlgoRelays) { - setHasMore(false) - } if (eosed) { - loadingRef.current = false - setLoading(false) - addReplies(events) + threadService.addRepliesToThread(events) + setInitialLoading(false) } }, onNew: (event) => { @@ -327,7 +310,7 @@ const NoteList = forwardRef< [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } - addReplies([event]) + threadService.addRepliesToThread([event]) }, onClose: (url, reason) => { if (!showRelayCloseReason) return @@ -362,57 +345,26 @@ const NoteList = forwardRef< } }, [JSON.stringify(subRequests), refreshCount, JSON.stringify(showKinds)]) - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 0.1 + const handleLoadMore = useCallback(async () => { + if (!timelineKey || areAlgoRelays) return false + const newEvents = await client.loadMoreTimeline( + timelineKey, + events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), + LIMIT + ) + if (newEvents.length === 0) { + return false } + setEvents((oldEvents) => [...oldEvents, ...newEvents]) + return true + }, [timelineKey, events, areAlgoRelays]) - const loadMore = async () => { - if (showCount < events.length) { - setShowCount((prev) => prev + SHOW_COUNT) - // preload more - if (events.length - showCount > LIMIT / 2) { - return - } - } - - if (!timelineKey || loadingRef.current || !hasMore) return - loadingRef.current = true - setLoading(true) - const newEvents = await client.loadMoreTimeline( - timelineKey, - events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), - LIMIT - ) - loadingRef.current = false - setLoading(false) - if (newEvents.length === 0) { - setHasMore(false) - return - } - setEvents((oldEvents) => [...oldEvents, ...newEvents]) - } - - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && hasMore) { - loadMore() - } - }, options) - - const currentBottomRef = bottomRef.current - - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } - - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) - } - } - }, [hasMore, events, showCount, timelineKey, loading]) + const { visibleItems, shouldShowLoadingIndicator, bottomRef } = useInfiniteScroll({ + items: filteredNotes, + showCount: SHOW_COUNT, + onLoadMore: handleLoadMore, + initialLoading + }) const showNewEvents = () => { setEvents((oldEvents) => [...newEvents, ...oldEvents]) @@ -425,7 +377,7 @@ const NoteList = forwardRef< const list = (
{pinnedEventIds?.map((id) => )} - {slicedNotes.map(({ key, event, reposters }) => ( + {visibleItems.map(({ key, event, reposters }) => ( ))} - {hasMore || showCount < events.length || loading || filtering ? ( -
- -
+
+ {shouldShowLoadingIndicator || filtering || initialLoading ? ( + ) : events.length ? (
{t('no more notes')}
) : ( diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 8a9ad88..5f8911c 100644 --- a/src/components/NotificationList/index.tsx +++ b/src/components/NotificationList/index.tsx @@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils' import { usePrimaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import { useNotification } from '@/providers/NotificationProvider' -import { useReply } from '@/providers/ReplyProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import client from '@/services/client.service' import stuffStatsService from '@/services/stuff-stats.service' +import threadService from '@/services/thread.service' import { TNotificationType } from '@/types' import dayjs from 'dayjs' import { NostrEvent, kinds, matchFilter } from 'nostr-tools' @@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => { const { pubkey } = useNostr() const { getNotificationsSeenAt } = useNotification() const { notificationListStyle } = useUserPreferences() - const { addReplies } = useReply() const [notificationType, setNotificationType] = useState('all') const [lastReadTime, setLastReadTime] = useState(0) const [refreshCount, setRefreshCount] = useState(0) @@ -143,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => { if (eosed) { setLoading(false) setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined) - addReplies(events) + threadService.addRepliesToThread(events) stuffStatsService.updateStuffStatsByEvents(events) } }, onNew: (event) => { handleNewEvent(event) - addReplies([event]) + threadService.addRepliesToThread([event]) } } ) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 4663466..4bf8598 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -11,8 +11,8 @@ import { } from '@/lib/draft-event' import { isTouchDevice } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' -import { useReply } from '@/providers/ReplyProvider' import postEditorCache from '@/services/post-editor-cache.service' +import threadService from '@/services/thread.service' import { TPollCreateData } from '@/types' import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react' import { Event, kinds } from 'nostr-tools' @@ -42,7 +42,6 @@ export default function PostContent({ }) { const { t } = useTranslation() const { pubkey, publish, checkLogin } = useNostr() - const { addReplies } = useReply() const [text, setText] = useState('') const textareaRef = useRef(null) const [posting, setPosting] = useState(false) @@ -157,7 +156,7 @@ export default function PostContent({ }) postEditorCache.clearPostCache({ defaultContent, parentStuff }) deleteDraftEventCache(draftEvent) - addReplies([newEvent]) + threadService.addRepliesToThread([newEvent]) toast.success(t('Post successful'), { duration: 2000 }) close() } catch (error) { diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 61d4eeb..b1ed253 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -1,12 +1,12 @@ import { useSecondaryPage } from '@/PageManager' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' +import { useThread } from '@/hooks/useThread' import { getEventKey, isMentioningMutedUsers } from '@/lib/event' import { toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/MuteListProvider' -import { useReply } from '@/providers/ReplyProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import { Event } from 'nostr-tools' @@ -44,7 +44,8 @@ export default function ReplyNote({ const { mutePubkeySet } = useMuteList() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const { hideContentMentioningMutedUsers } = useContentPolicy() - const { repliesMap } = useReply() + const eventKey = useMemo(() => getEventKey(event), [event]) + const replies = useThread(eventKey) const [showMuted, setShowMuted] = useState(false) const show = useMemo(() => { if (showMuted) { @@ -59,8 +60,6 @@ export default function ReplyNote({ return true }, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers]) const hasReplies = useMemo(() => { - const key = getEventKey(event) - const replies = repliesMap.get(key)?.events if (!replies || replies.length === 0) { return false } @@ -77,7 +76,7 @@ export default function ReplyNote({ } return true } - }, [event, repliesMap]) + }, [replies]) return (
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) => {
- + ) }) diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 18f7953..ee6623b 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -105,7 +105,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
- + ) }) diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx deleted file mode 100644 index db6cbef..0000000 --- a/src/providers/ReplyProvider.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { getEventKey, getKeyFromTag, getParentTag, isReplyNoteEvent } from '@/lib/event' -import { Event } from 'nostr-tools' -import { createContext, useCallback, useContext, useState } from 'react' - -type TReplyContext = { - repliesMap: Map }> - addReplies: (replies: Event[]) => void -} - -const ReplyContext = createContext(undefined) - -export const useReply = () => { - const context = useContext(ReplyContext) - if (!context) { - throw new Error('useReply must be used within a ReplyProvider') - } - return context -} - -export function ReplyProvider({ children }: { children: React.ReactNode }) { - const [repliesMap, setRepliesMap] = useState< - Map }> - >(new Map()) - - const addReplies = useCallback((replies: Event[]) => { - const newReplyKeySet = new Set() - const newReplyEventMap = new Map() - replies.forEach((reply) => { - if (!isReplyNoteEvent(reply)) return - - const key = getEventKey(reply) - if (newReplyKeySet.has(key)) return - newReplyKeySet.add(key) - - const parentTag = getParentTag(reply) - if (parentTag) { - const parentKey = getKeyFromTag(parentTag.tag) - if (parentKey) { - newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply]) - } - } - }) - if (newReplyEventMap.size === 0) return - - setRepliesMap((prev) => { - for (const [key, newReplyEvents] of newReplyEventMap.entries()) { - const replies = prev.get(key) || { events: [], eventKeySet: new Set() } - newReplyEvents.forEach((reply) => { - const key = getEventKey(reply) - if (!replies.eventKeySet.has(key)) { - replies.events.push(reply) - replies.eventKeySet.add(key) - } - }) - prev.set(key, replies) - } - return new Map(prev) - }) - }, []) - - return ( - - {children} - - ) -} diff --git a/src/services/thread.service.ts b/src/services/thread.service.ts new file mode 100644 index 0000000..f4a4b7a --- /dev/null +++ b/src/services/thread.service.ts @@ -0,0 +1,378 @@ +import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { + getEventKey, + getKeyFromTag, + getParentTag, + getReplaceableCoordinateFromEvent, + getRootTag, + isProtectedEvent, + isReplaceableEvent, + isReplyNoteEvent +} from '@/lib/event' +import { generateBech32IdFromETag } from '@/lib/tag' +import client from '@/services/client.service' +import dayjs from 'dayjs' +import { Filter, kinds, NostrEvent } from 'nostr-tools' + +type TRootInfo = + | { type: 'E'; id: string; pubkey: string } + | { type: 'A'; id: string; pubkey: string; relay?: string } + | { type: 'I'; id: string } + +class ThreadService { + static instance: ThreadService + + private rootInfoCache = new Map>() + private subscriptions = new Map< + string, + { + promise: Promise<{ + closer: () => void + timelineKey: string + }> + count: number + until?: number + } + >() + private threadMap = new Map() + private processedReplyKeys = new Set() + private parentKeyMap = new Map() + private descendantCache = new Map>() + + private threadListeners = new Map void>>() + private allDescendantThreadsListeners = new Map void>>() + private readonly EMPTY_ARRAY: NostrEvent[] = [] + private readonly EMPTY_MAP: Map = new Map() + + constructor() { + if (!ThreadService.instance) { + ThreadService.instance = this + } + return ThreadService.instance + } + + async subscribe(stuff: NostrEvent | string, limit = 100) { + const { event } = this.resolveStuff(stuff) + const rootInfo = await this.parseRootInfo(stuff) + if (!rootInfo) return + + const subscription = this.subscriptions.get(rootInfo.id) + if (subscription) { + subscription.count += 1 + return + } + + 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 + } + 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 + }) + if (event?.kind !== kinds.ShortTextNote) { + filters.push({ + '#E': [rootInfo.id], + kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], + limit + }) + } + } else if (rootInfo.type === 'A') { + filters.push( + { + '#a': [rootInfo.id], + kinds: [kinds.ShortTextNote], + limit + }, + { + '#A': [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 + }) + } + let resolve: () => void + const _promise = new Promise((res) => { + resolve = res + }) + 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 && events.length > 0) { + subscription.until = events[events.length - 1].created_at - 1 + } + resolve() + } + }, + onNew: (evt) => { + this.addRepliesToThread([evt]) + } + } + ) + await _promise + return { closer, timelineKey } + } + + const promise = _subscribe() + this.subscriptions.set(rootInfo.id, { + promise, + count: 1, + until: dayjs().unix() + }) + await promise + } + + async unsubscribe(stuff: NostrEvent | string) { + const rootInfo = await this.parseRootInfo(stuff) + if (!rootInfo) return + + const subscription = this.subscriptions.get(rootInfo.id) + if (!subscription) return + + setTimeout(() => { + subscription.count -= 1 + if (subscription.count <= 0) { + this.subscriptions.delete(rootInfo.id) + subscription.promise.then(({ closer }) => { + closer() + }) + } + }, 2000) + } + + async loadMore(stuff: NostrEvent | string, limit = 100): Promise { + const rootInfo = await this.parseRootInfo(stuff) + if (!rootInfo) return false + + const subscription = this.subscriptions.get(rootInfo.id) + if (!subscription) return false + + const { timelineKey } = await subscription.promise + if (!timelineKey) return false + + if (!subscription.until) return false + + const events = await client.loadMoreTimeline(timelineKey, subscription.until, limit) + this.addRepliesToThread(events) + + const { event } = this.resolveStuff(stuff) + let newUntil = events.length ? events[events.length - 1].created_at - 1 : undefined + if (newUntil && event && !isReplaceableEvent(event.kind) && newUntil < event.created_at) { + newUntil = undefined + } + subscription.until = newUntil + return !!newUntil + } + + addRepliesToThread(replies: NostrEvent[]) { + const newReplyEventMap = new Map() + replies.forEach((reply) => { + const key = getEventKey(reply) + if (this.processedReplyKeys.has(key)) return + this.processedReplyKeys.add(key) + + if (!isReplyNoteEvent(reply)) return + + const parentTag = getParentTag(reply) + if (parentTag) { + const parentKey = getKeyFromTag(parentTag.tag) + if (parentKey) { + const thread = newReplyEventMap.get(parentKey) ?? [] + thread.push(reply) + newReplyEventMap.set(parentKey, thread) + this.parentKeyMap.set(key, parentKey) + } + } + }) + if (newReplyEventMap.size === 0) return + + for (const [key, newReplyEvents] of newReplyEventMap.entries()) { + const thread = this.threadMap.get(key) ?? [] + thread.push(...newReplyEvents) + this.threadMap.set(key, thread) + } + + this.descendantCache.clear() + for (const key of newReplyEventMap.keys()) { + this.notifyThreadUpdate(key) + this.notifyAllDescendantThreadsUpdate(key) + } + } + + getThread(stuffKey: string): NostrEvent[] { + return this.threadMap.get(stuffKey) ?? this.EMPTY_ARRAY + } + + getAllDescendantThreads(stuffKey: string): Map { + const cached = this.descendantCache.get(stuffKey) + if (cached) return cached + + const build = () => { + const thread = this.threadMap.get(stuffKey) + if (!thread || thread.length === 0) { + return this.EMPTY_MAP + } + + const result = new Map() + const keys: string[] = [stuffKey] + while (keys.length > 0) { + const key = keys.pop()! + const thread = this.threadMap.get(key) ?? [] + if (thread.length > 0) { + result.set(key, thread) + thread.forEach((reply) => { + const replyKey = getEventKey(reply) + keys.push(replyKey) + }) + } + } + return result + } + + const allThreads = build() + this.descendantCache.set(stuffKey, allThreads) + return allThreads + } + + listenThread(key: string, callback: () => void) { + let set = this.threadListeners.get(key) + if (!set) { + set = new Set() + this.threadListeners.set(key, set) + } + set.add(callback) + return () => { + set?.delete(callback) + if (set?.size === 0) this.threadListeners.delete(key) + } + } + + private notifyThreadUpdate(key: string) { + const set = this.threadListeners.get(key) + if (set) { + set.forEach((cb) => cb()) + } + } + + listenAllDescendantThreads(key: string, callback: () => void) { + let set = this.allDescendantThreadsListeners.get(key) + if (!set) { + set = new Set() + this.allDescendantThreadsListeners.set(key, set) + } + set.add(callback) + return () => { + set?.delete(callback) + if (set?.size === 0) this.allDescendantThreadsListeners.delete(key) + } + } + + private notifyAllDescendantThreadsUpdate(key: string) { + const notify = (_key: string) => { + const set = this.allDescendantThreadsListeners.get(_key) + if (set) { + set.forEach((cb) => cb()) + } + } + + notify(key) + let parentKey = this.parentKeyMap.get(key) + while (parentKey) { + notify(parentKey) + parentKey = this.parentKeyMap.get(parentKey) + } + } + + private async parseRootInfo(stuff: NostrEvent | string): Promise { + const { event, externalContent } = this.resolveStuff(stuff) + if (!event && !externalContent) return + + const cacheKey = event ? getEventKey(event) : externalContent! + const cache = this.rootInfoCache.get(cacheKey) + if (cache) return cache + + const _parseRootInfo = async (): Promise => { + 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] } + } + return root + } + + const promise = _parseRootInfo() + this.rootInfoCache.set(cacheKey, promise) + return promise + } + + private resolveStuff(stuff: NostrEvent | string) { + return typeof stuff === 'string' + ? { event: undefined, externalContent: stuff, stuffKey: stuff } + : { event: stuff, externalContent: undefined, stuffKey: getEventKey(stuff) } + } +} + +const instance = new ThreadService() + +export default instance