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/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, 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/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 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/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8eda2a0..c3aafcd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1078,6 +1078,9 @@ 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', + 'Muted threads': 'Muted threads' } } diff --git a/src/lib/__tests__/event.spec.ts b/src/lib/__tests__/event.spec.ts new file mode 100644 index 0000000..55bdf2b --- /dev/null +++ b/src/lib/__tests__/event.spec.ts @@ -0,0 +1,92 @@ +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/__tests__/tag.spec.ts b/src/lib/__tests__/tag.spec.ts index 7badf9c..a1c682a 100644 --- a/src/lib/__tests__/tag.spec.ts +++ b/src/lib/__tests__/tag.spec.ts @@ -1,6 +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' const PK_A = getPublicKey(generateSecretKey()) const PK_B = getPublicKey(generateSecretKey()) @@ -54,3 +55,69 @@ 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/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 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 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) => { diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx index 8d5085d..ff2051a 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,11 @@ 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]) + // 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) @@ -69,12 +73,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) 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) 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',