Browse Source

note zaps on hover

imwald
Silberengel 3 weeks ago
parent
commit
85b8a0720f
  1. 84
      src/components/NoteStats/LikeButton.tsx
  2. 317
      src/components/NoteStats/NoteStatsCountHover.tsx
  3. 25
      src/components/NoteStats/RepostButton.tsx
  4. 30
      src/components/NoteStats/ZapButton.tsx
  5. 2
      src/i18n/locales/en.ts
  6. 42
      src/lib/note-stats-interactors.test.ts
  7. 82
      src/lib/note-stats-interactors.ts

84
src/components/NoteStats/LikeButton.tsx

@ -35,6 +35,7 @@ import logger from '@/lib/logger'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji' import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji'
import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker'
import { DiscussionVoteCountHover, ReactionCountHover } from './NoteStatsCountHover'
import { import {
type RelayStatus, type RelayStatus,
showPublishingError, showPublishingError,
@ -42,6 +43,7 @@ import {
showSimplePublishSuccess showSimplePublishSuccess
} from '@/lib/publishing-feedback' } from '@/lib/publishing-feedback'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { cn } from '@/lib/utils'
import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed' import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed'
type LikeButtonProps = { type LikeButtonProps = {
@ -237,46 +239,42 @@ export function LikeButtonWithStats({
}) })
} }
const trigger = ( const openReactionPicker = () => {
<button
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
title={t('Like')}
disabled={liking}
onClick={() => {
// If user has already reacted, clicking the button again should toggle it off
if (myLastEmoji && !isEmojiReactionsOpen) { if (myLastEmoji && !isEmojiReactionsOpen) {
like(myLastEmoji) like(myLastEmoji)
return return
} }
// Otherwise, open the emoji picker
setIsEmojiReactionsOpen(true) setIsEmojiReactionsOpen(true)
}} }
const likeIconButton = (
<button
type="button"
className="flex h-full items-center gap-1 pl-3 pr-1 text-muted-foreground enabled:hover:text-primary"
title={t('Like')}
disabled={liking}
onClick={openReactionPicker}
> >
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : myLastEmoji && !useIconOnlyLikeTrigger ? ( ) : myLastEmoji && !useIconOnlyLikeTrigger ? (
<>
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} /> <Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: EMOJI_IMG_INLINE_CLASS }} />
{showLikeCount && (
<div className="text-sm tabular-nums">
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
</div>
)}
</>
) : ( ) : (
<>
<SmilePlus /> <SmilePlus />
{showLikeCount && (
<div className="text-sm tabular-nums">
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
</div>
)}
</>
)} )}
</button> </button>
) )
const likeCountLabel = showLikeCount ? (
<ReactionCountHover noteStats={noteStats}>
<div className="pr-3 text-sm tabular-nums">
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
</div>
</ReactionCountHover>
) : (
<span className="pr-3" aria-hidden />
)
// Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions // Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions
if (showDiscussionVotes) { if (showDiscussionVotes) {
return ( return (
@ -287,11 +285,16 @@ export function LikeButtonWithStats({
const count = index === 0 ? upVoteCount : downVoteCount const count = index === 0 ? upVoteCount : downVoteCount
const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY
return ( return (
<button <div
key={emoji} key={emoji}
className={`flex items-center enabled:hover:text-primary gap-1 px-2 h-full text-muted-foreground rounded ${ className={cn(
isSelected ? 'text-primary bg-muted' : '' 'flex h-full items-center rounded',
}`} isSelected ? 'bg-muted text-primary' : 'text-muted-foreground'
)}
>
<button
type="button"
className="flex h-full items-center px-2 enabled:hover:text-primary"
title={emoji === '+' ? t('Upvote') : t('Downvote')} title={emoji === '+' ? t('Upvote') : t('Downvote')}
disabled={liking} disabled={liking}
onClick={() => { onClick={() => {
@ -301,18 +304,19 @@ export function LikeButtonWithStats({
{liking ? ( {liking ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : ( ) : (
<>
<span className="text-base leading-none" aria-hidden> <span className="text-base leading-none" aria-hidden>
{arrow} {arrow}
</span> </span>
{!hideCount && (noteStats?.updatedAt != null || count > 0) && (
<div className="text-sm tabular-nums">
{count >= 100 ? '99+' : count}
</div>
)}
</>
)} )}
</button> </button>
{!hideCount && (noteStats?.updatedAt != null || count > 0) ? (
<DiscussionVoteCountHover noteStats={noteStats} vote={index === 0 ? 'up' : 'down'}>
<div className="pr-2 text-sm tabular-nums">
{count >= 100 ? '99+' : count}
</div>
</DiscussionVoteCountHover>
) : null}
</div>
) )
})} })}
</div> </div>
@ -335,7 +339,10 @@ export function LikeButtonWithStats({
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<> <>
{trigger} <div className="flex h-full min-w-0 items-center">
{likeIconButton}
{likeCountLabel}
</div>
<Drawer handleOnly open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}> <Drawer handleOnly open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DrawerContent <DrawerContent
dragHandle="vaul" dragHandle="vaul"
@ -356,12 +363,15 @@ export function LikeButtonWithStats({
} }
return ( return (
<div className="flex h-full min-w-0 items-center">
<DropdownMenu open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}> <DropdownMenu open={isEmojiReactionsOpen} onOpenChange={setIsEmojiReactionsOpen}>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{likeIconButton}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="p-0 w-fit"> <DropdownMenuContent side="top" className="p-0 w-fit">
{likeEmojiPicker} {likeEmojiPicker}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{likeCountLabel}
</div>
) )
} }

317
src/components/NoteStats/NoteStatsCountHover.tsx

@ -0,0 +1,317 @@
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import Emoji from '@/components/Emoji'
import Username from '@/components/Username'
import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY,
isDiscussionDownvoteEmoji,
isDiscussionUpvoteEmoji
} from '@/lib/discussion-votes'
import {
aggregateZapsByPubkey,
dedupeBoostersByPubkey,
emojiStatsKey,
filterStatsInteractors,
groupReactionsByEmoji,
MAX_NOTE_STATS_INTERACTORS_SHOWN
} from '@/lib/note-stats-interactors'
import { cn } from '@/lib/utils'
import type { TNoteStats } from '@/services/note-stats.service'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { useUserTrust } from '@/contexts/user-trust-context'
import { TEmoji } from '@/types'
import { useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
function formatZapLineAmount(amount: number) {
if (amount < 1000) return String(amount)
if (amount < 1_000_000) return `${Math.round(amount / 100) / 10}k`
return `${Math.round(amount / 100_000) / 10}M`
}
function InteractorList({
pubkeys,
title,
suffixForPubkey
}: {
pubkeys: string[]
title: ReactNode
suffixForPubkey?: (pubkey: string) => ReactNode
}) {
const { t } = useTranslation()
const feedProfiles = useNoteFeedProfileContext()
const visible = pubkeys.slice(0, MAX_NOTE_STATS_INTERACTORS_SHOWN)
const overflow = pubkeys.length - visible.length
return (
<div className="min-w-0 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
<ul className="max-h-52 space-y-1 overflow-y-auto overscroll-y-contain pr-0.5">
{visible.map((pk) => (
<li
key={pk}
className="flex min-w-0 items-center gap-1.5 text-sm leading-snug text-foreground"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Username
userId={pk}
showAt
className="min-w-0 truncate font-medium"
prefetchedProfile={feedProfiles?.profiles.get(pk.toLowerCase())}
/>
{suffixForPubkey?.(pk)}
</li>
))}
</ul>
{overflow > 0 ? (
<p className="text-xs text-muted-foreground">
{t('n more interactors', { count: overflow })}
</p>
) : null}
</div>
)
}
function ReactionGroupsList({
groups,
title
}: {
groups: { emoji: TEmoji | string; pubkeys: string[] }[]
title: ReactNode
}) {
const { t } = useTranslation()
const feedProfiles = useNoteFeedProfileContext()
let shownPubkeys = 0
return (
<div className="min-w-0 space-y-2">
<p className="text-xs font-medium text-muted-foreground">{title}</p>
<div className="max-h-52 space-y-2 overflow-y-auto overscroll-y-contain pr-0.5">
{groups.map((group) => {
const remaining = MAX_NOTE_STATS_INTERACTORS_SHOWN - shownPubkeys
if (remaining <= 0) return null
const slice = group.pubkeys.slice(0, remaining)
shownPubkeys += slice.length
const overflowInGroup = group.pubkeys.length - slice.length
return (
<div key={emojiStatsKey(group.emoji)} className="min-w-0">
<div className="mb-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Emoji emoji={group.emoji} classNames={{ img: 'size-4' }} />
<span className="tabular-nums">{group.pubkeys.length}</span>
</div>
<ul className="space-y-0.5 pl-1">
{slice.map((pk) => (
<li
key={pk}
className="min-w-0 text-sm leading-snug"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Username
userId={pk}
showAt
className="min-w-0 truncate font-medium"
prefetchedProfile={feedProfiles?.profiles.get(pk.toLowerCase())}
/>
</li>
))}
</ul>
{overflowInGroup > 0 ? (
<p className="mt-0.5 pl-1 text-xs text-muted-foreground">
{t('n more interactors', { count: overflowInGroup })}
</p>
) : null}
</div>
)
})}
</div>
</div>
)
}
export function NoteStatsCountHover({
enabled,
children,
content,
className
}: {
enabled: boolean
children: ReactNode
content: ReactNode
className?: string
}) {
if (!enabled) {
return <>{children}</>
}
return (
<HoverCard openDelay={220} closeDelay={80}>
<HoverCardTrigger asChild>
<span
className={cn(
'cursor-help underline decoration-dotted decoration-muted-foreground/45 underline-offset-2',
className
)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
{children}
</span>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="z-[100] w-72 p-3"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{content}
</HoverCardContent>
</HoverCard>
)
}
export function BoostCountHover({
noteStats,
children
}: {
noteStats?: Partial<TNoteStats>
children: ReactNode
}) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const pubkeys = useMemo(() => {
const filtered = filterStatsInteractors(noteStats?.reposts, hideUntrustedInteractions, isUserTrusted)
return dedupeBoostersByPubkey(filtered).map((r) => r.pubkey)
}, [noteStats?.reposts, hideUntrustedInteractions, isUserTrusted])
return (
<NoteStatsCountHover
enabled={pubkeys.length > 0}
content={<InteractorList pubkeys={pubkeys} title={t('Boosted by:')} />}
>
{children}
</NoteStatsCountHover>
)
}
export function ReactionCountHover({
noteStats,
emojiFilter,
title: titleProp,
children
}: {
noteStats?: Partial<TNoteStats>
/** When set, only reactions matching this predicate (e.g. upvote / downvote). */
emojiFilter?: (emoji: TEmoji | string) => boolean
title?: ReactNode
children: ReactNode
}) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const { groups, title } = useMemo(() => {
let likes = filterStatsInteractors(noteStats?.likes, hideUntrustedInteractions, isUserTrusted)
if (emojiFilter) likes = likes.filter((l) => emojiFilter(l.emoji))
return {
groups: groupReactionsByEmoji(likes),
title: titleProp ?? t('Liked by:')
}
}, [noteStats?.likes, hideUntrustedInteractions, isUserTrusted, emojiFilter, titleProp, t])
const total = groups.reduce((n, g) => n + g.pubkeys.length, 0)
return (
<NoteStatsCountHover
enabled={total > 0}
content={<ReactionGroupsList groups={groups} title={title} />}
>
{children}
</NoteStatsCountHover>
)
}
export function DiscussionVoteCountHover({
noteStats,
vote,
children
}: {
noteStats?: Partial<TNoteStats>
vote: 'up' | 'down'
children: ReactNode
}) {
const { t } = useTranslation()
const emojiFilter = vote === 'up' ? isDiscussionUpvoteEmoji : isDiscussionDownvoteEmoji
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const pubkeys = useMemo(() => {
const likes = filterStatsInteractors(noteStats?.likes, hideUntrustedInteractions, isUserTrusted)
.filter((l) => emojiFilter(l.emoji))
const byPk = new Map<string, number>()
for (const l of likes) {
const pk = l.pubkey.toLowerCase()
const prev = byPk.get(pk)
if (prev == null || l.created_at > prev) byPk.set(pk, l.created_at)
}
return [...byPk.entries()]
.sort((a, b) => b[1] - a[1])
.map(([pk]) => pk)
}, [noteStats?.likes, hideUntrustedInteractions, isUserTrusted, emojiFilter])
const title = (
<span className="inline-flex items-center gap-1">
<span aria-hidden>
{vote === 'up' ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY}
</span>
{vote === 'up' ? t('Liked by:') : t('Disliked by:')}
</span>
)
return (
<NoteStatsCountHover
enabled={pubkeys.length > 0}
content={<InteractorList pubkeys={pubkeys} title={title} />}
>
{children}
</NoteStatsCountHover>
)
}
export function ZapCountHover({
noteStats,
children
}: {
noteStats?: Partial<TNoteStats>
children: ReactNode
}) {
const { t } = useTranslation()
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
const zappers = useMemo(() => {
const filtered = filterStatsInteractors(noteStats?.zaps, hideUntrustedInteractions, isUserTrusted)
return aggregateZapsByPubkey(filtered)
}, [noteStats?.zaps, hideUntrustedInteractions, isUserTrusted])
return (
<NoteStatsCountHover
enabled={zappers.length > 0}
content={
<InteractorList
pubkeys={zappers.map((z) => z.pubkey)}
title={t('Zapped by:')}
suffixForPubkey={(pk) => {
const row = zappers.find((z) => z.pubkey.toLowerCase() === pk.toLowerCase())
if (!row?.amount) return null
return (
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatZapLineAmount(row.amount)} {t('sats')}
</span>
)
}}
/>
}
>
{children}
</NoteStatsCountHover>
)
}

