From f23faa7ef3a4dd9225beb8aa62b2d70bf7d26bb7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 13 Mar 2026 21:42:56 -0500 Subject: [PATCH] Fix mute list not being honored: parse public tags and support NIP-04 The mute list (kind 10000) was only reading encrypted content, ignoring public tags in the event's tags array. Per NIP-51, mute items can be stored as either public tags or private encrypted content. Most Nostr clients store mutes as public tags, so these were being silently ignored. Changes: - Parse both public tags and encrypted content from kind 10000 events - Add NIP-04 backward compatibility (detect by '?iv=' in ciphertext) - Deduplicate items that appear in both public and private sections - Apply same fixes to useInitialSync for login-time bootstrapping Fixes: https://gitlab.com/soapbox-pub/ditto/-/work_items/96 --- src/hooks/useInitialSync.ts | 43 +++++++++++--- src/hooks/useMuteList.ts | 111 ++++++++++++++++++++++++++---------- 2 files changed, 115 insertions(+), 39 deletions(-) diff --git a/src/hooks/useInitialSync.ts b/src/hooks/useInitialSync.ts index 76a69e39..175d4afe 100644 --- a/src/hooks/useInitialSync.ts +++ b/src/hooks/useInitialSync.ts @@ -4,7 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useAppContext } from './useAppContext'; import type { EncryptedSettings } from './useEncryptedSettings'; -import { parseMuteTags, setCachedMuteItems } from './useMuteList'; +import { parseMuteTags, setCachedMuteItems, type MuteListItem } from './useMuteList'; import { EncryptedSettingsSchema } from '@/lib/schemas'; export type SyncPhase = @@ -192,20 +192,45 @@ export function useInitialSync() { // Seed the raw event into the muteList query cache queryClient.setQueryData(['muteList', user.pubkey], muteEvent); - // Decrypt and seed the parsed mute items + localStorage - if (muteEvent.content && user.signer.nip44) { - try { - const decrypted = await user.signer.nip44.decrypt(user.pubkey, muteEvent.content); - const tags = JSON.parse(decrypted) as string[][]; - const items = parseMuteTags(tags); + // Parse public tags from the event + const publicItems = parseMuteTags(muteEvent.tags); - queryClient.setQueryData(['muteItems', muteEvent.id], items); - setCachedMuteItems(user.pubkey, items); + // Decrypt private items from the content (supports NIP-44 and NIP-04) + let privateItems: MuteListItem[] = []; + if (muteEvent.content) { + try { + const isNip04 = muteEvent.content.includes('?iv='); + let decrypted: string | null = null; + + if (isNip04 && user.signer.nip04) { + decrypted = await user.signer.nip04.decrypt(user.pubkey, muteEvent.content); + } else if (!isNip04 && user.signer.nip44) { + decrypted = await user.signer.nip44.decrypt(user.pubkey, muteEvent.content); + } + + if (decrypted) { + const tags = JSON.parse(decrypted) as string[][]; + privateItems = parseMuteTags(tags); + } } catch (error) { console.error('Failed to decrypt mute list during initial sync:', error); } } + // Combine and deduplicate public + private items + const seen = new Set(); + const items: MuteListItem[] = []; + for (const item of [...publicItems, ...privateItems]) { + const key = `${item.type}:${item.value}`; + if (!seen.has(key)) { + seen.add(key); + items.push(item); + } + } + + queryClient.setQueryData(['muteItems', muteEvent.id], items); + setCachedMuteItems(user.pubkey, items); + foundSettings = true; } } catch (error) { diff --git a/src/hooks/useMuteList.ts b/src/hooks/useMuteList.ts index 0f22f351..aef5961d 100644 --- a/src/hooks/useMuteList.ts +++ b/src/hooks/useMuteList.ts @@ -76,24 +76,87 @@ async function fetchFreshMuteEvent( } /** - * Decrypt a kind 10000 event's content and parse into MuteListItems. - * Returns an empty array if the event has no encrypted content. + * Detect whether encrypted content uses NIP-04 (legacy) or NIP-44 encoding. + * NIP-51 says: "Clients can automatically discover if the encryption is NIP-04 + * or NIP-44 by searching for 'iv' in the ciphertext." */ -async function decryptMuteItems( +function isNip04Encrypted(content: string): boolean { + return content.includes('?iv='); +} + +/** + * Decrypt encrypted content from a kind 10000 event, handling both NIP-44 and + * legacy NIP-04 formats for backward compatibility per NIP-51. + */ +async function decryptContent( + content: string, + signer: NostrSigner, + pubkey: string, +): Promise { + if (!content) return null; + + try { + if (isNip04Encrypted(content)) { + // NIP-04 legacy encryption + if (signer.nip04) { + return await signer.nip04.decrypt(pubkey, content); + } + console.warn('Mute list uses NIP-04 encryption but signer does not support nip04'); + return null; + } else { + // NIP-44 encryption + if (signer.nip44) { + return await signer.nip44.decrypt(pubkey, content); + } + console.warn('Mute list uses NIP-44 encryption but signer does not support nip44'); + return null; + } + } catch (error) { + console.error('Failed to decrypt mute list content:', error); + return null; + } +} + +/** + * Parse all mute items from a kind 10000 event, combining both public tags + * and encrypted (private) content per NIP-51. + */ +async function getAllMuteItems( event: NostrEvent | null, signer: NostrSigner, pubkey: string, ): Promise { - if (!event?.content || !signer.nip44) return []; + if (!event) return []; - try { - const decrypted = await signer.nip44.decrypt(pubkey, event.content); - const tags = JSON.parse(decrypted) as string[][]; - return parseMuteTags(tags); - } catch (error) { - console.error('Failed to decrypt mute items:', error); - return []; + // Parse public tags from the event + const publicItems = parseMuteTags(event.tags); + + // Parse private (encrypted) items from the content + let privateItems: MuteListItem[] = []; + if (event.content) { + const decrypted = await decryptContent(event.content, signer, pubkey); + if (decrypted) { + try { + const tags = JSON.parse(decrypted) as string[][]; + privateItems = parseMuteTags(tags); + } catch (error) { + console.error('Failed to parse decrypted mute list content:', error); + } + } } + + // Deduplicate: combine public + private, removing duplicates + const seen = new Set(); + const combined: MuteListItem[] = []; + for (const item of [...publicItems, ...privateItems]) { + const key = `${item.type}:${item.value}`; + if (!seen.has(key)) { + seen.add(key); + combined.push(item); + } + } + + return combined; } /** @@ -130,31 +193,19 @@ export function useMuteList() { staleTime: 5 * 60 * 1000, }); - // Parse mute list into structured items + // Parse mute list into structured items (public tags + encrypted content) const muteItems = useQuery({ queryKey: ['muteItems', query.data?.id], queryFn: async () => { const event = query.data; if (!event || !user) return []; - // All mutes are encrypted in content field - if (!event.content || !user.signer.nip44) { - return []; - } + const items = await getAllMuteItems(event, user.signer, user.pubkey); - try { - const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content); - const tags = JSON.parse(decrypted) as string[][]; - const items = parseMuteTags(tags); + // Persist to localStorage for next page load + setCachedMuteItems(user.pubkey, items); - // Persist to localStorage for next page load - setCachedMuteItems(user.pubkey, items); - - return items; - } catch (error) { - console.error('Failed to decrypt mute items:', error); - return []; - } + return items; }, enabled: !!query.data && !!user, placeholderData: cachedItems, @@ -176,7 +227,7 @@ export function useMuteList() { // ① Fetch the freshest kind 10000 from relays before mutating const freshEvent = await fetchFreshMuteEvent(nostr, user.pubkey); - const currentItems = await decryptMuteItems(freshEvent, user.signer, user.pubkey); + const currentItems = await getAllMuteItems(freshEvent, user.signer, user.pubkey); // ② Add only if not already present (dedup) const alreadyMuted = currentItems.some( @@ -203,7 +254,7 @@ export function useMuteList() { // ① Fetch the freshest kind 10000 from relays before mutating const freshEvent = await fetchFreshMuteEvent(nostr, user.pubkey); - const currentItems = await decryptMuteItems(freshEvent, user.signer, user.pubkey); + const currentItems = await getAllMuteItems(freshEvent, user.signer, user.pubkey); // ② Remove the target item const newItems = currentItems.filter(