7 changed files with 612 additions and 130 deletions
@ -0,0 +1,317 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,82 @@
@@ -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…
Reference in new issue