refactor: thread
This commit is contained in:
15
src/App.tsx
15
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 {
|
||||
<PinListProvider>
|
||||
<PinnedUsersProvider>
|
||||
<FeedProvider>
|
||||
<ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</ReplyProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</FeedProvider>
|
||||
</PinnedUsersProvider>
|
||||
</PinListProvider>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
16
src/hooks/useThread.tsx
Normal 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)
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
369
src/services/thread.service.ts
Normal file
369
src/services/thread.service.ts
Normal 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
|
||||
Reference in New Issue
Block a user