feat: add more note interactions lists (#467)
Co-authored-by: Trevor Arjeski <tmarjeski@gmail.com>
This commit is contained in:
@@ -33,103 +33,114 @@ import MediaPlayer from '../MediaPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
|
||||
|
||||
const Content = memo(({ event, className }: { event: Event; className?: string }) => {
|
||||
const translatedEvent = useTranslatedEvent(event.id)
|
||||
const nodes = parseContent(translatedEvent?.content ?? event.content, [
|
||||
EmbeddedYoutubeParser,
|
||||
EmbeddedImageParser,
|
||||
EmbeddedMediaParser,
|
||||
EmbeddedNormalUrlParser,
|
||||
EmbeddedLNInvoiceParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedEmojiParser
|
||||
])
|
||||
const Content = memo(
|
||||
({ event, content, className }: { event?: Event; content?: string; className?: string }) => {
|
||||
const translatedEvent = useTranslatedEvent(event?.id)
|
||||
const _content = translatedEvent?.content ?? event?.content ?? content
|
||||
if (!_content) return null
|
||||
|
||||
const imageInfos = getImageInfosFromEvent(event)
|
||||
const allImages = nodes
|
||||
.map((node) => {
|
||||
if (node.type === 'image') {
|
||||
const imageInfo = imageInfos.find((image) => image.url === node.data)
|
||||
if (imageInfo) {
|
||||
return imageInfo
|
||||
}
|
||||
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
||||
return tag
|
||||
? getImageInfoFromImetaTag(tag, event.pubkey)
|
||||
: { url: node.data, pubkey: event.pubkey }
|
||||
}
|
||||
if (node.type === 'images') {
|
||||
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
||||
return urls.map((url) => {
|
||||
const imageInfo = imageInfos.find((image) => image.url === url)
|
||||
return imageInfo ?? { url, pubkey: event.pubkey }
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.flat() as TImageInfo[]
|
||||
let imageIndex = 0
|
||||
const nodes = parseContent(_content, [
|
||||
EmbeddedYoutubeParser,
|
||||
EmbeddedImageParser,
|
||||
EmbeddedMediaParser,
|
||||
EmbeddedNormalUrlParser,
|
||||
EmbeddedLNInvoiceParser,
|
||||
EmbeddedWebsocketUrlParser,
|
||||
EmbeddedEventParser,
|
||||
EmbeddedMentionParser,
|
||||
EmbeddedHashtagParser,
|
||||
EmbeddedEmojiParser
|
||||
])
|
||||
|
||||
const emojiInfos = getEmojiInfosFromEmojiTags(event.tags)
|
||||
|
||||
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
|
||||
const lastNormalUrl =
|
||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
||||
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'text') {
|
||||
return node.data
|
||||
const imageInfos = event ? getImageInfosFromEvent(event) : []
|
||||
const allImages = nodes
|
||||
.map((node) => {
|
||||
if (node.type === 'image') {
|
||||
const imageInfo = imageInfos.find((image) => image.url === node.data)
|
||||
if (imageInfo) {
|
||||
return imageInfo
|
||||
}
|
||||
const tag = mediaUpload.getImetaTagByUrl(node.data)
|
||||
return tag
|
||||
? getImageInfoFromImetaTag(tag, event?.pubkey)
|
||||
: { url: node.data, pubkey: event?.pubkey }
|
||||
}
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const start = imageIndex
|
||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||
imageIndex = end
|
||||
return (
|
||||
<ImageGallery className="mt-2" key={index} images={allImages} start={start} end={end} />
|
||||
)
|
||||
}
|
||||
if (node.type === 'media') {
|
||||
return <MediaPlayer className="mt-2" key={index} src={node.data} />
|
||||
}
|
||||
if (node.type === 'url') {
|
||||
return <EmbeddedNormalUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
const id = node.data.split(':')[1]
|
||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji emoji={emoji} key={index} className="size-4" />
|
||||
}
|
||||
if (node.type === 'youtube') {
|
||||
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
|
||||
if (node.type === 'images') {
|
||||
const urls = Array.isArray(node.data) ? node.data : [node.data]
|
||||
return urls.map((url) => {
|
||||
const imageInfo = imageInfos.find((image) => image.url === url)
|
||||
return imageInfo ?? { url, pubkey: event?.pubkey }
|
||||
})
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.flat() as TImageInfo[]
|
||||
let imageIndex = 0
|
||||
|
||||
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
|
||||
|
||||
const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url')
|
||||
const lastNormalUrl =
|
||||
typeof lastNormalUrlNode?.data === 'string' ? lastNormalUrlNode.data : undefined
|
||||
|
||||
return (
|
||||
<div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>
|
||||
{nodes.map((node, index) => {
|
||||
if (node.type === 'text') {
|
||||
return node.data
|
||||
}
|
||||
if (node.type === 'image' || node.type === 'images') {
|
||||
const start = imageIndex
|
||||
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
|
||||
imageIndex = end
|
||||
return (
|
||||
<ImageGallery
|
||||
className="mt-2"
|
||||
key={index}
|
||||
images={allImages}
|
||||
start={start}
|
||||
end={end}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.type === 'media') {
|
||||
return <MediaPlayer className="mt-2" key={index} src={node.data} />
|
||||
}
|
||||
if (node.type === 'url') {
|
||||
return <EmbeddedNormalUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'invoice') {
|
||||
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'websocket-url') {
|
||||
return <EmbeddedWebsocketUrl url={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'event') {
|
||||
const id = node.data.split(':')[1]
|
||||
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
|
||||
}
|
||||
if (node.type === 'mention') {
|
||||
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
|
||||
}
|
||||
if (node.type === 'hashtag') {
|
||||
return <EmbeddedHashtag hashtag={node.data} key={index} />
|
||||
}
|
||||
if (node.type === 'emoji') {
|
||||
const shortcode = node.data.split(':')[1]
|
||||
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
|
||||
if (!emoji) return node.data
|
||||
return <Emoji emoji={emoji} key={index} />
|
||||
}
|
||||
if (node.type === 'youtube') {
|
||||
return <YoutubeEmbeddedPlayer key={index} url={node.data} className="mt-2" />
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Content.displayName = 'Content'
|
||||
export default Content
|
||||
|
||||
@@ -5,30 +5,35 @@ import { HTMLAttributes, useState } from 'react'
|
||||
|
||||
export default function Emoji({
|
||||
emoji,
|
||||
className = ''
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
className?: string
|
||||
classNames
|
||||
}: Omit<HTMLAttributes<HTMLDivElement>, 'className'> & {
|
||||
emoji: TEmoji | string
|
||||
classNames?: {
|
||||
text?: string
|
||||
img?: string
|
||||
}
|
||||
}) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (typeof emoji === 'string') {
|
||||
return emoji === '+' ? (
|
||||
<Heart className={cn('size-4 text-red-400 fill-red-400', className)} />
|
||||
<Heart className={cn('size-4 text-red-400 fill-red-400', classNames?.img)} />
|
||||
) : (
|
||||
<span className={cn('whitespace-nowrap', className)}>{emoji}</span>
|
||||
<span className={cn('whitespace-nowrap', classNames?.text)}>{emoji}</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return <span className={cn('whitespace-nowrap', className)}>{`:${emoji.shortcode}:`}</span>
|
||||
return (
|
||||
<span className={cn('whitespace-nowrap', classNames?.text)}>{`:${emoji.shortcode}:`}</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={emoji.url}
|
||||
alt={emoji.shortcode}
|
||||
className={cn('inline-block size-4', className)}
|
||||
className={cn('inline-block size-4', classNames?.img)}
|
||||
onLoad={() => {
|
||||
setHasError(false)
|
||||
}}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { cn } from '@/lib/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
|
||||
export type TTabValue = 'replies' | 'quotes'
|
||||
export type TTabValue = 'replies' | 'quotes' | 'reactions' | 'reposts' | 'zaps'
|
||||
const TABS = [
|
||||
{ value: 'replies', label: 'Replies' },
|
||||
{ value: 'zaps', label: 'Zaps' },
|
||||
{ value: 'reposts', label: 'Reposts' },
|
||||
{ value: 'reactions', label: 'Reactions' },
|
||||
{ value: 'quotes', label: 'Quotes' }
|
||||
] as { value: TTabValue; label: string }[]
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import HideUntrustedContentButton from '../HideUntrustedContentButton'
|
||||
import QuoteList from '../QuoteList'
|
||||
import ReactionList from '../ReactionList'
|
||||
import ReplyNoteList from '../ReplyNoteList'
|
||||
import RepostList from '../RepostList'
|
||||
import ZapList from '../ZapList'
|
||||
import { Tabs, TTabValue } from './Tabs'
|
||||
|
||||
export default function NoteInteractions({
|
||||
@@ -14,19 +18,41 @@ export default function NoteInteractions({
|
||||
event: Event
|
||||
}) {
|
||||
const [type, setType] = useState<TTabValue>('replies')
|
||||
let list
|
||||
switch (type) {
|
||||
case 'replies':
|
||||
list = <ReplyNoteList index={pageIndex} event={event} />
|
||||
break
|
||||
case 'quotes':
|
||||
list = <QuoteList event={event} />
|
||||
break
|
||||
case 'reactions':
|
||||
list = <ReactionList event={event} />
|
||||
break
|
||||
case 'reposts':
|
||||
list = <RepostList event={event} />
|
||||
break
|
||||
case 'zaps':
|
||||
list = <ZapList event={event} />
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between pr-1">
|
||||
<Tabs selectedTab={type} onTabChange={setType} />
|
||||
<HideUntrustedContentButton type="interactions" />
|
||||
<div className="flex items-center justify-between">
|
||||
<ScrollArea className="flex-1 w-0">
|
||||
<Tabs selectedTab={type} onTabChange={setType} />
|
||||
<ScrollBar orientation="horizontal" className="opacity-0" />
|
||||
</ScrollArea>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="size-10 flex items-center justify-center">
|
||||
<HideUntrustedContentButton type="interactions" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
{type === 'replies' ? (
|
||||
<ReplyNoteList index={pageIndex} event={event} />
|
||||
) : (
|
||||
<QuoteList event={event} />
|
||||
)}
|
||||
{list}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,14 +22,14 @@ export default function TopZaps({ event }: { event: Event }) {
|
||||
{topZaps.map((zap, index) => (
|
||||
<div
|
||||
key={zap.pr}
|
||||
className="flex gap-1 py-1 pl-1 pr-2 text-sm rounded-full bg-muted/80 items-center text-yellow-400 border border-yellow-400 hover:bg-yellow-400/20 cursor-pointer"
|
||||
className="flex gap-1 py-1 pl-1 pr-2 text-sm max-w-72 rounded-full bg-muted/80 items-center text-yellow-400 border border-yellow-400 hover:bg-yellow-400/20 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setZapIndex(index)
|
||||
}}
|
||||
>
|
||||
<SimpleUserAvatar userId={zap.pubkey} size="xSmall" />
|
||||
<Zap className="size-3 fill-yellow-400" />
|
||||
<Zap className="size-3 fill-yellow-400 shrink-0" />
|
||||
<div className="font-semibold">{formatAmount(zap.amount)}</div>
|
||||
<div className="truncate">{zap.comment}</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
@@ -38,7 +39,9 @@ export default function QuoteList({ event, className }: { event: Event; classNam
|
||||
{
|
||||
urls: relayUrls,
|
||||
filter: {
|
||||
'#q': [event.id],
|
||||
'#q': [
|
||||
isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
|
||||
],
|
||||
kinds: [
|
||||
kinds.ShortTextNote,
|
||||
kinds.Highlights,
|
||||
@@ -130,7 +133,7 @@ export default function QuoteList({ event, className }: { event: Event; classNam
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-[80vh]">
|
||||
<div>
|
||||
{events.slice(0, showCount).map((event) => {
|
||||
if (hideUntrustedInteractions && !isUserTrusted(event.pubkey)) {
|
||||
|
||||
89
src/components/ReactionList/index.tsx
Normal file
89
src/components/ReactionList/index.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Emoji from '../Emoji'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
const SHOW_COUNT = 20
|
||||
|
||||
export default function ReactionList({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const filteredLikes = useMemo(() => {
|
||||
return (noteStats?.likes ?? [])
|
||||
.filter((like) => !hideUntrustedInteractions || isUserTrusted(like.pubkey))
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
|
||||
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomRef.current || filteredLikes.length <= showCount) return
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
|
||||
},
|
||||
{ rootMargin: '10px', threshold: 0.1 }
|
||||
)
|
||||
obs.observe(bottomRef.current)
|
||||
return () => obs.disconnect()
|
||||
}, [filteredLikes.length, showCount])
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{filteredLikes.slice(0, showCount).map((like) => (
|
||||
<div
|
||||
key={like.id}
|
||||
className="px-4 py-3 border-b transition-colors clickable flex items-center gap-3"
|
||||
onClick={() => push(toProfile(like.pubkey))}
|
||||
>
|
||||
<div className="w-6 flex flex-col items-center">
|
||||
<Emoji
|
||||
emoji={like.emoji}
|
||||
classNames={{
|
||||
text: 'text-xl',
|
||||
img: 'size-5'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UserAvatar userId={like.pubkey} size="medium" className="shrink-0" />
|
||||
|
||||
<div className="flex-1 w-0">
|
||||
<Username
|
||||
userId={like.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Nip05 pubkey={like.pubkey} append="·" />
|
||||
<FormattedTimestamp
|
||||
timestamp={like.created_at}
|
||||
className="shrink-0"
|
||||
short={isSmallScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
|
||||
<div className="text-sm mt-2 text-center text-muted-foreground">
|
||||
{filteredLikes.length > 0 ? t('No more reactions') : t('No reactions yet')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -274,7 +274,7 @@ export default function ReplyNoteList({ index, event }: { index?: number; event:
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="min-h-[80vh]">
|
||||
{loading && (replies.length === 0 ? <ReplyNoteSkeleton /> : <LoadingBar />)}
|
||||
{!loading && until && (
|
||||
<div
|
||||
|
||||
81
src/components/RepostList/index.tsx
Normal file
81
src/components/RepostList/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
const SHOW_COUNT = 20
|
||||
|
||||
export default function RepostList({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const filteredReposts = useMemo(() => {
|
||||
return (noteStats?.reposts ?? [])
|
||||
.filter((repost) => !hideUntrustedInteractions || isUserTrusted(repost.pubkey))
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
|
||||
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomRef.current || filteredReposts.length <= showCount) return
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
|
||||
},
|
||||
{ rootMargin: '10px', threshold: 0.1 }
|
||||
)
|
||||
obs.observe(bottomRef.current)
|
||||
return () => obs.disconnect()
|
||||
}, [filteredReposts.length, showCount])
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{filteredReposts.slice(0, showCount).map((repost) => (
|
||||
<div
|
||||
key={repost.id}
|
||||
className="px-4 py-3 border-b transition-colors clickable flex items-center gap-3"
|
||||
onClick={() => push(toProfile(repost.pubkey))}
|
||||
>
|
||||
<Repeat className="text-green-400 size-5" />
|
||||
|
||||
<UserAvatar userId={repost.pubkey} size="medium" className="shrink-0" />
|
||||
|
||||
<div className="flex-1 w-0">
|
||||
<Username
|
||||
userId={repost.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Nip05 pubkey={repost.pubkey} append="·" />
|
||||
<FormattedTimestamp
|
||||
timestamp={repost.created_at}
|
||||
className="shrink-0"
|
||||
short={isSmallScreen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
|
||||
<div className="text-sm mt-2 text-center text-muted-foreground">
|
||||
{filteredReposts.length > 0 ? t('No more reposts') : t('No reposts yet')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
src/components/ZapList/index.tsx
Normal file
84
src/components/ZapList/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
|
||||
import { formatAmount } from '@/lib/lightning'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Zap } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
const SHOW_COUNT = 20
|
||||
|
||||
export default function ZapList({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const noteStats = useNoteStatsById(event.id)
|
||||
const filteredZaps = useMemo(() => {
|
||||
return (noteStats?.zaps ?? []).sort((a, b) => b.amount - a.amount)
|
||||
}, [noteStats, event.id])
|
||||
|
||||
const [showCount, setShowCount] = useState(SHOW_COUNT)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bottomRef.current || filteredZaps.length <= showCount) return
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setShowCount((c) => c + SHOW_COUNT)
|
||||
},
|
||||
{ rootMargin: '10px', threshold: 0.1 }
|
||||
)
|
||||
obs.observe(bottomRef.current)
|
||||
return () => obs.disconnect()
|
||||
}, [filteredZaps.length, showCount])
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh]">
|
||||
{filteredZaps.slice(0, showCount).map((zap) => (
|
||||
<div
|
||||
key={zap.pr}
|
||||
className="px-4 py-3 border-b transition-colors clickable flex gap-2"
|
||||
onClick={() => push(toProfile(zap.pubkey))}
|
||||
>
|
||||
<div className="w-8 flex flex-col items-center mt-0.5">
|
||||
<Zap className="text-yellow-400 size-5" />
|
||||
<div className="text-sm font-semibold text-yellow-400">{formatAmount(zap.amount)}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 items-start">
|
||||
<UserAvatar userId={zap.pubkey} size="medium" className="shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Username
|
||||
userId={zap.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground max-w-fit truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Nip05 pubkey={zap.pubkey} append="·" />
|
||||
<FormattedTimestamp
|
||||
timestamp={zap.created_at}
|
||||
className="shrink-0"
|
||||
short={isSmallScreen}
|
||||
/>
|
||||
</div>
|
||||
<Content className="mt-2" content={zap.comment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
|
||||
<div className="text-sm mt-2 text-center text-muted-foreground">
|
||||
{filteredZaps.length > 0 ? t('No more zaps') : t('No zaps yet')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user