fix: replies
This commit is contained in:
@@ -13,6 +13,7 @@ import { MediaUploadServiceProvider } from './providers/MediaUploadServiceProvid
|
|||||||
import { MuteListProvider } from './providers/MuteListProvider'
|
import { MuteListProvider } from './providers/MuteListProvider'
|
||||||
import { NostrProvider } from './providers/NostrProvider'
|
import { NostrProvider } from './providers/NostrProvider'
|
||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
|
import { ReplyProvider } from './providers/ReplyProvider'
|
||||||
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
||||||
import { ZapProvider } from './providers/ZapProvider'
|
import { ZapProvider } from './providers/ZapProvider'
|
||||||
|
|
||||||
@@ -28,12 +29,14 @@ export default function App(): JSX.Element {
|
|||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
<BookmarksProvider>
|
<BookmarksProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
|
<ReplyProvider>
|
||||||
<NoteStatsProvider>
|
<NoteStatsProvider>
|
||||||
<MediaUploadServiceProvider>
|
<MediaUploadServiceProvider>
|
||||||
<PageManager />
|
<PageManager />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</MediaUploadServiceProvider>
|
</MediaUploadServiceProvider>
|
||||||
</NoteStatsProvider>
|
</NoteStatsProvider>
|
||||||
|
</ReplyProvider>
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</BookmarksProvider>
|
</BookmarksProvider>
|
||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||||
import { isCommentEvent, isProtectedEvent } from '@/lib/event'
|
import { isCommentEvent, isProtectedEvent } from '@/lib/event'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { generateEventId, tagNameEquals } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event as NEvent } from 'nostr-tools'
|
import { Event as NEvent } from 'nostr-tools'
|
||||||
@@ -31,7 +30,6 @@ export default function Nip22ReplyNoteList({
|
|||||||
>({})
|
>({})
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||||
const { updateNoteReplyCount } = useNoteStats()
|
|
||||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -111,8 +109,6 @@ export default function Nip22ReplyNoteList({
|
|||||||
}, [event])
|
}, [event])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateNoteReplyCount(event.id, replies.length)
|
|
||||||
|
|
||||||
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
||||||
{}
|
{}
|
||||||
for (const reply of replies) {
|
for (const reply of replies) {
|
||||||
@@ -128,7 +124,7 @@ export default function Nip22ReplyNoteList({
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
setReplyMap(replyMap)
|
setReplyMap(replyMap)
|
||||||
}, [replies, event.id, updateNoteReplyCount])
|
}, [replies, event.id])
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (loading || !until || !timelineKey) return
|
if (loading || !until || !timelineKey) return
|
||||||
@@ -192,8 +188,8 @@ export default function Nip22ReplyNoteList({
|
|||||||
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
||||||
<ReplyNote
|
<ReplyNote
|
||||||
event={reply}
|
event={reply}
|
||||||
parentEvent={info?.parent}
|
parentEventId={info?.parent ? generateEventId(info.parent) : undefined}
|
||||||
onClickParent={highlightReply}
|
onClickParent={() => info?.parent?.id && highlightReply(info?.parent?.id)}
|
||||||
highlight={highlightReplyId === reply.id}
|
highlight={highlightReplyId === reply.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { useFetchEvent } from '@/hooks'
|
|
||||||
import { extractImageInfosFromEventTags, getParentEventId, getUsingClient } from '@/lib/event'
|
import { extractImageInfosFromEventTags, getParentEventId, getUsingClient } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
@@ -30,7 +29,6 @@ export default function Note({
|
|||||||
[event, hideParentNotePreview]
|
[event, hideParentNotePreview]
|
||||||
)
|
)
|
||||||
const imageInfos = useMemo(() => extractImageInfosFromEventTags(event), [event])
|
const imageInfos = useMemo(() => extractImageInfosFromEventTags(event), [event])
|
||||||
const { event: parentEvent, isFetching } = useFetchEvent(parentEventId)
|
|
||||||
const usingClient = useMemo(() => getUsingClient(event), [event])
|
const usingClient = useMemo(() => getUsingClient(event), [event])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,8 +58,7 @@ export default function Note({
|
|||||||
</div>
|
</div>
|
||||||
{parentEventId && (
|
{parentEventId && (
|
||||||
<ParentNotePreview
|
<ParentNotePreview
|
||||||
event={parentEvent}
|
eventId={parentEventId}
|
||||||
isFetching={isFetching}
|
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
import { useReply } from '@/providers/ReplyProvider'
|
||||||
import { MessageCircle } from 'lucide-react'
|
import { MessageCircle } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
@@ -7,19 +7,13 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import PostEditor from '../PostEditor'
|
import PostEditor from '../PostEditor'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function ReplyButton({
|
export default function ReplyButton({ event }: { event: Event }) {
|
||||||
event,
|
|
||||||
variant = 'note'
|
|
||||||
}: {
|
|
||||||
event: Event
|
|
||||||
variant?: 'note' | 'reply'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
const { noteStatsMap } = useNoteStats()
|
const { repliesMap } = useReply()
|
||||||
const { replyCount } = useMemo(
|
const replyCount = useMemo(
|
||||||
() => (variant === 'reply' ? {} : (noteStatsMap.get(event.id) ?? {})),
|
() => repliesMap.get(event.id)?.events.length || 0,
|
||||||
[noteStatsMap, event.id, variant]
|
[repliesMap, event.id]
|
||||||
)
|
)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -36,9 +30,7 @@ export default function ReplyButton({
|
|||||||
title={t('Reply')}
|
title={t('Reply')}
|
||||||
>
|
>
|
||||||
<MessageCircle />
|
<MessageCircle />
|
||||||
{variant !== 'reply' && !!replyCount && (
|
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ export default function NoteStats({
|
|||||||
event,
|
event,
|
||||||
className,
|
className,
|
||||||
classNames,
|
classNames,
|
||||||
fetchIfNotExisting = false,
|
fetchIfNotExisting = false
|
||||||
variant = 'note'
|
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
@@ -25,7 +24,6 @@ export default function NoteStats({
|
|||||||
buttonBar?: string
|
buttonBar?: string
|
||||||
}
|
}
|
||||||
fetchIfNotExisting?: boolean
|
fetchIfNotExisting?: boolean
|
||||||
variant?: 'note' | 'reply'
|
|
||||||
}) {
|
}) {
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { fetchNoteStats } = useNoteStats()
|
const { fetchNoteStats } = useNoteStats()
|
||||||
@@ -50,7 +48,7 @@ export default function NoteStats({
|
|||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ReplyButton event={event} variant={variant} />
|
<ReplyButton event={event} />
|
||||||
<RepostButton event={event} />
|
<RepostButton event={event} />
|
||||||
<LikeButton event={event} />
|
<LikeButton event={event} />
|
||||||
<ZapButton event={event} />
|
<ZapButton event={event} />
|
||||||
@@ -70,7 +68,7 @@ export default function NoteStats({
|
|||||||
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
|
className={cn('flex items-center', loading ? 'animate-pulse' : '')}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ReplyButton event={event} variant={variant} />
|
<ReplyButton event={event} />
|
||||||
<RepostButton event={event} />
|
<RepostButton event={event} />
|
||||||
<LikeButton event={event} />
|
<LikeButton event={event} />
|
||||||
<ZapButton event={event} />
|
<ZapButton event={event} />
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event } from 'nostr-tools'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ContentPreview from '../ContentPreview'
|
import ContentPreview from '../ContentPreview'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
|
|
||||||
export default function ParentNotePreview({
|
export default function ParentNotePreview({
|
||||||
event,
|
eventId,
|
||||||
isFetching = false,
|
|
||||||
className,
|
className,
|
||||||
onClick
|
onClick
|
||||||
}: {
|
}: {
|
||||||
event?: Event
|
eventId: string
|
||||||
isFetching?: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { mutePubkeys } = useMuteList()
|
const { mutePubkeys } = useMuteList()
|
||||||
|
const { event, isFetching } = useFetchEvent(eventId)
|
||||||
const isMuted = useMemo(
|
const isMuted = useMemo(
|
||||||
() => (event ? mutePubkeys.includes(event.pubkey) : false),
|
() => (event ? mutePubkeys.includes(event.pubkey) : false),
|
||||||
[mutePubkeys, event]
|
[mutePubkeys, event]
|
||||||
@@ -43,6 +42,20 @@ export default function ParentNotePreview({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="shrink-0">{t('reply to')}</div>
|
||||||
|
<div>{`[${t('Not found the note')}]`}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toNote } from '@/lib/link'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
@@ -10,18 +12,16 @@ import NoteStats from '../NoteStats'
|
|||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { toNote } from '@/lib/link'
|
|
||||||
|
|
||||||
export default function ReplyNote({
|
export default function ReplyNote({
|
||||||
event,
|
event,
|
||||||
parentEvent,
|
parentEventId,
|
||||||
onClickParent = () => {},
|
onClickParent = () => {},
|
||||||
highlight = false
|
highlight = false
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
parentEvent?: Event
|
parentEventId?: string
|
||||||
onClickParent?: (eventId: string) => void
|
onClickParent?: () => void
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -53,20 +53,20 @@ export default function ReplyNote({
|
|||||||
</div>
|
</div>
|
||||||
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
|
||||||
</div>
|
</div>
|
||||||
{parentEvent && (
|
{parentEventId && (
|
||||||
<ParentNotePreview
|
<ParentNotePreview
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
event={parentEvent}
|
eventId={parentEventId}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onClickParent(parentEvent.id)
|
onClickParent()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{show ? (
|
{show ? (
|
||||||
<>
|
<>
|
||||||
<Content className="mt-2" event={event} />
|
<Content className="mt-2" event={event} />
|
||||||
<NoteStats className="mt-2" event={event} variant="reply" />
|
<NoteStats className="mt-2" event={event} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { BIG_RELAY_URLS } from '@/constants'
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
import {
|
import {
|
||||||
getParentEventHexId,
|
getParentEventTag,
|
||||||
getRootEventHexId,
|
getRootEventHexId,
|
||||||
getRootEventTag,
|
getRootEventTag,
|
||||||
isReplyNoteEvent
|
isReplyNoteEvent
|
||||||
} from '@/lib/event'
|
} from '@/lib/event'
|
||||||
import { generateEventIdFromETag } from '@/lib/tag'
|
import { generateEventIdFromETag } from '@/lib/tag'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
import { useReply } from '@/providers/ReplyProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event as NEvent, kinds } from 'nostr-tools'
|
import { Event as NEvent, kinds } from 'nostr-tools'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReplyNote from '../ReplyNote'
|
import ReplyNote from '../ReplyNote'
|
||||||
|
|
||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
|
const SHOW_COUNT = 10
|
||||||
|
|
||||||
export default function ReplyNoteList({
|
export default function ReplyNoteList({
|
||||||
index,
|
index,
|
||||||
@@ -29,16 +30,13 @@ export default function ReplyNoteList({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { currentIndex } = useSecondaryPage()
|
const { currentIndex } = useSecondaryPage()
|
||||||
const [rootInfo, setRootInfo] = useState<{ id: string; pubkey: string } | undefined>(undefined)
|
const [rootInfo, setRootInfo] = useState<{ id: string; pubkey: string } | undefined>(undefined)
|
||||||
|
const { repliesMap, addReplies } = useReply()
|
||||||
|
const replies = useMemo(() => repliesMap.get(event.id)?.events || [], [event.id, repliesMap])
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
const [until, setUntil] = useState<number | undefined>(undefined)
|
const [until, setUntil] = useState<number | undefined>(undefined)
|
||||||
const [events, setEvents] = useState<NEvent[]>([])
|
|
||||||
const [replies, setReplies] = useState<NEvent[]>([])
|
|
||||||
const [replyMap, setReplyMap] = useState<
|
|
||||||
Map<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
|
|
||||||
>(new Map())
|
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
|
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||||
const { updateNoteReplyCount } = useNoteStats()
|
|
||||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
@@ -66,10 +64,7 @@ export default function ReplyNoteList({
|
|||||||
}, [event])
|
}, [event])
|
||||||
|
|
||||||
const onNewReply = useCallback((evt: NEvent) => {
|
const onNewReply = useCallback((evt: NEvent) => {
|
||||||
setEvents((pre) => {
|
addReplies([evt])
|
||||||
if (pre.some((reply) => reply.id === evt.id)) return pre
|
|
||||||
return [...pre, evt]
|
|
||||||
})
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,7 +109,7 @@ export default function ReplyNoteList({
|
|||||||
{
|
{
|
||||||
onEvents: (evts, eosed) => {
|
onEvents: (evts, eosed) => {
|
||||||
if (evts.length > 0) {
|
if (evts.length > 0) {
|
||||||
setEvents(evts.filter((evt) => isReplyNoteEvent(evt)).reverse())
|
addReplies(evts.filter((evt) => isReplyNoteEvent(evt)))
|
||||||
}
|
}
|
||||||
if (eosed) {
|
if (eosed) {
|
||||||
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
||||||
@@ -123,7 +118,7 @@ export default function ReplyNoteList({
|
|||||||
},
|
},
|
||||||
onNew: (evt) => {
|
onNew: (evt) => {
|
||||||
if (!isReplyNoteEvent(evt)) return
|
if (!isReplyNoteEvent(evt)) return
|
||||||
onNewReply(evt)
|
addReplies([evt])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -142,44 +137,45 @@ export default function ReplyNoteList({
|
|||||||
}, [rootInfo, currentIndex, index, onNewReply])
|
}, [rootInfo, currentIndex, index, onNewReply])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const replies: NEvent[] = []
|
|
||||||
const replyMap: Map<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
|
||||||
new Map()
|
|
||||||
const rootEventId = getRootEventHexId(event) ?? event.id
|
|
||||||
const isRootEvent = rootEventId === event.id
|
|
||||||
for (const evt of events) {
|
|
||||||
const parentEventId = getParentEventHexId(evt)
|
|
||||||
if (parentEventId) {
|
|
||||||
const parentReplyInfo = replyMap.get(parentEventId)
|
|
||||||
if (!parentReplyInfo && parentEventId !== event.id) continue
|
|
||||||
|
|
||||||
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
|
|
||||||
replies.push(evt)
|
|
||||||
replyMap.set(evt.id, { event: evt, level, parent: parentReplyInfo?.event })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRootEvent) continue
|
|
||||||
|
|
||||||
replies.push(evt)
|
|
||||||
replyMap.set(evt.id, { event: evt, level: 1 })
|
|
||||||
}
|
|
||||||
setReplyMap(replyMap)
|
|
||||||
setReplies(replies)
|
|
||||||
updateNoteReplyCount(event.id, replies.length)
|
|
||||||
if (replies.length === 0) {
|
if (replies.length === 0) {
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}, [events, event, updateNoteReplyCount])
|
}, [replies])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const options = {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '10px',
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
const observerInstance = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0].isIntersecting && showCount < replies.length) {
|
||||||
|
setShowCount((prev) => prev + SHOW_COUNT)
|
||||||
|
}
|
||||||
|
}, options)
|
||||||
|
|
||||||
|
const currentBottomRef = bottomRef.current
|
||||||
|
|
||||||
|
if (currentBottomRef) {
|
||||||
|
observerInstance.observe(currentBottomRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (observerInstance && currentBottomRef) {
|
||||||
|
observerInstance.unobserve(currentBottomRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [replies, showCount])
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (loading || !until || !timelineKey) return
|
if (loading || !until || !timelineKey) return
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||||
const olderEvents = events.filter((evt) => isReplyNoteEvent(evt)).reverse()
|
const olderEvents = events.filter((evt) => isReplyNoteEvent(evt))
|
||||||
if (olderEvents.length > 0) {
|
if (olderEvents.length > 0) {
|
||||||
setEvents((pre) => [...olderEvents, ...pre])
|
addReplies(olderEvents)
|
||||||
}
|
}
|
||||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -210,14 +206,16 @@ export default function ReplyNoteList({
|
|||||||
)}
|
)}
|
||||||
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
{replies.map((reply) => {
|
{replies.slice(0, showCount).map((reply) => {
|
||||||
const info = replyMap.get(reply.id)
|
const parentEventTag = getParentEventTag(reply)
|
||||||
|
const parentEventOriginalId = parentEventTag?.[1]
|
||||||
|
const parentEventId = parentEventTag ? generateEventIdFromETag(parentEventTag) : undefined
|
||||||
return (
|
return (
|
||||||
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
||||||
<ReplyNote
|
<ReplyNote
|
||||||
event={reply}
|
event={reply}
|
||||||
parentEvent={info?.parent}
|
parentEventId={event.id !== parentEventOriginalId ? parentEventId : undefined}
|
||||||
onClickParent={highlightReply}
|
onClickParent={() => parentEventOriginalId && highlightReply(parentEventOriginalId)}
|
||||||
highlight={highlightReplyId === reply.id}
|
highlight={highlightReplyId === reply.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { useReply } from '@/providers/ReplyProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
export function useFetchEvent(eventId?: string) {
|
export function useFetchEvent(eventId?: string) {
|
||||||
const [isFetching, setIsFetching] = useState(true)
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
|
const { addReplies } = useReply()
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
const [event, setEvent] = useState<Event | undefined>(undefined)
|
const [event, setEvent] = useState<Event | undefined>(undefined)
|
||||||
|
|
||||||
@@ -20,6 +22,7 @@ export function useFetchEvent(eventId?: string) {
|
|||||||
const event = await client.fetchEvent(eventId)
|
const event = await client.fetchEvent(eventId)
|
||||||
if (event) {
|
if (event) {
|
||||||
setEvent(event)
|
setEvent(event)
|
||||||
|
addReplies([event])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(error as Error)
|
setError(error as Error)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import client from '@/services/client.service'
|
|
||||||
import { Event, nip19 } from 'nostr-tools'
|
import { Event, nip19 } from 'nostr-tools'
|
||||||
import { getSharableEventId } from './event'
|
import { getSharableEventId } from './event'
|
||||||
|
import { generateEventId } from './tag'
|
||||||
|
|
||||||
export const toHome = () => '/'
|
export const toHome = () => '/'
|
||||||
export const toNote = (eventOrId: Pick<Event, 'id' | 'pubkey'> | string) => {
|
export const toNote = (eventOrId: Pick<Event, 'id' | 'pubkey'> | string) => {
|
||||||
if (typeof eventOrId === 'string') return `/notes/${eventOrId}`
|
if (typeof eventOrId === 'string') return `/notes/${eventOrId}`
|
||||||
const relay = client.getEventHint(eventOrId.id)
|
const nevent = generateEventId(eventOrId)
|
||||||
const nevent = nip19.neventEncode({ id: eventOrId.id, author: eventOrId.pubkey, relays: [relay] })
|
|
||||||
return `/notes/${nevent}`
|
return `/notes/${nevent}`
|
||||||
}
|
}
|
||||||
export const toNoteList = ({ hashtag, search }: { hashtag?: string; search?: string }) => {
|
export const toNoteList = ({ hashtag, search }: { hashtag?: string; search?: string }) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import client from '@/services/client.service'
|
||||||
import { TImageInfo } from '@/types'
|
import { TImageInfo } from '@/types'
|
||||||
import { isBlurhashValid } from 'blurhash'
|
import { isBlurhashValid } from 'blurhash'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { Event, nip19 } from 'nostr-tools'
|
||||||
import { isValidPubkey } from './pubkey'
|
import { isValidPubkey } from './pubkey'
|
||||||
|
|
||||||
export function tagNameEquals(tagName: string) {
|
export function tagNameEquals(tagName: string) {
|
||||||
@@ -28,6 +29,11 @@ export function generateEventIdFromETag(tag: string[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function generateEventId(event: Pick<Event, 'id' | 'pubkey'>) {
|
||||||
|
const relay = client.getEventHint(event.id)
|
||||||
|
return nip19.neventEncode({ id: event.id, author: event.pubkey, relays: [relay] })
|
||||||
|
}
|
||||||
|
|
||||||
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
|
export function extractImageInfoFromTag(tag: string[]): TImageInfo | null {
|
||||||
if (tag[0] !== 'imeta') return null
|
if (tag[0] !== 'imeta') return null
|
||||||
const urlItem = tag.find((item) => item.startsWith('url '))
|
const urlItem = tag.find((item) => item.startsWith('url '))
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ export type TNoteStats = {
|
|||||||
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
|
likes: { id: string; pubkey: string; created_at: number; emoji: TEmoji | string }[]
|
||||||
reposts: Set<string>
|
reposts: Set<string>
|
||||||
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
|
zaps: { pr: string; pubkey: string; amount: number; comment?: string }[]
|
||||||
replyCount: number
|
|
||||||
updatedAt?: number
|
updatedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type TNoteStatsContext = {
|
type TNoteStatsContext = {
|
||||||
noteStatsMap: Map<string, Partial<TNoteStats>>
|
noteStatsMap: Map<string, Partial<TNoteStats>>
|
||||||
updateNoteReplyCount: (noteId: string, replyCount: number) => void
|
|
||||||
addZap: (eventId: string, pr: string, amount: number, comment?: string) => void
|
addZap: (eventId: string, pr: string, amount: number, comment?: string) => void
|
||||||
updateNoteStatsByEvents: (events: Event[]) => void
|
updateNoteStatsByEvents: (events: Event[]) => void
|
||||||
fetchNoteStats: (event: Event) => Promise<Partial<TNoteStats> | undefined>
|
fetchNoteStats: (event: Event) => Promise<Partial<TNoteStats> | undefined>
|
||||||
@@ -215,20 +213,6 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNoteReplyCount = (noteId: string, replyCount: number) => {
|
|
||||||
setNoteStatsMap((prev) => {
|
|
||||||
const old = prev.get(noteId)
|
|
||||||
if (!old) {
|
|
||||||
prev.set(noteId, { replyCount })
|
|
||||||
return new Map(prev)
|
|
||||||
} else if (old.replyCount === undefined || old.replyCount < replyCount) {
|
|
||||||
prev.set(noteId, { ...old, replyCount })
|
|
||||||
return new Map(prev)
|
|
||||||
}
|
|
||||||
return prev
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const addZap = (eventId: string, pr: string, amount: number, comment?: string) => {
|
const addZap = (eventId: string, pr: string, amount: number, comment?: string) => {
|
||||||
if (!pubkey) return
|
if (!pubkey) return
|
||||||
setNoteStatsMap((prev) => {
|
setNoteStatsMap((prev) => {
|
||||||
@@ -247,7 +231,6 @@ export function NoteStatsProvider({ children }: { children: React.ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
noteStatsMap,
|
noteStatsMap,
|
||||||
fetchNoteStats,
|
fetchNoteStats,
|
||||||
updateNoteReplyCount,
|
|
||||||
addZap,
|
addZap,
|
||||||
updateNoteStatsByEvents
|
updateNoteStatsByEvents
|
||||||
}}
|
}}
|
||||||
|
|||||||
70
src/providers/ReplyProvider.tsx
Normal file
70
src/providers/ReplyProvider.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { getParentEventTag, getRootEventTag } from '@/lib/event'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { createContext, useCallback, useContext, useState } from 'react'
|
||||||
|
|
||||||
|
type TReplyContext = {
|
||||||
|
repliesMap: Map<string, { events: Event[]; eventIdSet: 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[]; eventIdSet: Set<string> }>
|
||||||
|
>(new Map())
|
||||||
|
|
||||||
|
const addReplies = useCallback((replies: Event[]) => {
|
||||||
|
const newReplyIdSet = new Set<string>()
|
||||||
|
const newReplyEventMap = new Map<string, Event[]>()
|
||||||
|
replies.forEach((reply) => {
|
||||||
|
if (newReplyIdSet.has(reply.id)) return
|
||||||
|
newReplyIdSet.add(reply.id)
|
||||||
|
|
||||||
|
const rootETag = getRootEventTag(reply)
|
||||||
|
if (!rootETag) return
|
||||||
|
const rootId = rootETag[1]
|
||||||
|
newReplyEventMap.set(rootId, [...(newReplyEventMap.get(rootId) || []), reply])
|
||||||
|
|
||||||
|
const parentETag = getParentEventTag(reply)
|
||||||
|
if (!parentETag) return
|
||||||
|
const parentId = parentETag[1]
|
||||||
|
newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply])
|
||||||
|
})
|
||||||
|
if (newReplyEventMap.size === 0) return
|
||||||
|
|
||||||
|
setRepliesMap((prev) => {
|
||||||
|
for (const [id, newReplyEvents] of newReplyEventMap.entries()) {
|
||||||
|
const replies = prev.get(id) || { events: [], eventIdSet: new Set() }
|
||||||
|
newReplyEvents.forEach((reply) => {
|
||||||
|
if (!replies.eventIdSet.has(reply.id)) {
|
||||||
|
replies.events.push(reply)
|
||||||
|
replies.eventIdSet.add(reply.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
replies.events.sort((a, b) => a.created_at - b.created_at)
|
||||||
|
prev.set(id, replies)
|
||||||
|
}
|
||||||
|
return new Map(prev)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReplyContext.Provider
|
||||||
|
value={{
|
||||||
|
repliesMap,
|
||||||
|
addReplies
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReplyContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user