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/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/MuteListColumnBody.tsx b/src/components/Column/MuteListColumnBody.tsx new file mode 100644 index 0000000..a1fe169 --- /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.')} +
+ +
+ ) +} 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/components/Mute/MutedThreadItem.tsx b/src/components/Mute/MutedThreadItem.tsx new file mode 100644 index 0000000..251a719 --- /dev/null +++ b/src/components/Mute/MutedThreadItem.tsx @@ -0,0 +1,41 @@ +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' +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))} + > +
+ +
+ +
+ ) +} diff --git a/src/components/Mute/MutedUserItem.tsx b/src/components/Mute/MutedUserItem.tsx new file mode 100644 index 0000000..096857a --- /dev/null +++ b/src/components/Mute/MutedUserItem.tsx @@ -0,0 +1,73 @@ +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 { 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 ? ( + + ) : muteType === 'private' ? ( + + ) : muteType === 'public' ? ( + + ) : null} + +
+
+ ) +} diff --git a/src/components/Mute/MutedWordsSection.tsx b/src/components/Mute/MutedWordsSection.tsx new file mode 100644 index 0000000..bb61150 --- /dev/null +++ b/src/components/Mute/MutedWordsSection.tsx @@ -0,0 +1,101 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useContentPolicy } from '@/providers/ContentPolicyProvider' +import { Ban, Plus, X } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * Muted-words manager: an add input plus the current list. `variant` controls + * the list rendering only — `chips` (compact wrap, used in Settings) or `rows` + * (full-width rows, used in the Muted column so all three mute types read as + * the same kind of list). + */ +export default function MutedWordsSection({ + variant = 'chips' +}: { + variant?: 'chips' | 'rows' +}) { + 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 && + (variant === 'rows' ? ( +
+ {mutedWords.map((word) => ( +
+ + {word} + +
+ ))} +
+ ) : ( +
+ {mutedWords.map((word) => ( +
+ {word} + +
+ ))} +
+ ))} +
+ ) +} 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/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) }) 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/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} - -
- ))} -
- )} -
+
) } 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/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', 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: } 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: