diff --git a/package.json b/package.json index 2ec75ce..fcb7095 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Mute/MutedUserItem.tsx b/src/components/Mute/MutedUserItem.tsx index 096857a..4ef2ab3 100644 --- a/src/components/Mute/MutedUserItem.tsx +++ b/src/components/Mute/MutedUserItem.tsx @@ -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 (
{/* Action controls: stop row-click navigation so toggling/unmuting doesn't also open the profile. */} -
e.stopPropagation()}> - {switching ? ( - - ) : muteType === 'private' ? ( - - ) : muteType === 'public' ? ( - +
e.stopPropagation()}> + {shownType === 'private' || shownType === 'public' ? ( + + + + + + {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.')} + + ) : null}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 709b3f4..94efcc9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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.' } } diff --git a/src/providers/ScopedUserListsProvider.tsx b/src/providers/ScopedUserListsProvider.tsx index 8c5a437..3471e1d 100644 --- a/src/providers/ScopedUserListsProvider.tsx +++ b/src/providers/ScopedUserListsProvider.tsx @@ -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) @@ -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) diff --git a/src/release-notes.ts b/src/release-notes.ts index 392a143..c6ace8c 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.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',