From c803a6422a5095c33d5907b073e6542a52a43342 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:20:14 -0400 Subject: [PATCH 01/11] feat(mute): add pure e-tag list helpers for thread muting --- src/lib/__tests__/tag.spec.ts | 49 +++++++++++++++++++++++++++++++++++ src/lib/tag.ts | 21 +++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/lib/__tests__/tag.spec.ts b/src/lib/__tests__/tag.spec.ts index 7badf9c..0fd8835 100644 --- a/src/lib/__tests__/tag.spec.ts +++ b/src/lib/__tests__/tag.spec.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest' import { generateSecretKey, getPublicKey } from 'nostr-tools' import { getPubkeysFromPTags } from '@/lib/tag' +import { + getEventIdsFromETags, + appendETag, + stripETag +} from '@/lib/tag' const PK_A = getPublicKey(generateSecretKey()) const PK_B = getPublicKey(generateSecretKey()) @@ -54,3 +59,47 @@ describe('getPubkeysFromPTags', () => { expect(result.includes(PK_B)).toBe(false) }) }) + +const ID_A = 'a'.repeat(64) +const ID_B = 'b'.repeat(64) + +describe('getEventIdsFromETags', () => { + it('extracts ids from e tags', () => { + const result = getEventIdsFromETags([ + ['e', ID_A], + ['e', ID_B, 'wss://relay', 'root'], + ['p', ID_A] + ]) + expect(result).toContain(ID_A) + expect(result).toContain(ID_B) + expect(result).toHaveLength(2) + }) + + it('ignores non-e tags and empty ids', () => { + expect(getEventIdsFromETags([['p', ID_A], ['e', '']])).toEqual([]) + }) + + it('dedupes', () => { + expect(getEventIdsFromETags([['e', ID_A], ['e', ID_A]])).toEqual([ID_A]) + }) +}) + +describe('appendETag', () => { + it('appends an e tag', () => { + expect(appendETag([['p', ID_A]], ID_B)).toEqual([['p', ID_A], ['e', ID_B]]) + }) + + it('is a no-op (same reference) when already present', () => { + const tags = [['e', ID_A]] + expect(appendETag(tags, ID_A)).toBe(tags) + }) +}) + +describe('stripETag', () => { + it('removes the matching e tag, keeps others', () => { + expect(stripETag([['e', ID_A], ['e', ID_B], ['p', ID_A]], ID_A)).toEqual([ + ['e', ID_B], + ['p', ID_A] + ]) + }) +}) diff --git a/src/lib/tag.ts b/src/lib/tag.ts index 054bb0f..d1fb073 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -98,6 +98,27 @@ export function getPubkeysFromPTags(tags: string[][]) { ) } +export function getEventIdsFromETags(tags: string[][]) { + return Array.from( + new Set( + tags + .filter(tagNameEquals('e')) + .map(([, id]) => id) + .filter((id): id is string => !!id) + .reverse() + ) + ) +} + +export function appendETag(tags: string[][], id: string): string[][] { + if (tags.some((tag) => tag[0] === 'e' && tag[1] === id)) return tags + return [...tags, ['e', id]] +} + +export function stripETag(tags: string[][], id: string): string[][] { + return tags.filter((tag) => tag[0] !== 'e' || tag[1] !== id) +} + export function getEmojiInfosFromEmojiTags(tags: string[][] = []) { return tags .map((tag) => { From dcebf22d8937ce91cbafccaf9b5f24b878887316 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:23:11 -0400 Subject: [PATCH 02/11] feat(mute): add getThreadRootId + isInMutedThread predicate --- src/lib/__tests__/event.spec.ts | 67 +++++++++++++++++++++++++++++++++ src/lib/event.ts | 16 ++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/lib/__tests__/event.spec.ts diff --git a/src/lib/__tests__/event.spec.ts b/src/lib/__tests__/event.spec.ts new file mode 100644 index 0000000..d1d342b --- /dev/null +++ b/src/lib/__tests__/event.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { nip19, type Event } from 'nostr-tools' +import { getThreadRootId, isInMutedThread } from '@/lib/event' + +const ROOT = 'a'.repeat(64) +const PARENT = 'b'.repeat(64) +const OTHER = 'c'.repeat(64) + +// minimal Event factory — getThreadRootId only reads id, kind, tags, content +const ev = (over: Partial): Event => + ({ id: 'self', kind: 1, tags: [], content: '', pubkey: '', created_at: 0, sig: '', ...over }) as Event + +describe('getThreadRootId', () => { + it('returns own id for a top-level note', () => { + expect(getThreadRootId(ev({ id: ROOT, tags: [] }))).toBe(ROOT) + }) + + it('returns the root-marked id for a reply', () => { + expect(getThreadRootId(ev({ id: 'r', tags: [[ 'e', ROOT, '', 'root' ]] }))).toBe(ROOT) + }) + + it('returns the root for a deep reply (root marker constant down the thread)', () => { + const deep = ev({ id: 'd', tags: [['e', ROOT, '', 'root'], ['e', PARENT, '', 'reply']] }) + expect(getThreadRootId(deep)).toBe(ROOT) + }) + + it('falls back to first positional e tag (legacy, no markers)', () => { + expect(getThreadRootId(ev({ id: 'r', tags: [['e', ROOT]] }))).toBe(ROOT) + }) +}) + +describe('isInMutedThread', () => { + const muted = new Set([ROOT]) + + it('hides the muted root note itself', () => { + expect(isInMutedThread(ev({ id: ROOT, tags: [] }), muted)).toBe(true) + }) + + it('hides a direct reply to the muted root', () => { + expect(isInMutedThread(ev({ id: 'r', tags: [['e', ROOT, '', 'root']] }), muted)).toBe(true) + }) + + it('hides a deep descendant', () => { + const deep = ev({ id: 'd', tags: [['e', ROOT, '', 'root'], ['e', PARENT, '', 'reply']] }) + expect(isInMutedThread(deep, muted)).toBe(true) + }) + + it('does NOT hide a sibling thread (different root)', () => { + expect(isInMutedThread(ev({ id: 's', tags: [['e', OTHER, '', 'root']] }), muted)).toBe(false) + }) + + it('does NOT hide a standalone note', () => { + expect(isInMutedThread(ev({ id: 'x', tags: [] }), muted)).toBe(false) + }) + + it('does NOT hide a quote that embeds the root as nostr:nevent', () => { + const nevent = nip19.neventEncode({ id: ROOT }) + const quote = ev({ id: 'q', content: `look nostr:${nevent}`, tags: [['e', ROOT, '', 'mention']] }) + // getRootEventHexId excludes embedded-note ids from its positional fallback, + // so the quote's computed root is itself → visible. + expect(isInMutedThread(quote, muted)).toBe(false) + }) + + it('is a fast no-op for an empty mute set', () => { + expect(isInMutedThread(ev({ id: ROOT }), new Set())).toBe(false) + }) +}) diff --git a/src/lib/event.ts b/src/lib/event.ts index 634574e..4dfa62a 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -229,6 +229,22 @@ export function getRootEventHexId(event?: Event) { return tag?.[1] } +// The id of the conversation `event` belongs to: its NIP-10 thread root, or its +// own id when it is a top-level note. Used both to STORE a thread mute (mute the +// clicked note's root) and to FILTER (a note is in the thread iff its computed +// root matches). Same function at both ends keeps the two consistent. +export function getThreadRootId(event: Event): string { + return getRootEventHexId(event) ?? event.id +} + +// 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)) +} + export function getRootTag(event?: Event): { type: 'e' | 'a' | 'i'; tag: string[] } | undefined { if (!event) return undefined From 2e54b3393a35056094c761e5b049ee1c8a1c25f4 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:27:11 -0400 Subject: [PATCH 03/11] feat(mute): read/write thread e-tags on MuteListContext (private) --- src/providers/ScopedUserListsProvider.tsx | 85 ++++++++++++++++++++++- src/providers/UserListsProvider.tsx | 4 ++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/providers/ScopedUserListsProvider.tsx b/src/providers/ScopedUserListsProvider.tsx index 2ebd1ba..8c5a437 100644 --- a/src/providers/ScopedUserListsProvider.tsx +++ b/src/providers/ScopedUserListsProvider.tsx @@ -42,7 +42,7 @@ import { isReplaceableEvent } from '@/lib/event' import { getPinnedEventHexIdSetFromPinListEvent } from '@/lib/event-metadata' -import { getPubkeysFromPTags } from '@/lib/tag' +import { appendETag, getEventIdsFromETags, getPubkeysFromPTags, stripETag } from '@/lib/tag' import { ExtendedKind, MAX_PINNED_NOTES } from '@/constants' import { usePrivateTags, useUserListEvent } from '@/hooks/useReplaceableEvent' import followListService from '@/services/fetchers/follow-list.service' @@ -235,6 +235,23 @@ function ScopedMuteListInner({ viewContext, signingIdentity, children }: InnerPr [publicMutePubkeySet, privateMutePubkeySet] ) + const publicMuteEventIdSet = useMemo( + () => new Set(muteListEvent ? getEventIdsFromETags(muteListEvent.tags) : []), + [muteListEvent] + ) + const privateMuteEventIdSet = useMemo( + () => new Set(getEventIdsFromETags(privateTags)), + [privateTags] + ) + const muteEventIdSet = useMemo( + () => new Set([...Array.from(privateMuteEventIdSet), ...Array.from(publicMuteEventIdSet)]), + [publicMuteEventIdSet, privateMuteEventIdSet] + ) + const isThreadMuted = useCallback( + (rootId: string) => muteEventIdSet.has(rootId), + [muteEventIdSet] + ) + // The signer for the SIGNER's own list — used for nip44 encrypt/decrypt. Read // from the registry (not the AccountScope) so this same provider works at the // app-level mount, which is not inside an . @@ -490,9 +507,64 @@ function ScopedMuteListInner({ viewContext, signingIdentity, children }: InnerPr [signingIdentity, changing, decryptSignerPrivateTags, applyMutation] ) + const muteThread = useCallback( + async (rootId: string) => { + const signer = client.getSignerFor(signingIdentity ?? '') + if (changing || !signer || !signingIdentity) return + setChanging(true) + try { + const current = await muteListService.fetchMuteListEvent(signingIdentity) + if (!current) { + const result = confirm(t('MuteListNotFoundConfirmation')) + if (!result) return + } + const currentPrivate = current ? await decryptSignerPrivateTags(current) : [] + const newPrivate = appendETag(currentPrivate, rootId) + if (newPrivate === currentPrivate) return + const content = await signer.nip44Encrypt(signingIdentity, JSON.stringify(newPrivate)) + await applyMutation(current?.tags ?? [], content, newPrivate, current ?? undefined) + } catch (error) { + formatError(error).forEach((err) => { + toast.error('Failed to mute thread: ' + err, { duration: 10_000 }) + }) + } finally { + setChanging(false) + } + }, + [signingIdentity, changing, decryptSignerPrivateTags, applyMutation, t] + ) + + const unmuteThread = useCallback( + async (rootId: string) => { + const signer = client.getSignerFor(signingIdentity ?? '') + if (changing || !signer || !signingIdentity) return + setChanging(true) + try { + const current = await muteListService.fetchMuteListEvent(signingIdentity) + if (!current) return + const currentPrivate = await decryptSignerPrivateTags(current) + const newPrivate = stripETag(currentPrivate, rootId) + let content = current.content + if (newPrivate.length !== currentPrivate.length) { + content = await signer.nip44Encrypt(signingIdentity, JSON.stringify(newPrivate)) + } + const newTags = stripETag(current.tags, rootId) + await applyMutation(newTags, content, newPrivate, current) + } catch (error) { + formatError(error).forEach((err) => { + toast.error('Failed to unmute thread: ' + err, { duration: 10_000 }) + }) + } finally { + setChanging(false) + } + }, + [signingIdentity, changing, decryptSignerPrivateTags, applyMutation] + ) + const value = useMemo( () => ({ mutePubkeySet, + muteEventIdSet, changing, getMutePubkeys, getMuteType, @@ -500,10 +572,14 @@ function ScopedMuteListInner({ viewContext, signingIdentity, children }: InnerPr mutePubkeyPrivately, unmutePubkey, switchToPublicMute, - switchToPrivateMute + switchToPrivateMute, + muteThread, + unmuteThread, + isThreadMuted }), [ mutePubkeySet, + muteEventIdSet, changing, getMutePubkeys, getMuteType, @@ -511,7 +587,10 @@ function ScopedMuteListInner({ viewContext, signingIdentity, children }: InnerPr mutePubkeyPrivately, unmutePubkey, switchToPublicMute, - switchToPrivateMute + switchToPrivateMute, + muteThread, + unmuteThread, + isThreadMuted ] ) diff --git a/src/providers/UserListsProvider.tsx b/src/providers/UserListsProvider.tsx index 52d044e..a29eafb 100644 --- a/src/providers/UserListsProvider.tsx +++ b/src/providers/UserListsProvider.tsx @@ -32,6 +32,7 @@ export const useFollowList = () => { type TMuteListContext = { mutePubkeySet: Set + muteEventIdSet: Set changing: boolean getMutePubkeys: () => string[] getMuteType: (pubkey: string) => 'public' | 'private' | null @@ -40,6 +41,9 @@ type TMuteListContext = { unmutePubkey: (pubkey: string) => Promise switchToPublicMute: (pubkey: string) => Promise switchToPrivateMute: (pubkey: string) => Promise + muteThread: (rootId: string) => Promise + unmuteThread: (rootId: string) => Promise + isThreadMuted: (rootId: string) => boolean } export const MuteListContext = createContext(undefined) From d78a2b9d5afa54efd9924f7b6dff9badc5f89cf6 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:32:37 -0400 Subject: [PATCH 04/11] feat(mute): hide muted-thread notes in feeds --- src/components/NoteList/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 918d603..a41137d 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -3,7 +3,7 @@ import { Button } from '@/components/ui/button' import { FUTURE_EVENT_TOLERANCE_SECONDS, NOTIFICATION_LIST_STYLE } from '@/constants' import { useColumnVisible } from '@/hooks/useColumnVisible' import { useInfiniteScroll } from '@/hooks/useInfiniteScroll' -import { isMentioningMutedUsers } from '@/lib/event' +import { isInMutedThread, isMentioningMutedUsers } from '@/lib/event' import { buildNoteRows, TNoteRow } from '@/lib/note-rows' import { mergeTimelines } from '@/lib/timeline' import { useAccountScopeOptional } from '@/providers/AccountScope' @@ -140,7 +140,7 @@ const NoteList = forwardRef< const authPubkey = scope?.signingIdentity ?? undefined const { startLogin } = useNostr() const { isSpammer, isUserTrusted } = useUserTrust() - const { mutePubkeySet } = useMuteList() + const { mutePubkeySet, muteEventIdSet } = useMuteList() const { hideContentMentioningMutedUsers, mutedWords } = useContentPolicy() const { isEventDeleted } = useDeletedEvent() const [storedEvents, setStoredEvents] = useState([]) @@ -195,6 +195,7 @@ const NoteList = forwardRef< if (pinnedEventHexIdSet.has(evt.id)) return true if (isEventDeleted(evt)) return true if (filterMutedNotes && mutePubkeySet.has(evt.pubkey)) return true + if (filterMutedNotes && isInMutedThread(evt, muteEventIdSet)) return true if ( filterMutedNotes && hideContentMentioningMutedUsers && @@ -219,6 +220,7 @@ const NoteList = forwardRef< }, [ mutePubkeySet, + muteEventIdSet, isEventDeleted, filterFn, mutedWords, From 7dc01e9094345e4754b7d6f296087680819b00e1 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:34:45 -0400 Subject: [PATCH 05/11] feat(mute): hide muted-thread notes in notifications --- .../NotificationList/NotificationItem/index.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/NotificationList/NotificationItem/index.tsx b/src/components/NotificationList/NotificationItem/index.tsx index e5d8690..fa2f6d4 100644 --- a/src/components/NotificationList/NotificationItem/index.tsx +++ b/src/components/NotificationList/NotificationItem/index.tsx @@ -1,5 +1,5 @@ import { ExtendedKind } from '@/constants' -import { isMentioningMutedUsers } from '@/lib/event' +import { isInMutedThread, isMentioningMutedUsers } from '@/lib/event' import { tagNameEquals } from '@/lib/tag' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/UserListsProvider' @@ -23,7 +23,7 @@ export function NotificationItem({ // The notification surface's pubkey — the column's viewContext in column // mode, the active account in page mode. Not the sidebar-active singleton. const { pubkey } = useNotification() - const { mutePubkeySet } = useMuteList() + const { mutePubkeySet, muteEventIdSet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() const [canShow, setCanShow] = useState(false) @@ -34,6 +34,11 @@ export function NotificationItem({ return } + if (isInMutedThread(notification, muteEventIdSet)) { + setCanShow(false) + return + } + if (hideContentMentioningMutedUsers && isMentioningMutedUsers(notification, mutePubkeySet)) { setCanShow(false) return @@ -51,7 +56,7 @@ export function NotificationItem({ } checkCanShow() - }, [notification, pubkey, mutePubkeySet, hideContentMentioningMutedUsers]) + }, [notification, pubkey, mutePubkeySet, muteEventIdSet, hideContentMentioningMutedUsers]) if (!canShow) return null From 08224a0603cf98cd1a6420e93753d959b12ce1da Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:40:37 -0400 Subject: [PATCH 06/11] feat(mute): add Mute thread / Unmute thread to the note menu --- src/components/NoteOptions/useMenuActions.tsx | 36 +++++++++++++++++-- src/i18n/locales/en.ts | 4 ++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index abbd181..e4fe2dc 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -1,6 +1,6 @@ import { useSecondaryPage } from '@/DeckManager' import { formatError } from '@/lib/error' -import { getNoteBech32Id, isProtectedEvent } from '@/lib/event' +import { getNoteBech32Id, getThreadRootId, isProtectedEvent } from '@/lib/event' import { toRelaySettings, toShareNoteUrl } from '@/lib/link' import { pubkeyToNpub } from '@/lib/pubkey' import { simplifyUrl } from '@/lib/url' @@ -73,7 +73,15 @@ export function useMenuActions({ const relayUrls = useMemo(() => { return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays))) }, [currentBrowsingRelayUrls, favoriteRelays]) - const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList() + const { + mutePubkeyPublicly, + mutePubkeyPrivately, + unmutePubkey, + mutePubkeySet, + muteThread, + unmuteThread, + isThreadMuted + } = useMuteList() const { pinnedEventHexIdSet, pin, unpin } = usePinList() const { isFavorited, toggleFavorite } = useFavorites() const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event]) @@ -321,6 +329,25 @@ export function useMenuActions({ } } + if (pubkey) { + const rootId = getThreadRootId(event) + const threadMuted = isThreadMuted(rootId) + actions.push({ + icon: threadMuted ? Bell : BellOff, + label: threadMuted ? t('Unmute thread') : t('Mute thread'), + onClick: () => { + closeDrawer() + if (threadMuted) { + unmuteThread(rootId) + } else { + muteThread(rootId) + } + }, + className: 'text-destructive focus:text-destructive', + separator: true + }) + } + if (pubkey && event.pubkey === pubkey) { actions.push({ icon: Trash2, @@ -351,7 +378,10 @@ export function useMenuActions({ setIsRawEventDialogOpen, mutePubkeyPrivately, mutePubkeyPublicly, - unmutePubkey + unmutePubkey, + isThreadMuted, + muteThread, + unmuteThread ]) return menuActions diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8eda2a0..0393aec 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1078,6 +1078,8 @@ export default { 'Use updated offer': 'Use updated offer', Paid: 'Paid', 'Republished to {{count}} relays: {{relays}}': 'Republished to {{count}} relays: {{relays}}', - 'Configure relay sets': 'Configure relay sets' + 'Configure relay sets': 'Configure relay sets', + 'Mute thread': 'Mute thread', + 'Unmute thread': 'Unmute thread' } } From b4d42140b9e1e8d0ad5dbb91e58e8a0437c49bf4 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 12:58:28 -0400 Subject: [PATCH 07/11] feat(mute): list and unmute muted threads on the Mute List page --- src/i18n/locales/en.ts | 3 +- src/pages/secondary/MuteListPage/index.tsx | 45 +++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 0393aec..c3aafcd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1080,6 +1080,7 @@ export default { 'Republished to {{count}} relays: {{relays}}': 'Republished to {{count}} relays: {{relays}}', 'Configure relay sets': 'Configure relay sets', 'Mute thread': 'Mute thread', - 'Unmute thread': 'Unmute thread' + 'Unmute thread': 'Unmute thread', + 'Muted threads': 'Muted threads' } } diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 8d5085d..4716353 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -1,9 +1,10 @@ +import ContentPreview from '@/components/ContentPreview' import MuteButton from '@/components/MuteButton' import Nip05 from '@/components/Nip05' import { Button } from '@/components/ui/button' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' -import { useFetchProfile } from '@/hooks' +import { useFetchEvent, useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { useMuteList } from '@/providers/UserListsProvider' import { useNostr } from '@/providers/NostrProvider' @@ -15,8 +16,9 @@ import NotFoundPage from '../NotFoundPage' const MuteListPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() const { profile, pubkey } = useNostr() - const { getMutePubkeys } = useMuteList() + const { getMutePubkeys, muteEventIdSet } = useMuteList() const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) + const muteEventIds = useMemo(() => Array.from(muteEventIdSet), [pubkey]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState([]) const bottomRef = useRef(null) @@ -69,12 +71,51 @@ const MuteListPage = forwardRef(({ index }: { index?: number }, ref) => { ))} {mutePubkeys.length > visibleMutePubkeys.length &&
}
+ {muteEventIds.length > 0 && ( +
+
+ {t('Muted threads')} +
+
+ {muteEventIds.map((id) => ( + + ))} +
+
+ )} ) }) MuteListPage.displayName = 'MuteListPage' export default MuteListPage +function ThreadItem({ eventId }: { eventId: string }) { + const { t } = useTranslation() + const { changing, unmuteThread } = useMuteList() + const { event } = useFetchEvent(eventId) + const [removing, setRemoving] = useState(false) + + return ( +
+
+ +
+ +
+ ) +} + function UserItem({ pubkey }: { pubkey: string }) { const { changing, getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList() const { profile } = useFetchProfile(pubkey) From f00b98f45df64f171651eeed8c086843d2ac61e8 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 17:04:21 -0400 Subject: [PATCH 08/11] chore(release): v26.12.2 thread muting Renumbered from 26.12.0: the concurrent home-tab-persistence work shipped 26.12.0 and re-bumped to 26.12.1 on main while this branch was in flight, so thread muting takes the next free patch. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/release-notes.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a824cb2..05757e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jank", - "version": "26.12.1", + "version": "26.12.2", "description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient", "type": "module", "author": "DocNR", diff --git a/src/release-notes.ts b/src/release-notes.ts index 65c61fa..9713a70 100644 --- a/src/release-notes.ts +++ b/src/release-notes.ts @@ -14,6 +14,13 @@ export type ReleaseNote = { } export const RELEASE_NOTES: ReleaseNote[] = [ + { + version: '26.12.2', + date: '2026-06-13', + highlights: [ + 'You can now mute an entire thread. Choose "Mute thread" from any note\'s menu and the whole conversation, including every reply, disappears from your feeds and notifications. Muted threads stay muted across your devices, and you can unmute them from Settings.' + ] + }, { version: '26.12.1', date: '2026-06-13', From 6c71318f33d7945c5af3ca573393cd9556a4ae2b Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 13:05:09 -0400 Subject: [PATCH 09/11] style: prettier-format thread-muting code Normalizes formatting of the new test files and the MuteListPage 'Muted threads' section. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/__tests__/event.spec.ts | 35 ++++++++++++++++++--- src/lib/__tests__/tag.spec.ts | 36 ++++++++++++++++------ src/pages/secondary/MuteListPage/index.tsx | 22 ++++++------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/src/lib/__tests__/event.spec.ts b/src/lib/__tests__/event.spec.ts index d1d342b..55bdf2b 100644 --- a/src/lib/__tests__/event.spec.ts +++ b/src/lib/__tests__/event.spec.ts @@ -8,7 +8,16 @@ const OTHER = 'c'.repeat(64) // minimal Event factory — getThreadRootId only reads id, kind, tags, content const ev = (over: Partial): Event => - ({ id: 'self', kind: 1, tags: [], content: '', pubkey: '', created_at: 0, sig: '', ...over }) as Event + ({ + id: 'self', + kind: 1, + tags: [], + content: '', + pubkey: '', + created_at: 0, + sig: '', + ...over + }) as Event describe('getThreadRootId', () => { it('returns own id for a top-level note', () => { @@ -16,11 +25,17 @@ describe('getThreadRootId', () => { }) it('returns the root-marked id for a reply', () => { - expect(getThreadRootId(ev({ id: 'r', tags: [[ 'e', ROOT, '', 'root' ]] }))).toBe(ROOT) + expect(getThreadRootId(ev({ id: 'r', tags: [['e', ROOT, '', 'root']] }))).toBe(ROOT) }) it('returns the root for a deep reply (root marker constant down the thread)', () => { - const deep = ev({ id: 'd', tags: [['e', ROOT, '', 'root'], ['e', PARENT, '', 'reply']] }) + const deep = ev({ + id: 'd', + tags: [ + ['e', ROOT, '', 'root'], + ['e', PARENT, '', 'reply'] + ] + }) expect(getThreadRootId(deep)).toBe(ROOT) }) @@ -41,7 +56,13 @@ describe('isInMutedThread', () => { }) it('hides a deep descendant', () => { - const deep = ev({ id: 'd', tags: [['e', ROOT, '', 'root'], ['e', PARENT, '', 'reply']] }) + const deep = ev({ + id: 'd', + tags: [ + ['e', ROOT, '', 'root'], + ['e', PARENT, '', 'reply'] + ] + }) expect(isInMutedThread(deep, muted)).toBe(true) }) @@ -55,7 +76,11 @@ describe('isInMutedThread', () => { it('does NOT hide a quote that embeds the root as nostr:nevent', () => { const nevent = nip19.neventEncode({ id: ROOT }) - const quote = ev({ id: 'q', content: `look nostr:${nevent}`, tags: [['e', ROOT, '', 'mention']] }) + const quote = ev({ + id: 'q', + content: `look nostr:${nevent}`, + tags: [['e', ROOT, '', 'mention']] + }) // getRootEventHexId excludes embedded-note ids from its positional fallback, // so the quote's computed root is itself → visible. expect(isInMutedThread(quote, muted)).toBe(false) diff --git a/src/lib/__tests__/tag.spec.ts b/src/lib/__tests__/tag.spec.ts index 0fd8835..a1c682a 100644 --- a/src/lib/__tests__/tag.spec.ts +++ b/src/lib/__tests__/tag.spec.ts @@ -1,11 +1,7 @@ import { describe, it, expect } from 'vitest' import { generateSecretKey, getPublicKey } from 'nostr-tools' import { getPubkeysFromPTags } from '@/lib/tag' -import { - getEventIdsFromETags, - appendETag, - stripETag -} from '@/lib/tag' +import { getEventIdsFromETags, appendETag, stripETag } from '@/lib/tag' const PK_A = getPublicKey(generateSecretKey()) const PK_B = getPublicKey(generateSecretKey()) @@ -76,17 +72,30 @@ describe('getEventIdsFromETags', () => { }) it('ignores non-e tags and empty ids', () => { - expect(getEventIdsFromETags([['p', ID_A], ['e', '']])).toEqual([]) + expect( + getEventIdsFromETags([ + ['p', ID_A], + ['e', ''] + ]) + ).toEqual([]) }) it('dedupes', () => { - expect(getEventIdsFromETags([['e', ID_A], ['e', ID_A]])).toEqual([ID_A]) + expect( + getEventIdsFromETags([ + ['e', ID_A], + ['e', ID_A] + ]) + ).toEqual([ID_A]) }) }) describe('appendETag', () => { it('appends an e tag', () => { - expect(appendETag([['p', ID_A]], ID_B)).toEqual([['p', ID_A], ['e', ID_B]]) + expect(appendETag([['p', ID_A]], ID_B)).toEqual([ + ['p', ID_A], + ['e', ID_B] + ]) }) it('is a no-op (same reference) when already present', () => { @@ -97,7 +106,16 @@ describe('appendETag', () => { describe('stripETag', () => { it('removes the matching e tag, keeps others', () => { - expect(stripETag([['e', ID_A], ['e', ID_B], ['p', ID_A]], ID_A)).toEqual([ + expect( + stripETag( + [ + ['e', ID_A], + ['e', ID_B], + ['p', ID_A] + ], + ID_A + ) + ).toEqual([ ['e', ID_B], ['p', ID_A] ]) diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 4716353..5f25637 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -71,18 +71,18 @@ const MuteListPage = forwardRef(({ index }: { index?: number }, ref) => { ))} {mutePubkeys.length > visibleMutePubkeys.length &&
}
- {muteEventIds.length > 0 && ( -
-
- {t('Muted threads')} -
-
- {muteEventIds.map((id) => ( - - ))} -
+ {muteEventIds.length > 0 && ( +
+
+ {t('Muted threads')}
- )} +
+ {muteEventIds.map((id) => ( + + ))} +
+
+ )} ) }) From 538df2ad36e7a927fac7ccc25ed9038c16e46867 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 13:15:00 -0400 Subject: [PATCH 10/11] fix(mute): exclude muted threads from notification unread counts NotificationItem already hid muted-thread items at render time, but the unread badge (per-column header + favicon/title) is driven by a separate notificationFilter path that did not know about thread mutes. A muted hellthread that kept tagging you still incremented the badge while the list showed nothing. Thread the muteEventIdSet through notificationFilter + useNotificationFilter so counts and list agree. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/hooks/useNotificationFilter.ts | 5 ++-- src/lib/notification.spec.ts | 48 ++++++++++++++++++++++++++++++ src/lib/notification.ts | 11 ++++++- 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/lib/notification.spec.ts diff --git a/src/hooks/useNotificationFilter.ts b/src/hooks/useNotificationFilter.ts index c24f5ba..a31f7c0 100644 --- a/src/hooks/useNotificationFilter.ts +++ b/src/hooks/useNotificationFilter.ts @@ -16,7 +16,7 @@ import { useCallback } from 'react' * match the column's account. */ export function useNotificationFilter(pubkey: string | null | undefined) { - const { mutePubkeySet } = useMuteList() + const { mutePubkeySet, muteEventIdSet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() return useCallback( @@ -24,8 +24,9 @@ export function useNotificationFilter(pubkey: string | null | undefined) { notificationFilter(event, { pubkey, mutePubkeySet, + muteEventIdSet, hideContentMentioningMutedUsers }), - [pubkey, mutePubkeySet, hideContentMentioningMutedUsers] + [pubkey, mutePubkeySet, muteEventIdSet, hideContentMentioningMutedUsers] ) } diff --git a/src/lib/notification.spec.ts b/src/lib/notification.spec.ts new file mode 100644 index 0000000..24f7ebe --- /dev/null +++ b/src/lib/notification.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest' +import { kinds, type NostrEvent } from 'nostr-tools' +import { notificationFilter } from './notification' + +const ROOT = 'a'.repeat(64) +const OTHER_ROOT = 'b'.repeat(64) +const REPLY_ID = 'd'.repeat(64) + +const ev = (over: Partial): NostrEvent => + ({ + id: 'self', + kind: kinds.ShortTextNote, + tags: [], + content: '', + pubkey: 'author', + created_at: 0, + sig: '', + ...over + }) as NostrEvent + +const baseOpts = { + pubkey: 'me', + mutePubkeySet: new Set(), + muteEventIdSet: new Set(), + hideContentMentioningMutedUsers: false +} + +describe('notificationFilter — muted threads', () => { + it('drops a notification that is the muted root note', () => { + expect(notificationFilter(ev({ id: ROOT }), { ...baseOpts, muteEventIdSet: new Set([ROOT]) })).toBe( + false + ) + }) + + it('drops a reply that belongs to a muted thread', () => { + const reply = ev({ id: REPLY_ID, tags: [['e', ROOT, '', 'root']] }) + expect(notificationFilter(reply, { ...baseOpts, muteEventIdSet: new Set([ROOT]) })).toBe(false) + }) + + it('keeps a notification from a different (unmuted) thread', () => { + const note = ev({ id: REPLY_ID, tags: [['e', OTHER_ROOT, '', 'root']] }) + expect(notificationFilter(note, { ...baseOpts, muteEventIdSet: new Set([ROOT]) })).toBe(true) + }) + + it('keeps everything when no threads are muted', () => { + expect(notificationFilter(ev({ id: ROOT }), baseOpts)).toBe(true) + }) +}) diff --git a/src/lib/notification.ts b/src/lib/notification.ts index f8c3471..53b1dfe 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,5 +1,5 @@ import { kinds, NostrEvent } from 'nostr-tools' -import { isMentioningMutedUsers } from './event' +import { isInMutedThread, isMentioningMutedUsers } from './event' import { tagNameEquals } from './tag' export function notificationFilter( @@ -7,10 +7,12 @@ export function notificationFilter( { pubkey, mutePubkeySet, + muteEventIdSet, hideContentMentioningMutedUsers }: { pubkey?: string | null mutePubkeySet: Set + muteEventIdSet: Set hideContentMentioningMutedUsers?: boolean } ): boolean { @@ -21,6 +23,13 @@ export function notificationFilter( return false } + // Muted threads must also drop OUT of the unread counts (column badge + + // favicon/title), not just the rendered list — otherwise a muted hellthread + // that keeps tagging you still lights up the badge. + if (isInMutedThread(event, muteEventIdSet)) { + return false + } + if (pubkey && event.kind === kinds.Reaction) { const targetPubkey = event.tags.findLast(tagNameEquals('p'))?.[1] if (targetPubkey !== pubkey) return false From 9c698fb91de3c03ba12ef9b0bd87e41d9d5bd167 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 13:15:00 -0400 Subject: [PATCH 11/11] fix(mute): reactively drop unmuted rows on the Mute List page The muted-threads memo keyed on [pubkey], copied from the muted-users list. Users have a MuteButton that masks the stale row, but a thread row has no such toggle, so an unmuted thread lingered until remount. Key the memo on [muteEventIdSet] so the row vanishes on unmute. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/secondary/MuteListPage/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 5f25637..ff2051a 100644 --- a/src/pages/secondary/MuteListPage/index.tsx +++ b/src/pages/secondary/MuteListPage/index.tsx @@ -18,7 +18,9 @@ const MuteListPage = forwardRef(({ index }: { index?: number }, ref) => { const { profile, pubkey } = useNostr() const { getMutePubkeys, muteEventIdSet } = useMuteList() const mutePubkeys = useMemo(() => getMutePubkeys(), [pubkey]) - const muteEventIds = useMemo(() => Array.from(muteEventIdSet), [pubkey]) + // Reactive (unlike mutePubkeys above): unmuting a thread has no MuteButton + // toggle to mask the row, so the row should vanish as soon as the set updates. + const muteEventIds = useMemo(() => Array.from(muteEventIdSet), [muteEventIdSet]) const [visibleMutePubkeys, setVisibleMutePubkeys] = useState([]) const bottomRef = useRef(null)