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:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user