feat: add highlights to quotes

This commit is contained in:
codytseng
2025-11-17 22:37:04 +08:00
parent b4366325cd
commit 7065015462
5 changed files with 58 additions and 148 deletions

View File

@@ -2,10 +2,11 @@ import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'reactions'
export type TTabValue = 'replies' | 'reactions' | 'quotes'
const TABS = [
{ value: 'replies', label: 'Replies' },
{ value: 'reactions', label: 'Reactions' }
{ value: 'reactions', label: 'Reactions' },
{ value: 'quotes', label: 'Quotes' }
] as { value: TTabValue; label: string }[]
export function Tabs({

View File

@@ -2,9 +2,10 @@ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { useState } from 'react'
import HideUntrustedContentButton from '../HideUntrustedContentButton'
import QuoteList from '../QuoteList'
import ReactionList from '../ReactionList'
import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs'
import ReactionList from '../ReactionList'
export default function ExternalContentInteractions({
pageIndex,
@@ -22,6 +23,9 @@ export default function ExternalContentInteractions({
case 'reactions':
list = <ReactionList stuff={externalContent} />
break
case 'quotes':
list = <QuoteList stuff={externalContent} />
break
default:
break
}

View File

@@ -24,7 +24,7 @@ export default function NoteInteractions({
list = <ReplyNoteList index={pageIndex} stuff={event} />
break
case 'quotes':
list = <QuoteList event={event} />
list = <QuoteList stuff={event} />
break
case 'reactions':
list = <ReactionList stuff={event} />

View File

@@ -47,7 +47,7 @@ const NoteList = forwardRef(
showNewNotesDirectly = false
}: {
subRequests: TFeedSubRequest[]
showKinds: number[]
showKinds?: number[]
filterMutedNotes?: boolean
hideReplies?: boolean
hideUntrustedNotes?: boolean
@@ -236,7 +236,7 @@ const NoteList = forwardRef(
setNewEvents([])
setHasMore(true)
if (showKinds.length === 0) {
if (showKinds?.length === 0 && subRequests.every(({ filter }) => !filter.kinds)) {
setLoading(false)
setHasMore(false)
return () => {}
@@ -246,7 +246,7 @@ const NoteList = forwardRef(
subRequests.map(({ urls, filter }) => ({
urls,
filter: {
kinds: showKinds,
kinds: showKinds ?? [],
...filter,
limit: areAlgoRelays ? ALGO_LIMIT : LIMIT
}

View File

@@ -1,156 +1,61 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { useStuff } from '@/hooks/useStuff'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useNostr } from '@/providers/NostrProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import { TFeedSubRequest } from '@/types'
import { Event, Filter, kinds } from 'nostr-tools'
import { useEffect, useState } from 'react'
import NoteList from '../NoteList'
const LIMIT = 100
const SHOW_COUNT = 10
export default function QuoteList({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const { startLogin } = useNostr()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [hasMore, setHasMore] = useState<boolean>(true)
const [loading, setLoading] = useState(true)
const bottomRef = useRef<HTMLDivElement | null>(null)
export default function QuoteList({ stuff }: { stuff: Event | string }) {
const { event, externalContent } = useStuff(stuff)
const [subRequests, setSubRequests] = useState<TFeedSubRequest[]>([])
useEffect(() => {
async function init() {
setLoading(true)
setEvents([])
setHasMore(true)
const relaySet = new Set(BIG_RELAY_URLS)
const filters: Filter[] = []
if (event) {
const relayList = await client.fetchRelayList(event.pubkey)
const relayUrls = relayList.read.concat(BIG_RELAY_URLS)
relayList.read.slice(0, 5).forEach((url) => relaySet.add(url))
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.unshift(...seenOn)
seenOn.forEach((url) => relaySet.add(url))
const { closer, timelineKey } = await client.subscribeTimeline(
[
{
urls: relayUrls,
filter: {
'#q': [
isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
],
const isReplaceable = isReplaceableEvent(event.kind)
const key = isReplaceable ? getReplaceableCoordinateFromEvent(event) : event.id
filters.push({
'#q': [key],
kinds: [
kinds.ShortTextNote,
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.COMMENT,
ExtendedKind.POLL
],
limit: LIMIT
]
})
if (isReplaceable) {
filters.push({
'#a': [key],
kinds: [kinds.Highlights]
})
} else {
filters.push({
'#e': [key],
kinds: [kinds.Highlights]
})
}
}
],
{
onEvents: (events, eosed) => {
if (events.length > 0) {
setEvents(events)
if (externalContent) {
filters.push({
'#r': [externalContent],
kinds: [kinds.Highlights]
})
}
if (eosed) {
setLoading(false)
setHasMore(events.length > 0)
}
},
onNew: (event) => {
setEvents((oldEvents) =>
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
)
}
},
{ startLogin }
)
setTimelineKey(timelineKey)
return closer
const urls = Array.from(relaySet)
setSubRequests(filters.map((filter) => ({ urls, filter })))
}
const promise = init()
return () => {
promise.then((closer) => closer())
}
init()
}, [event])
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = async () => {
if (showCount < events.length) {
setShowCount((prev) => prev + SHOW_COUNT)
// preload more
if (events.length - showCount > LIMIT / 2) {
return
}
}
if (!timelineKey || loading || !hasMore) return
setLoading(true)
const newEvents = await client.loadMoreTimeline(
timelineKey,
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
LIMIT
)
setLoading(false)
if (newEvents.length === 0) {
setHasMore(false)
return
}
setEvents((oldEvents) => [...oldEvents, ...newEvents])
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore()
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [timelineKey, loading, hasMore, events, showCount])
return (
<div className={className}>
<div className="min-h-[80vh]">
<div>
{events.slice(0, showCount).map((event) => {
if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {
return null
}
return <NoteCard key={event.id} className="w-full" event={event} />
})}
</div>
{hasMore || loading ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
)}
</div>
<div className="h-40" />
</div>
)
return <NoteList subRequests={subRequests} />
}