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
This commit is contained in:
Alex Gleason
2026-03-13 21:42:56 -05:00
parent d6aec14936
commit f23faa7ef3
2 changed files with 115 additions and 39 deletions
+34 -9
View File
@@ -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<string>();
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) {
+81 -30
View File
@@ -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<string | null> {
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<MuteListItem[]> {
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<string>();
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(