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.14.0",
"version": "26.14.1",
"description": "A TweetDeck-style multi-column deck for Nostr β€” just another nostr klient",
"type": "module",
"author": "DocNR",
Expand Down
115 changes: 79 additions & 36 deletions src/components/Mute/MutedUserItem.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
import MuteButton from '@/components/MuteButton'
import Nip05 from '@/components/Nip05'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
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'
import { Globe, Loader, Lock } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'

export default function MutedUserItem({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { changing, getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList()
const { getMuteType, switchToPrivateMute, switchToPublicMute } = useMuteList()
const { profile } = useFetchProfile(pubkey)
const muteType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType])
const [switching, setSwitching] = useState(false)
const realType = useMemo(() => getMuteType(pubkey), [pubkey, getMuteType])

// Coalesced optimistic visibility toggle. The label flips instantly on every
// click (`override`); underneath we publish at most one mutation per settled
// target β€” extra clicks while a publish is in flight just update `pendingRef`,
// so furious flipping never fans out into a cascade of signed kind-10000
// events (each is a relay publish + a remote sign on a NIP-46 bunker).
const [override, setOverride] = useState<'public' | 'private' | null>(null)
const [saving, setSaving] = useState(false)
const pendingRef = useRef<'public' | 'private' | null>(null)
const runningRef = useRef(false)

const shownType = override ?? realType

const runCommit = async () => {
runningRef.current = true
setSaving(true)
try {
while (pendingRef.current !== null) {
const target = pendingRef.current
pendingRef.current = null
try {
await (target === 'public' ? switchToPublicMute(pubkey) : switchToPrivateMute(pubkey))
} catch {
// The provider already surfaced a toast. Don't break: if a newer click
// queued a different target while this one was failing, honor it on the
// next iteration. Otherwise the loop exits and the label reconciles to
// the real state below.
}
}
} finally {
runningRef.current = false
setSaving(false)
// Drop the optimistic override: a successful switch leaves the real type
// at `target` (label unchanged); a no-op / failure reverts it.
setOverride(null)
}
}

const handleToggleVisibility = () => {
const target = shownType === 'private' ? 'public' : 'private'
setOverride(target)
pendingRef.current = target
if (!runningRef.current) void runCommit()
}

return (
<div
Expand All @@ -34,37 +80,34 @@ export default function MutedUserItem({ pubkey }: { pubkey: string }) {
</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>
<div className="flex shrink-0 items-center gap-1" onClick={(e) => e.stopPropagation()}>
{shownType === 'private' || shownType === 'public' ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground h-7 gap-1.5 px-2"
onClick={handleToggleVisibility}
>
{saving ? (
<Loader className="size-3.5 animate-spin" />
) : shownType === 'private' ? (
<Lock className="size-3.5" />
) : (
<Globe className="size-3.5" />
)}
<span className="text-xs">
{shownType === 'private' ? t('Private') : t('Public')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{shownType === 'private'
? t('Only you can see this mute. Click to make it public.')
: t('Anyone can see this mute. Click to make it private.')}
</TooltipContent>
</Tooltip>
) : null}
<MuteButton pubkey={pubkey} />
</div>
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,12 @@ export default {
'Muted words apply to all your accounts.': 'Muted words apply to all your accounts.',
'Reveal muted thread': 'Reveal muted thread',
'Waiting for signer approval...': 'Waiting for signer approval...',
'Signer did not respond in time': 'Signer did not respond in time'
'Signer did not respond in time': 'Signer did not respond in time',
Private: 'Private',
Public: 'Public',
'Only you can see this mute. Click to make it public.':
'Only you can see this mute. Click to make it public.',
'Anyone can see this mute. Click to make it private.':
'Anyone can see this mute. Click to make it private.'
}
}
13 changes: 11 additions & 2 deletions src/providers/ScopedUserListsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,13 @@ function ScopedMuteListInner({ viewContext, signingIdentity, children }: InnerPr
if (!current) return
const currentPrivate = await decryptSignerPrivateTags(current)
const newPrivate = currentPrivate.filter((t) => t[0] !== 'p' || t[1] !== pubkey)
if (newPrivate.length === currentPrivate.length) return
if (newPrivate.length === currentPrivate.length) {
// The pubkey wasn't in the decrypted private list β€” usually the private
// portion failed to decrypt (e.g. a bunker round-trip). Don't fail
// silently; surface it so the toggle doesn't just snap back.
toast.error('Could not change mute visibility. Please try again.', { duration: 10_000 })
return
}
const content = await signer.nip44Encrypt(signingIdentity, JSON.stringify(newPrivate))
const newTags = current.tags
.filter((t) => t[0] !== 'p' || t[1] !== pubkey)
Expand All @@ -489,7 +495,10 @@ function ScopedMuteListInner({ viewContext, signingIdentity, children }: InnerPr
const current = await muteListService.fetchMuteListEvent(signingIdentity)
if (!current) return
const newTags = current.tags.filter((t) => t[0] !== 'p' || t[1] !== pubkey)
if (newTags.length === current.tags.length) return
if (newTags.length === current.tags.length) {
toast.error('Could not change mute visibility. Please try again.', { duration: 10_000 })
return
}
const currentPrivate = await decryptSignerPrivateTags(current)
const newPrivate = currentPrivate
.filter((t) => t[0] !== 'p' || t[1] !== pubkey)
Expand Down
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.14.1',
date: '2026-06-14',
highlights: [
'The Muted list now spells out whether each muted user is Public or Private, with a globe or lock label and a tooltip explaining what it means, instead of an unlabeled icon. Switching a mute between public and private is now instant, and tells you if it could not be saved instead of silently doing nothing.'
]
},
{
version: '26.14.0',
date: '2026-06-14',
Expand Down
Loading