refactor: thread

This commit is contained in:
codytseng
2025-12-25 17:06:32 +08:00
parent d964c7b7b3
commit 17d90a298a
16 changed files with 452 additions and 305 deletions

View File

@@ -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 {
<PinListProvider>
<PinnedUsersProvider>
<FeedProvider>
<ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</ReplyProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
</PinListProvider>

View File

@@ -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<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
list = <ReplyNoteList stuff={externalContent} />
break
case 'reactions':
list = <ReactionList stuff={externalContent} />

View File

@@ -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<TTabValue>('replies')
let list
switch (type) {
case 'replies':
list = <ReplyNoteList index={pageIndex} stuff={event} />
list = <ReplyNoteList stuff={event} />
break
case 'quotes':
list = <QuoteList stuff={event} />

View File

@@ -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<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [hasMore, setHasMore] = useState<boolean>(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

View File

@@ -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<TNotificationType>('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])
}
}
)

View File

@@ -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<TPostTextareaHandle>(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) {

View File

@@ -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 (
<div

View File

@@ -1,11 +1,11 @@
import { useSecondaryPage } from '@/PageManager'
import { useAllDescendantThreads } from '@/hooks/useThread'
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { NostrEvent } from 'nostr-tools'
@@ -16,7 +16,7 @@ import ReplyNote from '../ReplyNote'
export default function SubReplies({ parentKey }: { parentKey: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { repliesMap } = useReply()
const allThreads = useAllDescendantThreads(parentKey)
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
@@ -27,7 +27,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
let parentKeys = [parentKey]
while (parentKeys.length > 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

View File

@@ -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<TRootInfo | undefined>(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<string>()
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<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [hasMore, setHasMore] = useState(true)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [loading, setLoading] = useState<boolean>(false)
const loadingRef = useRef(false)
const bottomRef = useRef<HTMLDivElement | null>(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<Filter, 'since' | 'until'> & {
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 (
<div className="min-h-[80vh]">
@@ -280,7 +129,7 @@ export default function ReplyNoteList({
<Item key={reply.id} reply={reply} />
))}
</div>
{!!until || showCount < replies.length || loading ? (
{hasMore || showCount < replies.length || loading ? (
<ReplyNoteSkeleton />
) : (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">

View File

@@ -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 (

View File

@@ -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<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
@@ -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

16
src/hooks/useThread.tsx Normal file
View File

@@ -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)
)
}

View File

@@ -33,7 +33,7 @@ const ExternalContentPage = forwardRef(({ index }: { index?: number }, ref) => {
<StuffStats className="mt-3" stuff={id} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<ExternalContentInteractions pageIndex={index} externalContent={id} />
<ExternalContentInteractions externalContent={id} />
</SecondaryPageLayout>
)
})

View File

@@ -105,7 +105,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
</div>
<Separator className="mt-4" />
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
<NoteInteractions key={`note-interactions-${event.id}`} event={event} />
</SecondaryPageLayout>
)
})

View File

@@ -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<string, { events: Event[]; eventKeySet: Set<string> }>
addReplies: (replies: Event[]) => void
}
const ReplyContext = createContext<TReplyContext | undefined>(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<string, { events: Event[]; eventKeySet: Set<string> }>
>(new Map())
const addReplies = useCallback((replies: Event[]) => {
const newReplyKeySet = new Set<string>()
const newReplyEventMap = new Map<string, Event[]>()
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 (
<ReplyContext.Provider
value={{
repliesMap,
addReplies
}}
>
{children}
</ReplyContext.Provider>
)
}

View File

@@ -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<string, Promise<TRootInfo | undefined>>()
private subscriptions = new Map<
string,
{
closer?: () => void
timelineKey?: string
count: number
until?: number
}
>()
private threadMap = new Map<string, NostrEvent[]>()
private processedReplyKeys = new Set<string>()
private parentKeyMap = new Map<string, string>()
private descendantCache = new Map<string, Map<string, NostrEvent[]>>()
private threadListeners = new Map<string, Set<() => void>>()
private allDescendantThreadsListeners = new Map<string, Set<() => void>>()
private readonly EMPTY_ARRAY: NostrEvent[] = []
private readonly EMPTY_MAP: Map<string, NostrEvent[]> = 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<Filter, 'since' | 'until'> & {
limit: number
})[] = []
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
limit
})
if (event?.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit
})
}
} else if (rootInfo.type === 'A') {
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<void>((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<boolean> {
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<string, NostrEvent[]>()
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<string, NostrEvent[]> {
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<string, NostrEvent[]>()
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<TRootInfo | undefined> {
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<TRootInfo | undefined> => {
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