diff --git a/src/components/NoteStats/ReplyButton.tsx b/src/components/NoteStats/ReplyButton.tsx index de8406f..7faaf8f 100644 --- a/src/components/NoteStats/ReplyButton.tsx +++ b/src/components/NoteStats/ReplyButton.tsx @@ -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 - } - if (mutePubkeySet.has(evt.pubkey)) { - return false - } - if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) { - return false - } - return true - }).length ?? 0, - hasReplied + 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(reply.pubkey)) { + continue + } + if (hideContentMentioningMutedUsers && isMentioningMutedUsers(reply, mutePubkeySet)) { + continue + } + replyCount++ } - }, [repliesMap, event.id, hideUntrustedInteractions]) + + return { replyCount, hasReplied } + }, [repliesMap, event, hideUntrustedInteractions]) const [open, setOpen] = useState(false) return ( diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 2cf1fb6..7b384db 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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(undefined) const { repliesMap, addReplies } = useReply() const replies = useMemo(() => { - const replyIdSet = new Set() + const replyKeySet = new Set() 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(undefined) const [loading, setLoading] = useState(false) const [showCount, setShowCount] = useState(SHOW_COUNT) - const [highlightReplyId, setHighlightReplyId] = useState(undefined) + const [highlightReplyKey, setHighlightReplyKey] = useState(undefined) const replyRefs = useRef>({}) const bottomRef = useRef(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 - const [, pubkey] = coordinate.split(':') - root = { type: 'A', id: coordinate, eventId: event.id, pubkey, relay } - } + } 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 - 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:
{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 (
(replyRefs.current[reply.id] = el)} - key={reply.id} + ref={(el) => (replyRefs.current[currentReplyKey] = el)} + key={currentReplyKey} className="scroll-mt-12" > { - 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} />
) diff --git a/src/lib/event.ts b/src/lib/event.ts index 858867f..b5c670f 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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 = '') { diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 0515861..32df84d 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -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) } diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx index 10b8586..f20ec85 100644 --- a/src/providers/ReplyProvider.tsx +++ b/src/providers/ReplyProvider.tsx @@ -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 }> + repliesMap: Map }> addReplies: (replies: Event[]) => void } @@ -19,56 +19,38 @@ export const useReply = () => { export function ReplyProvider({ children }: { children: React.ReactNode }) { const [repliesMap, setRepliesMap] = useState< - Map }> + Map }> >(new Map()) const addReplies = useCallback((replies: Event[]) => { - const newReplyIdSet = new Set() + const newReplyKeySet = new Set() const newReplyEventMap = new Map() 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) })