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
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> |
|
) |
|
}
|
|
|