From 17d90a298a3acfd017a3b761c2ec6c66f9320b7d Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 25 Dec 2025 17:06:32 +0800 Subject: [PATCH] refactor: thread --- src/App.tsx | 15 +- .../ExternalContentInteractions/index.tsx | 4 +- src/components/NoteInteractions/index.tsx | 10 +- src/components/NoteList/index.tsx | 7 +- src/components/NotificationList/index.tsx | 7 +- src/components/PostEditor/PostContent.tsx | 5 +- src/components/ReplyNote/index.tsx | 9 +- src/components/ReplyNoteList/SubReplies.tsx | 12 +- src/components/ReplyNoteList/index.tsx | 209 ++-------- src/components/StuffStats/ReplyButton.tsx | 12 +- src/components/UserAggregationList/index.tsx | 7 +- src/hooks/useThread.tsx | 16 + .../secondary/ExternalContentPage/index.tsx | 2 +- src/pages/secondary/NotePage/index.tsx | 2 +- src/providers/ReplyProvider.tsx | 71 ---- src/services/thread.service.ts | 369 ++++++++++++++++++ 16 files changed, 452 insertions(+), 305 deletions(-) create mode 100644 src/hooks/useThread.tsx delete mode 100644 src/providers/ReplyProvider.tsx create mode 100644 src/services/thread.service.ts diff --git a/src/App.tsx b/src/App.tsx index 8c980267..b34ce2b1 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 023df2e4..a5cc483f 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 385899a1..8fd123d6 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 74d7315c..d1fa18a0 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -7,9 +7,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,7 +76,6 @@ 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) @@ -314,7 +313,7 @@ const NoteList = forwardRef< if (eosed) { loadingRef.current = false setLoading(false) - addReplies(events) + threadService.addRepliesToThread(events) } }, onNew: (event) => { @@ -327,7 +326,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 diff --git a/src/components/NotificationList/index.tsx b/src/components/NotificationList/index.tsx index 8a9ad886..5f8911cb 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 4663466a..4bf8598d 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 61d4eeb5..b1ed2538 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 ac7264d8..5bdc2932 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,53 +1,31 @@ -import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' 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 threadService from '@/services/thread.service' +import { Event as NEvent } from 'nostr-tools' import { useEffect, useMemo, useRef, 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 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 +34,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,155 +50,29 @@ export default function ReplyNoteList({ return replyEvents.sort((a, b) => b.created_at - a.created_at) }, [ stuffKey, - repliesMap, + allThreads, mutePubkeySet, hideContentMentioningMutedUsers, hideUntrustedInteractions ]) - const [timelineKey, setTimelineKey] = useState(undefined) - const [until, setUntil] = useState(undefined) + const [hasMore, setHasMore] = useState(true) const [showCount, setShowCount] = useState(SHOW_COUNT) const [loading, setLoading] = useState(false) const loadingRef = useRef(false) const bottomRef = useRef(null) useEffect(() => { - const fetchRootEvent = async () => { - if (!event && !externalContent) return + loadingRef.current = true + setLoading(true) + threadService.subscribe(stuff, LIMIT).finally(() => { + loadingRef.current = false + setLoading(false) + }) - 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 promise = init() return () => { - promise.then((closer) => closer?.()) + threadService.unsubscribe(stuff) } - }, [rootInfo, currentIndex, index]) + }, [stuff]) useEffect(() => { const options = { @@ -238,23 +90,20 @@ export default function ReplyNoteList({ } } - if (loadingRef.current || !until || !timelineKey) return + if (loadingRef.current) 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) + const newHasMore = await threadService.loadMore(stuff, LIMIT) + + setHasMore(newHasMore) loadingRef.current = false setLoading(false) } const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !!until) { + if (entries[0].isIntersecting && hasMore) { loadMore() } }, options) @@ -270,7 +119,7 @@ export default function ReplyNoteList({ observerInstance.unobserve(currentBottomRef) } } - }, [replies, showCount, until, timelineKey, loading, event]) + }, [replies, showCount, loading, stuff, hasMore]) return (
@@ -280,7 +129,7 @@ export default function ReplyNoteList({ ))}
- {!!until || showCount < replies.length || loading ? ( + {hasMore || showCount < replies.length || loading ? ( ) : (
diff --git a/src/components/StuffStats/ReplyButton.tsx b/src/components/StuffStats/ReplyButton.tsx index e39be9b7..53e8556d 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 baadd648..50d053b0 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/useThread.tsx b/src/hooks/useThread.tsx new file mode 100644 index 00000000..d2e2826e --- /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 7c4ff9e8..e87e9dfe 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 18f79533..ee6623be 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 db6cbef7..00000000 --- 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 00000000..5afc5ed7 --- /dev/null +++ b/src/services/thread.service.ts @@ -0,0 +1,369 @@ +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, + { + 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 + + let 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 & { + 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 + }) + } + + return new Promise((resolve) => { + client + .subscribeTimeline( + filters.map((filter) => ({ + urls: relayUrls.slice(0, 8), + filter + })), + { + 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]) + } + } + ) + .then(({ closer, timelineKey }) => { + subscription.closer = closer + subscription.timelineKey = timelineKey + }) + .catch(() => { + this.subscriptions.delete(rootInfo.id) + resolve() + }) + }) + } + + 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.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, until } = subscription + if (!timelineKey || !until) return false + + const events = await client.loadMoreTimeline(timelineKey, 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 && 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