refactor: thread
This commit is contained in:
@@ -15,7 +15,6 @@ import { MuteListProvider } from '@/providers/MuteListProvider'
|
|||||||
import { NostrProvider } from '@/providers/NostrProvider'
|
import { NostrProvider } from '@/providers/NostrProvider'
|
||||||
import { PinListProvider } from '@/providers/PinListProvider'
|
import { PinListProvider } from '@/providers/PinListProvider'
|
||||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||||
import { ReplyProvider } from '@/providers/ReplyProvider'
|
|
||||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||||
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
import { TranslationServiceProvider } from '@/providers/TranslationServiceProvider'
|
||||||
@@ -43,14 +42,12 @@ export default function App(): JSX.Element {
|
|||||||
<PinListProvider>
|
<PinListProvider>
|
||||||
<PinnedUsersProvider>
|
<PinnedUsersProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<ReplyProvider>
|
|
||||||
<MediaUploadServiceProvider>
|
<MediaUploadServiceProvider>
|
||||||
<KindFilterProvider>
|
<KindFilterProvider>
|
||||||
<PageManager />
|
<PageManager />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</KindFilterProvider>
|
</KindFilterProvider>
|
||||||
</MediaUploadServiceProvider>
|
</MediaUploadServiceProvider>
|
||||||
</ReplyProvider>
|
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</PinnedUsersProvider>
|
</PinnedUsersProvider>
|
||||||
</PinListProvider>
|
</PinListProvider>
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ import ReplyNoteList from '../ReplyNoteList'
|
|||||||
import { Tabs, TTabValue } from './Tabs'
|
import { Tabs, TTabValue } from './Tabs'
|
||||||
|
|
||||||
export default function ExternalContentInteractions({
|
export default function ExternalContentInteractions({
|
||||||
pageIndex,
|
|
||||||
externalContent
|
externalContent
|
||||||
}: {
|
}: {
|
||||||
pageIndex?: number
|
|
||||||
externalContent: string
|
externalContent: string
|
||||||
}) {
|
}) {
|
||||||
const [type, setType] = useState<TTabValue>('replies')
|
const [type, setType] = useState<TTabValue>('replies')
|
||||||
let list
|
let list
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replies':
|
case 'replies':
|
||||||
list = <ReplyNoteList index={pageIndex} stuff={externalContent} />
|
list = <ReplyNoteList stuff={externalContent} />
|
||||||
break
|
break
|
||||||
case 'reactions':
|
case 'reactions':
|
||||||
list = <ReactionList stuff={externalContent} />
|
list = <ReactionList stuff={externalContent} />
|
||||||
|
|||||||
@@ -10,18 +10,12 @@ import RepostList from '../RepostList'
|
|||||||
import ZapList from '../ZapList'
|
import ZapList from '../ZapList'
|
||||||
import { Tabs, TTabValue } from './Tabs'
|
import { Tabs, TTabValue } from './Tabs'
|
||||||
|
|
||||||
export default function NoteInteractions({
|
export default function NoteInteractions({ event }: { event: Event }) {
|
||||||
pageIndex,
|
|
||||||
event
|
|
||||||
}: {
|
|
||||||
pageIndex?: number
|
|
||||||
event: Event
|
|
||||||
}) {
|
|
||||||
const [type, setType] = useState<TTabValue>('replies')
|
const [type, setType] = useState<TTabValue>('replies')
|
||||||
let list
|
let list
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replies':
|
case 'replies':
|
||||||
list = <ReplyNoteList index={pageIndex} stuff={event} />
|
list = <ReplyNoteList stuff={event} />
|
||||||
break
|
break
|
||||||
case 'quotes':
|
case 'quotes':
|
||||||
list = <QuoteList stuff={event} />
|
list = <QuoteList stuff={event} />
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
|||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import { TFeedSubRequest } from '@/types'
|
import { TFeedSubRequest } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
@@ -76,7 +76,6 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||||
@@ -314,7 +313,7 @@ const NoteList = forwardRef<
|
|||||||
if (eosed) {
|
if (eosed) {
|
||||||
loadingRef.current = false
|
loadingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
addReplies(events)
|
threadService.addRepliesToThread(events)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
@@ -327,7 +326,7 @@ const NoteList = forwardRef<
|
|||||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
addReplies([event])
|
threadService.addRepliesToThread([event])
|
||||||
},
|
},
|
||||||
onClose: (url, reason) => {
|
onClose: (url, reason) => {
|
||||||
if (!showRelayCloseReason) return
|
if (!showRelayCloseReason) return
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { isTouchDevice } from '@/lib/utils'
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNotification } from '@/providers/NotificationProvider'
|
import { useNotification } from '@/providers/NotificationProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import stuffStatsService from '@/services/stuff-stats.service'
|
import stuffStatsService from '@/services/stuff-stats.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import { TNotificationType } from '@/types'
|
import { TNotificationType } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
import { NostrEvent, kinds, matchFilter } from 'nostr-tools'
|
||||||
@@ -37,7 +37,6 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { getNotificationsSeenAt } = useNotification()
|
const { getNotificationsSeenAt } = useNotification()
|
||||||
const { notificationListStyle } = useUserPreferences()
|
const { notificationListStyle } = useUserPreferences()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
const [notificationType, setNotificationType] = useState<TNotificationType>('all')
|
||||||
const [lastReadTime, setLastReadTime] = useState(0)
|
const [lastReadTime, setLastReadTime] = useState(0)
|
||||||
const [refreshCount, setRefreshCount] = useState(0)
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
@@ -143,13 +142,13 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
setUntil(events.length > 0 ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
addReplies(events)
|
threadService.addRepliesToThread(events)
|
||||||
stuffStatsService.updateStuffStatsByEvents(events)
|
stuffStatsService.updateStuffStatsByEvents(events)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
handleNewEvent(event)
|
handleNewEvent(event)
|
||||||
addReplies([event])
|
threadService.addRepliesToThread([event])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
} from '@/lib/draft-event'
|
} from '@/lib/draft-event'
|
||||||
import { isTouchDevice } from '@/lib/utils'
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import postEditorCache from '@/services/post-editor-cache.service'
|
import postEditorCache from '@/services/post-editor-cache.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import { TPollCreateData } from '@/types'
|
import { TPollCreateData } from '@/types'
|
||||||
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, X } from 'lucide-react'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
@@ -42,7 +42,6 @@ export default function PostContent({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey, publish, checkLogin } = useNostr()
|
const { pubkey, publish, checkLogin } = useNostr()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const textareaRef = useRef<TPostTextareaHandle>(null)
|
const textareaRef = useRef<TPostTextareaHandle>(null)
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
@@ -157,7 +156,7 @@ export default function PostContent({
|
|||||||
})
|
})
|
||||||
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
postEditorCache.clearPostCache({ defaultContent, parentStuff })
|
||||||
deleteDraftEventCache(draftEvent)
|
deleteDraftEventCache(draftEvent)
|
||||||
addReplies([newEvent])
|
threadService.addRepliesToThread([newEvent])
|
||||||
toast.success(t('Post successful'), { duration: 2000 })
|
toast.success(t('Post successful'), { duration: 2000 })
|
||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useThread } from '@/hooks/useThread'
|
||||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -44,7 +44,8 @@ export default function ReplyNote({
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { repliesMap } = useReply()
|
const eventKey = useMemo(() => getEventKey(event), [event])
|
||||||
|
const replies = useThread(eventKey)
|
||||||
const [showMuted, setShowMuted] = useState(false)
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
const show = useMemo(() => {
|
const show = useMemo(() => {
|
||||||
if (showMuted) {
|
if (showMuted) {
|
||||||
@@ -59,8 +60,6 @@ export default function ReplyNote({
|
|||||||
return true
|
return true
|
||||||
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
|
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
|
||||||
const hasReplies = useMemo(() => {
|
const hasReplies = useMemo(() => {
|
||||||
const key = getEventKey(event)
|
|
||||||
const replies = repliesMap.get(key)?.events
|
|
||||||
if (!replies || replies.length === 0) {
|
if (!replies || replies.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -77,7 +76,7 @@ export default function ReplyNote({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}, [event, repliesMap])
|
}, [replies])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { generateBech32IdFromETag } from '@/lib/tag'
|
import { generateBech32IdFromETag } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
@@ -16,7 +16,7 @@ import ReplyNote from '../ReplyNote'
|
|||||||
export default function SubReplies({ parentKey }: { parentKey: string }) {
|
export default function SubReplies({ parentKey }: { parentKey: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { repliesMap } = useReply()
|
const allThreads = useAllDescendantThreads(parentKey)
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
@@ -27,7 +27,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
|
|
||||||
let parentKeys = [parentKey]
|
let parentKeys = [parentKey]
|
||||||
while (parentKeys.length > 0) {
|
while (parentKeys.length > 0) {
|
||||||
const events = parentKeys.flatMap((key) => repliesMap.get(key)?.events || [])
|
const events = parentKeys.flatMap((key) => allThreads.get(key) ?? [])
|
||||||
events.forEach((evt) => {
|
events.forEach((evt) => {
|
||||||
const key = getEventKey(evt)
|
const key = getEventKey(evt)
|
||||||
if (replyKeySet.has(key)) return
|
if (replyKeySet.has(key)) return
|
||||||
@@ -35,11 +35,11 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
|
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
|
||||||
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
||||||
const replyKey = getEventKey(evt)
|
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 the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
||||||
if (
|
if (
|
||||||
!repliesForThisReply ||
|
!repliesForThisReply ||
|
||||||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
|
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
return replyEvents.sort((a, b) => a.created_at - b.created_at)
|
||||||
}, [
|
}, [
|
||||||
parentKey,
|
parentKey,
|
||||||
repliesMap,
|
allThreads,
|
||||||
mutePubkeySet,
|
mutePubkeySet,
|
||||||
hideContentMentioningMutedUsers,
|
hideContentMentioningMutedUsers,
|
||||||
hideUntrustedInteractions
|
hideUntrustedInteractions
|
||||||
|
|||||||
@@ -1,53 +1,31 @@
|
|||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
|
||||||
import { useStuff } from '@/hooks/useStuff'
|
import { useStuff } from '@/hooks/useStuff'
|
||||||
import {
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
getEventKey,
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
getReplaceableCoordinateFromEvent,
|
|
||||||
getRootTag,
|
|
||||||
isMentioningMutedUsers,
|
|
||||||
isProtectedEvent,
|
|
||||||
isReplaceableEvent
|
|
||||||
} from '@/lib/event'
|
|
||||||
import { generateBech32IdFromETag } from '@/lib/tag'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import threadService from '@/services/thread.service'
|
||||||
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
|
import { Event as NEvent } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LoadingBar } from '../LoadingBar'
|
import { LoadingBar } from '../LoadingBar'
|
||||||
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
|
||||||
import SubReplies from './SubReplies'
|
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 LIMIT = 100
|
||||||
const SHOW_COUNT = 10
|
const SHOW_COUNT = 10
|
||||||
|
|
||||||
export default function ReplyNoteList({
|
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
||||||
stuff,
|
|
||||||
index
|
|
||||||
}: {
|
|
||||||
stuff: NEvent | string
|
|
||||||
index?: number
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { currentIndex } = useSecondaryPage()
|
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
|
const { stuffKey } = useStuff(stuff)
|
||||||
const { repliesMap, addReplies } = useReply()
|
const allThreads = useAllDescendantThreads(stuffKey)
|
||||||
const { event, externalContent, stuffKey } = useStuff(stuff)
|
|
||||||
const replies = useMemo(() => {
|
const replies = useMemo(() => {
|
||||||
const replyKeySet = new Set<string>()
|
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)
|
const key = getEventKey(evt)
|
||||||
if (replyKeySet.has(key)) return false
|
if (replyKeySet.has(key)) return false
|
||||||
if (mutePubkeySet.has(evt.pubkey)) return false
|
if (mutePubkeySet.has(evt.pubkey)) return false
|
||||||
@@ -56,11 +34,11 @@ export default function ReplyNoteList({
|
|||||||
}
|
}
|
||||||
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) {
|
||||||
const replyKey = getEventKey(evt)
|
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 the reply is not trusted and there are no trusted replies for this reply, skip rendering
|
||||||
if (
|
if (
|
||||||
!repliesForThisReply ||
|
!repliesForThisReply ||
|
||||||
repliesForThisReply.events.every((evt) => !isUserTrusted(evt.pubkey))
|
repliesForThisReply.every((evt) => !isUserTrusted(evt.pubkey))
|
||||||
) {
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -72,155 +50,29 @@ export default function ReplyNoteList({
|
|||||||
return replyEvents.sort((a, b) => b.created_at - a.created_at)
|
return replyEvents.sort((a, b) => b.created_at - a.created_at)
|
||||||
}, [
|
}, [
|
||||||
stuffKey,
|
stuffKey,
|
||||||
repliesMap,
|
allThreads,
|
||||||
mutePubkeySet,
|
mutePubkeySet,
|
||||||
hideContentMentioningMutedUsers,
|
hideContentMentioningMutedUsers,
|
||||||
hideUntrustedInteractions
|
hideUntrustedInteractions
|
||||||
])
|
])
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [until, setUntil] = useState<number | undefined>(undefined)
|
|
||||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const loadingRef = useRef(false)
|
const loadingRef = useRef(false)
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRootEvent = async () => {
|
|
||||||
if (!event && !externalContent) return
|
|
||||||
|
|
||||||
let root: TRootInfo = event
|
|
||||||
? isReplaceableEvent(event.kind)
|
|
||||||
? {
|
|
||||||
type: 'A',
|
|
||||||
id: getReplaceableCoordinateFromEvent(event),
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
relay: client.getEventHint(event.id)
|
|
||||||
}
|
|
||||||
: { type: 'E', id: event.id, pubkey: event.pubkey }
|
|
||||||
: { type: 'I', id: externalContent! }
|
|
||||||
|
|
||||||
const rootTag = getRootTag(event)
|
|
||||||
if (rootTag?.type === 'e') {
|
|
||||||
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
|
|
||||||
if (rootEventHexId && rootEventPubkey) {
|
|
||||||
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
|
|
||||||
} else {
|
|
||||||
const rootEventId = generateBech32IdFromETag(rootTag.tag)
|
|
||||||
if (rootEventId) {
|
|
||||||
const rootEvent = await client.fetchEvent(rootEventId)
|
|
||||||
if (rootEvent) {
|
|
||||||
root = { type: 'E', id: rootEvent.id, pubkey: rootEvent.pubkey }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (rootTag?.type === 'a') {
|
|
||||||
const [, coordinate, relay] = rootTag.tag
|
|
||||||
const [, pubkey] = coordinate.split(':')
|
|
||||||
root = { type: 'A', id: coordinate, pubkey, relay }
|
|
||||||
} else if (rootTag?.type === 'i') {
|
|
||||||
root = { type: 'I', id: rootTag.tag[1] }
|
|
||||||
}
|
|
||||||
setRootInfo(root)
|
|
||||||
}
|
|
||||||
fetchRootEvent()
|
|
||||||
}, [event])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (loadingRef.current || !rootInfo || currentIndex !== index) return
|
|
||||||
|
|
||||||
const init = async () => {
|
|
||||||
loadingRef.current = true
|
loadingRef.current = true
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
threadService.subscribe(stuff, LIMIT).finally(() => {
|
||||||
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
|
loadingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const promise = init()
|
|
||||||
return () => {
|
return () => {
|
||||||
promise.then((closer) => closer?.())
|
threadService.unsubscribe(stuff)
|
||||||
}
|
}
|
||||||
}, [rootInfo, currentIndex, index])
|
}, [stuff])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const options = {
|
const options = {
|
||||||
@@ -238,23 +90,20 @@ export default function ReplyNoteList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingRef.current || !until || !timelineKey) return
|
if (loadingRef.current) return
|
||||||
|
|
||||||
loadingRef.current = true
|
loadingRef.current = true
|
||||||
setLoading(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
|
const newHasMore = await threadService.loadMore(stuff, LIMIT)
|
||||||
if (newUntil && event && newUntil < event.created_at) {
|
|
||||||
newUntil = undefined
|
setHasMore(newHasMore)
|
||||||
}
|
|
||||||
setUntil(newUntil)
|
|
||||||
loadingRef.current = false
|
loadingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const observerInstance = new IntersectionObserver((entries) => {
|
const observerInstance = new IntersectionObserver((entries) => {
|
||||||
if (entries[0].isIntersecting && !!until) {
|
if (entries[0].isIntersecting && hasMore) {
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}, options)
|
}, options)
|
||||||
@@ -270,7 +119,7 @@ export default function ReplyNoteList({
|
|||||||
observerInstance.unobserve(currentBottomRef)
|
observerInstance.unobserve(currentBottomRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [replies, showCount, until, timelineKey, loading, event])
|
}, [replies, showCount, loading, stuff, hasMore])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[80vh]">
|
<div className="min-h-[80vh]">
|
||||||
@@ -280,7 +129,7 @@ export default function ReplyNoteList({
|
|||||||
<Item key={reply.id} reply={reply} />
|
<Item key={reply.id} reply={reply} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!!until || showCount < replies.length || loading ? (
|
{hasMore || showCount < replies.length || loading ? (
|
||||||
<ReplyNoteSkeleton />
|
<ReplyNoteSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
|
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useStuff } from '@/hooks/useStuff'
|
import { useStuff } from '@/hooks/useStuff'
|
||||||
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import { MessageCircle } from 'lucide-react'
|
import { MessageCircle } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -17,23 +17,23 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey, checkLogin } = useNostr()
|
const { pubkey, checkLogin } = useNostr()
|
||||||
const { event, stuffKey } = useStuff(stuff)
|
const { event, stuffKey } = useStuff(stuff)
|
||||||
const { repliesMap } = useReply()
|
const allThreads = useAllDescendantThreads(stuffKey)
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { replyCount, hasReplied } = useMemo(() => {
|
const { replyCount, hasReplied } = useMemo(() => {
|
||||||
const hasReplied = pubkey
|
const hasReplied = pubkey
|
||||||
? repliesMap.get(stuffKey)?.events.some((evt) => evt.pubkey === pubkey)
|
? allThreads.get(stuffKey)?.some((evt) => evt.pubkey === pubkey)
|
||||||
: false
|
: false
|
||||||
|
|
||||||
let replyCount = 0
|
let replyCount = 0
|
||||||
const replies = [...(repliesMap.get(stuffKey)?.events || [])]
|
const replies = [...(allThreads.get(stuffKey) ?? [])]
|
||||||
while (replies.length > 0) {
|
while (replies.length > 0) {
|
||||||
const reply = replies.pop()
|
const reply = replies.pop()
|
||||||
if (!reply) break
|
if (!reply) break
|
||||||
|
|
||||||
const replyKey = getEventKey(reply)
|
const replyKey = getEventKey(reply)
|
||||||
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
|
const nestedReplies = allThreads.get(replyKey) ?? []
|
||||||
replies.push(...nestedReplies)
|
replies.push(...nestedReplies)
|
||||||
|
|
||||||
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
|
||||||
@@ -49,7 +49,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { replyCount, hasReplied }
|
return { replyCount, hasReplied }
|
||||||
}, [repliesMap, event, stuffKey, hideUntrustedInteractions])
|
}, [allThreads, event, stuffKey, hideUntrustedInteractions])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
|||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
import { usePinnedUsers } from '@/providers/PinnedUsersProvider'
|
||||||
import { useReply } from '@/providers/ReplyProvider'
|
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import threadService from '@/services/thread.service'
|
||||||
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
|
import userAggregationService, { TUserAggregation } from '@/services/user-aggregation.service'
|
||||||
import { TFeedSubRequest } from '@/types'
|
import { TFeedSubRequest } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -71,7 +71,6 @@ const UserAggregationList = forwardRef<
|
|||||||
const { pinnedPubkeySet } = usePinnedUsers()
|
const { pinnedPubkeySet } = usePinnedUsers()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
const { addReplies } = useReply()
|
|
||||||
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
|
const [since, setSince] = useState(() => dayjs().subtract(1, 'day').unix())
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
@@ -156,14 +155,14 @@ const UserAggregationList = forwardRef<
|
|||||||
if (eosed) {
|
if (eosed) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setHasMore(events.length > 0)
|
setHasMore(events.length > 0)
|
||||||
addReplies(events)
|
threadService.addRepliesToThread(events)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
setNewEvents((oldEvents) =>
|
setNewEvents((oldEvents) =>
|
||||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||||
)
|
)
|
||||||
addReplies([event])
|
threadService.addRepliesToThread([event])
|
||||||
},
|
},
|
||||||
onClose: (url, reason) => {
|
onClose: (url, reason) => {
|
||||||
if (!showRelayCloseReason) return
|
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 />
|
<StuffStats className="mt-3" stuff={id} fetchIfNotExisting displayTopZapsAndLikes />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
<ExternalContentInteractions pageIndex={index} externalContent={id} />
|
<ExternalContentInteractions externalContent={id} />
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
|
|||||||
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
|
<StuffStats className="mt-3" stuff={event} fetchIfNotExisting displayTopZapsAndLikes />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mt-4" />
|
<Separator className="mt-4" />
|
||||||
<NoteInteractions key={`note-interactions-${event.id}`} pageIndex={index} event={event} />
|
<NoteInteractions key={`note-interactions-${event.id}`} event={event} />
|
||||||
</SecondaryPageLayout>
|
</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