feat: add enhanced support for kind:1111 comments

This commit is contained in:
codytseng
2025-05-24 16:26:28 +08:00
parent 78725d1e88
commit 06f9ea984f
6 changed files with 97 additions and 258 deletions

View File

@@ -1,205 +0,0 @@
import { Separator } from '@/components/ui/separator'
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { isCommentEvent, isProtectedEvent } from '@/lib/event'
import { generateEventId, tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event as NEvent } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
const LIMIT = 100
export default function Nip22ReplyNoteList({
event,
className
}: {
event: NEvent
className?: string
}) {
const { t } = useTranslation()
const { pubkey, startLogin } = useNostr()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
const [replies, setReplies] = useState<NEvent[]>([])
const [replyMap, setReplyMap] = useState<
Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
>({})
const [loading, setLoading] = useState<boolean>(false)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const handleEventPublished = (data: Event) => {
const customEvent = data as CustomEvent<NEvent>
const evt = customEvent.detail
if (
isCommentEvent(evt) &&
evt.tags.some(([tagName, tagValue]) => tagName === 'E' && tagValue === event.id)
) {
onNewReply(evt)
}
}
client.addEventListener('eventPublished', handleEventPublished)
return () => {
client.removeEventListener('eventPublished', handleEventPublished)
}
}, [event])
useEffect(() => {
if (loading) return
const init = async () => {
setLoading(true)
setReplies([])
try {
const relayList = await client.fetchRelayList(event.pubkey)
const relayUrls = relayList.read.concat(BIG_RELAY_URLS)
if (isProtectedEvent(event)) {
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.unshift(...seenOn)
}
const { closer, timelineKey } = await client.subscribeTimeline(
[
{
urls: relayUrls.slice(0, 4),
filter: {
'#E': [event.id],
kinds: [ExtendedKind.COMMENT],
limit: LIMIT
}
}
],
{
onEvents: (evts, eosed) => {
if (evts.length > 0) {
setReplies(evts.reverse())
}
if (eosed) {
setLoading(false)
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
}
},
onNew: (evt) => {
onNewReply(evt)
}
},
{
startLogin
}
)
setTimelineKey(timelineKey)
return closer
} catch {
setLoading(false)
}
return
}
const promise = init()
return () => {
promise.then((closer) => closer?.())
}
}, [event])
useEffect(() => {
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
{}
for (const reply of replies) {
const parentEventId = reply.tags.find(tagNameEquals('e'))?.[1]
if (parentEventId && parentEventId !== event.id) {
const parentReplyInfo = replyMap[parentEventId]
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
continue
}
replyMap[reply.id] = { event: reply, level: 1 }
continue
}
setReplyMap(replyMap)
}, [replies, event.id])
const loadMore = useCallback(async () => {
if (loading || !until || !timelineKey) return
setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderReplies = events.reverse()
if (olderReplies.length > 0) {
setReplies((pre) => [...olderReplies, ...pre])
}
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false)
}, [loading, until, timelineKey])
const onNewReply = useCallback(
(evt: NEvent) => {
setReplies((pre) => {
if (pre.some((reply) => reply.id === evt.id)) return pre
return [...pre, evt]
})
if (evt.pubkey === pubkey) {
setTimeout(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
highlightReply(evt.id, false)
}, 100)
}
},
[pubkey]
)
const highlightReply = useCallback((eventId: string, scrollTo = true) => {
if (scrollTo) {
const ref = replyRefs.current[eventId]
if (ref) {
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
setHighlightReplyId(eventId)
setTimeout(() => {
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre))
}, 1500)
}, [])
return (
<>
{(loading || until) && (
<div
className={`text-sm text-center text-muted-foreground mt-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{loading ? t('loading...') : t('load more older replies')}
</div>
)}
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
<div className={cn('mb-2', className)}>
{replies.map((reply) => {
const info = replyMap[reply.id]
return (
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
<ReplyNote
event={reply}
parentEventId={info?.parent ? generateEventId(info.parent) : undefined}
onClickParent={() => info?.parent?.id && highlightReply(info?.parent?.id)}
highlight={highlightReplyId === reply.id}
/>
</div>
)
})}
</div>
{replies.length === 0 && !loading && !until && (
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
)}
<div ref={bottomRef} />
</>
)
}

View File

@@ -1,5 +1,5 @@
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { BIG_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { import {
getParentEventTag, getParentEventTag,
getRootEventHexId, getRootEventHexId,
@@ -10,7 +10,7 @@ import { generateEventIdFromETag } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useReply } from '@/providers/ReplyProvider' 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 { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, 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'
@@ -109,17 +109,28 @@ export default function ReplyNoteList({
const relayUrls = relayList.read.concat(BIG_RELAY_URLS) const relayUrls = relayList.read.concat(BIG_RELAY_URLS)
const seenOn = client.getSeenEventRelayUrls(rootInfo.id) const seenOn = client.getSeenEventRelayUrls(rootInfo.id)
relayUrls.unshift(...seenOn) relayUrls.unshift(...seenOn)
const { closer, timelineKey } = await client.subscribeTimeline(
[ const filters: (Omit<Filter, 'since' | 'until'> & {
limit: number
})[] = [
{ {
urls: relayUrls.slice(0, 5),
filter: {
'#e': [rootInfo.id], '#e': [rootInfo.id],
kinds: [kinds.ShortTextNote], kinds: [kinds.ShortTextNote],
limit: LIMIT limit: LIMIT
} }
]
if (event.kind !== kinds.ShortTextNote) {
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT],
limit: LIMIT
})
} }
], const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
urls: relayUrls.slice(0, 5),
filter
})),
{ {
onEvents: (evts, eosed) => { onEvents: (evts, eosed) => {
if (evts.length > 0) { if (evts.length > 0) {

View File

@@ -177,11 +177,12 @@ export async function createCommentDraftEvent(
const { const {
quoteEventIds, quoteEventIds,
rootEventId, rootEventId,
rootEventKind, rootKind,
rootEventPubkey, rootPubkey,
rootUrl,
parentEventId, parentEventId,
parentEventKind, parentKind,
parentEventPubkey parentPubkey
} = await extractCommentMentions(content, parentEvent) } = await extractCommentMentions(content, parentEvent)
const hashtags = extractHashtags(content) const hashtags = extractHashtags(content)
@@ -194,17 +195,29 @@ export async function createCommentDraftEvent(
tags.push(...generateImetaTags(images)) tags.push(...generateImetaTags(images))
} }
tags.push(...mentions.filter((pubkey) => pubkey !== parentPubkey).map((pubkey) => ['p', pubkey]))
if (rootEventId) {
tags.push( tags.push(
...mentions.filter((pubkey) => pubkey !== parentEventPubkey).map((pubkey) => ['p', pubkey]) rootPubkey
? ['E', rootEventId, client.getEventHint(rootEventId), rootPubkey]
: ['E', rootEventId, client.getEventHint(rootEventId)]
) )
}
if (rootPubkey) {
tags.push(['P', rootPubkey])
}
if (rootKind) {
tags.push(['K', rootKind.toString()])
}
if (rootUrl) {
tags.push(['I', rootUrl])
}
tags.push( tags.push(
...[ ...[
['E', rootEventId, client.getEventHint(rootEventId), rootEventPubkey], ['e', parentEventId, client.getEventHint(parentEventId), parentPubkey],
['K', rootEventKind.toString()], ['k', parentKind.toString()],
['P', rootEventPubkey], ['p', parentPubkey]
['e', parentEventId, client.getEventHint(parentEventId), parentEventPubkey],
['k', parentEventKind.toString()],
['p', parentEventPubkey]
] ]
) )

View File

@@ -26,6 +26,7 @@ export function isNsfwEvent(event: Event) {
} }
export function isReplyNoteEvent(event: Event) { export function isReplyNoteEvent(event: Event) {
if (event.kind === ExtendedKind.COMMENT) return true
if (event.kind !== kinds.ShortTextNote) return false if (event.kind !== kinds.ShortTextNote) return false
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id) const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id)
@@ -64,7 +65,12 @@ export function isSupportedKind(kind: number) {
} }
export function getParentEventTag(event?: Event) { export function getParentEventTag(event?: Event) {
if (!event) return undefined if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
if (event.kind === ExtendedKind.COMMENT) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
let tag = event.tags.find(isReplyETag) let tag = event.tags.find(isReplyETag)
if (!tag) { if (!tag) {
const embeddedEventIds = extractEmbeddedEventIds(event) const embeddedEventIds = extractEmbeddedEventIds(event)
@@ -88,7 +94,12 @@ export function getParentEventId(event?: Event) {
} }
export function getRootEventTag(event?: Event) { export function getRootEventTag(event?: Event) {
if (!event) return undefined if (!event || ![kinds.ShortTextNote, ExtendedKind.COMMENT].includes(event.kind)) return undefined
if (event.kind === ExtendedKind.COMMENT) {
return event.tags.find(tagNameEquals('E'))
}
let tag = event.tags.find(isRootETag) let tag = event.tags.find(isRootETag)
if (!tag) { if (!tag) {
const embeddedEventIds = extractEmbeddedEventIds(event) const embeddedEventIds = extractEmbeddedEventIds(event)
@@ -338,12 +349,32 @@ export async function extractRelatedEventIds(content: string, parentEvent?: Even
export async function extractCommentMentions(content: string, parentEvent: Event) { export async function extractCommentMentions(content: string, parentEvent: Event) {
const quoteEventIds: string[] = [] const quoteEventIds: string[] = []
const rootEventId = parentEvent.tags.find(tagNameEquals('E'))?.[1] ?? parentEvent.id let rootEventId =
const rootEventKind = parentEvent.tags.find(tagNameEquals('K'))?.[1] ?? parentEvent.kind parentEvent.kind === ExtendedKind.COMMENT
const rootEventPubkey = parentEvent.tags.find(tagNameEquals('P'))?.[1] ?? parentEvent.pubkey ? parentEvent.tags.find(tagNameEquals('E'))?.[1]
: parentEvent.id
let rootKind =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('K'))?.[1]
: parentEvent.kind
let rootPubkey =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('P'))?.[1]
: parentEvent.pubkey
const rootUrl =
parentEvent.kind === ExtendedKind.COMMENT
? parentEvent.tags.find(tagNameEquals('I'))?.[1]
: undefined
if (parentEvent.kind === ExtendedKind.COMMENT && !rootEventId) {
rootEventId = parentEvent.id
rootKind = parentEvent.kind
rootPubkey = parentEvent.pubkey
}
const parentEventId = parentEvent.id const parentEventId = parentEvent.id
const parentEventKind = parentEvent.kind const parentKind = parentEvent.kind
const parentEventPubkey = parentEvent.pubkey const parentPubkey = parentEvent.pubkey
const addToSet = (arr: string[], item: string) => { const addToSet = (arr: string[], item: string) => {
if (!arr.includes(item)) arr.push(item) if (!arr.includes(item)) arr.push(item)
@@ -367,11 +398,12 @@ export async function extractCommentMentions(content: string, parentEvent: Event
return { return {
quoteEventIds, quoteEventIds,
rootEventId, rootEventId,
rootEventKind, rootKind,
rootEventPubkey, rootPubkey,
rootUrl,
parentEventId, parentEventId,
parentEventKind, parentKind,
parentEventPubkey parentPubkey
} }
} }

View File

@@ -1,6 +1,5 @@
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import ContentPreview from '@/components/ContentPreview' import ContentPreview from '@/components/ContentPreview'
import Nip22ReplyNoteList from '@/components/Nip22ReplyNoteList'
import Note from '@/components/Note' import Note from '@/components/Note'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import PictureNote from '@/components/PictureNote' import PictureNote from '@/components/PictureNote'
@@ -14,7 +13,6 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { getParentEventId, getRootEventId, isPictureEvent } from '@/lib/event' import { getParentEventId, getRootEventId, isPictureEvent } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
import { kinds } from 'nostr-tools'
import { forwardRef, useMemo } from 'react' import { forwardRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import NotFoundPage from '../NotFoundPage' import NotFoundPage from '../NotFoundPage'
@@ -22,14 +20,8 @@ import NotFoundPage from '../NotFoundPage'
const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => { const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(id) const { event, isFetching } = useFetchEvent(id)
const parentEventId = useMemo( const parentEventId = useMemo(() => getParentEventId(event), [event])
() => (event?.kind === kinds.ShortTextNote ? getParentEventId(event) : undefined), const rootEventId = useMemo(() => getRootEventId(event), [event])
[event]
)
const rootEventId = useMemo(
() => (event?.kind === kinds.ShortTextNote ? getRootEventId(event) : undefined),
[event]
)
if (!event && isFetching) { if (!event && isFetching) {
return ( return (
@@ -65,7 +57,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
<SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton> <SecondaryPageLayout ref={ref} index={index} title={t('Note')} displayScrollToTopButton>
<PictureNote key={`note-${event.id}`} event={event} fetchNoteStats /> <PictureNote key={`note-${event.id}`} event={event} fetchNoteStats />
<Separator className="mt-4" /> <Separator className="mt-4" />
<Nip22ReplyNoteList key={`nip22-reply-note-list-${event.id}`} event={event} /> <ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} />
</SecondaryPageLayout> </SecondaryPageLayout>
) )
} }
@@ -86,11 +78,7 @@ const NotePage = forwardRef(({ id, index }: { id?: string; index?: number }, ref
<NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes /> <NoteStats className="mt-3" event={event} fetchIfNotExisting displayTopZapsAndLikes />
</div> </div>
<Separator className="mt-4" /> <Separator className="mt-4" />
{[kinds.ShortTextNote, kinds.Highlights].includes(event.kind) ? (
<ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} /> <ReplyNoteList key={`reply-note-list-${event.id}`} index={index} event={event} />
) : (
<Nip22ReplyNoteList key={`nip22-reply-note-list-${event.id}`} event={event} />
)}
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })

View File

@@ -186,7 +186,7 @@ class ClientService extends EventTarget {
) { ) {
const newEventIdSet = new Set<string>() const newEventIdSet = new Set<string>()
const requestCount = subRequests.length const requestCount = subRequests.length
const threshold = Math.ceil(requestCount / 2) const threshold = Math.floor(requestCount / 2)
let eventIdSet = new Set<string>() let eventIdSet = new Set<string>()
let events: NEvent[] = [] let events: NEvent[] = []
let eosedCount = 0 let eosedCount = 0