feat: quotes

This commit is contained in:
codytseng
2025-06-08 14:05:35 +08:00
parent 00866fd73a
commit 5913cc3b88
20 changed files with 311 additions and 22 deletions

View File

@@ -0,0 +1,14 @@
import { cn } from '@/lib/utils'
export function LoadingBar({ className }: { className?: string }) {
return (
<div className={cn('h-0.5 w-full overflow-hidden', className)}>
<div
className="h-full w-full bg-gradient-to-r from-primary/40 from-25% via-primary via-50% to-primary/40 to-75% animate-shimmer"
style={{
backgroundSize: '400% 100%'
}}
/>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { cn } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { useRef, useEffect, useState } from 'react'
export type TTabValue = 'replies' | 'quotes'
const TABS = [
{ value: 'replies', label: 'Replies' },
{ value: 'quotes', label: 'Quotes' }
] as { value: TTabValue; label: string }[]
export function Tabs({
selectedTab,
onTabChange
}: {
selectedTab: TTabValue
onTabChange: (tab: TTabValue) => void
}) {
const { t } = useTranslation()
const activeIndex = TABS.findIndex((tab) => tab.value === selectedTab)
const tabRefs = useRef<(HTMLDivElement | null)[]>([])
const [indicatorStyle, setIndicatorStyle] = useState({ width: 0, left: 0 })
useEffect(() => {
if (activeIndex >= 0 && tabRefs.current[activeIndex]) {
const activeTab = tabRefs.current[activeIndex]
const { offsetWidth, offsetLeft } = activeTab
const padding = 32 // 16px padding on each side
setIndicatorStyle({
width: offsetWidth - padding,
left: offsetLeft + padding / 2
})
}
}, [activeIndex])
return (
<div className="w-fit">
<div className="flex relative">
{TABS.map((tab, index) => (
<div
key={tab.value}
ref={(el) => (tabRefs.current[index] = el)}
className={cn(
`text-center px-4 py-2 font-semibold clickable cursor-pointer rounded-lg`,
selectedTab === tab.value ? '' : 'text-muted-foreground'
)}
onClick={() => onTabChange(tab.value)}
>
{t(tab.label)}
</div>
))}
<div
className="absolute top-0 h-1 bg-primary rounded-full transition-all duration-500"
style={{
width: `${indicatorStyle.width}px`,
left: `${indicatorStyle.left}px`
}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Separator } from '@/components/ui/separator'
import { Event } from 'nostr-tools'
import { useState } from 'react'
import QuoteList from '../QuoteList'
import ReplyNoteList from '../ReplyNoteList'
import { Tabs, TTabValue } from './Tabs'
export default function NoteInteractions({
pageIndex,
event
}: {
pageIndex?: number
event: Event
}) {
const [type, setType] = useState<TTabValue>('replies')
return (
<>
<Tabs selectedTab={type} onTabChange={setType} />
<Separator />
{type === 'replies' ? (
<ReplyNoteList index={pageIndex} event={event} />
) : (
<QuoteList event={event} />
)}
</>
)
}

View File

@@ -0,0 +1,142 @@
import { BIG_RELAY_URLS } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
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'
const LIMIT = 100
const SHOW_COUNT = 10
export default function QuoteList({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const { startLogin } = useNostr()
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)
useEffect(() => {
async function init() {
setLoading(true)
setEvents([])
setHasMore(true)
const relayList = await client.fetchRelayList(event.pubkey)
const relayUrls = relayList.read.concat(BIG_RELAY_URLS)
const seenOn = client.getSeenEventRelayUrls(event.id)
relayUrls.unshift(...seenOn)
const { closer, timelineKey } = await client.subscribeTimeline(
[
{
urls: relayUrls,
filter: {
'#q': [event.id],
kinds: [kinds.ShortTextNote],
limit: LIMIT
}
}
],
{
onEvents: (events, eosed) => {
if (events.length > 0) {
setEvents(events)
}
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 promise = init()
return () => {
promise.then((closer) => closer())
}
}, [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-screen">
<div>
{events.slice(0, showCount).map((event) => (
<NoteCard key={event.id} className="w-full" event={event} />
))}
</div>
{hasMore || loading ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton isPictures={false} />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
)}
</div>
<div className="h-40" />
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { useSecondaryPage } from '@/PageManager'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { toNote } from '@/lib/link'
import { useMuteList } from '@/providers/MuteListProvider'
import { Event } from 'nostr-tools'
@@ -84,3 +85,22 @@ export default function ReplyNote({
</div>
)
}
export function ReplyNoteSkeleton() {
return (
<div className="px-4 py-3 flex items-start space-x-2 w-full">
<Skeleton className="w-8 h-8 rounded-full shrink-0" />
<div className="w-full">
<div className="py-1">
<Skeleton className="h-3 w-16" />
</div>
<div className="my-1">
<Skeleton className="w-full h-4 my-1 mt-2" />
</div>
<div className="my-1">
<Skeleton className="w-2/3 h-4 my-1" />
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,3 @@
import { Separator } from '@/components/ui/separator'
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import {
getParentEventTag,
@@ -14,7 +13,8 @@ import client from '@/services/client.service'
import { Filter, Event as NEvent, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReplyNote from '../ReplyNote'
import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
type TRootInfo = { type: 'event'; id: string; pubkey: string } | { type: 'I'; id: string }
@@ -239,15 +239,15 @@ export default function ReplyNoteList({
return (
<>
{(loading || (!!until && replies.length > 0)) && (
{loading && (replies.length === 0 ? <ReplyNoteSkeleton /> : <LoadingBar />)}
{!loading && until && (
<div
className={`text-sm text-center text-muted-foreground mt-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{loading ? t('loading...') : t('load more older replies')}
{t('load more older replies')}
</div>
)}
{replies.length > 0 && (loading || until) && <Separator className="mt-2" />}
<div className={className}>
{replies.slice(0, showCount).map((reply) => {
if (!isUserTrusted(reply.pubkey)) {