fix: partial replies not being displayed

This commit is contained in:
codytseng
2024-11-19 16:09:15 +08:00
parent 610dfbc9d8
commit 32cc34582d
5 changed files with 101 additions and 45 deletions

View File

@@ -38,7 +38,12 @@ export default function ReplyNote({
<Content event={event} size="small" /> <Content event={event} size="small" />
<div className="flex gap-2 text-xs"> <div className="flex gap-2 text-xs">
<div className="text-muted-foreground/60">{formatTimestamp(event.created_at)}</div> <div className="text-muted-foreground/60">{formatTimestamp(event.created_at)}</div>
<div className="text-muted-foreground hover:text-primary cursor-pointer">reply</div> <div
className="text-muted-foreground hover:text-primary cursor-pointer"
onClick={() => setIsPostDialogOpen(true)}
>
reply
</div>
</div> </div>
</div> </div>
<LikeButton event={event} variant="reply" /> <LikeButton event={event} variant="reply" />

View File

@@ -1,5 +1,6 @@
import { Separator } from '@renderer/components/ui/separator' import { Separator } from '@renderer/components/ui/separator'
import { getParentEventId, isReplyNoteEvent } from '@renderer/lib/event' import { isReplyNoteEvent } from '@renderer/lib/event'
import { isReplyETag, isRootETag } from '@renderer/lib/tag'
import { cn } from '@renderer/lib/utils' import { cn } from '@renderer/lib/utils'
import { useNoteStats } from '@renderer/providers/NoteStatsProvider' import { useNoteStats } from '@renderer/providers/NoteStatsProvider'
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
@@ -9,8 +10,10 @@ import { useEffect, useRef, useState } from 'react'
import ReplyNote from '../ReplyNote' import ReplyNote from '../ReplyNote'
export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) { export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) {
const [eventsWithParentIds, setEventsWithParentId] = useState<[Event, string | undefined][]>([]) const [replies, setReplies] = useState<Event[]>([])
const [eventMap, setEventMap] = useState<Record<string, Event>>({}) const [replyMap, setReplyMap] = useState<
Record<string, { event: Event; level: number; parent?: Event } | undefined>
>({})
const [until, setUntil] = useState<number>(() => dayjs().unix()) const [until, setUntil] = useState<number>(() => dayjs().unix())
const [loading, setLoading] = useState<boolean>(false) const [loading, setLoading] = useState<boolean>(false)
const [hasMore, setHasMore] = useState<boolean>(false) const [hasMore, setHasMore] = useState<boolean>(false)
@@ -30,13 +33,7 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
const sortedEvents = events.sort((a, b) => a.created_at - b.created_at) const sortedEvents = events.sort((a, b) => a.created_at - b.created_at)
const processedEvents = events.filter((e) => isReplyNoteEvent(e)) const processedEvents = events.filter((e) => isReplyNoteEvent(e))
if (processedEvents.length > 0) { if (processedEvents.length > 0) {
const eventMap: Record<string, Event> = {} setReplies((pre) => [...processedEvents, ...pre])
const eventsWithParentIds = processedEvents.map((event) => {
eventMap[event.id] = event
return [event, getParentEventId(event)] as [Event, string | undefined]
})
setEventsWithParentId((pre) => [...eventsWithParentIds, ...pre])
setEventMap((pre) => ({ ...pre, ...eventMap }))
} }
if (sortedEvents.length > 0) { if (sortedEvents.length > 0) {
setUntil(sortedEvents[0].created_at - 1) setUntil(sortedEvents[0].created_at - 1)
@@ -50,8 +47,39 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
}, []) }, [])
useEffect(() => { useEffect(() => {
updateNoteReplyCount(event.id, eventsWithParentIds.length) updateNoteReplyCount(event.id, replies.length)
}, [eventsWithParentIds])
const replyMap: Record<string, { event: Event; level: number; parent?: Event } | undefined> = {}
for (const reply of replies) {
const parentReplyTag = reply.tags.find(isReplyETag)
if (parentReplyTag) {
const parentReplyInfo = replyMap[parentReplyTag[1]]
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
continue
}
const rootReplyTag = reply.tags.find(isRootETag)
if (rootReplyTag) {
replyMap[reply.id] = { event: reply, level: 1 }
continue
}
let level = 0
let parent: Event | undefined
for (const [tagName, tagValue] of reply.tags) {
if (tagName === 'e') {
const info = replyMap[tagValue]
if (info && info.level > level) {
level = info.level
parent = info.event
}
}
}
replyMap[reply.id] = { event: reply, level: level + 1, parent }
}
setReplyMap(replyMap)
}, [replies])
const onClickParent = (eventId: string) => { const onClickParent = (eventId: string) => {
const ref = replyRefs.current[eventId] const ref = replyRefs.current[eventId]
@@ -72,20 +100,23 @@ export default function ReplyNoteList({ event, className }: { event: Event; clas
> >
{loading ? 'loading...' : hasMore ? 'load more older replies' : null} {loading ? 'loading...' : hasMore ? 'load more older replies' : null}
</div> </div>
{eventsWithParentIds.length > 0 && (loading || hasMore) && <Separator className="my-4" />} {replies.length > 0 && (loading || hasMore) && <Separator className="my-4" />}
<div className={cn('mb-4', className)}> <div className={cn('mb-4', className)}>
{eventsWithParentIds.map(([event, parentEventId], index) => ( {replies.map((reply, index) => {
<div ref={(el) => (replyRefs.current[event.id] = el)} key={index}> const info = replyMap[reply.id]
return (
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={index}>
<ReplyNote <ReplyNote
event={event} event={reply}
parentEvent={parentEventId ? eventMap[parentEventId] : undefined} parentEvent={info?.parent}
onClickParent={onClickParent} onClickParent={onClickParent}
highlight={highlightReplyId === event.id} highlight={highlightReplyId === reply.id}
/> />
</div> </div>
))} )
})}
</div> </div>
{eventsWithParentIds.length === 0 && !loading && !hasMore && ( {replies.length === 0 && !loading && !hasMore && (
<div className="text-sm text-center text-muted-foreground">no replies</div> <div className="text-sm text-center text-muted-foreground">no replies</div>
)} )}
</> </>

View File

@@ -41,15 +41,14 @@ export async function createShortTextNoteDraftEvent(
content: string, content: string,
parentEvent?: Event parentEvent?: Event
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { pubkeys, eventIds, rootEventId, parentEventId } = await extractMentions( const { pubkeys, otherRelatedEventIds, quoteEventIds, rootEventId, parentEventId } =
content, await extractMentions(content, parentEvent)
parentEvent
)
const hashtags = extractHashtags(content) const hashtags = extractHashtags(content)
const tags = pubkeys const tags = pubkeys
.map((pubkey) => ['p', pubkey]) .map((pubkey) => ['p', pubkey])
.concat(eventIds.map((eventId) => ['q', eventId])) // TODO: ["q", <event-id>, <relay-url>, <pubkey>] .concat(otherRelatedEventIds.map((eventId) => ['e', eventId]))
.concat(quoteEventIds.map((eventId) => ['q', eventId]))
.concat(hashtags.map((hashtag) => ['t', hashtag])) .concat(hashtags.map((hashtag) => ['t', hashtag]))
.concat([['client', 'jumble']]) .concat([['client', 'jumble']])

View File

@@ -1,6 +1,6 @@
import client from '@renderer/services/client.service' import client from '@renderer/services/client.service'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { replyETag, rootETag, tagNameEquals } from './tag' import { isReplyETag, isRootETag, tagNameEquals } from './tag'
export function isNsfwEvent(event: Event) { export function isNsfwEvent(event: Event) {
return event.tags.some( return event.tags.some(
@@ -10,15 +10,28 @@ export function isNsfwEvent(event: Event) {
} }
export function isReplyNoteEvent(event: Event) { export function isReplyNoteEvent(event: Event) {
return event.kind === kinds.ShortTextNote && event.tags.some(rootETag) if (event.kind !== kinds.ShortTextNote) return false
let hasETag = false
let hasMarker = false
for (const [tagName, , , marker] of event.tags) {
if (tagName !== 'e') continue
hasETag = true
if (!marker) continue
hasMarker = true
if (['root', 'reply'].includes(marker)) return true
}
return hasETag && !hasMarker
} }
export function getParentEventId(event?: Event) { export function getParentEventId(event?: Event) {
return event?.tags.find(replyETag)?.[1] return event?.tags.find(isReplyETag)?.[1]
} }
export function getRootEventId(event?: Event) { export function getRootEventId(event?: Event) {
return event?.tags.find(rootETag)?.[1] return event?.tags.find(isRootETag)?.[1]
} }
export function isReplaceable(kind: number) { export function isReplaceable(kind: number) {
@@ -40,7 +53,8 @@ export function getSharableEventId(event: Event) {
export async function extractMentions(content: string, parentEvent?: Event) { export async function extractMentions(content: string, parentEvent?: Event) {
const pubkeySet = new Set<string>() const pubkeySet = new Set<string>()
const eventIdSet = new Set<string>() const relatedEventIdSet = new Set<string>()
const quoteEventIdSet = new Set<string>()
let rootEventId: string | undefined let rootEventId: string | undefined
let parentEventId: string | undefined let parentEventId: string | undefined
const matches = content.match( const matches = content.match(
@@ -59,6 +73,7 @@ export async function extractMentions(content: string, parentEvent?: Event) {
const event = await client.fetchEventByBench32Id(id) const event = await client.fetchEventByBench32Id(id)
if (event) { if (event) {
pubkeySet.add(event.pubkey) pubkeySet.add(event.pubkey)
quoteEventIdSet.add(event.id)
} }
} }
} catch (e) { } catch (e) {
@@ -67,29 +82,31 @@ export async function extractMentions(content: string, parentEvent?: Event) {
} }
if (parentEvent) { if (parentEvent) {
relatedEventIdSet.add(parentEvent.id)
pubkeySet.add(parentEvent.pubkey) pubkeySet.add(parentEvent.pubkey)
parentEvent.tags.forEach((tag) => { parentEvent.tags.forEach((tag) => {
if (tagNameEquals('p')(tag)) { if (tagNameEquals('p')(tag)) {
pubkeySet.add(tag[1]) pubkeySet.add(tag[1])
} else if (rootETag(tag)) { } else if (isRootETag(tag)) {
rootEventId = tag[1] rootEventId = tag[1]
} else if (tagNameEquals('e')(tag)) { } else if (tagNameEquals('e')(tag)) {
eventIdSet.add(tag[1]) relatedEventIdSet.add(tag[1])
} }
}) })
if (rootEventId) { if (rootEventId || isReplyNoteEvent(parentEvent)) {
parentEventId = parentEvent.id parentEventId = parentEvent.id
} else { } else {
rootEventId = parentEvent.id rootEventId = parentEvent.id
} }
} }
if (rootEventId) eventIdSet.delete(rootEventId) if (rootEventId) relatedEventIdSet.delete(rootEventId)
if (parentEventId) eventIdSet.delete(parentEventId) if (parentEventId) relatedEventIdSet.delete(parentEventId)
return { return {
pubkeys: Array.from(pubkeySet), pubkeys: Array.from(pubkeySet),
eventIds: Array.from(eventIdSet), otherRelatedEventIds: Array.from(relatedEventIdSet),
quoteEventIds: Array.from(quoteEventIdSet),
rootEventId, rootEventId,
parentEventId parentEventId
} }

View File

@@ -2,10 +2,14 @@ export function tagNameEquals(tagName: string) {
return (tag: string[]) => tag[0] === tagName return (tag: string[]) => tag[0] === tagName
} }
export function replyETag([tagName, , , alt]: string[]) { export function isReplyETag([tagName, , , marker]: string[]) {
return tagName === 'e' && alt === 'reply' return tagName === 'e' && marker === 'reply'
} }
export function rootETag([tagName, , , alt]: string[]) { export function isRootETag([tagName, , , marker]: string[]) {
return tagName === 'e' && alt === 'root' return tagName === 'e' && marker === 'root'
}
export function isMentionETag([tagName, , , marker]: string[]) {
return tagName === 'e' && marker === 'mention'
} }