fix: replies

This commit is contained in:
codytseng
2025-05-15 23:38:22 +08:00
parent a6c2decfe3
commit 304bbe4f01
13 changed files with 178 additions and 120 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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