From 414747cde7c47d2ee2251cc2c81158b9e850b3ec Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 09:49:52 -0400 Subject: [PATCH 1/3] fix(mute): clear, instant public/private visibility toggle in Muted list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Muted list showed a bare green Lock (private) / gray Unlock (public) icon with no label or tooltip, and the icon showed the CURRENT state while clicking did the OPPOSITE — genuinely ambiguous. Toggling was also janky on a NIP-46 bunker: it ran fetch + decrypt + re-encrypt + sign behind a blocking spinner, and silently no-oped (no feedback) if the private list failed to decrypt. - Labeled control: globe + 'Public' / lock + 'Private', with a tooltip spelling out the action ('Only you can see this... click to make public'). Drops the unexplained green. - Coalesced optimistic toggle: the label flips instantly on every click; underneath we publish at most one mutation per settled target (extra clicks while in flight just update the pending target), so rapid flipping never fans out into a cascade of signed events. Reverts + relies on the toast on failure. - No silent no-op: the two switch functions now toast when the pubkey isn't where expected (decrypt-empty), instead of returning quietly. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Mute/MutedUserItem.tsx | 114 +++++++++++++++------- src/i18n/locales/en.ts | 8 +- src/providers/ScopedUserListsProvider.tsx | 13 ++- 3 files changed, 96 insertions(+), 39 deletions(-) diff --git a/src/components/Mute/MutedUserItem.tsx b/src/components/Mute/MutedUserItem.tsx index 096857a..a470deb 100644 --- a/src/components/Mute/MutedUserItem.tsx +++ b/src/components/Mute/MutedUserItem.tsx @@ -1,21 +1,66 @@ 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; stop and let the label + // reconcile to the real state below. + break + } + } + } 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) From 61c3fd6e08d2a2ebc249f62b2fce88d9dfa0d9cf Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 09:49:52 -0400 Subject: [PATCH 2/3] chore(release): v26.14.1 clearer mute visibility toggle Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/release-notes.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) 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/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', From e9c9938a0d9d40a8216698fb61e4f7eb6aa09422 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 14 Jun 2026 10:00:16 -0400 Subject: [PATCH 3/3] fix(mute): honor a queued visibility toggle after a failed publish The coalesce loop's catch handler used `break`, which abandoned any newer target a user had queued while a failing publish was in flight (e.g. a slow NIP-46 bunker timing out the first switch). Drop the break: the provider already surfaced the failure toast, so let the loop honor the last click on its next iteration instead of silently dropping the user's most recent intent. The loop still exits cleanly when nothing is queued. Caught in code review. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/Mute/MutedUserItem.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/Mute/MutedUserItem.tsx b/src/components/Mute/MutedUserItem.tsx index a470deb..4ef2ab3 100644 --- a/src/components/Mute/MutedUserItem.tsx +++ b/src/components/Mute/MutedUserItem.tsx @@ -41,9 +41,10 @@ export default function MutedUserItem({ pubkey }: { pubkey: string }) { try { await (target === 'public' ? switchToPublicMute(pubkey) : switchToPrivateMute(pubkey)) } catch { - // The provider already surfaced a toast; stop and let the label - // reconcile to the real state below. - break + // 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 {