25
src/components/NoteStats/RepostButton.tsx

@ -24,6 +24,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import PostEditor from '../PostEditor' import PostEditor from '../PostEditor'
import { BoostCountHover } from './NoteStatsCountHover'
import { formatCount } from './utils' import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
@ -102,10 +103,11 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
}) })
} }
const trigger = ( const iconButton = (
<button <button
type="button"
className={cn( className={cn(
'flex gap-1 items-center enabled:hover:text-lime-500 px-3 h-full', 'flex h-full items-center enabled:hover:text-lime-500 pl-3 pr-1',
hasReposted ? 'text-lime-500' : 'text-muted-foreground' hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)} )}
title={t('Boost')} title={t('Boost')}
@ -116,10 +118,17 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
}} }}
> >
{reposting ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : <Repeat />} {reposting ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : <Repeat />}
{showRepostCount && <div className="text-sm tabular-nums">{formatCount(repostCount ?? 0)}</div>}
</button> </button>
) )
const countLabel = showRepostCount ? (
<BoostCountHover noteStats={noteStats}>
<div className="pr-3 text-sm tabular-nums">{formatCount(repostCount ?? 0)}</div>
</BoostCountHover>
) : (
<span className="pr-3" aria-hidden />
)
const postEditor = ( const postEditor = (
<PostEditor <PostEditor
open={isPostDialogOpen} open={isPostDialogOpen}
@ -131,7 +140,10 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
if (isSmallScreen) { if (isSmallScreen) {
return ( return (
<> <>
{trigger} <div className="flex h-full min-w-0 items-center">
{iconButton}
{countLabel}
</div>
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> <Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} /> <DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay> <DrawerContent hideOverlay>
@ -174,8 +186,9 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
return ( return (
<> <>
<div className="flex h-full min-w-0 items-center">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{iconButton}</DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
@ -198,6 +211,8 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{countLabel}
</div>
{postEditor} {postEditor}
</> </>
) )

30
src/components/NoteStats/ZapButton.tsx

@ -29,6 +29,7 @@ import { toast } from 'sonner'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt' import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context' import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { ZapCountHover } from './NoteStatsCountHover'
type ZapButtonProps = { type ZapButtonProps = {
event: Event event: Event
@ -230,10 +231,11 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
return ( return (
<> <>
<div className="flex h-full min-w-0 select-none items-center">
<button <button
type="button" type="button"
className={cn( className={cn(
'group flex items-center gap-1 select-none px-3 h-full', 'group flex h-full items-center pl-3 pr-1',
disable ? 'cursor-not-allowed' : 'cursor-pointer' disable ? 'cursor-not-allowed' : 'cursor-pointer'
)} )}
title={disable ? t('Zaps') : t('Payment methods')} title={disable ? t('Zaps') : t('Payment methods')}
@ -252,17 +254,22 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
) )
)} )}
/> />
{showZapAmount && ( </button>
{showZapAmount ? (
<ZapCountHover noteStats={noteStats}>
<div <div
className={cn( className={cn(
'text-sm tabular-nums', 'pr-3 text-sm tabular-nums',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground' hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)} )}
> >
{formatAmount(zapAmount ?? 0)} {formatAmount(zapAmount ?? 0)}
</div> </div>
</ZapCountHover>
) : (
<span className="pr-3" aria-hidden />
)} )}
</button> </div>
<ZapDialog <ZapDialog
open={openPaymentDialog} open={openPaymentDialog}
setOpen={setOpenPaymentDialog} setOpen={setOpenPaymentDialog}
@ -498,9 +505,11 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
return ( return (
<> <>
<div className="flex h-full min-w-0 select-none items-center">
<button <button
type="button"
className={cn( className={cn(
'group flex items-center gap-1 select-none px-3 h-full', 'group flex h-full items-center pl-3 pr-1',
disable ? 'cursor-not-allowed' : 'cursor-pointer' disable ? 'cursor-not-allowed' : 'cursor-pointer'
)} )}
title={t('Zap')} title={t('Zap')}
@ -526,17 +535,22 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
)} )}
/> />
)} )}
{showZapAmount && ( </button>
{showZapAmount ? (
<ZapCountHover noteStats={noteStats}>
<div <div
className={cn( className={cn(
'text-sm tabular-nums', 'pr-3 text-sm tabular-nums',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground' hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)} )}
> >
{formatAmount(zapAmount ?? 0)} {formatAmount(zapAmount ?? 0)}
</div> </div>
</ZapCountHover>
) : (
<span className="pr-3" aria-hidden />
)} )}
</button> </div>
<ZapDialog <ZapDialog
open={openZapDialog} open={openZapDialog}
setOpen={(open) => { setOpen={(open) => {

2
src/i18n/locales/en.ts

@ -36,8 +36,10 @@ export default {
followings: "followings", followings: "followings",
boosted: "boosted", boosted: "boosted",
"Boosted by:": "Boosted by:", "Boosted by:": "Boosted by:",
"Zapped by:": "Zapped by:",
"Liked by:": "Liked by:", "Liked by:": "Liked by:",
"Disliked by:": "Disliked by:", "Disliked by:": "Disliked by:",
"n more interactors": "{{count}} more",
"Thumbs up": "Thumbs up", "Thumbs up": "Thumbs up",
"Thumbs down": "Thumbs down", "Thumbs down": "Thumbs down",
"Arrow up": "Arrow up", "Arrow up": "Arrow up",

42
src/lib/note-stats-interactors.test.ts

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
aggregateZapsByPubkey,
dedupeBoostersByPubkey,
groupReactionsByEmoji
} from './note-stats-interactors'
describe('note-stats-interactors', () => {
it('dedupes boosters by pubkey keeping latest', () => {
const out = dedupeBoostersByPubkey([
{ id: 'a', pubkey: 'AA'.repeat(32), created_at: 1 },
{ id: 'b', pubkey: 'AA'.repeat(32), created_at: 5 },
{ id: 'c', pubkey: 'BB'.repeat(32), created_at: 3 }
])
expect(out).toHaveLength(2)
expect(out[0].pubkey).toBe('aa'.repeat(32))
expect(out[0].created_at).toBe(5)
})
it('groups reactions by emoji', () => {
const pk1 = '1'.repeat(64)
const pk2 = '2'.repeat(64)
const groups = groupReactionsByEmoji([
{ id: 'a', pubkey: pk1, created_at: 1, emoji: '❤' },
{ id: 'b', pubkey: pk2, created_at: 2, emoji: '❤' },
{ id: 'c', pubkey: pk1, created_at: 3, emoji: '🔥' }
])
expect(groups).toHaveLength(2)
expect(groups[0].emoji).toBe('❤')
expect(groups[0].pubkeys).toHaveLength(2)
})
it('aggregates zap amounts per pubkey', () => {
const pk = 'A'.repeat(64)
const out = aggregateZapsByPubkey([
{ pr: '1', pubkey: pk, amount: 100, created_at: 1 },
{ pr: '2', pubkey: pk, amount: 50, created_at: 2 }
])
expect(out).toHaveLength(1)
expect(out[0].amount).toBe(150)
})
})

82
src/lib/note-stats-interactors.ts

@ -0,0 +1,82 @@
import type { TNoteStats } from '@/services/note-stats.service'
import { TEmoji } from '@/types'
export const MAX_NOTE_STATS_INTERACTORS_SHOWN = 32
export function filterStatsInteractors<T extends { pubkey: string }>(
items: T[] | undefined,
hideUntrusted: boolean,
isUserTrusted: (pk: string) => boolean
): T[] {
if (!items?.length) return []
if (!hideUntrusted) return items
return items.filter((item) => isUserTrusted(item.pubkey))
}
export function emojiStatsKey(emoji: TEmoji | string): string {
return typeof emoji === 'string' ? emoji : emoji.shortcode
}
/** Latest boost per pubkey, newest first. */
export function dedupeBoostersByPubkey(
reposts: NonNullable<TNoteStats['reposts']>
): { pubkey: string; created_at: number }[] {
const byPk = new Map<string, number>()
for (const r of reposts) {
const pk = r.pubkey.toLowerCase()
const prev = byPk.get(pk)
if (prev == null || r.created_at > prev) byPk.set(pk, r.created_at)
}
return [...byPk.entries()]
.map(([pubkey, created_at]) => ({ pubkey, created_at }))
.sort((a, b) => b.created_at - a.created_at)
}
export function groupReactionsByEmoji(
likes: NonNullable<TNoteStats['likes']>
): { emoji: TEmoji | string; pubkeys: string[] }[] {
const groups = new Map<string, { emoji: TEmoji | string; byPk: Map<string, number> }>()
for (const like of likes) {
const key = emojiStatsKey(like.emoji)
let group = groups.get(key)
if (!group) {
group = { emoji: like.emoji, byPk: new Map() }
groups.set(key, group)
}
const pk = like.pubkey.toLowerCase()
const prev = group.byPk.get(pk)
if (prev == null || like.created_at > prev) group.byPk.set(pk, like.created_at)
}
return [...groups.values()]
.map((g) => ({
emoji: g.emoji,
pubkeys: [...g.byPk.entries()]
.sort((a, b) => b[1] - a[1])
.map(([pk]) => pk)
}))
.sort((a, b) => b.pubkeys.length - a.pubkeys.length)
}
/** Sum sats per pubkey, highest total first. */
export function aggregateZapsByPubkey(
zaps: NonNullable<TNoteStats['zaps']>
): { pubkey: string; amount: number; created_at: number }[] {
const byPk = new Map<string, { amount: number; created_at: number }>()
for (const z of zaps) {
const pk = z.pubkey.toLowerCase()
const cur = byPk.get(pk)
if (!cur) {
byPk.set(pk, { amount: z.amount, created_at: z.created_at })
} else {
byPk.set(pk, {
amount: cur.amount + z.amount,
created_at: Math.max(cur.created_at, z.created_at)
})
}
}
return [...byPk.entries()]
.map(([pubkey, v]) => ({ pubkey, ...v }))
.sort((a, b) => b.amount - a.amount || b.created_at - a.created_at)
}
Loading…
Cancel
Save