Browse Source

hide stringified json on resposts

imwald
Silberengel 1 month ago
parent
commit
9c467ea204
  1. 3
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  2. 3
      src/components/NoteBoostBadges/index.tsx
  3. 10
      src/components/ReplyNote/index.tsx
  4. 67
      src/components/ReplyNoteList/ThreadContextRootNote.tsx
  5. 144
      src/components/ReplyNoteList/ThreadLowEffortStrip.tsx
  6. 54
      src/components/ReplyNoteList/index.tsx
  7. 2
      src/i18n/locales/en.ts
  8. 13
      src/lib/like-reaction-emojis.ts
  9. 48
      src/lib/thread-response-filter.test.ts
  10. 13
      src/lib/thread-response-filter.ts
  11. 37
      src/pages/secondary/NotePage/index.tsx
  12. 6
      src/providers/ReplyProvider.tsx
  13. 4
      src/services/client-events.service.ts
  14. 7
      src/services/discussion-feed-cache.service.ts

3
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -60,6 +60,7 @@ import katex from 'katex'
import '@/styles/katex-bundle.css' import '@/styles/katex-bundle.css'
import { isContentSpacingDebug, reprString } from '@/lib/content-spacing-debug' import { isContentSpacingDebug, reprString } from '@/lib/content-spacing-debug'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { stripTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json'
/** /**
* Inline/block image metadata: use merged rows from {@link extractAllMediaFromEvent} first * 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 // Preprocess content to convert URLs to markdown syntax
const preprocessedContent = useMemo(() => { const preprocessedContent = useMemo(() => {
// First unescape JSON-encoded escape sequences // 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. // Keep multi-newline runs intact so Marked `space` tokens can reproduce intentional vertical gaps.
// Normalize single newlines within bold/italic spans to spaces // Normalize single newlines within bold/italic spans to spaces
processed = normalizeInlineFormattingNewlines(processed) processed = normalizeInlineFormattingNewlines(processed)

3
src/components/NoteBoostBadges/index.tsx

@ -10,7 +10,8 @@ import UserAvatar from '../UserAvatar'
const MAX_VISIBLE = 28 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 }) { export default function NoteBoostBadges({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()

10
src/components/ReplyNote/index.tsx

@ -12,7 +12,7 @@ import {
DISCUSSION_UPVOTE_DISPLAY DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes' } from '@/lib/discussion-votes'
import { getZapInfoFromEvent } from '@/lib/event-metadata' 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 { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
@ -31,7 +31,6 @@ import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import Nip05 from '../Nip05' import Nip05 from '../Nip05'
import NoteOptions from '../NoteOptions' import NoteOptions from '../NoteOptions'
import NoteBoostBadges from '../NoteBoostBadges'
import NoteStats from '../NoteStats' import NoteStats from '../NoteStats'
import ParentNotePreview from '../ParentNotePreview' import ParentNotePreview from '../ParentNotePreview'
import WebPreview from '../WebPreview' import WebPreview from '../WebPreview'
@ -196,7 +195,7 @@ export default function ReplyNote({
</div> </div>
) : event.kind === kinds.Zap ? ( ) : event.kind === kinds.Zap ? (
<Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" /> <Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" />
) : ( ) : isNip18RepostKind(event.kind) ? null : (
<MarkdownArticle <MarkdownArticle
className="mt-2" className="mt-2"
event={event} event={event}
@ -220,11 +219,8 @@ export default function ReplyNote({
</div> </div>
</div> </div>
</Collapsible> </Collapsible>
{show && ( {show && !isNip18RepostKind(event.kind) && (
<> <>
{!isNip25ReactionKind(event.kind) && (
<NoteBoostBadges event={event} className="ml-14 pl-1 mr-4 mt-2" />
)}
<NoteStats <NoteStats
className="ml-14 pl-1 mr-4 mt-2" className="ml-14 pl-1 mr-4 mt-2"
event={event} event={event}

67
src/components/ReplyNoteList/ThreadContextRootNote.tsx

@ -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>
)
}

144
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<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>
)
}

54
src/components/ReplyNoteList/index.tsx

@ -17,12 +17,17 @@ import {
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip56ReportEvent, isNip56ReportEvent,
isMentioningMutedUsers,
isNip18RepostKind,
isNip25ReactionKind,
isReplaceableEvent, isReplaceableEvent,
kind1QuotesThreadRoot, kind1QuotesThreadRoot,
resolveDeclaredThreadRootEventHex resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent, shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' 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 { normalizeAnyRelayUrl } from '@/lib/url'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
@ -64,11 +69,19 @@ import { useTranslation } from 'react-i18next'
import { useQuoteEvents } from '@/hooks' import { useQuoteEvents } from '@/hooks'
import { LoadingBar } from '../LoadingBar' import { LoadingBar } from '../LoadingBar'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ThreadContextRootNote from './ThreadContextRootNote'
import ThreadLowEffortStrip from './ThreadLowEffortStrip'
import ThreadQuoteBacklink, { import ThreadQuoteBacklink, {
BacklinkAvatarStrip, BacklinkAvatarStrip,
ThreadQuoteBacklinkSkeleton ThreadQuoteBacklinkSkeleton
} from './ThreadQuoteBacklink' } 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 TRootInfo =
| { type: 'E'; id: string; pubkey: string } | { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string }
@ -1026,6 +1039,22 @@ function ReplyNoteList({
(evt: NEvent) => { (evt: NEvent) => {
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) 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 ( if (
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
@ -1606,6 +1635,7 @@ function ReplyNoteList({
(item: NEvent) => { (item: NEvent) => {
if (isPollVoteKind(item)) return false if (isPollVoteKind(item)) return false
if (isZapPollThreadZapReceipt(item, event)) return false if (isZapPollThreadZapReceipt(item, event)) return false
if (isDefaultPlusLikeReactionEvent(item, isDiscussionRoot)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false return false
} }
@ -1633,13 +1663,27 @@ function ReplyNoteList({
isUserTrusted, isUserTrusted,
rootInfo?.type, rootInfo?.type,
repliesMap, 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( const visibleForRender = useMemo(
() => visibleFeed.filter(shouldShowFeedItem), () => visibleFeed.filter((e) => shouldShowFeedItem(e) && e.id !== event.id),
[visibleFeed, shouldShowFeedItem] [visibleFeed, shouldShowFeedItem, event.id]
) )
const displayRows = useMemo( const displayRows = useMemo(
@ -1660,6 +1704,9 @@ function ReplyNoteList({
</div> </div>
)} )}
<div> <div>
{showThreadContextRoot && rootInfo?.type === 'E' && (
<ThreadContextRootNote rootHex={rootInfo.id} contextEvent={event} />
)}
{displayRows.map((row, ri) => { {displayRows.map((row, ri) => {
const prevRow = ri > 0 ? displayRows[ri - 1] : undefined const prevRow = ri > 0 ? displayRows[ri - 1] : undefined
if (row.type === 'reply') { if (row.type === 'reply') {
@ -1799,6 +1846,7 @@ function ReplyNoteList({
<ThreadQuoteBacklinkSkeleton /> <ThreadQuoteBacklinkSkeleton />
</div> </div>
)} )}
<ThreadLowEffortStrip event={event} statsNoteId={threadStatsNoteId} className="mt-1" />
{!loading && !quoteLoading && ( {!loading && !quoteLoading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground"> <div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')} {mergedFeed.length > 0 ? t('no more replies') : t('no replies')}

2
src/i18n/locales/en.ts

@ -36,6 +36,8 @@ export default {
followings: "followings", followings: "followings",
boosted: "boosted", boosted: "boosted",
"Boosted by:": "Boosted by:", "Boosted by:": "Boosted by:",
"Liked by:": "Liked by:",
"Original post": "Original post",
"just now": "just now", "just now": "just now",
"n minutes ago": "{{n}} minutes ago", "n minutes ago": "{{n}} minutes ago",
"n m": "{{n}}m", "n m": "{{n}}m",

13
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. * Single source for the quick-like emoji row used by the EmojiPicker / LikeButton.
* EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton. * EmojiPicker re-exports this list as EMOJI_PICKER_REACTIONS for LikeButton.
@ -21,3 +23,14 @@ export const DEFAULT_SUGGESTED_EMOJIS = [
'🫂', '🫂',
'🚀' '🚀'
] as const ] 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)
}

48
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> = {}): 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)
})
})

13
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 { muteSetHas } from '@/lib/mute-set'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -13,12 +13,21 @@ export function buildNormalizedBlockedRelaySet(blockedRelays: readonly string[]
return s 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( export function shouldHideThreadResponseEvent(
evt: Event, evt: Event,
mutePubkeySet: Set<string>, mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined hideContentMentioningMutedUsers: boolean | undefined
): boolean { ): boolean {
if (isThreadBoosterOnlyRow(evt)) return true
if (muteSetHas(mutePubkeySet, evt.pubkey)) return true if (muteSetHas(mutePubkeySet, evt.pubkey)) return true
if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true if (hideContentMentioningMutedUsers === true && isMentioningMutedUsers(evt, mutePubkeySet)) return true
return false return false

37
src/pages/secondary/NotePage/index.tsx

@ -5,7 +5,6 @@ import { ExtendedKind } from '@/constants'
import ContentPreview from '@/components/ContentPreview' import ContentPreview from '@/components/ContentPreview'
import client from '@/services/client.service' import client from '@/services/client.service'
import Note from '@/components/Note' import Note from '@/components/Note'
import NoteBoostBadges from '@/components/NoteBoostBadges'
import NoteInteractions from '@/components/NoteInteractions' import NoteInteractions from '@/components/NoteInteractions'
import NoteStats from '@/components/NoteStats' import NoteStats from '@/components/NoteStats'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
@ -507,24 +506,29 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
> >
<div className="px-4 pt-3 w-full"> <div className="px-4 pt-3 w-full">
{rootITag && <ExternalRoot value={rootITag[1]} />} {rootITag && <ExternalRoot value={rootITag[1]} />}
{rootEventId && {rootEventId && (
!eventPointersReferenceSameNote(rootEventId, parentEventId) && (
<ParentNote
key={`root-note-${finalEvent.id}`}
isFetching={isFetchingRootEvent}
event={rootEventForStrip}
eventBech32Id={rootEventId}
isConsecutive={isConsecutive(rootEventForStrip, parentEventForStrip)}
/>
)}
{parentEventId && (
<ParentNote <ParentNote
key={`parent-note-${finalEvent.id}`} key={`thread-root-${finalEvent.id}`}
isFetching={isFetchingParentEvent} isFetching={isFetchingRootEvent}
event={parentEventForStrip} event={rootEventForStrip}
eventBech32Id={parentEventId} eventBech32Id={rootEventId}
isConsecutive={
!parentEventId ||
eventPointersReferenceSameNote(parentEventId, rootEventId) ||
isConsecutive(rootEventForStrip, parentEventForStrip)
}
/> />
)} )}
{parentEventId &&
!eventPointersReferenceSameNote(parentEventId, rootEventId) &&
!eventPointersReferenceSameNote(parentEventId, finalEvent.id) && (
<ParentNote
key={`parent-note-${finalEvent.id}`}
isFetching={isFetchingParentEvent}
event={parentEventForStrip}
eventBech32Id={parentEventId}
/>
)}
<Note <Note
key={`note-${finalEvent.id}`} key={`note-${finalEvent.id}`}
event={finalEvent} event={finalEvent}
@ -539,7 +543,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
: undefined : undefined
} }
/> />
<NoteBoostBadges event={finalEvent} className="mt-2" />
<NoteStats <NoteStats
className="mt-3" className="mt-3"
event={finalEvent} event={finalEvent}

6
src/providers/ReplyProvider.tsx

@ -9,6 +9,7 @@ import {
getQuotedReferenceFromQTags, getQuotedReferenceFromQTags,
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip18RepostKind,
isNip25ReactionKind, isNip25ReactionKind,
resolveDeclaredThreadRootEventHex resolveDeclaredThreadRootEventHex
} from '@/lib/event' } from '@/lib/event'
@ -42,6 +43,11 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
const newReplyEventMap = new Map<string, Event[]>() const newReplyEventMap = new Map<string, Event[]>()
replies.forEach((reply) => { replies.forEach((reply) => {
if (newReplyIdSet.has(reply.id)) return 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)) { if (isNip25ReactionKind(reply.kind)) {
newReplyIdSet.add(reply.id) newReplyIdSet.add(reply.id)
client.addEventToCache(reply) client.addEventToCache(reply)

4
src/services/client-events.service.ts

@ -16,6 +16,7 @@ import {
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip18RepostKind,
isNip25ReactionKind, isNip25ReactionKind,
isReplyNoteEvent, isReplyNoteEvent,
isReplaceableEvent, isReplaceableEvent,
@ -969,7 +970,7 @@ export class EventService {
const qref = getQuotedReferenceFromQTags(ev) const qref = getQuotedReferenceFromQTags(ev)
add(qref?.hexId) add(qref?.hexId)
add(qref?.coordinate) 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)) add(getFirstHexEventIdFromETags(ev.tags))
} }
if ( if (
@ -998,6 +999,7 @@ export class EventService {
let added = 0 let added = 0
for (const [, ev] of this.sessionEventCache.entries()) { for (const [, ev] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(ev)) continue if (shouldDropEventOnIngest(ev)) continue
if (isNip18RepostKind(ev.kind)) continue
const threadishKind1Quote = const threadishKind1Quote =
(root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root) (root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root)
if (!isReplyNoteEvent(ev) && !threadishKind1Quote && !isNip25ReactionKind(ev.kind)) if (!isReplyNoteEvent(ev) && !threadishKind1Quote && !isNip25ReactionKind(ev.kind))

7
src/services/discussion-feed-cache.service.ts

@ -1,4 +1,5 @@
import { isNip25ReactionKind } from '@/lib/event' import { isNip25ReactionKind } from '@/lib/event'
import { isThreadBoosterOnlyRow } from '@/lib/thread-response-filter'
import { Event as NEvent } from 'nostr-tools' import { Event as NEvent } from 'nostr-tools'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -99,7 +100,7 @@ class DiscussionFeedCacheService {
logger.debug('[DiscussionFeedCache] Cache hit (fresh) for thread:', cacheKey, 'replies:', cachedData.replies.length) 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 existingReplyIds = new Set(existingData.replies.map(r => r.id))
const newReplies = replies.filter(r => !existingReplyIds.has(r.id)) const newReplies = replies.filter(r => !existingReplyIds.has(r.id))
mergedReplies = [...existingData.replies, ...newReplies].filter( 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) logger.debug('[DiscussionFeedCache] Merged replies for thread:', cacheKey, 'existing:', existingData.replies.length, 'new:', newReplies.length, 'total:', mergedReplies.length)
} else { } else {
// No existing cache or rootInfo mismatch, use new replies // 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) logger.debug('[DiscussionFeedCache] Cached new replies for thread:', cacheKey, 'replies:', replies.length)
} }

Loading…
Cancel
Save