feat: support displaying highlights in replies

This commit is contained in:
codytseng
2025-11-17 21:55:24 +08:00
parent d3f0704eae
commit d2c5c923a3
6 changed files with 112 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
import { useFetchEvent, useTranslatedEvent } from '@/hooks'
import { createFakeEvent } from '@/lib/event'
import { getHighlightSourceTag } from '@/lib/event-metadata'
import { toNote } from '@/lib/link'
import { isValidPubkey } from '@/lib/pubkey'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
@@ -37,36 +38,7 @@ export default function Highlight({ event, className }: { event: Event; classNam
function HighlightSource({ event }: { event: Event }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const sourceTag = useMemo(() => {
let sourceTag: string[] | undefined
for (const tag of event.tags) {
// Highest priority: 'source' tag
if (tag[2] === 'source') {
sourceTag = tag
break
}
// Give 'e' tags highest priority
if (tag[0] === 'e') {
sourceTag = tag
continue
}
// Give 'a' tags second priority over 'e' tags
if (tag[0] === 'a' && (!sourceTag || sourceTag[0] !== 'e')) {
sourceTag = tag
continue
}
// Give 'r' tags lowest priority
if (tag[0] === 'r' && (!sourceTag || sourceTag[0] === 'r')) {
sourceTag = tag
continue
}
}
return sourceTag
}, [event])
const sourceTag = useMemo(() => getHighlightSourceTag(event), [event])
const { event: referenceEvent } = useFetchEvent(
sourceTag
? sourceTag[0] === 'e'

View File

@@ -0,0 +1,26 @@
import { useTranslatedEvent } from '@/hooks'
import { createFakeEvent } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Content from '../Content'
export default function Highlight({ event, className }: { event: Event; className?: string }) {
const translatedEvent = useTranslatedEvent(event.id)
const comment = useMemo(
() => (translatedEvent?.tags ?? event.tags).find((tag) => tag[0] === 'comment')?.[1],
[event, translatedEvent]
)
return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-4', className)}>
{comment && <Content event={createFakeEvent({ content: comment })} />}
<div className="flex gap-4">
<div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
<div className="italic whitespace-pre-line">
{translatedEvent?.content ?? event.content}
</div>
</div>
</div>
)
}

View File

@@ -6,7 +6,7 @@ import { toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
@@ -15,11 +15,12 @@ import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions'
import StuffStats from '../StuffStats'
import ParentNotePreview from '../ParentNotePreview'
import StuffStats from '../StuffStats'
import TranslateButton from '../TranslateButton'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import Highlight from './Highlight'
export default function ReplyNote({
event,
@@ -51,6 +52,13 @@ export default function ReplyNote({
return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
let content: React.ReactNode
if (event.kind === kinds.Highlights) {
content = <Highlight className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
}
return (
<div
className={`pb-3 border-b transition-colors duration-500 clickable ${highlight ? 'bg-primary/50' : ''}`}
@@ -95,7 +103,7 @@ export default function ReplyNote({
/>
)}
{show ? (
<Content className="mt-2" event={event} />
content
) : (
<Button
variant="outline"

View File

@@ -1,4 +1,5 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { useStuff } from '@/hooks/useStuff'
import {
getEventKey,
getKeyFromTag,
@@ -23,7 +24,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import { useStuff } from '@/hooks/useStuff'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
@@ -144,7 +144,7 @@ export default function ReplyNoteList({
if (rootInfo.type === 'E') {
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote],
kinds: [kinds.ShortTextNote, kinds.Highlights],
limit: LIMIT
})
if (event?.kind !== kinds.ShortTextNote) {
@@ -158,7 +158,7 @@ export default function ReplyNoteList({
filters.push(
{
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote],
kinds: [kinds.ShortTextNote, kinds.Highlights],
limit: LIMIT
},
{
@@ -171,11 +171,18 @@ export default function ReplyNoteList({
relayUrls.push(rootInfo.relay)
}
} else {
filters.push({
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
})
filters.push(
{
'#I': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: LIMIT
},
{
'#r': [rootInfo.id],
kinds: [kinds.Highlights],
limit: LIMIT
}
)
}
const { closer, timelineKey } = await client.subscribeTimeline(
filters.map((filter) => ({
@@ -185,7 +192,9 @@ export default function ReplyNoteList({
{
onEvents: (evts, eosed) => {
if (evts.length > 0) {
addReplies(evts.filter((evt) => isReplyNoteEvent(evt)))
addReplies(
evts.filter((evt) => isReplyNoteEvent(evt) || evt.kind === kinds.Highlights)
)
}
if (eosed) {
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
@@ -193,7 +202,7 @@ export default function ReplyNoteList({
}
},
onNew: (evt) => {
if (!isReplyNoteEvent(evt)) return
if (!isReplyNoteEvent(evt) || evt.kind === kinds.Highlights) return
addReplies([evt])
}
}
@@ -249,7 +258,9 @@ export default function ReplyNoteList({
setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderEvents = events.filter((evt) => isReplyNoteEvent(evt))
const olderEvents = events.filter(
(evt) => isReplyNoteEvent(evt) || evt.kind === kinds.Highlights
)
if (olderEvents.length > 0) {
addReplies(olderEvents)
}

View File

@@ -403,3 +403,34 @@ export function getPinnedEventHexIdSetFromPinListEvent(event?: Event | null): Se
.slice(0, MAX_PINNED_NOTES) ?? []
)
}
export function getHighlightSourceTag(event: Event) {
let sourceTag: string[] | undefined
for (const tag of event.tags) {
// Highest priority: 'source' tag
if (tag[2] === 'source') {
sourceTag = tag
break
}
// Give 'e' tags highest priority
if (tag[0] === 'e') {
sourceTag = tag
continue
}
// Give 'a' tags second priority over 'e' tags
if (tag[0] === 'a' && (!sourceTag || sourceTag[0] !== 'e')) {
sourceTag = tag
continue
}
// Give 'r' tags lowest priority
if (tag[0] === 'r' && (!sourceTag || sourceTag[0] === 'r')) {
sourceTag = tag
continue
}
}
return sourceTag
}

View File

@@ -1,5 +1,6 @@
import { getEventKey, getKeyFromTag, getParentTag } from '@/lib/event'
import { Event } from 'nostr-tools'
import { getHighlightSourceTag } from '@/lib/event-metadata'
import { Event, kinds } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react'
type TReplyContext = {
@@ -30,12 +31,25 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
if (newReplyKeySet.has(key)) return
newReplyKeySet.add(key)
const parentTag = getParentTag(reply)
if (parentTag) {
const parentKey = getKeyFromTag(parentTag.tag)
if (parentKey) {
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
let parentKey: string | undefined
if (reply.kind === kinds.Highlights) {
console.log('reply', reply)
const sourceTag = getHighlightSourceTag(reply)
if (!sourceTag) return
parentKey = getKeyFromTag(sourceTag)
} else {
const parentTag = getParentTag(reply)
if (!parentTag) return
parentKey = getKeyFromTag(parentTag.tag)
}
if (parentKey) {
if (reply.kind === kinds.Highlights) {
console.log('parentKey', parentKey)
}
newReplyEventMap.set(parentKey, [...(newReplyEventMap.get(parentKey) || []), reply])
}
})
if (newReplyEventMap.size === 0) return