diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 5f2468bf..363d6637 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -60,6 +60,7 @@ import katex from 'katex' import '@/styles/katex-bundle.css' import { isContentSpacingDebug, reprString } from '@/lib/content-spacing-debug' import logger from '@/lib/logger' +import { stripTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json' /** * Inline/block image metadata: use merged rows from {@link extractAllMediaFromEvent} first @@ -5776,7 +5777,7 @@ export default function MarkdownArticle({ // Preprocess content to convert URLs to markdown syntax const preprocessedContent = useMemo(() => { // First unescape JSON-encoded escape sequences - let processed = unescapeJsonContent(event.content) + let processed = stripTrailingStringifiedNostrEvent(unescapeJsonContent(event.content)) // Keep multi-newline runs intact so Marked `space` tokens can reproduce intentional vertical gaps. // Normalize single newlines within bold/italic spans to spaces processed = normalizeInlineFormattingNewlines(processed) diff --git a/src/components/NoteBoostBadges/index.tsx b/src/components/NoteBoostBadges/index.tsx index 37b318a6..641a57d2 100644 --- a/src/components/NoteBoostBadges/index.tsx +++ b/src/components/NoteBoostBadges/index.tsx @@ -10,7 +10,8 @@ import UserAvatar from '../UserAvatar' const MAX_VISIBLE = 28 /** - * Small avatar strip of users who boosted (kind 6 / 16) the note — shown under the OP on the note page. + * Avatar strip of users who boosted (kind 6 / 16) — feed cards only (attention on the timeline). + * Thread view uses {@link ThreadLowEffortStrip} at the bottom of replies instead. */ export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) { const { t } = useTranslation() diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index d0caed27..8fdd0a6f 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -12,7 +12,7 @@ import { DISCUSSION_UPVOTE_DISPLAY } from '@/lib/discussion-votes' import { getZapInfoFromEvent } from '@/lib/event-metadata' -import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event' +import { isMentioningMutedUsers, isNip18RepostKind, isNip25ReactionKind } from '@/lib/event' import { getWebExternalReactionTargetUrl } from '@/lib/rss-article' import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { toNote } from '@/lib/link' @@ -31,7 +31,6 @@ import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay' import { FormattedTimestamp } from '../FormattedTimestamp' import Nip05 from '../Nip05' import NoteOptions from '../NoteOptions' -import NoteBoostBadges from '../NoteBoostBadges' import NoteStats from '../NoteStats' import ParentNotePreview from '../ParentNotePreview' import WebPreview from '../WebPreview' @@ -196,7 +195,7 @@ export default function ReplyNote({ ) : event.kind === kinds.Zap ? ( - ) : ( + ) : isNip18RepostKind(event.kind) ? null : ( - {show && ( + {show && !isNip18RepostKind(event.kind) && ( <> - {!isNip25ReactionKind(event.kind) && ( - - )} { + 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 ( +
+

+ {t('Original post')} +

+ +
+ ) + } + if (!rootEvent) return null + + return ( +
+

+ {t('Original post')} +

+ +
+ ) +} diff --git a/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx b/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx new file mode 100644 index 00000000..a4e93a13 --- /dev/null +++ b/src/components/ReplyNoteList/ThreadLowEffortStrip.tsx @@ -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() + 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 ( +
+ {visible.map((item) => ( +
+ +
+ ))} + {overflow > 0 ? ( + +{overflow} + ) : null} +
+ ) +} + +/** + * 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 ( +
+ {boosters.length > 0 ? ( +
+ {t('Boosted by:')} + +
+ ) : null} + {plusLikers.length > 0 ? ( +
+ {t('Liked by:')} + + {DEFAULT_LIKE_REACTION_DISPLAY_EMOJI} + + +
+ ) : null} +
+ ) +} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index f1b352aa..38056e3c 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -17,12 +17,17 @@ import { getRootATag, getRootETag, isNip56ReportEvent, + isMentioningMutedUsers, + isNip18RepostKind, + isNip25ReactionKind, isReplaceableEvent, kind1QuotesThreadRoot, resolveDeclaredThreadRootEventHex } from '@/lib/event' import logger from '@/lib/logger' import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' +import { isDefaultPlusLikeReactionContent } from '@/lib/like-reaction-emojis' +import { muteSetHas } from '@/lib/mute-set' import { normalizeAnyRelayUrl } from '@/lib/url' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' @@ -64,11 +69,19 @@ import { useTranslation } from 'react-i18next' import { useQuoteEvents } from '@/hooks' import { LoadingBar } from '../LoadingBar' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' +import ThreadContextRootNote from './ThreadContextRootNote' +import ThreadLowEffortStrip from './ThreadLowEffortStrip' import ThreadQuoteBacklink, { BacklinkAvatarStrip, ThreadQuoteBacklinkSkeleton } from './ThreadQuoteBacklink' +/** Collapse default `+` likes into {@link ThreadLowEffortStrip}; keep discussion ⬆️/⬇️ vote rows. */ +function isDefaultPlusLikeReactionEvent(evt: NEvent, isDiscussionRoot: boolean): boolean { + if (isDiscussionRoot) return false + return isNip25ReactionKind(evt.kind) && isDefaultPlusLikeReactionContent(evt.content) +} + type TRootInfo = | { type: 'E'; id: string; pubkey: string } | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } @@ -1026,6 +1039,22 @@ function ReplyNoteList({ (evt: NEvent) => { if (isPollVoteKind(evt)) return if (isZapPollThreadZapReceipt(evt, event)) return + if (isNip18RepostKind(evt.kind)) { + if ( + rootInfo && + replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) && + !muteSetHas(mutePubkeySet, evt.pubkey) && + !( + hideContentMentioningMutedUsers === true && + isMentioningMutedUsers(evt, mutePubkeySet) + ) + ) { + noteStatsService.updateNoteStatsByEvents([evt], event.pubkey, { + statsRootEvent: event + }) + } + return + } if ( shouldHideThreadResponseEvent( evt, @@ -1606,6 +1635,7 @@ function ReplyNoteList({ (item: NEvent) => { if (isPollVoteKind(item)) return false if (isZapPollThreadZapReceipt(item, event)) return false + if (isDefaultPlusLikeReactionEvent(item, isDiscussionRoot)) return false if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { return false } @@ -1633,13 +1663,27 @@ function ReplyNoteList({ isUserTrusted, rootInfo?.type, repliesMap, - event + event, + isDiscussionRoot ] ) + const threadStatsNoteId = useMemo(() => { + if (rootInfo?.type === 'E') return rootInfo.id + if (rootInfo?.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId)) { + return rootInfo.eventId.toLowerCase() + } + return event.id + }, [rootInfo, event.id]) + + const showThreadContextRoot = + rootInfo?.type === 'E' && + /^[0-9a-f]{64}$/i.test(rootInfo.id) && + rootInfo.id.toLowerCase() !== event.id.toLowerCase() + const visibleForRender = useMemo( - () => visibleFeed.filter(shouldShowFeedItem), - [visibleFeed, shouldShowFeedItem] + () => visibleFeed.filter((e) => shouldShowFeedItem(e) && e.id !== event.id), + [visibleFeed, shouldShowFeedItem, event.id] ) const displayRows = useMemo( @@ -1660,6 +1704,9 @@ function ReplyNoteList({ )}
+ {showThreadContextRoot && rootInfo?.type === 'E' && ( + + )} {displayRows.map((row, ri) => { const prevRow = ri > 0 ? displayRows[ri - 1] : undefined if (row.type === 'reply') { @@ -1799,6 +1846,7 @@ function ReplyNoteList({
)} + {!loading && !quoteLoading && (
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 48bfde4a..9573581b 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -36,6 +36,8 @@ export default { followings: "followings", boosted: "boosted", "Boosted by:": "Boosted by:", + "Liked by:": "Liked by:", + "Original post": "Original post", "just now": "just now", "n minutes ago": "{{n}} minutes ago", "n m": "{{n}}m", diff --git a/src/lib/like-reaction-emojis.ts b/src/lib/like-reaction-emojis.ts index 553ace3c..c477d2dc 100644 --- a/src/lib/like-reaction-emojis.ts +++ b/src/lib/like-reaction-emojis.ts @@ -1,3 +1,5 @@ +import type { TEmoji } from '@/types' + /** * Single source for the quick-like emoji row used by the EmojiPicker / LikeButton. * EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton. @@ -21,3 +23,14 @@ export const DEFAULT_SUGGESTED_EMOJIS = [ '🫂', '🚀' ] as const + +/** Kind-7 content (or stats row emoji) for the default quick-like (`+`). */ +export function isDefaultPlusLikeReactionEmoji(emoji: TEmoji | string): boolean { + if (typeof emoji !== 'string') return false + const c = emoji.trim() + return c === '' || c === DEFAULT_LIKE_REACTION_CONTENT +} + +export function isDefaultPlusLikeReactionContent(content: string): boolean { + return isDefaultPlusLikeReactionEmoji(content) +} diff --git a/src/lib/thread-response-filter.test.ts b/src/lib/thread-response-filter.test.ts new file mode 100644 index 00000000..cf02af53 --- /dev/null +++ b/src/lib/thread-response-filter.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/lib/thread-response-filter.ts b/src/lib/thread-response-filter.ts index dcb6adbb..fd2d0f82 100644 --- a/src/lib/thread-response-filter.ts +++ b/src/lib/thread-response-filter.ts @@ -1,4 +1,4 @@ -import { isMentioningMutedUsers } from '@/lib/event' +import { isMentioningMutedUsers, isNip18RepostKind } from '@/lib/event' import { muteSetHas } from '@/lib/mute-set' import { normalizeUrl } from '@/lib/url' import type { Event } from 'nostr-tools' @@ -13,12 +13,21 @@ export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[] return s } -/** Hide thread replies / backlinks: muted author or (when enabled) mentions of mutes. */ +/** + * NIP-18 boosts: kind **6** (repost kind-1) and kind **16** (generic repost). Shown on the OP + * booster strip only — never as discussion thread rows. + */ +export function isThreadBoosterOnlyRow(evt: Event): boolean { + return isNip18RepostKind(evt.kind) +} + +/** Hide thread replies / backlinks: boosts, wire-format JSON blobs, muted author, or mute mentions. */ export function shouldHideThreadResponseEvent( evt: Event, mutePubkeySet: Set, hideContentMentioningMutedUsers: boolean | undefined ): boolean { + if (isThreadBoosterOnlyRow(evt)) return true if (muteSetHas(mutePubkeySet, evt.pubkey)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true return false diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 5b126389..85f92f9a 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -5,7 +5,6 @@ import { ExtendedKind } from '@/constants' import ContentPreview from '@/components/ContentPreview' import client from '@/services/client.service' import Note from '@/components/Note' -import NoteBoostBadges from '@/components/NoteBoostBadges' import NoteInteractions from '@/components/NoteInteractions' import NoteStats from '@/components/NoteStats' import UserAvatar from '@/components/UserAvatar' @@ -507,24 +506,29 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: >
{rootITag && } - {rootEventId && - !eventPointersReferenceSameNote(rootEventId, parentEventId) && ( - - )} - {parentEventId && ( + {rootEventId && ( )} + {parentEventId && + !eventPointersReferenceSameNote(parentEventId, rootEventId) && + !eventPointersReferenceSameNote(parentEventId, finalEvent.id) && ( + + )} - () replies.forEach((reply) => { if (newReplyIdSet.has(reply.id)) return + // NIP-18 kind 6 / 16 — stats + OP booster strip only, not thread reply map keys. + if (isNip18RepostKind(reply.kind)) { + client.addEventToCache(reply) + return + } if (isNip25ReactionKind(reply.kind)) { newReplyIdSet.add(reply.id) client.addEventToCache(reply) diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index bfd6ce08..227f6651 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -16,6 +16,7 @@ import { getReplaceableCoordinateFromEvent, getRootATag, getRootETag, + isNip18RepostKind, isNip25ReactionKind, isReplyNoteEvent, isReplaceableEvent, @@ -969,7 +970,7 @@ export class EventService { const qref = getQuotedReferenceFromQTags(ev) add(qref?.hexId) add(qref?.coordinate) - if (ev.kind === kinds.Zap || ev.kind === kinds.Repost || ev.kind === ExtendedKind.GENERIC_REPOST) { + if (ev.kind === kinds.Zap || isNip18RepostKind(ev.kind)) { add(getFirstHexEventIdFromETags(ev.tags)) } if ( @@ -998,6 +999,7 @@ export class EventService { let added = 0 for (const [, ev] of this.sessionEventCache.entries()) { if (shouldDropEventOnIngest(ev)) continue + if (isNip18RepostKind(ev.kind)) continue const threadishKind1Quote = (root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root) if (!isReplyNoteEvent(ev) && !threadishKind1Quote && !isNip25ReactionKind(ev.kind)) diff --git a/src/services/discussion-feed-cache.service.ts b/src/services/discussion-feed-cache.service.ts index 75ad0a44..4e2fbe08 100644 --- a/src/services/discussion-feed-cache.service.ts +++ b/src/services/discussion-feed-cache.service.ts @@ -1,4 +1,5 @@ import { isNip25ReactionKind } from '@/lib/event' +import { isThreadBoosterOnlyRow } from '@/lib/thread-response-filter' import { Event as NEvent } from 'nostr-tools' import logger from '@/lib/logger' @@ -99,7 +100,7 @@ class DiscussionFeedCacheService { logger.debug('[DiscussionFeedCache] Cache hit (fresh) for thread:', cacheKey, 'replies:', cachedData.replies.length) } - return cachedData.replies.filter((r) => !isNip25ReactionKind(r.kind)) + return cachedData.replies.filter((r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r)) } /** @@ -140,12 +141,12 @@ class DiscussionFeedCacheService { const existingReplyIds = new Set(existingData.replies.map(r => r.id)) const newReplies = replies.filter(r => !existingReplyIds.has(r.id)) mergedReplies = [...existingData.replies, ...newReplies].filter( - (r) => !isNip25ReactionKind(r.kind) + (r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r) ) logger.debug('[DiscussionFeedCache] Merged replies for thread:', cacheKey, 'existing:', existingData.replies.length, 'new:', newReplies.length, 'total:', mergedReplies.length) } else { // No existing cache or rootInfo mismatch, use new replies - mergedReplies = replies.filter((r) => !isNip25ReactionKind(r.kind)) + mergedReplies = replies.filter((r) => !isNip25ReactionKind(r.kind) && !isThreadBoosterOnlyRow(r)) logger.debug('[DiscussionFeedCache] Cached new replies for thread:', cacheKey, 'replies:', replies.length) }