Skip to content
Merged
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.12.2",
"version": "26.13.0",
"description": "A TweetDeck-style multi-column deck for Nostr — just another nostr klient",
"type": "module",
"author": "DocNR",
Expand Down
24 changes: 23 additions & 1 deletion src/components/AddColumnModal/column-types.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -15,6 +16,7 @@ import { TAccountPointer } from '@/types'
import { TColumn, TColumnType } from '@/types/column'
import {
Bell,
BellOff,
Bookmark,
BookOpen,
Compass,
Expand Down Expand Up @@ -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: () => <MuteListColumnBody />,
previewHint: 'Pick an account to preview'
}

export const COLUMN_TYPES: ColumnTypeDescriptor[] = [
HOME_DESCRIPTOR,
NOTIFICATIONS_DESCRIPTOR,
Expand All @@ -354,5 +375,6 @@ export const COLUMN_TYPES: ColumnTypeDescriptor[] = [
ARTICLES_DESCRIPTOR,
DVM_FEED_DESCRIPTOR,
RELATR_DISCOVERY_DESCRIPTOR,
MESSAGES_DESCRIPTOR
MESSAGES_DESCRIPTOR,
MUTE_LIST_DESCRIPTOR
]
2 changes: 2 additions & 0 deletions src/components/Column/ColumnHeader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
119 changes: 119 additions & 0 deletions src/components/Column/MuteListColumnBody.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Tabs tabs={TABS} value={tab} onTabChange={setTab} />
<div className="space-y-6 px-4 pb-4 pt-2">
{showUsers && <UsersSection />}
{showThreads && <ThreadsSection />}
{showWords && <WordsSection />}
</div>
</>
)
}

function SectionHeader({ children }: { children: React.ReactNode }) {
return <div className="text-muted-foreground mb-2 text-sm font-semibold">{children}</div>
}

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<string[]>([])
const bottomRef = useRef<HTMLDivElement>(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 (
<div>
<SectionHeader>{t('Muted users')}</SectionHeader>
{mutePubkeys.length === 0 ? (
<div className="text-muted-foreground text-sm">{t('No muted users')}</div>
) : (
<div className="space-y-2">
{visible.map((pubkey, index) => (
<MutedUserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{mutePubkeys.length > visible.length && <div ref={bottomRef} />}
</div>
)}
</div>
)
}

function ThreadsSection() {
const { t } = useTranslation()
const { muteEventIdSet } = useMuteList()
const muteEventIds = useMemo(() => Array.from(muteEventIdSet), [muteEventIdSet])

return (
<div>
<SectionHeader>{t('Muted threads')}</SectionHeader>
{muteEventIds.length === 0 ? (
<div className="text-muted-foreground text-sm">{t('No muted threads')}</div>
) : (
<div className="space-y-2">
{muteEventIds.map((id) => (
<MutedThreadItem key={id} eventId={id} />
))}
</div>
)}
</div>
)
}

function WordsSection() {
const { t } = useTranslation()
return (
<div>
<SectionHeader>{t('Muted words')}</SectionHeader>
<div className="text-muted-foreground mb-2 text-xs">
{t('Muted words apply to all your accounts.')}
</div>
<MutedWordsSection variant="rows" />
</div>
)
}
3 changes: 2 additions & 1 deletion src/components/Column/dispatch-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TColumnType, true>) as TColumnType[]

function makeColumn(type: TColumnType): TColumn {
Expand Down
3 changes: 3 additions & 0 deletions src/components/Column/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -411,6 +412,8 @@ export function dispatchBody(column: TColumn): React.ReactNode {
return <RelatrDiscoveryColumnBody column={column} />
case 'messages':
return <MessagesColumnBody />
case 'mute-list':
return <MuteListColumnBody />
case 'detail':
return <DetailColumnBody column={column} />
case 'relay':
Expand Down
41 changes: 41 additions & 0 deletions src/components/Mute/MutedThreadItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="hover:bg-accent/30 -mx-2 flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5"
onClick={() => push(toNote(event ?? eventId))}
>
<div className="w-full overflow-hidden">
<ContentPreview event={event} className="line-clamp-3" />
</div>
<Button
variant="ghost"
size="sm"
className="shrink-0"
disabled={changing || removing}
onClick={(e) => {
e.stopPropagation()
setRemoving(true)
unmuteThread(eventId).finally(() => setRemoving(false))
}}
>
{removing ? <Loader className="animate-spin" /> : t('Unmute')}
</Button>
</div>
)
}
73 changes: 73 additions & 0 deletions src/components/Mute/MutedUserItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="hover:bg-accent/30 -mx-2 flex cursor-pointer items-start gap-2 rounded-md px-2 py-1.5"
onClick={() => push(toProfile(pubkey))}
>
<UserAvatar userId={pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username
userId={pubkey}
className="w-fit max-w-full truncate font-semibold"
skeletonClassName="h-4"
/>
<Nip05 pubkey={pubkey} />
<div className="text-muted-foreground truncate text-sm">{profile?.about}</div>
</div>
{/* Action controls: stop row-click navigation so toggling/unmuting
doesn't also open the profile. */}
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
{switching ? (
<Button disabled variant="ghost" size="icon">
<Loader className="animate-spin" />
</Button>
) : muteType === 'private' ? (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (switching) return
setSwitching(true)
switchToPublicMute(pubkey).finally(() => setSwitching(false))
}}
disabled={changing}
>
<Lock className="text-green-400" />
</Button>
) : muteType === 'public' ? (
<Button
variant="ghost"
size="icon"
onClick={() => {
if (switching) return
setSwitching(true)
switchToPrivateMute(pubkey).finally(() => setSwitching(false))
}}
disabled={changing}
>
<Unlock className="text-muted-foreground" />
</Button>
) : null}
<MuteButton pubkey={pubkey} />
</div>
</div>
)
}
Loading
Loading