14 changed files with 376 additions and 35 deletions
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
import { useFetchEvent } from '@/hooks' |
||||
import { useNoteStatsRelayHints } from '@/hooks/useNoteStatsRelayHints' |
||||
import { generateBech32IdFromETag } from '@/lib/tag' |
||||
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' |
||||
import Note from '@/components/Note' |
||||
import { LoadingBar } from '@/components/LoadingBar' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import noteStatsService from '@/services/note-stats.service' |
||||
import { useEffect, useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** |
||||
* Thread OP at the top of “Antworten” when the open note is a reply (not the root). |
||||
*/ |
||||
export default function ThreadContextRootNote({ |
||||
rootHex, |
||||
contextEvent |
||||
}: { |
||||
rootHex: string |
||||
/** Note whose tags supply relay hints for fetching the root. */ |
||||
contextEvent: Event |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const rootId = useMemo(() => { |
||||
const hex = rootHex.trim().toLowerCase() |
||||
if (!/^[0-9a-f]{64}$/i.test(hex)) return hex |
||||
try { |
||||
return generateBech32IdFromETag(['e', hex]) ?? hex |
||||
} catch { |
||||
return hex |
||||
} |
||||
}, [rootHex]) |
||||
const fetchOpts = useMemo(() => { |
||||
const hints = relayHintsFromEventTags(contextEvent) |
||||
return hints.length ? { relayHints: hints } : undefined |
||||
}, [contextEvent]) |
||||
const { event: rootEvent, isFetching } = useFetchEvent(rootId, undefined, fetchOpts) |
||||
const { pubkey } = useNostr() |
||||
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() |
||||
|
||||
useEffect(() => { |
||||
if (!rootEvent) return |
||||
void noteStatsService.fetchNoteStats(rootEvent, pubkey, statsRelays, { foreground: true }) |
||||
}, [rootEvent, pubkey, statsRelays, currentRelaysKey]) |
||||
|
||||
if (isFetching && !rootEvent) { |
||||
return ( |
||||
<div className="border-b border-border/50 pb-3 mb-2"> |
||||
<p className="px-4 pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> |
||||
{t('Original post')} |
||||
</p> |
||||
<LoadingBar /> |
||||
</div> |
||||
) |
||||
} |
||||
if (!rootEvent) return null |
||||
|
||||
return ( |
||||
<div className="border-b border-border/60 pb-3 mb-3"> |
||||
<p className="px-4 pb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> |
||||
{t('Original post')} |
||||
</p> |
||||
<Note event={rootEvent} hideParentNotePreview className="opacity-95" /> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,144 @@
@@ -0,0 +1,144 @@
|
||||
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, |
||||
isDefaultPlusLikeReactionEmoji |
||||
} 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 client from '@/services/client.service' |
||||
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 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> |
||||
) |
||||
} |
||||
|
||||
/** |
||||
* Subtle booster + default-like rows at the bottom of a note thread (secondary page). |
||||
* Feed cards keep the prominent {@link NoteBoostBadges} strip. |
||||
*/ |
||||
export default function ThreadLowEffortStrip({ |
||||
event, |
||||
statsNoteId, |
||||
className |
||||
}: { |
||||
/** Open note (for quiet-mode / discussion checks). */ |
||||
event: Event |
||||
/** Hex id of the thread root whose boosts/likes to show (usually the OP). */ |
||||
statsNoteId: string |
||||
className?: string |
||||
}) { |
||||
const { t } = useTranslation() |
||||
const { pubkey } = useNostr() |
||||
const noteStats = useNoteStatsById(statsNoteId) |
||||
const { relays: statsRelays, currentRelaysKey } = useNoteStatsRelayHints() |
||||
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(() => { |
||||
if (!statsNoteId || shouldHideInteractions(event)) return |
||||
const target = statsTargetEvent ?? client.peekSessionCachedEvent(statsNoteId) |
||||
if (!target) return |
||||
void noteStatsService.fetchNoteStats(target, pubkey, statsRelays, { foreground: true }) |
||||
}, [statsNoteId, statsTargetEvent, event, pubkey, statsRelays, currentRelaysKey]) |
||||
|
||||
const boosters = useMemo(() => { |
||||
let rows = [...(noteStats?.reposts ?? [])] |
||||
if (hideUntrustedInteractions && isTrustLoaded) { |
||||
rows = rows.filter((r) => isUserTrusted(r.pubkey)) |
||||
} |
||||
return dedupeByPubkeyNewestFirst(rows) |
||||
}, [noteStats?.reposts, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
||||
|
||||
const plusLikers = useMemo(() => { |
||||
if (event.kind === ExtendedKind.DISCUSSION) return [] |
||||
let rows = |
||||
noteStats?.likes?.filter( |
||||
(like) => |
||||
isDefaultPlusLikeReactionEmoji(like.emoji) && |
||||
!isDiscussionUpvoteEmoji(like.emoji) && |
||||
!isDiscussionDownvoteEmoji(like.emoji) |
||||
) ?? [] |
||||
if (hideUntrustedInteractions && isTrustLoaded) { |
||||
rows = rows.filter((like) => isUserTrusted(like.pubkey)) |
||||
} |
||||
return dedupeByPubkeyNewestFirst(rows) |
||||
}, [event.kind, noteStats?.likes, hideUntrustedInteractions, isTrustLoaded, isUserTrusted]) |
||||
|
||||
if (shouldHideInteractions(event) || (boosters.length === 0 && plusLikers.length === 0)) { |
||||
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} |
||||
{plusLikers.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('Liked by:')}</span> |
||||
<span className="text-sm leading-none shrink-0" aria-hidden> |
||||
{DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} |
||||
</span> |
||||
<CompactAvatarRow items={plusLikers} ariaLabel={t('Likes')} /> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { kinds } from 'nostr-tools' |
||||
import type { Event } from 'nostr-tools' |
||||
import { ExtendedKind } from '@/constants' |
||||
import { isThreadBoosterOnlyRow, shouldHideThreadResponseEvent } from './thread-response-filter' |
||||
|
||||
function baseEvent(overrides: Partial<Event> = {}): Event { |
||||
return { |
||||
id: 'a'.repeat(64), |
||||
pubkey: 'b'.repeat(64), |
||||
created_at: 1000, |
||||
kind: kinds.ShortTextNote, |
||||
tags: [], |
||||
content: 'hello', |
||||
sig: 'd'.repeat(128), |
||||
...overrides |
||||
} |
||||
} |
||||
|
||||
describe('thread response filter', () => { |
||||
it('treats NIP-18 reposts as booster-only rows', () => { |
||||
const repost = baseEvent({ |
||||
kind: kinds.Repost, |
||||
tags: [['e', 'c'.repeat(64)]], |
||||
content: '' |
||||
}) |
||||
expect(isThreadBoosterOnlyRow(repost)).toBe(true) |
||||
expect(shouldHideThreadResponseEvent(repost, new Set(), false)).toBe(true) |
||||
}) |
||||
|
||||
it('does not treat kind-1 rows as booster-only (only kinds 6 and 16)', () => { |
||||
const target = baseEvent({ content: 'boosted note' }) |
||||
expect(isThreadBoosterOnlyRow(baseEvent({ content: JSON.stringify(target) }))).toBe(false) |
||||
expect( |
||||
isThreadBoosterOnlyRow( |
||||
baseEvent({ content: `My take.\n\n${JSON.stringify(target)}` }) |
||||
) |
||||
).toBe(false) |
||||
}) |
||||
|
||||
it('hides generic repost kind 16', () => { |
||||
const repost = baseEvent({ |
||||
kind: ExtendedKind.GENERIC_REPOST, |
||||
tags: [['e', 'c'.repeat(64)]] |
||||
}) |
||||
expect(isThreadBoosterOnlyRow(repost)).toBe(true) |
||||
}) |
||||
}) |
||||
Loading…
Reference in new issue