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

View file

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

View file

@ -31,12 +31,13 @@ export function isReplyNoteEvent(event: Event) {
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(event.id)
if (cache !== undefined) return cache
const isReply = !!getParentETag(event) || !!getParentATag(event)
const isReply = !!getParentTag(event)
EVENT_IS_REPLY_NOTE_CACHE.set(event.id, isReply)
return isReply
}
export function isReplaceableEvent(kind: number) {
if (isNaN(kind)) return false
return kinds.isReplaceableKind(kind) || kinds.isAddressableKind(kind)
}
@ -98,16 +99,32 @@ export function getParentEventHexId(event?: Event) {
return tag?.[1]
}
export function getParentBech32Id(event?: Event) {
const eTag = getParentETag(event)
if (!eTag) {
const aTag = getParentATag(event)
if (!aTag) return undefined
export function getParentTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined {
if (!event) 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) {
@ -147,16 +164,42 @@ export function getRootEventHexId(event?: Event) {
return tag?.[1]
}
export function getRootBech32Id(event?: Event) {
const eTag = getRootETag(event)
if (!eTag) {
const aTag = getRootATag(event)
if (!aTag) return undefined
export function getRootTag(event?: Event): { type: 'e' | 'a'; tag: string[] } | undefined {
if (!event) 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 = '') {

View file

@ -10,7 +10,13 @@ import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants'
import { useFetchEvent } from '@/hooks'
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 { tagNameEquals } from '@/lib/tag'
import { cn } from '@/lib/utils'
@ -173,8 +179,10 @@ function ParentNote({
}
function isConsecutive(rootEvent?: Event, parentEvent?: Event) {
const eTag = getParentETag(parentEvent)
if (!eTag) return false
if (!rootEvent || !parentEvent) 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 { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = {
repliesMap: Map<string, { events: Event[]; eventIdSet: Set<string> }>
repliesMap: Map<string, { events: Event[]; eventKeySet: Set<string> }>
addReplies: (replies: Event[]) => void
}
@ -19,56 +19,38 @@ export const useReply = () => {
export function ReplyProvider({ children }: { children: React.ReactNode }) {
const [repliesMap, setRepliesMap] = useState<
Map<string, { events: Event[]; eventIdSet: Set<string> }>
Map<string, { events: Event[]; eventKeySet: Set<string> }>
>(new Map())
const addReplies = useCallback((replies: Event[]) => {
const newReplyIdSet = new Set<string>()
const newReplyKeySet = new Set<string>()
const newReplyEventMap = new Map<string, Event[]>()
replies.forEach((reply) => {
if (newReplyIdSet.has(reply.id)) return
newReplyIdSet.add(reply.id)
const key = getEventKey(reply)
if (newReplyKeySet.has(key)) return
newReplyKeySet.add(key)
let rootId: string | undefined
const rootETag = getRootETag(reply)
if (rootETag) {
rootId = rootETag[1]
} else {
const rootATag = getRootATag(reply)
if (rootATag) {
rootId = rootATag[1]
const parentTag = getParentTag(reply)
if (parentTag) {
const parentKey = getEventKeyFromTag(parentTag.tag)
if (parentKey) {
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
}
}
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
setRepliesMap((prev) => {
for (const [id, newReplyEvents] of newReplyEventMap.entries()) {
const replies = prev.get(id) || { events: [], eventIdSet: new Set() }
for (const [key, newReplyEvents] of newReplyEventMap.entries()) {
const replies = prev.get(key) || { events: [], eventKeySet: new Set() }
newReplyEvents.forEach((reply) => {
if (!replies.eventIdSet.has(reply.id)) {
const key = getEventKey(reply)
if (!replies.eventKeySet.has(key)) {
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)
})