7 changed files with 77 additions and 283 deletions
@ -1,183 +0,0 @@ |
|||||||
import { ExtendedKind } from '@/constants' |
|
||||||
import { useNoteStatsById } from '@/hooks/useNoteStatsById' |
|
||||||
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' |
|
||||||
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' |
|
||||||
import { |
|
||||||
DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, |
|
||||||
isLowEffortCollapsedReactionEmoji, |
|
||||||
isNegativeLowEffortReactionEmoji, |
|
||||||
isPositiveLowEffortReactionEmoji |
|
||||||
} from '@/lib/like-reaction-emojis' |
|
||||||
import { shouldHideInteractions } from '@/lib/event-filtering' |
|
||||||
import { cn } from '@/lib/utils' |
|
||||||
import { useUserTrust } from '@/contexts/user-trust-context' |
|
||||||
import { useNostr } from '@/providers/NostrProvider' |
|
||||||
import noteStatsService from '@/services/note-stats.service' |
|
||||||
import type { Event } from 'nostr-tools' |
|
||||||
import { useEffect, useMemo } from 'react' |
|
||||||
import { useTranslation } from 'react-i18next' |
|
||||||
import UserAvatar from '../UserAvatar' |
|
||||||
|
|
||||||
const MAX_AVATARS = 20 |
|
||||||
|
|
||||||
type LowEffortRow = { id: string; pubkey: string; created_at: number } |
|
||||||
|
|
||||||
function dedupeByPubkeyNewestFirst(rows: LowEffortRow[]): LowEffortRow[] { |
|
||||||
const byPubkey = new Map<string, LowEffortRow>() |
|
||||||
for (const row of rows) { |
|
||||||
const prev = byPubkey.get(row.pubkey) |
|
||||||
if (!prev || row.created_at > prev.created_at) byPubkey.set(row.pubkey, row) |
|
||||||
} |
|
||||||
return [...byPubkey.values()].sort((a, b) => b.created_at - a.created_at) |
|
||||||
} |
|
||||||
|
|
||||||
function filterTrustedRows( |
|
||||||
rows: LowEffortRow[], |
|
||||||
hideUntrusted: boolean, |
|
||||||
isTrustLoaded: boolean, |
|
||||||
isUserTrusted: (pk: string) => boolean |
|
||||||
): LowEffortRow[] { |
|
||||||
if (!hideUntrusted || !isTrustLoaded) return rows |
|
||||||
return rows.filter((r) => isUserTrusted(r.pubkey)) |
|
||||||
} |
|
||||||
|
|
||||||
function CompactAvatarRow({ |
|
||||||
items, |
|
||||||
ariaLabel |
|
||||||
}: { |
|
||||||
items: LowEffortRow[] |
|
||||||
ariaLabel: string |
|
||||||
}) { |
|
||||||
if (items.length === 0) return null |
|
||||||
const visible = items.slice(0, MAX_AVATARS) |
|
||||||
const overflow = items.length - visible.length |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="flex flex-wrap items-center gap-0.5" role="list" aria-label={ariaLabel}> |
|
||||||
{visible.map((item) => ( |
|
||||||
<div key={item.id} role="listitem" className="shrink-0"> |
|
||||||
<UserAvatar userId={item.pubkey} size="xSmall" className="ring-1 ring-background" /> |
|
||||||
</div> |
|
||||||
))} |
|
||||||
{overflow > 0 ? ( |
|
||||||
<span className="text-[10px] font-medium text-muted-foreground/80 px-0.5">+{overflow}</span> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
function ReactionRow({ |
|
||||||
label, |
|
||||||
glyph, |
|
||||||
items, |
|
||||||
ariaLabel |
|
||||||
}: { |
|
||||||
label: string |
|
||||||
glyph?: string |
|
||||||
items: LowEffortRow[] |
|
||||||
ariaLabel: string |
|
||||||
}) { |
|
||||||
if (items.length === 0) return null |
|
||||||
return ( |
|
||||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-1"> |
|
||||||
<span className="text-muted-foreground text-sm shrink-0 mr-0.5">{label}</span> |
|
||||||
{glyph ? ( |
|
||||||
<span className="text-sm leading-none shrink-0" aria-hidden> |
|
||||||
{glyph} |
|
||||||
</span> |
|
||||||
) : null} |
|
||||||
<CompactAvatarRow items={items} ariaLabel={ariaLabel} /> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Subtle booster + collapsed-reaction rows at the bottom of a note thread (secondary page). |
|
||||||
* Feed cards keep the prominent {@link NoteBoostBadges} strip. |
|
||||||
*/ |
|
||||||
export default function ThreadLowEffortStrip({ |
|
||||||
event, |
|
||||||
className |
|
||||||
}: { |
|
||||||
/** Open note (for quiet-mode / discussion checks); boost/like stats use this note’s id. */ |
|
||||||
event: Event |
|
||||||
className?: string |
|
||||||
}) { |
|
||||||
const { t } = useTranslation() |
|
||||||
const { pubkey } = useNostr() |
|
||||||
const noteStats = useNoteStatsById(event.id) |
|
||||||
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() |
|
||||||
const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!event.id || shouldHideInteractions(event)) return |
|
||||||
void noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) |
|
||||||
}, [event, pubkey, statsRelays, currentRelaysKey]) |
|
||||||
|
|
||||||
const boosters = useMemo(() => { |
|
||||||
const rows = [...(noteStats?.reposts ?? [])] |
|
||||||
return filterTrustedRows( |
|
||||||
dedupeByPubkeyNewestFirst(rows), |
|
||||||
hideUntrustedInteractions, |
|
||||||
isTrustLoaded, |
|
||||||
isUserTrusted |
|
||||||
) |
|
||||||
}, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
|
||||||
|
|
||||||
const { likedBy, dislikedBy } = useMemo(() => { |
|
||||||
if (event.kind === ExtendedKind.DISCUSSION) { |
|
||||||
return { likedBy: [], dislikedBy: [] } |
|
||||||
} |
|
||||||
const positive: LowEffortRow[] = [] |
|
||||||
const negative: LowEffortRow[] = [] |
|
||||||
for (const like of noteStats?.likes ?? []) { |
|
||||||
if ( |
|
||||||
!isLowEffortCollapsedReactionEmoji(like.emoji) || |
|
||||||
isDiscussionUpvoteEmoji(like.emoji) || |
|
||||||
isDiscussionDownvoteEmoji(like.emoji) |
|
||||||
) { |
|
||||||
continue |
|
||||||
} |
|
||||||
const row = { id: like.id, pubkey: like.pubkey, created_at: like.created_at } |
|
||||||
if (isNegativeLowEffortReactionEmoji(like.emoji)) { |
|
||||||
negative.push(row) |
|
||||||
} else if (isPositiveLowEffortReactionEmoji(like.emoji)) { |
|
||||||
positive.push(row) |
|
||||||
} |
|
||||||
} |
|
||||||
const trust = (rows: LowEffortRow[]) => |
|
||||||
filterTrustedRows(rows, hideUntrustedInteractions, isTrustLoaded, isUserTrusted) |
|
||||||
return { |
|
||||||
likedBy: trust(dedupeByPubkeyNewestFirst(positive)), |
|
||||||
dislikedBy: trust(dedupeByPubkeyNewestFirst(negative)) |
|
||||||
} |
|
||||||
}, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
|
||||||
|
|
||||||
const hasReactions = likedBy.length > 0 || dislikedBy.length > 0 |
|
||||||
if (shouldHideInteractions(event) || (boosters.length === 0 && !hasReactions)) { |
|
||||||
return null |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={cn( |
|
||||||
'mx-2 sm:mx-4 border-t border-border/40 pt-2 pb-1 space-y-1.5 opacity-80', |
|
||||||
className |
|
||||||
)} |
|
||||||
> |
|
||||||
{boosters.length > 0 ? ( |
|
||||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-1"> |
|
||||||
<span className="text-muted-foreground text-sm shrink-0 mr-0.5">{t('Boosted by:')}</span> |
|
||||||
<CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} /> |
|
||||||
</div> |
|
||||||
) : null} |
|
||||||
<ReactionRow |
|
||||||
label={t('Liked by:')} |
|
||||||
glyph={DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} |
|
||||||
items={likedBy} |
|
||||||
ariaLabel={t('Likes')} |
|
||||||
/> |
|
||||||
<ReactionRow label={t('Disliked by:')} items={dislikedBy} ariaLabel={t('Dislikes')} /> |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
Loading…
Reference in new issue