20 changed files with 311 additions and 22 deletions
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
@ -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} /> |
||||||
|
)} |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -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> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue