From 97e5966b283fbf541c62a49a3bf6d37b9576462f Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 07:15:51 -0400 Subject: [PATCH 1/2] fix(mute): reveal a muted thread's whole conversation (note + replies) Opening a muted thread showed the note but hid every reply: the reveal was note-scoped (showMuted on each Note), so clicking reveal un-collapsed only the root while useFilteredReplies kept hard-hiding the replies. Make the reveal thread-scoped AND per-column via MutedThreadRevealProvider (mounted once per ). The Note collapse and the reply filters (useFilteredReplies + useFilteredAllReplies) both read it, keyed by thread root, so one click on 'Reveal muted thread' shows the note and all its replies together. Being per-column, closing the thread's detail column drops the reveal: reopening starts collapsed again, so a still-muted thread never looks unmuted (reveal = temporary peek, unmute = permanent). Also fixes a parity-commit inconsistency where useFilteredAllReplies carried muteEventIdSet as an unused dep but never applied the hide. Threads stay muted in feeds/notifications regardless. Thread button copy: 'Temporarily display this note' -> 'Reveal muted thread'. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Column/index.tsx | 5 +- src/components/Note/MutedNote.tsx | 2 +- src/components/Note/index.tsx | 14 ++++-- src/hooks/useFilteredReplies.tsx | 28 +++++++++-- src/i18n/locales/en.ts | 3 +- src/providers/MutedThreadRevealProvider.tsx | 53 +++++++++++++++++++++ 6 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 src/providers/MutedThreadRevealProvider.tsx diff --git a/src/components/Column/index.tsx b/src/components/Column/index.tsx index a2a27b5..85618b0 100644 --- a/src/components/Column/index.tsx +++ b/src/components/Column/index.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { useColumns } from '@/providers/ColumnsProvider' import { AccountScope } from '@/providers/AccountScope' import { ScrollContainerProvider } from '@/providers/ScrollContainerProvider' +import { MutedThreadRevealProvider } from '@/providers/MutedThreadRevealProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' import { useNostr } from '@/providers/NostrProvider' @@ -332,7 +333,9 @@ export function Column({ column, dragHandleProps, style }: Props) { viewContext={column.viewContext} signingIdentity={column.signingIdentity} > - }>{dispatchBody(column)} + + }>{dispatchBody(column)} + diff --git a/src/components/Note/MutedNote.tsx b/src/components/Note/MutedNote.tsx index 4a04e5a..499fef5 100644 --- a/src/components/Note/MutedNote.tsx +++ b/src/components/Note/MutedNote.tsx @@ -26,7 +26,7 @@ export default function MutedNote({ variant="outline" > - {t('Temporarily display this note')} + {reason === 'thread' ? t('Reveal muted thread') : t('Temporarily display this note')} ) diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 312ea4c..5289ce5 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -1,13 +1,14 @@ import { useSecondaryPage } from '@/DeckManager' import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants' import { cn } from '@/lib/utils' -import { getParentStuff, isInMutedThread, isNsfwEvent } from '@/lib/event' +import { getParentStuff, getThreadRootId, isInMutedThread, isNsfwEvent } from '@/lib/event' import { toExternalContent, toNote } from '@/lib/link' import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/UserListsProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserPreferences } from '@/providers/UserPreferencesProvider' +import { useMutedThreadReveal } from '@/providers/MutedThreadRevealProvider' import { Event, kinds } from 'nostr-tools' import { ReactNode, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -79,6 +80,10 @@ export default function Note({ const [showNsfw, setShowNsfw] = useState(false) const { mutePubkeySet, muteEventIdSet } = useMuteList() const [showMuted, setShowMuted] = useState(false) + // Thread reveal is per-column (resets when the column closes) and keyed by + // thread root, so revealing the root note also un-hides this thread's replies + // — see useFilteredReplies. + const { revealed: revealedMutedThreads, reveal: revealMutedThread } = useMutedThreadReveal() const isNsfw = useMemo( () => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)), [event, nsfwDisplayPolicy] @@ -104,8 +109,11 @@ export default function Note({ content = } else if (mutePubkeySet.has(event.pubkey) && !showMuted) { content = setShowMuted(true)} /> - } else if (isInMutedThread(event, muteEventIdSet) && !showMuted) { - content = setShowMuted(true)} /> + } else if ( + isInMutedThread(event, muteEventIdSet) && + !revealedMutedThreads.has(getThreadRootId(event)) + ) { + content = revealMutedThread(getThreadRootId(event))} /> } else if (isNsfw && !showNsfw) { content = setShowNsfw(true)} /> } else if (event.kind === kinds.Highlights) { diff --git a/src/hooks/useFilteredReplies.tsx b/src/hooks/useFilteredReplies.tsx index bf6b910..82888e0 100644 --- a/src/hooks/useFilteredReplies.tsx +++ b/src/hooks/useFilteredReplies.tsx @@ -1,6 +1,7 @@ -import { getEventKey, isInMutedThread, isMentioningMutedUsers } from '@/lib/event' +import { getEventKey, getThreadRootId, isInMutedThread, isMentioningMutedUsers } from '@/lib/event' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/providers/UserListsProvider' +import { useMutedThreadReveal } from '@/providers/MutedThreadRevealProvider' import { useNostr } from '@/providers/NostrProvider' import { NostrEvent } from 'nostr-tools' import { useEffect, useState } from 'react' @@ -10,6 +11,7 @@ export function useFilteredReplies(stuffKey: string) { const { pubkey } = useNostr() const { mutePubkeySet, muteEventIdSet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() + const { revealed: revealedMutedThreads } = useMutedThreadReveal() const allThreads = useAllDescendantThreads(stuffKey) const [replies, setReplies] = useState([]) const [hasReplied, setHasReplied] = useState(false) @@ -25,7 +27,8 @@ export function useFilteredReplies(stuffKey: string) { replyKeySet.add(key) if (mutePubkeySet.has(evt.pubkey)) continue - if (isInMutedThread(evt, muteEventIdSet)) continue + if (isInMutedThread(evt, muteEventIdSet) && !revealedMutedThreads.has(getThreadRootId(evt))) + continue if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) continue filtered.push(evt) @@ -33,7 +36,14 @@ export function useFilteredReplies(stuffKey: string) { filtered.sort((a, b) => b.created_at - a.created_at) setReplies(filtered) - }, [stuffKey, allThreads, mutePubkeySet, muteEventIdSet, hideContentMentioningMutedUsers]) + }, [ + stuffKey, + allThreads, + mutePubkeySet, + muteEventIdSet, + revealedMutedThreads, + hideContentMentioningMutedUsers + ]) useEffect(() => { let replied = false @@ -54,6 +64,7 @@ export function useFilteredAllReplies(stuffKey: string) { const allThreads = useAllDescendantThreads(stuffKey) const { mutePubkeySet, muteEventIdSet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() + const { revealed: revealedMutedThreads } = useMutedThreadReveal() const [replies, setReplies] = useState([]) const [hasReplied, setHasReplied] = useState(false) @@ -70,6 +81,8 @@ export function useFilteredAllReplies(stuffKey: string) { replyKeySet.add(key) if (mutePubkeySet.has(evt.pubkey)) continue + if (isInMutedThread(evt, muteEventIdSet) && !revealedMutedThreads.has(getThreadRootId(evt))) + continue if (hideContentMentioningMutedUsers && isMentioningMutedUsers(evt, mutePubkeySet)) continue replyEvents.push(evt) @@ -77,7 +90,14 @@ export function useFilteredAllReplies(stuffKey: string) { parentKeys = events.map((evt) => getEventKey(evt)) } setReplies(replyEvents.sort((a, b) => a.created_at - b.created_at)) - }, [stuffKey, allThreads, mutePubkeySet, muteEventIdSet, hideContentMentioningMutedUsers]) + }, [ + stuffKey, + allThreads, + mutePubkeySet, + muteEventIdSet, + revealedMutedThreads, + hideContentMentioningMutedUsers + ]) useEffect(() => { let replied = false diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 2cbf94f..828a1c9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1088,6 +1088,7 @@ export default { Words: 'Words', 'No muted users': 'No muted users', 'No muted threads': 'No muted threads', - 'Muted words apply to all your accounts.': 'Muted words apply to all your accounts.' + 'Muted words apply to all your accounts.': 'Muted words apply to all your accounts.', + 'Reveal muted thread': 'Reveal muted thread' } } diff --git a/src/providers/MutedThreadRevealProvider.tsx b/src/providers/MutedThreadRevealProvider.tsx new file mode 100644 index 0000000..ea89c8a --- /dev/null +++ b/src/providers/MutedThreadRevealProvider.tsx @@ -0,0 +1,53 @@ +import { createContext, ReactNode, useContext, useMemo, useState } from 'react' + +type TMutedThreadReveal = { + /** Thread ROOT ids revealed within this column. */ + revealed: Set + /** Reveal a muted thread (by its root id) for this column. */ + reveal: (rootId: string) => void +} + +const MutedThreadRevealContext = createContext(null) + +/** + * Per-column reveal state for muted threads. Mounted once inside every + * , so the reveal is scoped to that column's lifetime: + * + * - A muted thread opens COLLAPSED ("Reveal muted thread"). + * - Clicking reveal adds the thread root here; the `Note` collapse (root + + * parent notes) and the reply filters (useFilteredReplies / + * useFilteredAllReplies) both read it, so one click reveals the whole + * conversation — note AND replies — together. + * - CLOSING the column drops this state. Reopening the thread starts collapsed + * again, because the thread is still muted. This keeps "reveal" (a temporary + * peek) visually distinct from "unmute" (permanent). + * + * Not persisted; not shared across columns. Threads stay muted in feeds / + * notifications regardless. + */ +export function MutedThreadRevealProvider({ children }: { children: ReactNode }) { + const [revealed, setRevealed] = useState>(() => new Set()) + const value = useMemo( + () => ({ + revealed, + reveal: (rootId: string) => + setRevealed((prev) => { + if (prev.has(rootId)) return prev + const next = new Set(prev) + next.add(rootId) + return next + }) + }), + [revealed] + ) + return ( + {children} + ) +} + +// Stable empty fallback for notes rendered outside any column (e.g. tests). +const EMPTY: TMutedThreadReveal = { revealed: new Set(), reveal: () => {} } + +export function useMutedThreadReveal(): TMutedThreadReveal { + return useContext(MutedThreadRevealContext) ?? EMPTY +} From 97200174f91c208bc7cdbec170d7c185d626a7b7 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 07:15:51 -0400 Subject: [PATCH 2/2] chore(release): v26.13.1 reveal whole muted thread 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 c5456f0..7c753ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jank", - "version": "26.13.0", + "version": "26.13.1", "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 ac18182..aeae8ce 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.13.1', + date: '2026-06-14', + highlights: [ + 'Opening a muted thread now lets you read the whole conversation. Click "Reveal muted thread" and the note and all of its replies appear together, instead of showing only the one note. The thread stays muted in your feeds and notifications.' + ] + }, { version: '26.13.0', date: '2026-06-13',