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 = ( ) + 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)} +
+
) : ( - - )} - {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) +}