You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

197 lines
6.8 KiB

import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import { useNearViewport } from '@/hooks/useNearViewport'
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints'
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useRssUrlThreadQueryRelays } from '@/hooks/useRssUrlThreadQueryRelays'
import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { Event } from 'nostr-tools'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { LikeButtonWithStats } from './LikeButton'
import { ReplyButtonWithStats } from './ReplyButton'
import { RepostButtonWithStats } from './RepostButton'
import { ZapButtonWithStats } from './ZapButton'
/** One slot in the note action bar; left-aligned with gap spacing (not equal-width columns). */
function NoteStatsBarItem({
children,
className
}: {
children: ReactNode
className?: string
}) {
return (
<div
className={cn(
'flex shrink-0 items-center overflow-hidden [&>*]:min-w-0 [&>*]:max-w-full',
className
)}
>
{children}
</div>
)
}
export default function NoteStats({
event,
className,
classNames,
fetchIfNotExisting = false,
foregroundStats = false,
deferFetchUntilNearViewport,
useIconOnlyLikeTrigger = false,
/** Home feed: stats + “Seen on” only use these relays (favorites + trending, or reply widen stack). */
seenOnAllowlist
}: {
event: Event
className?: string
classNames?: {
buttonBar?: string
}
fetchIfNotExisting?: boolean
/** Jump ahead of spell-feed backlog so counts resolve on the open note / article. */
foregroundStats?: boolean
/**
* When true, {@link fetchNoteStats} waits until the stats row is near the viewport.
* Defaults to on for feed cards (`fetchIfNotExisting` && !`foregroundStats`).
*/
deferFetchUntilNearViewport?: boolean
/**
* Thread rows for kind-7 reactions: like control shows icon + total only (body already shows the reaction glyph).
*/
useIconOnlyLikeTrigger?: boolean
seenOnAllowlist?: readonly string[]
}) {
const { pubkey } = useNostr()
const noteStats = useNoteStatsById(event.id)
const { relays: hintRelays, currentRelaysKey } = useNoteStatsRelayHints()
const { relayUrls: rssUrlThreadRelays, relayMergeTier } = useRssUrlThreadQueryRelays()
const [loading, setLoading] = useState(false)
// Hide boost button for discussion events and replies to discussions
const isDiscussion = event.kind === ExtendedKind.DISCUSSION
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
/** Synthetic RSS article root: no boost/quote/zap bar entries that normal notes have. */
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
/** Match {@link RssUrlThreadStatsBar}: inbox/favorites/fast-read merge — plain hints miss many #i indexers. */
const statsRelays = isRssArticleRoot
? rssUrlThreadRelays
: seenOnAllowlist?.length
? seenOnAllowlist
: hintRelays
/** At most two background refetches per card: before vs after inbox/favorite hints hydrate. */
const seenOnAllowlistKey = seenOnAllowlist?.length
? [...seenOnAllowlist]
.map((u) => normalizeAnyRelayUrl(u) || u.trim())
.filter(Boolean)
.sort()
.join('|')
: ''
/** Home favorites feed: stats are scoped to the feed allowlist — ignore hint/current-relay churn. */
const usesFeedStatsAllowlist = Boolean(seenOnAllowlistKey)
const statsRelayFetchTier = isRssArticleRoot
? relayMergeTier
: usesFeedStatsAllowlist
? 0
: hintRelays.length > 0
? 1
: 0
const statsFetchRelayScopeKey = usesFeedStatsAllowlist
? seenOnAllowlistKey
: `${statsRelayFetchTier}|${currentRelaysKey}`
const statsRelaysRef = useRef(statsRelays)
statsRelaysRef.current = statsRelays
const seenOnAllowlistRef = useRef(seenOnAllowlist)
seenOnAllowlistRef.current = seenOnAllowlist
const shouldDeferStatsFetch =
deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats)
const containerRef = useRef<HTMLDivElement>(null)
const isNearViewport = useNearViewport(containerRef, { enabled: shouldDeferStatsFetch })
useEffect(() => {
if (!fetchIfNotExisting) return
if (shouldDeferStatsFetch && !isNearViewport) return
setLoading(true)
noteStatsService
.fetchNoteStats(event, pubkey, statsRelaysRef.current, {
foreground: foregroundStats,
relayAllowlist: seenOnAllowlistRef.current?.length ? seenOnAllowlistRef.current : null
})
.finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render;
// id/sig/kind/created_at identify the note for refetch boundaries.
// `statsFetchRelayScopeKey` bundles tier + current relays, or feed allowlist only on home favorites.
}, [
event.id,
event.kind,
event.created_at,
event.sig,
fetchIfNotExisting,
foregroundStats,
shouldDeferStatsFetch,
isNearViewport,
pubkey,
statsFetchRelayScopeKey
])
/** Kind 11 / 1111 under a discussion: up+down votes need more width than a single like button. */
const isDiscussionBar = isDiscussion || isReplyToDiscussion
const voteBarItem = isDiscussionBar ? 'min-w-[6.75rem] sm:min-w-[7.25rem]' : undefined
const barItems: ReactNode[] = [
<NoteStatsBarItem key="reply">
<ReplyButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
]
if (!isDiscussion && !isReplyToDiscussion && !isRssArticleRoot) {
barItems.push(
<NoteStatsBarItem key="repost">
<RepostButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
)
}
barItems.push(
<NoteStatsBarItem key="like" className={voteBarItem}>
<LikeButtonWithStats
event={event}
noteStats={noteStats}
isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/>
</NoteStatsBarItem>
)
if (!isRssArticleRoot) {
barItems.push(
<NoteStatsBarItem key="tip">
<ZapButtonWithStats event={event} noteStats={noteStats} />
</NoteStatsBarItem>
)
}
return (
<div
ref={containerRef}
className={cn('select-none min-w-0', className)}
data-note-stats
onClick={(e) => e.stopPropagation()}
>
<div
className={cn(
'flex w-full min-w-0 flex-wrap items-center justify-start gap-x-6 gap-y-2 sm:gap-x-5',
'[&_svg]:size-5 [&_button]:min-h-11 [&_button]:max-w-full [&_button]:px-3 [&_button]:touch-manipulation sm:[&_button]:min-h-10 sm:[&_button]:px-2',
loading ? 'animate-pulse' : '',
classNames?.buttonBar
)}
>
{barItems}
</div>
</div>
)
}