diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx
index 3813febb..f9741e30 100644
--- a/src/components/NoteStats/LikeButton.tsx
+++ b/src/components/NoteStats/LikeButton.tsx
@@ -35,6 +35,7 @@ import logger from '@/lib/logger'
import { useTranslation } from 'react-i18next'
import Emoji, { EMOJI_IMG_INLINE_CLASS } from '../Emoji'
import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker'
+import { DiscussionVoteCountHover, ReactionCountHover } from './NoteStatsCountHover'
import {
type RelayStatus,
showPublishingError,
@@ -42,6 +43,7 @@ import {
showSimplePublishSuccess
} from '@/lib/publishing-feedback'
import { LoginRequiredError } from '@/lib/nostr-errors'
+import { cn } from '@/lib/utils'
import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed'
type LikeButtonProps = {
@@ -237,46 +239,42 @@ export function LikeButtonWithStats({
})
}
- const trigger = (
+ const openReactionPicker = () => {
+ if (myLastEmoji && !isEmojiReactionsOpen) {
+ like(myLastEmoji)
+ return
+ }
+ setIsEmojiReactionsOpen(true)
+ }
+
+ const likeIconButton = (
)
+ const likeCountLabel = showLikeCount ? (
+
+
+ {(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
+
+
+ ) : (
+
+ )
+
// Discussions (kind 11) and kind 1111 under a discussion: only +/- vote reactions
if (showDiscussionVotes) {
return (
@@ -287,32 +285,38 @@ export function LikeButtonWithStats({
const count = index === 0 ? upVoteCount : downVoteCount
const arrow = index === 0 ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY
return (
-
+ {!hideCount && (noteStats?.updatedAt != null || count > 0) ? (
+
+
+ {count >= 100 ? '99+' : count}
+
+
+ ) : null}
+
)
})}
@@ -335,7 +339,10 @@ export function LikeButtonWithStats({
if (isSmallScreen) {
return (
<>
- {trigger}
+
+ {likeIconButton}
+ {likeCountLabel}
+
- {trigger}
-
- {likeEmojiPicker}
-
-
+
+
+ {likeIconButton}
+
+ {likeEmojiPicker}
+
+
+ {likeCountLabel}
+
)
}
diff --git a/src/components/NoteStats/NoteStatsCountHover.tsx b/src/components/NoteStats/NoteStatsCountHover.tsx
new file mode 100644
index 00000000..fe112e97
--- /dev/null
+++ b/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 (
+
+
{title}
+
+ {visible.map((pk) => (
+ - e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+
+ {suffixForPubkey?.(pk)}
+
+ ))}
+
+ {overflow > 0 ? (
+
+ {t('n more interactors', { count: overflow })}
+
+ ) : null}
+
+ )
+}
+
+function ReactionGroupsList({
+ groups,
+ title
+}: {
+ groups: { emoji: TEmoji | string; pubkeys: string[] }[]
+ title: ReactNode
+}) {
+ const { t } = useTranslation()
+ const feedProfiles = useNoteFeedProfileContext()
+ let shownPubkeys = 0
+
+ return (
+
+
{title}
+
+ {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 (
+
+
+
+ {group.pubkeys.length}
+
+
+ {slice.map((pk) => (
+ - e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+
+
+ ))}
+
+ {overflowInGroup > 0 ? (
+
+ {t('n more interactors', { count: overflowInGroup })}
+
+ ) : null}
+
+ )
+ })}
+
+
+ )
+}
+
+export function NoteStatsCountHover({
+ enabled,
+ children,
+ content,
+ className
+}: {
+ enabled: boolean
+ children: ReactNode
+ content: ReactNode
+ className?: string
+}) {
+ if (!enabled) {
+ return <>{children}>
+ }
+
+ return (
+
+
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+ {children}
+
+
+ e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+ {content}
+
+
+ )
+}
+
+export function BoostCountHover({
+ noteStats,
+ children
+}: {
+ noteStats?: Partial
+ 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 (
+ 0}
+ content={}
+ >
+ {children}
+
+ )
+}
+
+export function ReactionCountHover({
+ noteStats,
+ emojiFilter,
+ title: titleProp,
+ children
+}: {
+ noteStats?: Partial
+ /** 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 (
+ 0}
+ content={}
+ >
+ {children}
+
+ )
+}
+
+export function DiscussionVoteCountHover({
+ noteStats,
+ vote,
+ children
+}: {
+ noteStats?: Partial
+ 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()
+ 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 = (
+
+
+ {vote === 'up' ? DISCUSSION_UPVOTE_DISPLAY : DISCUSSION_DOWNVOTE_DISPLAY}
+
+ {vote === 'up' ? t('Liked by:') : t('Disliked by:')}
+
+ )
+
+ return (
+ 0}
+ content={}
+ >
+ {children}
+
+ )
+}
+
+export function ZapCountHover({
+ noteStats,
+ children
+}: {
+ noteStats?: Partial
+ 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 (
+ 0}
+ content={
+ z.pubkey)}
+ title={t('Zapped by:')}
+ suffixForPubkey={(pk) => {
+ const row = zappers.find((z) => z.pubkey.toLowerCase() === pk.toLowerCase())
+ if (!row?.amount) return null
+ return (
+
+ {formatZapLineAmount(row.amount)} {t('sats')}
+
+ )
+ }}
+ />
+ }
+ >
+ {children}
+
+ )
+}
diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx
index b74d1c93..dd58163e 100644
--- a/src/components/NoteStats/RepostButton.tsx
+++ b/src/components/NoteStats/RepostButton.tsx
@@ -24,6 +24,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import logger from '@/lib/logger'
import PostEditor from '../PostEditor'
+import { BoostCountHover } from './NoteStatsCountHover'
import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
@@ -102,10 +103,11 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
})
}
- const trigger = (
+ const iconButton = (
{reposting ? : }
- {showRepostCount && {formatCount(repostCount ?? 0)}
}
)
+ const countLabel = showRepostCount ? (
+
+ {formatCount(repostCount ?? 0)}
+
+ ) : (
+
+ )
+
const postEditor = (
- {trigger}
+
+ {iconButton}
+ {countLabel}
+
setIsDrawerOpen(false)} />
@@ -174,9 +186,10 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
return (
<>
-
- {trigger}
-
+
+
+ {iconButton}
+
{
e.stopPropagation()
@@ -196,8 +209,10 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
>
{t('Quote')}
-
-
+
+
+ {countLabel}
+
{postEditor}
>
)
diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx
index 5b950f19..e00b066b 100644
--- a/src/components/NoteStats/ZapButton.tsx
+++ b/src/components/NoteStats/ZapButton.tsx
@@ -29,6 +29,7 @@ import { toast } from 'sonner'
import ZapDialog from '../ZapDialog'
import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
+import { ZapCountHover } from './NoteStatsCountHover'
type ZapButtonProps = {
event: Event
@@ -230,39 +231,45 @@ function ZapPaymentMethodsButton({ event, hideCount = false, noteStats }: ZapBut
return (
<>
-
-
+
- {showZapAmount && (
-
+
- {formatAmount(zapAmount ?? 0)}
-
+ />
+
+ {showZapAmount ? (
+
+
+ {formatAmount(zapAmount ?? 0)}
+
+
+ ) : (
+
)}
-
+
-
- {zapping ? (
-
+
+
+ {zapping ? (
+
+ ) : (
+
+ )}
+
+ {showZapAmount ? (
+
+
+ {formatAmount(zapAmount ?? 0)}
+
+
) : (
-
- )}
- {showZapAmount && (
-
- {formatAmount(zapAmount ?? 0)}
-
+
)}
-
+
{
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index b78bccc0..7b7c2633 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -36,8 +36,10 @@ export default {
followings: "followings",
boosted: "boosted",
"Boosted by:": "Boosted by:",
+ "Zapped by:": "Zapped by:",
"Liked by:": "Liked by:",
"Disliked by:": "Disliked by:",
+ "n more interactors": "{{count}} more",
"Thumbs up": "Thumbs up",
"Thumbs down": "Thumbs down",
"Arrow up": "Arrow up",
diff --git a/src/lib/note-stats-interactors.test.ts b/src/lib/note-stats-interactors.test.ts
new file mode 100644
index 00000000..286de3ee
--- /dev/null
+++ b/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)
+ })
+})
diff --git a/src/lib/note-stats-interactors.ts b/src/lib/note-stats-interactors.ts
new file mode 100644
index 00000000..cd58ef9a
--- /dev/null
+++ b/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(
+ 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
+): { pubkey: string; created_at: number }[] {
+ const byPk = new Map()
+ 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
+): { emoji: TEmoji | string; pubkeys: string[] }[] {
+ const groups = new Map }>()
+
+ 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
+): { pubkey: string; amount: number; created_at: number }[] {
+ const byPk = new Map()
+ 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)
+}