|
|
|
@ -4,13 +4,14 @@ import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' |
|
|
|
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' |
|
|
|
import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' |
|
|
|
import { |
|
|
|
import { |
|
|
|
DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, |
|
|
|
DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, |
|
|
|
isDefaultPlusLikeReactionEmoji |
|
|
|
isLowEffortCollapsedReactionEmoji, |
|
|
|
|
|
|
|
isNegativeLowEffortReactionEmoji, |
|
|
|
|
|
|
|
isPositiveLowEffortReactionEmoji |
|
|
|
} from '@/lib/like-reaction-emojis' |
|
|
|
} from '@/lib/like-reaction-emojis' |
|
|
|
import { shouldHideInteractions } from '@/lib/event-filtering' |
|
|
|
import { shouldHideInteractions } from '@/lib/event-filtering' |
|
|
|
import { cn } from '@/lib/utils' |
|
|
|
import { cn } from '@/lib/utils' |
|
|
|
import { useUserTrust } from '@/contexts/user-trust-context' |
|
|
|
import { useUserTrust } from '@/contexts/user-trust-context' |
|
|
|
import { useNostr } from '@/providers/NostrProvider' |
|
|
|
import { useNostr } from '@/providers/NostrProvider' |
|
|
|
import client from '@/services/client.service' |
|
|
|
|
|
|
|
import noteStatsService from '@/services/note-stats.service' |
|
|
|
import noteStatsService from '@/services/note-stats.service' |
|
|
|
import type { Event } from 'nostr-tools' |
|
|
|
import type { Event } from 'nostr-tools' |
|
|
|
import { useEffect, useMemo } from 'react' |
|
|
|
import { useEffect, useMemo } from 'react' |
|
|
|
@ -30,6 +31,16 @@ function dedupeByPubkeyNewestFirst(rows: LowEffortRow[]): LowEffortRow[] { |
|
|
|
return [...byPubkey.values()].sort((a, b) => b.created_at - a.created_at) |
|
|
|
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({ |
|
|
|
function CompactAvatarRow({ |
|
|
|
items, |
|
|
|
items, |
|
|
|
ariaLabel |
|
|
|
ariaLabel |
|
|
|
@ -55,65 +66,95 @@ function CompactAvatarRow({ |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 + default-like rows at the bottom of a note thread (secondary page). |
|
|
|
* Subtle booster + collapsed-reaction rows at the bottom of a note thread (secondary page). |
|
|
|
* Feed cards keep the prominent {@link NoteBoostBadges} strip. |
|
|
|
* Feed cards keep the prominent {@link NoteBoostBadges} strip. |
|
|
|
*/ |
|
|
|
*/ |
|
|
|
export default function ThreadLowEffortStrip({ |
|
|
|
export default function ThreadLowEffortStrip({ |
|
|
|
event, |
|
|
|
event, |
|
|
|
statsNoteId, |
|
|
|
|
|
|
|
className |
|
|
|
className |
|
|
|
}: { |
|
|
|
}: { |
|
|
|
/** Open note (for quiet-mode / discussion checks). */ |
|
|
|
/** Open note (for quiet-mode / discussion checks); boost/like stats use this note’s id. */ |
|
|
|
event: Event |
|
|
|
event: Event |
|
|
|
/** Hex id of the thread root whose boosts/likes to show (usually the OP). */ |
|
|
|
|
|
|
|
statsNoteId: string |
|
|
|
|
|
|
|
className?: string |
|
|
|
className?: string |
|
|
|
}) { |
|
|
|
}) { |
|
|
|
const { t } = useTranslation() |
|
|
|
const { t } = useTranslation() |
|
|
|
const { pubkey } = useNostr() |
|
|
|
const { pubkey } = useNostr() |
|
|
|
const noteStats = useNoteStatsById(statsNoteId) |
|
|
|
const noteStats = useNoteStatsById(event.id) |
|
|
|
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() |
|
|
|
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() |
|
|
|
const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() |
|
|
|
const { hideUntrustedInteractions, isUserTrusted, isTrustLoaded } = useUserTrust() |
|
|
|
|
|
|
|
|
|
|
|
const statsTargetEvent = useMemo(() => { |
|
|
|
|
|
|
|
const cached = client.peekSessionCachedEvent(statsNoteId) |
|
|
|
|
|
|
|
if (cached) return cached |
|
|
|
|
|
|
|
if (event.id === statsNoteId) return event |
|
|
|
|
|
|
|
return undefined |
|
|
|
|
|
|
|
}, [statsNoteId, event]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
useEffect(() => { |
|
|
|
if (!statsNoteId || shouldHideInteractions(event)) return |
|
|
|
if (!event.id || shouldHideInteractions(event)) return |
|
|
|
const target = statsTargetEvent ?? client.peekSessionCachedEvent(statsNoteId) |
|
|
|
void noteStatsService.fetchNoteStats(event, pubkey, statsRelays, { foreground: true }) |
|
|
|
if (!target) return |
|
|
|
}, [event, pubkey, statsRelays, currentRelaysKey]) |
|
|
|
void noteStatsService.fetchNoteStats(target, pubkey, statsRelays, { foreground: true }) |
|
|
|
|
|
|
|
}, [statsNoteId, statsTargetEvent, event, pubkey, statsRelays, currentRelaysKey]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const boosters = useMemo(() => { |
|
|
|
const boosters = useMemo(() => { |
|
|
|
let rows = [...(noteStats?.reposts ?? [])] |
|
|
|
const rows = [...(noteStats?.reposts ?? [])] |
|
|
|
if (hideUntrustedInteractions && isTrustLoaded) { |
|
|
|
return filterTrustedRows( |
|
|
|
rows = rows.filter((r) => isUserTrusted(r.pubkey)) |
|
|
|
dedupeByPubkeyNewestFirst(rows), |
|
|
|
} |
|
|
|
hideUntrustedInteractions, |
|
|
|
return dedupeByPubkeyNewestFirst(rows) |
|
|
|
isTrustLoaded, |
|
|
|
|
|
|
|
isUserTrusted |
|
|
|
|
|
|
|
) |
|
|
|
}, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
|
|
|
}, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
|
|
|
|
|
|
|
|
|
|
|
const plusLikers = useMemo(() => { |
|
|
|
const { likedBy, dislikedBy } = useMemo(() => { |
|
|
|
if (event.kind === ExtendedKind.DISCUSSION) return [] |
|
|
|
if (event.kind === ExtendedKind.DISCUSSION) { |
|
|
|
let rows = |
|
|
|
return { likedBy: [], dislikedBy: [] } |
|
|
|
noteStats?.likes?.filter( |
|
|
|
} |
|
|
|
(like) => |
|
|
|
const positive: LowEffortRow[] = [] |
|
|
|
isDefaultPlusLikeReactionEmoji(like.emoji) && |
|
|
|
const negative: LowEffortRow[] = [] |
|
|
|
!isDiscussionUpvoteEmoji(like.emoji) && |
|
|
|
for (const like of noteStats?.likes ?? []) { |
|
|
|
!isDiscussionDownvoteEmoji(like.emoji) |
|
|
|
if ( |
|
|
|
) ?? [] |
|
|
|
!isLowEffortCollapsedReactionEmoji(like.emoji) || |
|
|
|
if (hideUntrustedInteractions && isTrustLoaded) { |
|
|
|
isDiscussionUpvoteEmoji(like.emoji) || |
|
|
|
rows = rows.filter((like) => isUserTrusted(like.pubkey)) |
|
|
|
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)) |
|
|
|
} |
|
|
|
} |
|
|
|
return dedupeByPubkeyNewestFirst(rows) |
|
|
|
|
|
|
|
}, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
|
|
|
}, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
|
|
|
|
|
|
|
|
|
|
|
if (shouldHideInteractions(event) || (boosters.length === 0 && plusLikers.length === 0)) { |
|
|
|
const hasReactions = likedBy.length > 0 || dislikedBy.length > 0 |
|
|
|
|
|
|
|
if (shouldHideInteractions(event) || (boosters.length === 0 && !hasReactions)) { |
|
|
|
return null |
|
|
|
return null |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -130,15 +171,13 @@ export default function ThreadLowEffortStrip({ |
|
|
|
<CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} /> |
|
|
|
<CompactAvatarRow items={boosters} ariaLabel={t('Boosts')} /> |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) : null} |
|
|
|
) : null} |
|
|
|
{plusLikers.length > 0 ? ( |
|
|
|
<ReactionRow |
|
|
|
<div className="flex flex-wrap items-center gap-x-1 gap-y-1"> |
|
|
|
label={t('Liked by:')} |
|
|
|
<span className="text-muted-foreground text-sm shrink-0 mr-0.5">{t('Liked by:')}</span> |
|
|
|
glyph={DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} |
|
|
|
<span className="text-sm leading-none shrink-0" aria-hidden> |
|
|
|
items={likedBy} |
|
|
|
{DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} |
|
|
|
ariaLabel={t('Likes')} |
|
|
|
</span> |
|
|
|
/> |
|
|
|
<CompactAvatarRow items={plusLikers} ariaLabel={t('Likes')} /> |
|
|
|
<ReactionRow label={t('Disliked by:')} items={dislikedBy} ariaLabel={t('Dislikes')} /> |
|
|
|
</div> |
|
|
|
|
|
|
|
) : null} |
|
|
|
|
|
|
|
</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
|