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.13.0",
"version": "26.13.1",
"description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient",
"type": "module",
"author": "DocNR",
Expand Down
5 changes: 4 additions & 1 deletion src/components/Column/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -332,7 +333,9 @@ export function Column({ column, dragHandleProps, style }: Props) {
viewContext={column.viewContext}
signingIdentity={column.signingIdentity}
>
<Suspense fallback={<ColumnBodyLoading />}>{dispatchBody(column)}</Suspense>
<MutedThreadRevealProvider>
<Suspense fallback={<ColumnBodyLoading />}>{dispatchBody(column)}</Suspense>
</MutedThreadRevealProvider>
</ScopedSecondaryPage>
</AccountScope>
</ScrollContainerProvider>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Note/MutedNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function MutedNote({
variant="outline"
>
<Eye />
{t('Temporarily display this note')}
{reason === 'thread' ? t('Reveal muted thread') : t('Temporarily display this note')}
</Button>
</div>
)
Expand Down
14 changes: 11 additions & 3 deletions src/components/Note/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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]
Expand All @@ -104,8 +109,11 @@ export default function Note({
content = <UnknownNote className="mt-1" event={event} />
} else if (mutePubkeySet.has(event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} />
} else if (isInMutedThread(event, muteEventIdSet) && !showMuted) {
content = <MutedNote reason="thread" show={() => setShowMuted(true)} />
} else if (
isInMutedThread(event, muteEventIdSet) &&
!revealedMutedThreads.has(getThreadRootId(event))
) {
content = <MutedNote reason="thread" show={() => revealMutedThread(getThreadRootId(event))} />
} else if (isNsfw && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} />
} else if (event.kind === kinds.Highlights) {
Expand Down
28 changes: 24 additions & 4 deletions src/hooks/useFilteredReplies.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<NostrEvent[]>([])
const [hasReplied, setHasReplied] = useState(false)
Expand All @@ -25,15 +27,23 @@ 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)
}

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
Expand All @@ -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<NostrEvent[]>([])
const [hasReplied, setHasReplied] = useState(false)

Expand All @@ -70,14 +81,23 @@ 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)
}
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
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 @@ -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'
}
}
53 changes: 53 additions & 0 deletions src/providers/MutedThreadRevealProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createContext, ReactNode, useContext, useMemo, useState } from 'react'

type TMutedThreadReveal = {
/** Thread ROOT ids revealed within this column. */
revealed: Set<string>
/** Reveal a muted thread (by its root id) for this column. */
reveal: (rootId: string) => void
}

const MutedThreadRevealContext = createContext<TMutedThreadReveal | null>(null)

/**
* Per-column reveal state for muted threads. Mounted once inside every
* <Column>, 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<Set<string>>(() => new Set())
const value = useMemo<TMutedThreadReveal>(
() => ({
revealed,
reveal: (rootId: string) =>
setRevealed((prev) => {
if (prev.has(rootId)) return prev
const next = new Set(prev)
next.add(rootId)
return next
})
}),
[revealed]
)
return (
<MutedThreadRevealContext.Provider value={value}>{children}</MutedThreadRevealContext.Provider>
)
}

// 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
}
7 changes: 7 additions & 0 deletions src/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading