From ada2fc2a4563f479038a379b5f40b7433da50429 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:10:36 -0400 Subject: [PATCH 1/9] refactor(mute): extract MutedUserItem into shared component --- src/components/Mute/MutedUserItem.tsx | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/components/Mute/MutedUserItem.tsx diff --git a/src/components/Mute/MutedUserItem.tsx b/src/components/Mute/MutedUserItem.tsx new file mode 100644 index 0000000..8d542af --- /dev/null +++ b/src/components/Mute/MutedUserItem.tsx @@ -0,0 +1,65 @@ +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 { useMuteList } from '@/providers/UserListsProvider' +import { Loader, Lock, Unlock } from 'lucide-react' +import { useMemo, useState } from 'react' + +export default function MutedUserItem({ pubkey }: { pubkey: string }) { + const { changing, getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList() + const { profile } = useFetchProfile(pubkey) + const muteType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType]) + const [switching, setSwitching] = useState(false) + + return ( +
+ +
+ + +
{profile?.about}
+
+
+ {switching ? ( + + ) : muteType === 'private' ? ( + + ) : muteType === 'public' ? ( + + ) : null} + +
+
+ ) +} From 8faeb151587b34b01813e3e15e3b908c933f9134 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:12:01 -0400 Subject: [PATCH 2/9] refactor(mute): extract MutedThreadItem into shared component --- src/components/Mute/MutedThreadItem.tsx | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/components/Mute/MutedThreadItem.tsx diff --git a/src/components/Mute/MutedThreadItem.tsx b/src/components/Mute/MutedThreadItem.tsx new file mode 100644 index 0000000..92f890b --- /dev/null +++ b/src/components/Mute/MutedThreadItem.tsx @@ -0,0 +1,34 @@ +import ContentPreview from '@/components/ContentPreview' +import { Button } from '@/components/ui/button' +import { useFetchEvent } from '@/hooks' +import { useMuteList } from '@/providers/UserListsProvider' +import { Loader } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function MutedThreadItem({ eventId }: { eventId: string }) { + const { t } = useTranslation() + const { changing, unmuteThread } = useMuteList() + const { event } = useFetchEvent(eventId) + const [removing, setRemoving] = useState(false) + + return ( +
+
+ +
+ +
+ ) +} From 3c2a82a2539b8a7510943c6bce0f9fead265d1f6 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:13:39 -0400 Subject: [PATCH 3/9] refactor(mute): extract MutedWordsSection; settings MutedWords wraps it --- src/components/Mute/MutedWordsSection.tsx | 73 +++++++++++++++++++ .../GeneralSettingsPage/MutedWords.tsx | 67 +---------------- 2 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 src/components/Mute/MutedWordsSection.tsx diff --git a/src/components/Mute/MutedWordsSection.tsx b/src/components/Mute/MutedWordsSection.tsx new file mode 100644 index 0000000..b5cec5b --- /dev/null +++ b/src/components/Mute/MutedWordsSection.tsx @@ -0,0 +1,73 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { Plus, X } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +export default function MutedWordsSection() { + const { t } = useTranslation() + const { mutedWords, setMutedWords } = useContentPolicy() + const [newMutedWord, setNewMutedWord] = useState('') + + const handleAddMutedWord = () => { + const word = newMutedWord.trim().toLowerCase() + if (word && !mutedWords.includes(word)) { + setMutedWords([...mutedWords, word]) + setNewMutedWord('') + } + } + + const handleRemoveMutedWord = (word: string) => { + setMutedWords(mutedWords.filter((w) => w !== word)) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddMutedWord() + } + } + + return ( +
+
+ setNewMutedWord(e.target.value)} + onKeyDown={handleKeyDown} + className="flex-1" + /> + +
+ {mutedWords.length > 0 && ( +
+ {mutedWords.map((word) => ( +
+ {word} + +
+ ))} +
+ )} +
+ ) +} diff --git a/src/pages/secondary/GeneralSettingsPage/MutedWords.tsx b/src/pages/secondary/GeneralSettingsPage/MutedWords.tsx index 9d8ad52..a21cb1c 100644 --- a/src/pages/secondary/GeneralSettingsPage/MutedWords.tsx +++ b/src/pages/secondary/GeneralSettingsPage/MutedWords.tsx @@ -1,78 +1,15 @@ -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { useContentPolicy } from '@/providers/ContentPolicyProvider' -import { Plus, X } from 'lucide-react' -import { useState } from 'react' +import MutedWordsSection from '@/components/Mute/MutedWordsSection' import { useTranslation } from 'react-i18next' import SettingItem from './SettingItem' export default function MutedWords() { const { t } = useTranslation() - const { mutedWords, setMutedWords } = useContentPolicy() - const [newMutedWord, setNewMutedWord] = useState('') - - const handleAddMutedWord = () => { - const word = newMutedWord.trim().toLowerCase() - if (word && !mutedWords.includes(word)) { - setMutedWords([...mutedWords, word]) - setNewMutedWord('') - } - } - - const handleRemoveMutedWord = (word: string) => { - setMutedWords(mutedWords.filter((w) => w !== word)) - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleAddMutedWord() - } - } return ( -
-
- setNewMutedWord(e.target.value)} - onKeyDown={handleKeyDown} - className="flex-1" - /> - -
- {mutedWords.length > 0 && ( -
- {mutedWords.map((word) => ( -
- {word} - -
- ))} -
- )} -
+
) } From 41b71c3e28eaac87d2ad14a38e86c782d797555f Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:15:29 -0400 Subject: [PATCH 4/9] feat(mute): add MuteListColumnBody (Tabs over users/threads/words) --- src/components/Column/MuteListColumnBody.tsx | 119 +++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/components/Column/MuteListColumnBody.tsx diff --git a/src/components/Column/MuteListColumnBody.tsx b/src/components/Column/MuteListColumnBody.tsx new file mode 100644 index 0000000..f20f072 --- /dev/null +++ b/src/components/Column/MuteListColumnBody.tsx @@ -0,0 +1,119 @@ +import Tabs from '@/components/Tabs' +import MutedThreadItem from '@/components/Mute/MutedThreadItem' +import MutedUserItem from '@/components/Mute/MutedUserItem' +import MutedWordsSection from '@/components/Mute/MutedWordsSection' +import { useMuteList } from '@/providers/UserListsProvider' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const TABS = [ + { value: 'all', label: 'All' }, + { value: 'users', label: 'Users' }, + { value: 'threads', label: 'Threads' }, + { value: 'words', label: 'Words' } +] + +export default function MuteListColumnBody() { + const [tab, setTab] = useState('all') + + const showUsers = tab === 'all' || tab === 'users' + const showThreads = tab === 'all' || tab === 'threads' + const showWords = tab === 'all' || tab === 'words' + + return ( + <> + +
+ {showUsers && } + {showThreads && } + {showWords && } +
+ + ) +} + +function SectionHeader({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function UsersSection() { + const { t } = useTranslation() + const { getMutePubkeys } = useMuteList() + // Reactive so the list populates once the mute-list event loads from relay, + // and reflects unmutes immediately. Sorted by pubkey for a STABLE order so + // toggling a user public<->private (which reorders the underlying union) + // doesn't make rows jump. + const mutePubkeys = useMemo(() => [...getMutePubkeys()].sort(), [getMutePubkeys]) + const [visible, setVisible] = useState([]) + const bottomRef = useRef(null) + + useEffect(() => { + setVisible(mutePubkeys.slice(0, 10)) + }, [mutePubkeys]) + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && mutePubkeys.length > visible.length) { + setVisible((prev) => [...prev, ...mutePubkeys.slice(prev.length, prev.length + 10)]) + } + }, + { root: null, rootMargin: '10px', threshold: 1 } + ) + const el = bottomRef.current + if (el) observer.observe(el) + return () => { + if (el) observer.unobserve(el) + } + }, [visible, mutePubkeys]) + + return ( +
+ {t('Muted users')} + {mutePubkeys.length === 0 ? ( +
{t('No muted users')}
+ ) : ( +
+ {visible.map((pubkey, index) => ( + + ))} + {mutePubkeys.length > visible.length &&
} +
+ )} +
+ ) +} + +function ThreadsSection() { + const { t } = useTranslation() + const { muteEventIdSet } = useMuteList() + const muteEventIds = useMemo(() => Array.from(muteEventIdSet), [muteEventIdSet]) + + return ( +
+ {t('Muted threads')} + {muteEventIds.length === 0 ? ( +
{t('No muted threads')}
+ ) : ( +
+ {muteEventIds.map((id) => ( + + ))} +
+ )} +
+ ) +} + +function WordsSection() { + const { t } = useTranslation() + return ( +
+ {t('Muted words')} +
+ {t('Muted words apply to all your accounts.')} +
+ +
+ ) +} From 8ad349e4b4de3f1b95a1ee6cae2e768c063c6f7e Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:25:06 -0400 Subject: [PATCH 5/9] feat(mute): register mute-list column type (dispatch, picker, migrator, i18n) Co-Authored-By: Claude Sonnet 4.6 --- .../AddColumnModal/column-types.tsx | 24 ++++++++++++++++++- src/components/Column/ColumnHeader/index.tsx | 2 ++ .../Column/dispatch-coverage.spec.ts | 3 ++- src/components/Column/index.tsx | 3 +++ src/i18n/locales/en.ts | 8 ++++++- .../__tests__/migrate-columns.spec.ts | 10 ++++++++ src/services/local-storage.service.ts | 3 ++- src/types/column.d.ts | 1 + 8 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/components/AddColumnModal/column-types.tsx b/src/components/AddColumnModal/column-types.tsx index 9fb0793..695c402 100644 --- a/src/components/AddColumnModal/column-types.tsx +++ b/src/components/AddColumnModal/column-types.tsx @@ -1,5 +1,6 @@ // src/components/AddColumnModal/column-types.ts import MessagesColumnBody from '@/components/Column/MessagesColumnBody' +import MuteListColumnBody from '@/components/Column/MuteListColumnBody' import ArticlesColumnBody from '@/components/Column/ArticlesColumnBody' import RelayColumnBody from '@/components/Column/RelayColumnBody' import HomeColumnBody from '@/components/Column/HomeColumnBody' @@ -15,6 +16,7 @@ import { TAccountPointer } from '@/types' import { TColumn, TColumnType } from '@/types/column' import { Bell, + BellOff, Bookmark, BookOpen, Compass, @@ -342,6 +344,25 @@ const MESSAGES_DESCRIPTOR: ColumnTypeDescriptor = { previewHint: 'Pick a signing account to preview' } +const MUTE_LIST_DESCRIPTOR: ColumnTypeDescriptor = { + type: 'mute-list', + icon: BellOff, + label: 'Muted', + // 'm' is taken by Messages; use 'u' (mUted) for the tile shortcut. + shortcut: 'u', + defaults: (account) => ({ + viewContext: account?.pubkey, + signingIdentity: account?.pubkey ?? null, + type: 'mute-list' + }), + isReadyToPreview: (draft) => !!draft.viewContext, + // Mute management is for your own account: you can't read others' private + // mutes, and viewing a foreign public mute list is out of scope. + supportsViewAs: false, + PreviewBody: () => , + previewHint: 'Pick an account to preview' +} + export const COLUMN_TYPES: ColumnTypeDescriptor[] = [ HOME_DESCRIPTOR, NOTIFICATIONS_DESCRIPTOR, @@ -354,5 +375,6 @@ export const COLUMN_TYPES: ColumnTypeDescriptor[] = [ ARTICLES_DESCRIPTOR, DVM_FEED_DESCRIPTOR, RELATR_DISCOVERY_DESCRIPTOR, - MESSAGES_DESCRIPTOR + MESSAGES_DESCRIPTOR, + MUTE_LIST_DESCRIPTOR ] diff --git a/src/components/Column/ColumnHeader/index.tsx b/src/components/Column/ColumnHeader/index.tsx index 96270ba..ab13659 100644 --- a/src/components/Column/ColumnHeader/index.tsx +++ b/src/components/Column/ColumnHeader/index.tsx @@ -283,6 +283,8 @@ export function columnLabel(column: TColumn, t: (k: string) => string): string { } case 'messages': return t('Messages') + case 'mute-list': + return t('Muted') case 'detail': return t('Detail') case 'relay': { diff --git a/src/components/Column/dispatch-coverage.spec.ts b/src/components/Column/dispatch-coverage.spec.ts index 95f0bd6..326b004 100644 --- a/src/components/Column/dispatch-coverage.spec.ts +++ b/src/components/Column/dispatch-coverage.spec.ts @@ -30,7 +30,8 @@ const ALL_COLUMN_TYPES = Object.keys({ 'relatr-discovery': true, articles: true, favorites: true, - messages: true + messages: true, + 'mute-list': true } satisfies Record) as TColumnType[] function makeColumn(type: TColumnType): TColumn { diff --git a/src/components/Column/index.tsx b/src/components/Column/index.tsx index c7f06e6..a2a27b5 100644 --- a/src/components/Column/index.tsx +++ b/src/components/Column/index.tsx @@ -31,6 +31,7 @@ import { useTranslation } from 'react-i18next' import { SecondaryPageContext, useSecondaryPage } from '@/DeckManager' import HomeColumnBody from './HomeColumnBody' import MessagesColumnBody from './MessagesColumnBody' +import MuteListColumnBody from './MuteListColumnBody' import NotificationsColumnBody from './NotificationsColumnBody' import DetailColumnBody from './DetailColumnBody' import RelayColumnBody from './RelayColumnBody' @@ -411,6 +412,8 @@ export function dispatchBody(column: TColumn): React.ReactNode { return case 'messages': return + case 'mute-list': + return case 'detail': return case 'relay': diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4a88abf..2cbf94f 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1082,6 +1082,12 @@ export default { 'Mute thread': 'Mute thread', 'Unmute thread': 'Unmute thread', 'Muted threads': 'Muted threads', - 'This note is from a thread you muted': 'This note is from a thread you muted' + 'This note is from a thread you muted': 'This note is from a thread you muted', + 'Muted users': 'Muted users', + Threads: 'Threads', + 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.' } } diff --git a/src/services/__tests__/migrate-columns.spec.ts b/src/services/__tests__/migrate-columns.spec.ts index a2835eb..e32b913 100644 --- a/src/services/__tests__/migrate-columns.spec.ts +++ b/src/services/__tests__/migrate-columns.spec.ts @@ -134,6 +134,16 @@ describe('migrateColumns', () => { }) }) + describe('mute-list column type', () => { + it('keeps columns with type "mute-list"', () => { + const result = migrateColumns([ + { id: 'c1', viewContext: 'pk1', signingIdentity: 'pk1', type: 'mute-list' } + ]) + expect(result).toHaveLength(1) + expect(result[0].type).toBe('mute-list') + }) + }) + describe('regression — pre-existing migration behavior', () => { it('preserves relayUrl config', () => { const result = migrateColumns([ diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 5e584bd..e7667d4 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -77,7 +77,8 @@ export function migrateColumns(raw: unknown): TColumn[] { type !== 'relatr-discovery' && type !== 'articles' && type !== 'favorites' && - type !== 'messages' + type !== 'messages' && + type !== 'mute-list' ) { // 'dms' falls through here too — DM feature removed, drop the column. continue diff --git a/src/types/column.d.ts b/src/types/column.d.ts index 03ccf9b..546b292 100644 --- a/src/types/column.d.ts +++ b/src/types/column.d.ts @@ -14,6 +14,7 @@ export type TColumnType = | 'articles' | 'favorites' | 'messages' + | 'mute-list' /** * Type-specific column config. Fields are populated based on column type: From 42900a26ffc80afa74ab81eae972be7868e5e806 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:28:03 -0400 Subject: [PATCH 6/9] feat(mute): open /mutes as a deduped Muted column; retire MuteListPage --- src/lib/link.ts | 1 + src/pages/secondary/MuteListPage/index.tsx | 177 --------------------- src/providers/ColumnsProvider.tsx | 9 +- src/routes/secondary.tsx | 2 - 4 files changed, 9 insertions(+), 180 deletions(-) delete mode 100644 src/pages/secondary/MuteListPage/index.tsx diff --git a/src/lib/link.ts b/src/lib/link.ts index bc08a17..bd0d33c 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -225,6 +225,7 @@ export function routeOpensOwnColumn(route: string): boolean { parseSearchRoute(route) !== null || route === '/notifications' || route === '/bookmarks' || + route === '/mutes' || route === '/me' || route === '/profile' ) diff --git a/src/pages/secondary/MuteListPage/index.tsx b/src/pages/secondary/MuteListPage/index.tsx deleted file mode 100644 index ff2051a..0000000 --- a/src/pages/secondary/MuteListPage/index.tsx +++ /dev/null @@ -1,177 +0,0 @@ -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 { useFetchEvent, useFetchProfile } from '@/hooks' -import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' -import { useMuteList } from '@/providers/UserListsProvider' -import { useNostr } from '@/providers/NostrProvider' -import { Loader, Lock, Unlock } from 'lucide-react' -import { forwardRef, useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import NotFoundPage from '../NotFoundPage' - -const MuteListPage = forwardRef(({ index }: { index?: number }, ref) => { - const { t } = useTranslation() - const { profile, pubkey } = useNostr() - 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) - - useEffect(() => { - setVisibleMutePubkeys(mutePubkeys.slice(0, 10)) - }, [mutePubkeys]) - - useEffect(() => { - const options = { - root: null, - rootMargin: '10px', - threshold: 1 - } - - const observerInstance = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && mutePubkeys.length > visibleMutePubkeys.length) { - setVisibleMutePubkeys((prev) => [ - ...prev, - ...mutePubkeys.slice(prev.length, prev.length + 10) - ]) - } - }, options) - - const currentBottomRef = bottomRef.current - if (currentBottomRef) { - observerInstance.observe(currentBottomRef) - } - - return () => { - if (observerInstance && currentBottomRef) { - observerInstance.unobserve(currentBottomRef) - } - } - }, [visibleMutePubkeys, mutePubkeys]) - - if (!profile) { - return - } - - return ( - -
- {visibleMutePubkeys.map((pubkey, index) => ( - - ))} - {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) - const muteType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType]) - const [switching, setSwitching] = useState(false) - - return ( -
- -
- - -
{profile?.about}
-
-
- {switching ? ( - - ) : muteType === 'private' ? ( - - ) : muteType === 'public' ? ( - - ) : null} - -
-
- ) -} diff --git a/src/providers/ColumnsProvider.tsx b/src/providers/ColumnsProvider.tsx index d240445..548ed8e 100644 --- a/src/providers/ColumnsProvider.tsx +++ b/src/providers/ColumnsProvider.tsx @@ -224,7 +224,8 @@ const STANDING_TYPES: ReadonlySet = new Set([ 'profile', 'notifications', 'bookmarks', - 'search' + 'search', + 'mute-list' ]) /** @@ -660,6 +661,12 @@ export function ColumnsProvider({ children }: { children: ReactNode }) { resolvedConfig = undefined resolvedViewContext = actingPubkey resolvedSigningIdentity = actingPubkey + } else if (route === '/mutes') { + if (!actingPubkey) return + resolvedType = 'mute-list' + resolvedConfig = undefined + resolvedViewContext = actingPubkey + resolvedSigningIdentity = actingPubkey } else if (route === '/me' || route === '/profile') { // Self-profile shorthand. Rewrite to a Profile column scoped to self. if (!actingPubkey) return diff --git a/src/routes/secondary.tsx b/src/routes/secondary.tsx index f23dc36..cffe6ba 100644 --- a/src/routes/secondary.tsx +++ b/src/routes/secondary.tsx @@ -6,7 +6,6 @@ import ExternalContentPage from '@/pages/secondary/ExternalContentPage' import FollowingListPage from '@/pages/secondary/FollowingListPage' import FollowPackPage from '@/pages/secondary/FollowPackPage' import GeneralSettingsPage from '@/pages/secondary/GeneralSettingsPage' -import MuteListPage from '@/pages/secondary/MuteListPage' import NoteListPage from '@/pages/secondary/NoteListPage' import NotePage from '@/pages/secondary/NotePage' import OthersRelaySettingsPage from '@/pages/secondary/OthersRelaySettingsPage' @@ -53,7 +52,6 @@ const SECONDARY_ROUTE_CONFIGS: { { path: '/settings/system', element: }, { path: '/settings/agents', element: }, { path: '/profile-editor', element: }, - { path: '/mutes', element: }, { path: '/rizful', element: }, { path: '/bookmarks', element: }, { path: '/follow-packs/:id', element: } From 53118e6ee33cda5261f3542bcdbcbbc0f51a3612 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:32:25 -0400 Subject: [PATCH 7/9] test(mute): cover /mutes in routeOpensOwnColumn --- src/lib/__tests__/link.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/__tests__/link.spec.ts b/src/lib/__tests__/link.spec.ts index 8e3d31d..917f7b0 100644 --- a/src/lib/__tests__/link.spec.ts +++ b/src/lib/__tests__/link.spec.ts @@ -250,6 +250,10 @@ describe('routeOpensOwnColumn — Detail-column replace-mode delegation policy', expect(routeOpensOwnColumn('/bookmarks')).toBe(true) }) + it('returns true for /mutes', () => { + expect(routeOpensOwnColumn('/mutes')).toBe(true) + }) + it('returns true for the /me self-profile shorthand', () => { expect(routeOpensOwnColumn('/me')).toBe(true) }) From b6313e80953c2e209ef73677e0c87159f5a8021d Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 13 Jun 2026 23:33:49 -0400 Subject: [PATCH 8/9] chore(release): v26.13.0 Muted column --- package.json | 2 +- src/release-notes.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 05757e7..c5456f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jank", - "version": "26.12.2", + "version": "26.13.0", "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 9713a70..ac18182 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.0', + date: '2026-06-13', + highlights: [ + 'A new "Muted" column puts everything you have muted in one place. See and manage muted users, muted threads, and muted words together, with tabs to filter by type. Add it from the column picker, or open it from your profile menu.' + ] + }, { version: '26.12.2', date: '2026-06-13', From 53b4adc21b299e053d5bd9d26d05b558196aa32f Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 00:05:16 -0400 Subject: [PATCH 9/9] feat(mute): clickable Muted-column rows + consistent word rows Address review feedback on the Muted column: - User rows now navigate to the profile on click; thread rows open the thread (push(toNote)/push(toProfile), mirroring how feed cards open). Action controls (lock toggle, unmute) stopPropagation so they don't also navigate. - Words render as full-width rows (icon + word + remove) in the column via a new MutedWordsSection variant='rows', so users/threads/words read as one consistent list. Settings keeps the compact 'chips' default. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Column/MuteListColumnBody.tsx | 2 +- src/components/Mute/MutedThreadItem.tsx | 11 ++- src/components/Mute/MutedUserItem.tsx | 12 +++- src/components/Mute/MutedWordsSection.tsx | 70 ++++++++++++++------ 4 files changed, 69 insertions(+), 26 deletions(-) diff --git a/src/components/Column/MuteListColumnBody.tsx b/src/components/Column/MuteListColumnBody.tsx index f20f072..a1fe169 100644 --- a/src/components/Column/MuteListColumnBody.tsx +++ b/src/components/Column/MuteListColumnBody.tsx @@ -113,7 +113,7 @@ function WordsSection() {
{t('Muted words apply to all your accounts.')}
- +
) } diff --git a/src/components/Mute/MutedThreadItem.tsx b/src/components/Mute/MutedThreadItem.tsx index 92f890b..251a719 100644 --- a/src/components/Mute/MutedThreadItem.tsx +++ b/src/components/Mute/MutedThreadItem.tsx @@ -1,6 +1,8 @@ import ContentPreview from '@/components/ContentPreview' import { Button } from '@/components/ui/button' +import { useSecondaryPage } from '@/DeckManager' import { useFetchEvent } from '@/hooks' +import { toNote } from '@/lib/link' import { useMuteList } from '@/providers/UserListsProvider' import { Loader } from 'lucide-react' import { useState } from 'react' @@ -8,12 +10,16 @@ import { useTranslation } from 'react-i18next' export default function MutedThreadItem({ eventId }: { eventId: string }) { const { t } = useTranslation() + const { push } = useSecondaryPage() const { changing, unmuteThread } = useMuteList() const { event } = useFetchEvent(eventId) const [removing, setRemoving] = useState(false) return ( -
+
push(toNote(event ?? eventId))} + >
@@ -22,7 +28,8 @@ export default function MutedThreadItem({ eventId }: { eventId: string }) { size="sm" className="shrink-0" disabled={changing || removing} - onClick={() => { + onClick={(e) => { + e.stopPropagation() setRemoving(true) unmuteThread(eventId).finally(() => setRemoving(false)) }} diff --git a/src/components/Mute/MutedUserItem.tsx b/src/components/Mute/MutedUserItem.tsx index 8d542af..096857a 100644 --- a/src/components/Mute/MutedUserItem.tsx +++ b/src/components/Mute/MutedUserItem.tsx @@ -3,19 +3,25 @@ import Nip05 from '@/components/Nip05' import { Button } from '@/components/ui/button' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' +import { useSecondaryPage } from '@/DeckManager' import { useFetchProfile } from '@/hooks' +import { toProfile } from '@/lib/link' import { useMuteList } from '@/providers/UserListsProvider' import { Loader, Lock, Unlock } from 'lucide-react' import { useMemo, useState } from 'react' export default function MutedUserItem({ pubkey }: { pubkey: string }) { + const { push } = useSecondaryPage() const { changing, getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList() const { profile } = useFetchProfile(pubkey) const muteType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType]) const [switching, setSwitching] = useState(false) return ( -
+
push(toProfile(pubkey))} + >
{profile?.about}
-
+ {/* Action controls: stop row-click navigation so toggling/unmuting + doesn't also open the profile. */} +
e.stopPropagation()}> {switching ? (
- {mutedWords.length > 0 && ( -
- {mutedWords.map((word) => ( -
- {word} - +
+ ))} +
+ ) : ( +
+ {mutedWords.map((word) => ( +
- - -
- ))} -
- )} + {word} + +
+ ))} +
+ ))}
) }