From 5a719ded6f44b7f85a9766da5b4251e86cd1633c Mon Sep 17 00:00:00 2001 From: DocNR Date: Wed, 17 Jun 2026 18:57:04 -0400 Subject: [PATCH] fix(mute): stop reposts of muted threads leaking + false "your note" label Two bugs surfaced by a kind-6 repost of a muted hellthread that copied the thread's whole p-tag list (so it tagged, and notified, a user who is neither the author nor a participant beyond being tagged): 1. Mute bypass. isInMutedThread() treated a repost as opaque: getThreadRootId only understands kind-1 threads, so for a kind-6/16 wrapper it returned the repost's own id and never checked the reposted note. A repost of a muted note/thread slipped into both feeds and notifications (and the unread counts that route through the same filter). Now isInMutedThread unwraps the reposted target (embedded JSON, else the e-tag id) and checks its id + root. 2. False attribution. RepostNotification hardcoded "reposted your note" for every repost that tagged you. Now it says "reposted your note" only when the reposted note's author is the viewer, else "reposted a note". Also adds a defense-in-depth isInMutedThread check on the async-fetched target in RepostNoteCard (covers a no-embedded-JSON repost of a reply in a muted thread). New en.ts key 'reposted a note'. Tests cover kind-6/16 reposts via embedded JSON and e-tag-only forms. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/components/NoteCard/RepostNoteCard.tsx | 19 ++++++- .../NotificationItem/RepostNotification.tsx | 5 +- src/i18n/locales/en.ts | 3 +- src/lib/__tests__/event.spec.ts | 56 +++++++++++++++++++ src/lib/event.ts | 34 ++++++++++- src/release-notes.ts | 8 +++ 7 files changed, 120 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 528d5e8..776bfe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jank", - "version": "26.15.3", + "version": "26.15.4", "description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient", "type": "module", "author": "DocNR", diff --git a/src/components/NoteCard/RepostNoteCard.tsx b/src/components/NoteCard/RepostNoteCard.tsx index f59d735..c357060 100644 --- a/src/components/NoteCard/RepostNoteCard.tsx +++ b/src/components/NoteCard/RepostNoteCard.tsx @@ -1,4 +1,4 @@ -import { isMentioningMutedUsers } from '@/lib/event' +import { isInMutedThread, isMentioningMutedUsers } from '@/lib/event' import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/UserListsProvider' @@ -22,7 +22,7 @@ export default function RepostNoteCard({ pinned?: boolean reposters?: string[] }) { - const { mutePubkeySet } = useMuteList() + const { mutePubkeySet, muteEventIdSet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const [targetEvent, setTargetEvent] = useState(null) const shouldHide = useMemo(() => { @@ -30,11 +30,24 @@ export default function RepostNoteCard({ if (filterMutedNotes && mutePubkeySet.has(targetEvent.pubkey)) { return true } + // Hide a repost whose target is in a muted thread. The outer NoteCard check + // runs isInMutedThread on the kind-6 wrapper, but this also catches the case + // where the target was fetched async (no embedded JSON) and is a reply whose + // root we only know after the fetch. + if (filterMutedNotes && isInMutedThread(targetEvent, muteEventIdSet)) { + return true + } if (hideContentMentioningMutedUsers && isMentioningMutedUsers(targetEvent, mutePubkeySet)) { return true } return false - }, [targetEvent, filterMutedNotes, hideContentMentioningMutedUsers, mutePubkeySet]) + }, [ + targetEvent, + filterMutedNotes, + hideContentMentioningMutedUsers, + mutePubkeySet, + muteEventIdSet + ]) useEffect(() => { const fetch = async () => { let eventFromContent: Event | null = null diff --git a/src/components/NotificationList/NotificationItem/RepostNotification.tsx b/src/components/NotificationList/NotificationItem/RepostNotification.tsx index 6edf81d..8bd6d97 100644 --- a/src/components/NotificationList/NotificationItem/RepostNotification.tsx +++ b/src/components/NotificationList/NotificationItem/RepostNotification.tsx @@ -41,7 +41,10 @@ export function RepostNotification({ sender={notification.pubkey} sentAt={notification.created_at} targetEvent={event} - description={t('reposted your note')} + // Only "your note" when the reposted note is actually the viewer's. A + // reposter's client often copies the whole p-tag list, so a repost of a + // hellthread you're merely tagged in lands here too — don't claim it's yours. + description={event.pubkey === pubkey ? t('reposted your note') : t('reposted a note')} isNew={isNew} /> ) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3c6beb5..61c2934 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1108,6 +1108,7 @@ export default { 'Your agent never gets your signing key — your signer still gates every event you publish. {{brand}} must stay open in a browser tab for your agent to work.', 'Columns in this deck that view your other paired {{brand}} accounts (their npubs become visible to the agent)': 'Columns in this deck that view your other paired {{brand}} accounts (their npubs become visible to the agent)', - 'Close all temporary columns': 'Close all temporary columns' + 'Close all temporary columns': 'Close all temporary columns', + 'reposted a note': 'reposted a note' } } diff --git a/src/lib/__tests__/event.spec.ts b/src/lib/__tests__/event.spec.ts index 55bdf2b..280da4a 100644 --- a/src/lib/__tests__/event.spec.ts +++ b/src/lib/__tests__/event.spec.ts @@ -89,4 +89,60 @@ describe('isInMutedThread', () => { it('is a fast no-op for an empty mute set', () => { expect(isInMutedThread(ev({ id: ROOT }), new Set())).toBe(false) }) + + // Reposts (kind 6 / 16) re-surface another note verbatim, so they belong to + // the reposted note's thread — not their own. A reposter's client commonly + // copies the whole p-tag list, which is how a repost of a muted hellthread + // ends up tagging (and notifying) you. + it('hides a kind-6 repost of the muted root (embedded JSON)', () => { + const inner = ev({ id: ROOT, tags: [] }) + const repost = ev({ + id: 'rp', + kind: 6, + content: JSON.stringify(inner), + tags: [['e', ROOT]] + }) + expect(isInMutedThread(repost, muted)).toBe(true) + }) + + it('hides a kind-6 repost of a reply inside the muted thread (embedded JSON)', () => { + const innerReply = ev({ id: PARENT, tags: [['e', ROOT, '', 'root']] }) + const repost = ev({ + id: 'rp2', + kind: 6, + content: JSON.stringify(innerReply), + tags: [['e', PARENT]] + }) + expect(isInMutedThread(repost, muted)).toBe(true) + }) + + it('hides a kind-6 repost when only the e-tag is present (no content)', () => { + const repost = ev({ id: 'rp3', kind: 6, content: '', tags: [['e', ROOT]] }) + expect(isInMutedThread(repost, muted)).toBe(true) + }) + + it('hides a kind-16 generic repost of the muted root', () => { + const inner = ev({ id: ROOT, tags: [] }) + const repost = ev({ + id: 'rp4', + kind: 16, + content: JSON.stringify(inner), + tags: [ + ['e', ROOT], + ['k', '1'] + ] + }) + expect(isInMutedThread(repost, muted)).toBe(true) + }) + + it('does NOT hide a repost of an unmuted note', () => { + const inner = ev({ id: OTHER, tags: [] }) + const repost = ev({ + id: 'rp5', + kind: 6, + content: JSON.stringify(inner), + tags: [['e', OTHER]] + }) + expect(isInMutedThread(repost, muted)).toBe(false) + }) }) diff --git a/src/lib/event.ts b/src/lib/event.ts index 4dfa62a..7d8e05b 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -237,12 +237,44 @@ export function getThreadRootId(event: Event): string { return getRootEventHexId(event) ?? event.id } +// Parse the note embedded in a repost's content. Kind-6/16 reposts carry the +// full reposted event JSON in `content`; returns undefined when it is absent or +// not a parseable event (caller falls back to the e tag). +function getRepostedNote(event: Event): Event | undefined { + if (!event.content) return undefined + try { + const parsed = JSON.parse(event.content) as Event + if (parsed && typeof parsed.id === 'string' && Array.isArray(parsed.tags)) { + return parsed + } + } catch { + // not JSON + } + return undefined +} + // True when `event` belongs to a muted thread: either it IS a muted id, or its // computed thread root is muted (which every descendant shares via the NIP-10 // root marker). O(1); no reply-tree traversal. export function isInMutedThread(event: Event, muteEventIdSet: Set): boolean { if (muteEventIdSet.size === 0) return false - return muteEventIdSet.has(event.id) || muteEventIdSet.has(getThreadRootId(event)) + if (muteEventIdSet.has(event.id) || muteEventIdSet.has(getThreadRootId(event))) return true + + // A repost (kind 6 / 16) re-surfaces another note verbatim, so it belongs to + // the reposted note's thread — not its own. getThreadRootId only understands + // kind-1 threads and treats a repost as its own root, so the reposted id never + // gets checked. Unwrap the target explicitly: prefer the embedded JSON, else + // the e-tag id (a top-level note is its own thread root). Without this a repost + // of a muted hellthread slips into feeds and notifications. + if (event.kind === kinds.Repost || event.kind === kinds.GenericRepost) { + const target = getRepostedNote(event) + if (target) { + return muteEventIdSet.has(target.id) || muteEventIdSet.has(getThreadRootId(target)) + } + const repostedId = event.tags.find(tagNameEquals('e'))?.[1] + if (repostedId && muteEventIdSet.has(repostedId)) return true + } + return false } export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined { diff --git a/src/release-notes.ts b/src/release-notes.ts index 2057623..02e951c 100644 --- a/src/release-notes.ts +++ b/src/release-notes.ts @@ -14,6 +14,14 @@ export type ReleaseNote = { } export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: '26.15.4', + date: '2026-06-17', + highlights: [ + 'Muting a thread now also hides reposts of that thread. Before, someone reposting a muted conversation could still surface it in your feed and notifications.', + 'Repost notifications no longer claim "reposted your note" when the reposted note is not actually yours, such as when someone reposts a thread that only tags you.' + ] + }, { version: '26.15.3', date: '2026-06-17',