refactor: replies

This commit is contained in:
codytseng 2025-10-29 23:12:54 +08:00
parent 25233344e7
commit 63c9713ea8
5 changed files with 166 additions and 138 deletions

View file

@ -1,4 +1,4 @@
import { 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'
@ -20,27 +20,35 @@ export default function ReplyButton({ event }: { event: Event }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
const { replyCount, hasReplied } = useMemo(() => { const { replyCount, hasReplied } = useMemo(() => {
const key = getEventKey(event)
const hasReplied = pubkey const hasReplied = pubkey
? repliesMap.get(event.id)?.events.some((evt) => evt.pubkey === pubkey) ? repliesMap.get(key)?.events.some((evt) => evt.pubkey === pubkey)
: false : false
return { let replyCount = 0
replyCount: const replies = [...(repliesMap.get(key)?.events || [])]
repliesMap.get(event.id)?.events.filter((evt) => { while (replies.length > 0) {
if (hideUntrustedInteractions && !isUserTrusted(evt.pubkey)) { const reply = replies.pop()
return false if (!reply) break
const replyKey = getEventKey(reply)
const nestedReplies = repliesMap.get(replyKey)?.events ?? []
replies.push(...nestedReplies)
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
continue
} }
if (mutePubkeySet.has(evt.pubkey)) { if (mutePubkeySet.has(reply.pubkey)) {
return false continue
} }
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) { if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) {
return false continue
} }
return true replyCount++
}).length ?? 0,
hasReplied
} }
}, [repliesMap, event.id, hideUntrustedInteractions])
return { replyCount, hasReplied }
}, [repliesMap, event, hideUntrustedInteractions])
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (

View file

@ -1,16 +1,16 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { import {
getParentETag, getEventKey,
getEventKeyFromTag,
getParentTag,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootTag,
getRootETag,
getRootEventHexId,
isMentioningMutedUsers, isMentioningMutedUsers,
isReplaceableEvent, isReplaceableEvent,
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider' import { useMuteList } from '@/providers/MuteListProvider'
@ -40,23 +40,22 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined) const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(undefined)
const { repliesMap, addReplies } = useReply() const { repliesMap, addReplies } = useReply()
const replies = useMemo(() => { const replies = useMemo(() => {
const replyIdSet = new Set<string>() const replyKeySet = new Set<string>()
const replyEvents: NEvent[] = [] const replyEvents: NEvent[] = []
const currentEventKey = isReplaceableEvent(event.kind) const currentEventKey = getEventKey(event)
? getReplaceableCoordinateFromEvent(event)
: event.id
let parentEventKeys = [currentEventKey] let parentEventKeys = [currentEventKey]
while (parentEventKeys.length > 0) { while (parentEventKeys.length > 0) {
const events = parentEventKeys.flatMap((id) => repliesMap.get(id)?.events || []) const events = parentEventKeys.flatMap((key) => repliesMap.get(key)?.events || [])
events.forEach((evt) => { events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return const key = getEventKey(evt)
if (replyKeySet.has(key)) return
if (mutePubkeySet.has(evt.pubkey)) return if (mutePubkeySet.has(evt.pubkey)) return
if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) return
replyIdSet.add(evt.id) replyKeySet.add(key)
replyEvents.push(evt) replyEvents.push(evt)
}) })
parentEventKeys = events.map((evt) => evt.id) parentEventKeys = events.map((evt) => getEventKey(evt))
} }
return replyEvents.sort((a, b) => a.created_at - b.created_at) return replyEvents.sort((a, b) => a.created_at - b.created_at)
}, [event.id, repliesMap]) }, [event.id, repliesMap])
@ -64,7 +63,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined) const [highlightReplyKey, setHighlightReplyKey] = useState<string | undefined>(undefined)
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)
@ -79,13 +78,14 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
relay: client.getEventHint(event.id) relay: client.getEventHint(event.id)
} }
: { type: 'E', id: event.id, pubkey: event.pubkey } : { type: 'E', id: event.id, pubkey: event.pubkey }
const rootETag = getRootETag(event)
if (rootETag) { const rootTag = getRootTag(event)
const [, rootEventHexId, , , rootEventPubkey] = rootETag if (rootTag?.type === 'e') {
const [, rootEventHexId, , , rootEventPubkey] = rootTag.tag
if (rootEventHexId && rootEventPubkey) { if (rootEventHexId && rootEventPubkey) {
root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey } root = { type: 'E', id: rootEventHexId, pubkey: rootEventPubkey }
} else { } else {
const rootEventId = generateBech32IdFromETag(rootETag) const rootEventId = generateBech32IdFromETag(rootTag.tag)
if (rootEventId) { if (rootEventId) {
const rootEvent = await client.fetchEvent(rootEventId) const rootEvent = await client.fetchEvent(rootEventId)
if (rootEvent) { if (rootEvent) {
@ -93,13 +93,11 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
} }
} }
} }
} else if (event.kind === ExtendedKind.COMMENT) { } else if (rootTag?.type === 'a') {
const rootATag = getRootATag(event) const [, coordinate, relay] = rootTag.tag
if (rootATag) {
const [, coordinate, relay] = rootATag
const [, pubkey] = coordinate.split(':') const [, pubkey] = coordinate.split(':')
root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay } root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay }
} } else {
const rootITag = event.tags.find(tagNameEquals('I')) const rootITag = event.tags.find(tagNameEquals('I'))
if (rootITag) { if (rootITag) {
root = { type: 'I', id: rootITag[1] } root = { type: 'I', id: rootITag[1] }
@ -110,27 +108,6 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
fetchRootEvent() fetchRootEvent()
}, [event]) }, [event])
const onNewReply = useCallback((evt: NEvent) => {
addReplies([evt])
}, [])
useEffect(() => {
if (!rootInfo) return
const handleEventPublished = (data: Event) => {
const customEvent = data as CustomEvent<NEvent>
const evt = customEvent.detail
const rootId = getRootEventHexId(evt)
if (rootId === rootInfo.id && isReplyNoteEvent(evt)) {
onNewReply(evt)
}
}
client.addEventListener('newEvent', handleEventPublished)
return () => {
client.removeEventListener('newEvent', handleEventPublished)
}
}, [rootInfo, onNewReply])
useEffect(() => { useEffect(() => {
if (loading || !rootInfo || currentIndex !== index) return if (loading || !rootInfo || currentIndex !== index) return
@ -222,7 +199,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
return () => { return () => {
promise.then((closer) => closer?.()) promise.then((closer) => closer?.())
} }
}, [rootInfo, currentIndex, index, onNewReply]) }, [rootInfo, currentIndex, index])
useEffect(() => { useEffect(() => {
if (replies.length === 0) { if (replies.length === 0) {
@ -269,16 +246,23 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
setLoading(false) setLoading(false)
}, [loading, until, timelineKey]) }, [loading, until, timelineKey])
const highlightReply = useCallback((eventId: string, scrollTo = true) => { const highlightReply = useCallback((key: string, eventId?: string, scrollTo = true) => {
let found = false
if (scrollTo) { if (scrollTo) {
const ref = replyRefs.current[eventId] const ref = replyRefs.current[key]
if (ref) { if (ref) {
found = true
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
} }
} }
setHighlightReplyId(eventId) if (!found) {
if (eventId) push(toNote(eventId))
return
}
setHighlightReplyKey(key)
setTimeout(() => { setTimeout(() => {
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre)) setHighlightReplyKey((pre) => (pre === key ? undefined : pre))
}, 1500) }, 1500)
}, []) }, [])
@ -296,7 +280,8 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
<div> <div>
{replies.slice(0, showCount).map((reply) => { {replies.slice(0, showCount).map((reply) => {
if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) { if (hideUntrustedInteractions && !isUserTrusted(reply.pubkey)) {
const repliesForThisReply = repliesMap.get(reply.id) const replyKey = getEventKey(reply)
const repliesForThisReply = repliesMap.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 ||
@ -306,27 +291,29 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
} }
} }
const parentETag = getParentETag(reply) const rootEventKey = getEventKey(event)
const parentEventHexId = parentETag?.[1] const currentReplyKey = getEventKey(reply)
const parentEventId = parentETag ? generateBech32IdFromETag(parentETag) : undefined const parentTag = getParentTag(reply)
const parentEventKey = parentTag ? getEventKeyFromTag(parentTag.tag) : undefined
const parentEventId = parentTag
? parentTag.type === 'e'
? generateBech32IdFromETag(parentTag.tag)
: generateBech32IdFromATag(parentTag.tag)
: undefined
return ( return (
<div <div
ref={(el) => (replyRefs.current[reply.id] = el)} ref={(el) => (replyRefs.current[currentReplyKey] = el)}
key={reply.id} key={currentReplyKey}
className="scroll-mt-12" className="scroll-mt-12"
> >
<ReplyNote <ReplyNote
event={reply} event={reply}
parentEventId={event.id !== parentEventHexId ? parentEventId : undefined} parentEventId={rootEventKey !== parentEventKey ? parentEventId : undefined}
onClickParent={() => { onClickParent={() => {
if (!parentEventHexId) return if (!parentEventKey) return
if (replies.every((r) => r.id !== parentEventHexId)) { highlightReply(parentEventKey, parentEventId)
push(toNote(parentEventId ?? parentEventHexId))
return
}
highlightReply(parentEventHexId)
}} }}
highlight={highlightReplyId === reply.id} highlight={highlightReplyKey === currentReplyKey}
/> />
</div> </div>
) )

View file

@ -31,12 +31,13 @@ export function isReplyNoteEvent(event: Event) {
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id) const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id)
if (cache !== undefined) return cache if (cache !== undefined) return cache
const isReply = !!getParentETag(event) || !!getParentATag(event) const isReply = !!getParentTag(event)
EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply) EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply)
return isReply return isReply
} }
export function isReplaceableEvent(kind: number) { export function isReplaceableEvent(kind: number) {
if (isNaN(kind)) return false
return kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind) return kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind)
} }
@ -98,16 +99,32 @@ export function getParentEventHexId(event?: Event) {
return tag?.[1] return tag?.[1]
} }
export function getParentBech32Id(event?: Event) { export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined {
const eTag = getParentETag(event) if (!event) return undefined
if (!eTag) {
const aTag = getParentATag(event)
if (!aTag) return undefined
return generateBech32IdFromATag(aTag) if (event.kind === kinds.ShortTextNote) {
const tag = getParentETag(event)
return tag ? { type: 'e', tag } : undefined
} }
return generateBech32IdFromETag(eTag) // NIP-22
const parentKindStr = event.tags.find(tagNameEquals('k'))?.[1]
if (parentKindStr && isReplaceableEvent(parseInt(parentKindStr))) {
const tag = getParentATag(event)
return tag ? { type: 'a', tag } : undefined
}
const tag = getParentETag(event)
return tag ? { type: 'e', tag } : undefined
}
export function getParentBech32Id(event?: Event) {
const parentTag = getParentTag(event)
if (!parentTag) return undefined
return parentTag.type === 'e'
? generateBech32IdFromETag(parentTag.tag)
: generateBech32IdFromATag(parentTag.tag)
} }
export function getRootETag(event?: Event) { export function getRootETag(event?: Event) {
@ -147,16 +164,42 @@ export function getRootEventHexId(event?: Event) {
return tag?.[1] return tag?.[1]
} }
export function getRootBech32Id(event?: Event) { export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined {
const eTag = getRootETag(event) if (!event) return undefined
if (!eTag) {
const aTag = getRootATag(event)
if (!aTag) return undefined
return generateBech32IdFromATag(aTag) if (event.kind === kinds.ShortTextNote) {
const tag = getRootETag(event)
return tag ? { type: 'e', tag } : undefined
} }
return generateBech32IdFromETag(eTag) // NIP-22
const rootKindStr = event.tags.find(tagNameEquals('K'))?.[1]
if (rootKindStr && isReplaceableEvent(parseInt(rootKindStr))) {
const tag = getRootATag(event)
return tag ? { type: 'a', tag } : undefined
}
const tag = getRootETag(event)
return tag ? { type: 'e', tag } : undefined
}
export function getRootBech32Id(event?: Event) {
const rootTag = getRootTag(event)
if (!rootTag) return undefined
return rootTag.type === 'e'
? generateBech32IdFromETag(rootTag.tag)
: generateBech32IdFromATag(rootTag.tag)
}
// For internal identification of events
export function getEventKey(event: Event) {
return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
}
// Only used for e, E, a, A tags
export function getEventKeyFromTag([, tagValue]: (string | undefined)[]) {
return tagValue
} }
export function getReplaceableCoordinate(kind: number, pubkey: string, d: string = '') { export function getReplaceableCoordinate(kind: number, pubkey: string, d: string = '') {

View file

@ -10,7 +10,13 @@ import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useFetchEvent } from '@/hooks' import { useFetchEvent } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { getParentBech32Id, getParentETag, getRootBech32Id } from '@/lib/event' import {
getEventKey,
getEventKeyFromTag,
getParentBech32Id,
getParentTag,
getRootBech32Id
} from '@/lib/event'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -173,8 +179,10 @@ function ParentNote({
} }
function isConsecutive(rootEvent?: Event, parentEvent?: Event) { function isConsecutive(rootEvent?: Event, parentEvent?: Event) {
const eTag = getParentETag(parentEvent) if (!rootEvent || !parentEvent) return false
if (!eTag) return false
return rootEvent?.id === eTag[1] const tag = getParentTag(parentEvent)
if (!tag) return false
return getEventKey(rootEvent) === getEventKeyFromTag(tag.tag)
} }

View file

@ -1,9 +1,9 @@
import { getParentATag, getParentETag, getRootATag, getRootETag } from '@/lib/event' import { getEventKey, getEventKeyFromTag, getParentTag } from '@/lib/event'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = { type TReplyContext = {
repliesMap: Map<string, { events: Event[]; eventIdSet: Set<string> }> repliesMap: Map<string, { events: Event[]; eventKeySet: Set<string> }>
addReplies: (replies: Event[]) => void addReplies: (replies: Event[]) => void
} }
@ -19,56 +19,38 @@ export const useReply = () => {
export function ReplyProvider({ children }: { children: React.ReactNode }) { export function ReplyProvider({ children }: { children: React.ReactNode }) {
const [repliesMap, setRepliesMap] = useState< const [repliesMap, setRepliesMap] = useState<
Map<string, { events: Event[]; eventIdSet: Set<string> }> Map<string, { events: Event[]; eventKeySet: Set<string> }>
>(new Map()) >(new Map())
const addReplies = useCallback((replies: Event[]) => { const addReplies = useCallback((replies: Event[]) => {
const newReplyIdSet = new Set<string>() const newReplyKeySet = new Set<string>()
const newReplyEventMap = new Map<string, Event[]>() const newReplyEventMap = new Map<string, Event[]>()
replies.forEach((reply) => { replies.forEach((reply) => {
if (newReplyIdSet.has(reply.id)) return const key = getEventKey(reply)
newReplyIdSet.add(reply.id) if (newReplyKeySet.has(key)) return
newReplyKeySet.add(key)
let rootId: string | undefined const parentTag = getParentTag(reply)
const rootETag = getRootETag(reply) if (parentTag) {
if (rootETag) { const parentKey = getEventKeyFromTag(parentTag.tag)
rootId = rootETag[1] if (parentKey) {
} else { newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
const rootATag = getRootATag(reply)
if (rootATag) {
rootId = rootATag[1]
} }
} }
if (rootId) {
newReplyEventMap.set(rootId, [...(newReplyEventMap.get(rootId) || []), reply])
}
let parentId: string | undefined
const parentETag = getParentETag(reply)
if (parentETag) {
parentId = parentETag[1]
} else {
const parentATag = getParentATag(reply)
if (parentATag) {
parentId = parentATag[1]
}
}
if (parentId && parentId !== rootId) {
newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply])
}
}) })
if (newReplyEventMap.size === 0) return if (newReplyEventMap.size === 0) return
setRepliesMap((prev) => { setRepliesMap((prev) => {
for (const [id, newReplyEvents] of newReplyEventMap.entries()) { for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
const replies = prev.get(id) || { events: [], eventIdSet: new Set() } const replies = prev.get(key) || { events: [], eventKeySet: new Set() }
newReplyEvents.forEach((reply) => { newReplyEvents.forEach((reply) => {
if (!replies.eventIdSet.has(reply.id)) { const key = getEventKey(reply)
if (!replies.eventKeySet.has(key)) {
replies.events.push(reply) replies.events.push(reply)
replies.eventIdSet.add(reply.id) replies.eventKeySet.add(key)
} }
}) })
prev.set(id, replies) prev.set(key, replies)
} }
return new Map(prev) return new Map(prev)
}) })