14 changed files with 376 additions and 35 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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