Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 16 additions & 3 deletions src/components/NoteCard/RepostNoteCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,19 +22,32 @@ export default function RepostNoteCard({
pinned?: boolean
reposters?: string[]
}) {
const { mutePubkeySet } = useMuteList()
const { mutePubkeySet, muteEventIdSet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const [targetEvent, setTargetEvent] = useState<Event | null>(null)
const shouldHide = useMemo(() => {
if (!targetEvent) return true
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
56 changes: 56 additions & 0 deletions src/lib/__tests__/event.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
34 changes: 33 additions & 1 deletion src/lib/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): 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 {
Expand Down
8 changes: 8 additions & 0 deletions src/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading