diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index 4c9b0069..0e745ef3 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -174,13 +174,13 @@ Video posts should treat `videoRootId` as the stable series identifier that rema | View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history month (`NOTE_TYPES.WATCH_HISTORY`) | `WATCH_HISTORY_KIND` (default `30079`) | Replaceable list tag `['d', `${WATCH_HISTORY_LIST_IDENTIFIER}:`]` with optional `['month', ]` marker plus schema append tags; no chunk pointers required. | JSON payload `{ version, month: 'YYYY-MM', items: [{ id: , watched_at?: }] }` | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30000` | `['d', 'subscriptions']`, `['encrypted', 'nip44_v2']` (updated to the negotiated scheme at publish time) | NIP-04/NIP-44 encrypted JSON array of NIP-51 follow-set tuples (e.g., `[['p', ], …]`) | -| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }` | +| User block list (`NOTE_TYPES.USER_BLOCK_LIST`) | `10000` | `['d', 'user-blocks']` | **LEGACY**. NIP-04/NIP-44 encrypted JSON `{ blockedPubkeys: string[] }`. Migrated to standard Mute List. | | Hashtag preferences (`NOTE_TYPES.HASHTAG_PREFERENCES`) | `30015` | `['d', 'bitvid:tag-preferences']` plus schema-appended `['encrypted','nip44_v2']` | NIP-44 encrypted JSON `{ version, interests: string[], disinterests: string[] }` | | Admin moderation list (`NOTE_TYPES.ADMIN_MODERATION_LIST`) | `30000` | `['d', 'bitvid:admin:editors']`, repeated `['p', ]` entries | Empty content | | Admin blacklist (`NOTE_TYPES.ADMIN_BLACKLIST`) | `30000` | `['d', 'bitvid:admin:blacklist']`, repeated `['p', ]` entries | Empty content | | Admin whitelist (`NOTE_TYPES.ADMIN_WHITELIST`) | `30000` | `['d', 'bitvid:admin:whitelist']`, repeated `['p', ]` entries | Empty content | | Profile metadata (`NOTE_TYPES.PROFILE_METADATA`) | `0` | `['d', ...]` is not used | JSON payload with NIP-01 fields (`name`, `about`, `picture`, `nip05`, etc.) | -| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', ]` tags for blocked/muted users | Optional content (often encrypted) | +| Mute list (`NOTE_TYPES.MUTE_LIST`) | `10000` | Repeated `['p', ]` tags for public mutes | Encrypted content for private blocks. Standard NIP-51. | | Deletion (`NOTE_TYPES.DELETION`) | `5` | `['e', ]` or `['a', ]` | Reason for deletion | | Legacy Direct Message (`NOTE_TYPES.LEGACY_DM`) | `4` | `['p', ]` | NIP-04 encrypted ciphertext | | Zap request (`NOTE_TYPES.ZAP_REQUEST`) | `9734` | `['p', ]` plus optional `['e', ]`, `['a', ]`, `['amount', ]`, `['lnurl', ]`, and `['relays', ...]` for receipt publishing | Optional plaintext zap note | @@ -270,7 +270,16 @@ To prevent relay spikes, we only invoke `publish()` when the local interests or disinterests change—UI handlers short-circuit if a user toggles a tag back to its prior state. The method fans out to the configured write relays via `publishEventToRelays`, and `assertAnyRelayAccepted` ensures at least one relay -acknowledges the update before treating the migration as complete.【F:js/services/hashtagPreferencesService.js†L761-L784】 +acknowledges the update before treating the migration as complete. + +### User Mute/Block List (Kind 10000) + +bitvid now uses the standard NIP-51 Mute List (kind 10000 without a `d` tag) to store both public mutes and private blocks. + +* **Public Mutes**: Stored as `['p', ]` tags. These are visible to anyone and are used for "Trusted Mute" graph analysis. +* **Private Blocks**: Stored as NIP-04/NIP-44 encrypted JSON in the `content` field (`{ blockedPubkeys: string[] }`). These are only visible to the user. + +The legacy `d=user-blocks` list is still read for backward compatibility but is no longer updated. New updates merge both legacy and standard lists into the standard Kind 10000 event. ### Operator checklist diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 6174ea34..d96aa848 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -522,7 +522,7 @@ const BASE_SCHEMAS = { }, [NOTE_TYPES.USER_BLOCK_LIST]: { type: NOTE_TYPES.USER_BLOCK_LIST, - label: "User block list", + label: "User block list (legacy)", kind: 10000, identifierTag: { name: "d", @@ -532,7 +532,7 @@ const BASE_SCHEMAS = { content: { format: "encrypted-tag-list", description: - "NIP-04/NIP-44 encrypted JSON array of mute tags per NIP-51 (e.g., [['p', ], …]).", + "Legacy NIP-51 block list using custom d tag. Migrated to standard Mute List (kind 10000).", }, }, [NOTE_TYPES.HASHTAG_PREFERENCES]: { @@ -604,7 +604,7 @@ const BASE_SCHEMAS = { appendTags: DEFAULT_APPEND_TAGS, content: { format: "text", - description: "Optional content (often encrypted) with public p tags.", + description: "Standard NIP-51 Mute List. Public mutes in 'p' tags, private blocks in encrypted content.", }, }, [NOTE_TYPES.DELETION]: { diff --git a/js/services/moderationService.js b/js/services/moderationService.js index 8e47fef3..5fe3f7f7 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -1489,101 +1489,8 @@ export class ModerationService { } } - async publishViewerMuteList({ owner, muted }) { - const viewer = normalizeHex(owner || this.viewerPubkey); - if (!viewer) { - const error = new Error("viewer-not-logged-in"); - error.code = "viewer-not-logged-in"; - throw error; - } - - if (this.viewerIsSessionActor && viewer === this.viewerPubkey) { - this.saveLocalMutes(viewer, muted); - this.replaceTrustedMuteList(viewer, muted, { - createdAt: Date.now() / 1000, - eventId: `local-${Date.now()}`, - }); - return { ok: true, event: null, results: [] }; - } - - await this.ensurePool(); - - let signer = getActiveSigner(); - if (!signer && typeof this.nostrClient?.ensureActiveSignerForPubkey === "function") { - signer = await this.nostrClient.ensureActiveSignerForPubkey(viewer); - } - - const canSign = typeof signer?.canSign === "function" - ? signer.canSign() - : typeof signer?.signEvent === "function"; - if (!canSign || typeof signer?.signEvent !== "function") { - const error = new Error("nostr-extension-missing"); - error.code = "nostr-extension-missing"; - throw error; - } - - if (signer?.type === "extension") { - const permissionResult = await this.requestExtensionPermissions([ - "sign_event", - "get_public_key", - ]); - if (!permissionResult?.ok) { - const error = new Error("extension-permission-denied"); - error.code = "extension-permission-denied"; - error.details = permissionResult?.error || null; - throw error; - } - } - - const tags = []; - if (muted instanceof Set || Array.isArray(muted)) { - for (const value of muted) { - const normalized = normalizeHex(value); - if (!normalized || normalized === viewer) { - continue; - } - tags.push(["p", normalized]); - } - } - - const event = { - kind: 10000, - created_at: Math.floor(Date.now() / 1000), - tags, - content: "", - pubkey: viewer, - }; - - let signedEvent; - try { - signedEvent = await signer.signEvent(event); - } catch (error) { - const wrapped = new Error("signature-failed"); - wrapped.code = "signature-failed"; - wrapped.details = error; - throw wrapped; - } - - const relays = resolveRelayList(this.nostrClient, { write: true }); - if (!relays.length) { - const error = new Error("no-relays-configured"); - error.code = "no-relays-configured"; - throw error; - } - - let results = []; - try { - results = await publishEventToRelays(this.nostrClient.pool, relays, signedEvent); - assertAnyRelayAccepted(results, { context: "mute list" }); - } catch (error) { - const wrapped = new Error("mute-list-publish-failed"); - wrapped.code = "mute-list-publish-failed"; - wrapped.details = error; - throw wrapped; - } - - this.applyTrustedMuteEvent(viewer, signedEvent); - return { ok: true, event: signedEvent, results }; + async publishViewerMuteList() { + throw new Error("Mute list publishing is now handled by userBlocks."); } async addAuthorToViewerMuteList(pubkey) { @@ -1594,29 +1501,11 @@ export class ModerationService { throw error; } - const target = normalizeHex(pubkey); - if (!target) { - const error = new Error("invalid-target"); - error.code = "invalid-target"; - throw error; + if (this.userBlocks && typeof this.userBlocks.addMute === "function") { + return this.userBlocks.addMute(pubkey, viewer); } - if (target === viewer) { - const error = new Error("self"); - error.code = "self"; - throw error; - } - - await this.ensureViewerMuteListLoaded(viewer); - - if (this.viewerMuteList.has(target)) { - return { ok: true, already: true }; - } - - const next = new Set(this.viewerMuteList); - next.add(target); - - return this.publishViewerMuteList({ owner: viewer, muted: next }); + throw new Error("Mute list management is delegated to userBlocks."); } async removeAuthorFromViewerMuteList(pubkey) { @@ -1627,21 +1516,11 @@ export class ModerationService { throw error; } - const target = normalizeHex(pubkey); - if (!target) { - return { ok: true, already: true }; + if (this.userBlocks && typeof this.userBlocks.removeMute === "function") { + return this.userBlocks.removeMute(pubkey, viewer); } - await this.ensureViewerMuteListLoaded(viewer); - - if (!this.viewerMuteList.has(target)) { - return { ok: true, already: true }; - } - - const next = new Set(this.viewerMuteList); - next.delete(target); - - return this.publishViewerMuteList({ owner: viewer, muted: next }); + throw new Error("Mute list management is delegated to userBlocks."); } isAuthorMutedByTrusted(pubkey) { diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 7b2082f1..823afc77 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -1225,9 +1225,7 @@ export default class MoreMenuController { } try { - const result = await this.moderationService.addAuthorToViewerMuteList( - targetHex, - ); + const result = await this.userBlocks.addMute(targetHex, viewerPubkey); if (result?.already) { this.callbacks.showSuccess("You already muted this creator."); } else { @@ -1251,21 +1249,18 @@ export default class MoreMenuController { this.refreshActiveMuteButtons({ author: targetHex }); } catch (error) { switch (error?.code) { - case "viewer-not-logged-in": - this.callbacks.showError("Please login to manage your mute list."); - break; - case "invalid-target": + case "invalid": this.callbacks.showError("Unable to determine the creator to mute."); break; case "self": this.callbacks.showError("You cannot mute yourself."); break; - case "nostr-extension-missing": + case "signer-missing": this.callbacks.showError( "A Nostr extension is required to update your mute list.", ); break; - case "extension-permission-denied": + case "extension-encryption-permission-denied": this.callbacks.showError( "Allow your Nostr extension to sign events before muting creators.", ); @@ -1293,9 +1288,7 @@ export default class MoreMenuController { } try { - const result = await this.moderationService.removeAuthorFromViewerMuteList( - targetHex, - ); + const result = await this.userBlocks.removeMute(targetHex, viewerPubkey); if (result?.already) { this.callbacks.showSuccess("This creator is not on your mute list."); } else { @@ -1319,15 +1312,12 @@ export default class MoreMenuController { this.refreshActiveMuteButtons({ author: targetHex }); } catch (error) { switch (error?.code) { - case "viewer-not-logged-in": - this.callbacks.showError("Please login to manage your mute list."); - break; - case "nostr-extension-missing": + case "signer-missing": this.callbacks.showError( "A Nostr extension is required to update your mute list.", ); break; - case "extension-permission-denied": + case "extension-encryption-permission-denied": this.callbacks.showError( "Allow your Nostr extension to sign events before updating your mute list.", ); diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 421acc6e..3801e144 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1899,7 +1899,7 @@ export class ProfileModalController { ); ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions"); ensureAriaLabel(this.profileFriendsRefreshBtn, "Refresh friends"); - ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh blocked creators"); + ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh muted & blocked users"); ensureAriaLabel(this.adminModeratorsRefreshBtn, "Refresh moderators"); ensureAriaLabel(this.adminWhitelistRefreshBtn, "Refresh whitelist"); ensureAriaLabel(this.adminBlacklistRefreshBtn, "Refresh blacklist"); @@ -9519,9 +9519,9 @@ export class ProfileModalController { ? "Your Nostr extension must support NIP-04 to manage private lists." : mutationResult?.error?.code === "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your block list." + ? "Your Nostr extension must allow encryption to update your mute/block list." : mutationResult?.error?.message || - "Failed to update your block list. Please try again."; + "Failed to update your mute/block list. Please try again."; context.error = mutationResult?.error || null; context.reason = mutationResult?.reason || "service-error"; if (message) { @@ -9541,8 +9541,8 @@ export class ProfileModalController { error?.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : error?.code === "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your block list." - : "Failed to update your block list. Please try again."; + ? "Your Nostr extension must allow encryption to update your mute/block list." + : "Failed to update your mute/block list. Please try again."; this.showError(message); } @@ -9585,18 +9585,18 @@ export class ProfileModalController { }); if (mutationResult?.ok) { - this.showSuccess("Creator removed from your block list."); + this.showSuccess("Creator removed from your mute/block list."); } else if (mutationResult?.reason === "not-blocked") { - this.showSuccess("Creator already removed from your block list."); + this.showSuccess("Creator already removed from your mute/block list."); } else if (mutationResult?.error) { const message = mutationResult.error.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : mutationResult.error.code === "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your block list." + ? "Your Nostr extension must allow encryption to update your mute/block list." : mutationResult.error.message || - "Failed to update your block list. Please try again."; + "Failed to update your mute/block list. Please try again."; if (message) { this.showError(message); } @@ -9612,8 +9612,8 @@ export class ProfileModalController { error?.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : error?.code === "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your block list." - : "Failed to update your block list. Please try again."; + ? "Your Nostr extension must allow encryption to update your mute/block list." + : "Failed to update your mute/block list. Please try again."; this.showError(message); } } diff --git a/js/userBlocks.js b/js/userBlocks.js index 9d469a52..f0e6f52b 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -677,6 +677,8 @@ function normalizeHex(pubkey) { class UserBlockListManager { constructor() { this.blockedPubkeys = new Set(); + this._privateBlocks = new Set(); + this._publicMutes = new Set(); this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; @@ -705,6 +707,8 @@ class UserBlockListManager { reset() { this.stopBlockListSubscription(); this.blockedPubkeys.clear(); + this._privateBlocks.clear(); + this._publicMutes.clear(); this.blockEventId = null; this.blockEventCreatedAt = null; this.lastPublishedCreatedAt = null; @@ -775,6 +779,14 @@ class UserBlockListManager { return Array.from(this.blockedPubkeys); } + getPublicMutes() { + return Array.from(this._publicMutes); + } + + getPrivateBlocks() { + return Array.from(this._privateBlocks); + } + scheduleDecryptRetry(userPubkey, detail = {}) { if (!userPubkey) { return; @@ -922,7 +934,10 @@ class UserBlockListManager { const hasLocalData = !!localData; if (localData) { - this.blockedPubkeys = localData.blockedPubkeys; + this._privateBlocks = localData.blockedPubkeys; + this._publicMutes = new Set(); // Local cache structure currently only stores 'blockedPubkeys' + this.blockedPubkeys = new Set(this._privateBlocks); + if (Number.isFinite(localData.createdAt)) { this.blockEventCreatedAt = localData.createdAt; } @@ -934,9 +949,10 @@ class UserBlockListManager { }); } - const applyBlockedPubkeys = (nextValues, meta = {}) => { - const nextSet = new Set(Array.isArray(nextValues) ? nextValues : []); - this.blockedPubkeys = nextSet; + const applySets = (privateSet, publicSet, meta = {}) => { + this._privateBlocks = privateSet; + this._publicMutes = publicSet; + this.blockedPubkeys = new Set([...privateSet, ...publicSet]); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { action: "sync", @@ -1147,6 +1163,8 @@ class UserBlockListManager { ) => { const previousState = { blockedPubkeys: new Set(this.blockedPubkeys), + privateBlocks: new Set(this._privateBlocks), + publicMutes: new Set(this._publicMutes), blockEventId: this.blockEventId, blockEventCreatedAt: this.blockEventCreatedAt, loaded: this.loaded, @@ -1166,7 +1184,7 @@ class UserBlockListManager { if (!this.loaded) { this.blockEventId = null; this.blockEventCreatedAt = null; - applyBlockedPubkeys([], { source, reason: "empty-result" }); + applySets(new Set(), new Set(), { source, reason: "empty-result" }); } emitStatus({ status: "applied-empty", source }); return; @@ -1191,37 +1209,37 @@ class UserBlockListManager { if (!this.loaded) { this.blockEventId = null; this.blockEventCreatedAt = null; - applyBlockedPubkeys([], { source, reason: "empty-result" }); + applySets(new Set(), new Set(), { source, reason: "empty-result" }); } emitStatus({ status: "applied-empty", source }); return; } // Separate by logic (Block vs Mute) - // Block: Kind 10000 with d=user-blocks OR Kind 30002 (legacy) - const blockEvents = validEvents + // Legacy Block: Kind 10000 with d=user-blocks OR Kind 30002 + const legacyBlockEvents = validEvents .filter((e) => isTaggedBlockListEvent(e) || e.kind === 30002) .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); - // Mute: Kind 10000 WITHOUT d=user-blocks - const muteEvents = validEvents + // Standard Mute: Kind 10000 WITHOUT d=user-blocks + const standardMuteEvents = validEvents .filter((e) => e.kind === 10000 && !isTaggedBlockListEvent(e)) .sort((a, b) => (b?.created_at || 0) - (a?.created_at || 0)); - const newestBlock = blockEvents[0] || null; - const newestMute = muteEvents[0] || null; + const newestLegacy = legacyBlockEvents[0] || null; + const newestStandard = standardMuteEvents[0] || null; - const newestMuteTime = Number.isFinite(newestMute?.created_at) - ? newestMute.created_at + const newestStandardTime = Number.isFinite(newestStandard?.created_at) + ? newestStandard.created_at : 0; - const newestBlockTime = Number.isFinite(newestBlock?.created_at) - ? newestBlock.created_at + const newestLegacyTime = Number.isFinite(newestLegacy?.created_at) + ? newestLegacy.created_at : 0; // The "canonical" newest event for stale checks is simply the latest of either. const newestOverall = - newestMuteTime >= newestBlockTime ? newestMute : newestBlock; - const newestCreatedAt = Math.max(newestMuteTime, newestBlockTime); + newestStandardTime >= newestLegacyTime ? newestStandard : newestLegacy; + const newestCreatedAt = Math.max(newestStandardTime, newestLegacyTime); // We accept this state. this.blockEventId = newestOverall?.id || null; @@ -1233,12 +1251,6 @@ class UserBlockListManager { const isContentEmpty = !ev.content || !ev.content.trim(); if (isContentEmpty) { - // Handle tag-only lists (e.g. Kind 10000 with empty content but populated p tags) - if (Array.isArray(ev.tags) && ev.tags.length) { - return extractPubkeysFromTags(ev.tags, normalized, { - limit: MAX_BLOCKLIST_ENTRIES, - }); - } return []; } @@ -1275,8 +1287,6 @@ class UserBlockListManager { } if (!decryptedText) { - // If decryption fails completely for a present event, we might want to log it - // but return empty for that specific event. return []; } @@ -1285,14 +1295,15 @@ class UserBlockListManager { }); }; - const mergedPubkeys = new Set(); + const nextPrivateBlocks = new Set(); + const nextPublicMutes = new Set(); let decryptionError = null; const decryptMeta = { - mute: { eventId: newestMute?.id || null }, - block: { eventId: newestBlock?.id || null }, + standard: { eventId: newestStandard?.id || null }, + legacy: { eventId: newestLegacy?.id || null }, }; const resolveSignerStatus = () => { - const statuses = [decryptMeta.mute.signerStatus, decryptMeta.block.signerStatus] + const statuses = [decryptMeta.standard.signerStatus, decryptMeta.legacy.signerStatus] .filter(Boolean); if (!statuses.length) { return "unknown"; @@ -1308,8 +1319,8 @@ class UserBlockListManager { try { const decryptPromise = Promise.all([ - decryptEvent(newestMute, decryptMeta.mute), - decryptEvent(newestBlock, decryptMeta.block), + decryptEvent(newestStandard, decryptMeta.standard), + decryptEvent(newestLegacy, decryptMeta.legacy), ]); const timeoutPromise = new Promise((_, reject) => setTimeout( @@ -1325,13 +1336,20 @@ class UserBlockListManager { ), ); - const [muteList, blockList] = await Promise.race([ + const [standardDecrypted, legacyDecrypted] = await Promise.race([ decryptPromise, timeoutPromise, ]); - muteList.forEach((pk) => mergedPubkeys.add(pk)); - blockList.forEach((pk) => mergedPubkeys.add(pk)); + // Only apply the newest list to avoid zombie data from stale lists + if (newestOverall === newestStandard && newestStandard) { + const publicTags = extractPubkeysFromTags(newestStandard.tags, normalized, { limit: MAX_BLOCKLIST_ENTRIES }); + publicTags.forEach((pk) => nextPublicMutes.add(pk)); + standardDecrypted.forEach((pk) => nextPrivateBlocks.add(pk)); + } else if (newestLegacy) { + legacyDecrypted.forEach((pk) => nextPrivateBlocks.add(pk)); + } + } catch (error) { // If permission denied or critical failure decryptionError = error; @@ -1342,6 +1360,8 @@ class UserBlockListManager { const signerStatus = decryptionError.signerStatus || resolveSignerStatus(); this.blockedPubkeys = previousState.blockedPubkeys; + this._privateBlocks = previousState.privateBlocks; + this._publicMutes = previousState.publicMutes; this.blockEventId = previousState.blockEventId; this.blockEventCreatedAt = previousState.blockEventCreatedAt; this.loaded = true; @@ -1352,7 +1372,7 @@ class UserBlockListManager { reason: "decryptor-permission", allowPermissionPrompt, mode: loadMode, - events: [newestMute?.id, newestBlock?.id].filter(Boolean), + events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), error: decryptionError, }); emitStatus({ status: "settled" }); @@ -1361,11 +1381,15 @@ class UserBlockListManager { if (decryptionError.code === "user-blocklist-decrypt-timeout") { const signerStatus = decryptionError.signerStatus || resolveSignerStatus(); - applyBlockedPubkeys([], { + // If decrypt times out, we can still load public mutes from standard event tags? + // For safety, we probably shouldn't partially load if we expect encryption. + // But preserving existing state is safer or applying empty. + // Current logic applies empty. + applySets(new Set(), new Set(), { source, reason: "decrypt-timeout", signerStatus, - events: [newestMute?.id, newestBlock?.id].filter(Boolean), + events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); this.loaded = true; emitStatus({ @@ -1379,7 +1403,7 @@ class UserBlockListManager { "[UserBlockList] Decryption timed out; applying empty list and retrying in background.", { signerStatus, - events: [newestMute?.id, newestBlock?.id].filter(Boolean), + events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }, ); this.scheduleDecryptRetry(normalized, { @@ -1393,6 +1417,8 @@ class UserBlockListManager { decryptionError, ); this.blockedPubkeys = previousState.blockedPubkeys; + this._privateBlocks = previousState.privateBlocks; + this._publicMutes = previousState.publicMutes; this.blockEventId = previousState.blockEventId; this.blockEventCreatedAt = previousState.blockEventCreatedAt; this.loaded = true; @@ -1408,22 +1434,17 @@ class UserBlockListManager { return; } - const hasMute = Boolean(newestMute); - const hasBlock = Boolean(newestBlock); + const isStandardEffectiveEmpty = !newestStandard || ((!newestStandard.content || !newestStandard.content.trim()) && (!newestStandard.tags || !extractPubkeysFromTags(newestStandard.tags, normalized).length)); + const isLegacyEffectiveEmpty = !newestLegacy || ((!newestLegacy.content || !newestLegacy.content.trim()) && (!newestLegacy.tags || !extractPubkeysFromTags(newestLegacy.tags, normalized).length)); - // Check if we should log "empty-events" reason - // Effectively empty means: missing content AND missing valid tags - const isMuteEffectiveEmpty = !newestMute || ((!newestMute.content || !newestMute.content.trim()) && (!newestMute.tags || !extractPubkeysFromTags(newestMute.tags, normalized).length)); - const isBlockEffectiveEmpty = !newestBlock || ((!newestBlock.content || !newestBlock.content.trim()) && (!newestBlock.tags || !extractPubkeysFromTags(newestBlock.tags, normalized).length)); - - const effectiveReason = (isMuteEffectiveEmpty && isBlockEffectiveEmpty) + const effectiveReason = (isStandardEffectiveEmpty && isLegacyEffectiveEmpty) ? "empty-events" : "applied-merge"; - applyBlockedPubkeys(Array.from(mergedPubkeys), { + applySets(nextPrivateBlocks, nextPublicMutes, { source, reason: effectiveReason, - events: [newestMute?.id, newestBlock?.id].filter(Boolean), + events: [newestStandard?.id, newestLegacy?.id].filter(Boolean), }); if (effectiveReason === "empty-events") { @@ -1513,7 +1534,7 @@ class UserBlockListManager { return; } profileCache.setProfileData(normalizedActorHex, "blocks", { - blockedPubkeys: Array.from(this.blockedPubkeys), + blockedPubkeys: Array.from(this._privateBlocks), // Local cache historically stores private blocks createdAt: this.blockEventCreatedAt, version: 1, }); @@ -1635,13 +1656,21 @@ class UserBlockListManager { return { ok: true, seeded: false, reason: "no-candidates" }; } - const snapshot = new Set(this.blockedPubkeys); - additions.forEach((hex) => this.blockedPubkeys.add(hex)); + const snapshotPrivate = new Set(this._privateBlocks); + const snapshotPublic = new Set(this._publicMutes); + const snapshotUnion = new Set(this.blockedPubkeys); + + additions.forEach((hex) => { + this._privateBlocks.add(hex); + this.blockedPubkeys.add(hex); + }); try { await this.publishBlockList(actorHex); } catch (error) { - this.blockedPubkeys = snapshot; + this._privateBlocks = snapshotPrivate; + this._publicMutes = snapshotPublic; + this.blockedPubkeys = snapshotUnion; throw error; } @@ -1716,7 +1745,10 @@ class UserBlockListManager { return { ok: true, seeded: true, addedPubkeys: [] }; } - additions.forEach((hex) => this.blockedPubkeys.add(hex)); + additions.forEach((hex) => { + this._privateBlocks.add(hex); + this.blockedPubkeys.add(hex); + }); this._setSeeded(actorHex, true); additions.forEach((hex) => this._clearSeedRemoval(actorHex, hex)); @@ -1787,7 +1819,10 @@ class UserBlockListManager { return { ok: true, seeded: state.seeded, reason: "no-candidates" }; } - additions.forEach((hex) => this.blockedPubkeys.add(hex)); + additions.forEach((hex) => { + this._privateBlocks.add(hex); + this.blockedPubkeys.add(hex); + }); this._setSeeded(actorHex, true); this._saveLocal(actorHex); @@ -1808,6 +1843,22 @@ class UserBlockListManager { } async addBlock(targetPubkey, userPubkey) { + return this.modifyList("block", targetPubkey, userPubkey); + } + + async removeBlock(targetPubkey, userPubkey) { + return this.modifyList("unblock", targetPubkey, userPubkey); + } + + async addMute(targetPubkey, userPubkey) { + return this.modifyList("mute", targetPubkey, userPubkey); + } + + async removeMute(targetPubkey, userPubkey) { + return this.modifyList("unmute", targetPubkey, userPubkey); + } + + async modifyList(action, targetPubkey, userPubkey) { const actorHex = normalizeHex(userPubkey); if (!actorHex) { throw new Error("Invalid user pubkey."); @@ -1821,19 +1872,45 @@ class UserBlockListManager { } if (actorHex === targetHex) { - const err = new Error("Cannot block yourself."); + const err = new Error("Cannot block/mute yourself."); err.code = "self"; throw err; } await this.ensureLoaded(actorHex); - if (this.blockedPubkeys.has(targetHex)) { + const snapshotPrivate = new Set(this._privateBlocks); + const snapshotPublic = new Set(this._publicMutes); + const snapshotUnion = new Set(this.blockedPubkeys); + + let changed = false; + + if (action === "block") { + if (this._privateBlocks.has(targetHex)) { + return { ok: true, already: true }; + } + this._privateBlocks.add(targetHex); + changed = true; + } else if (action === "unblock") { + const inPrivate = this._privateBlocks.delete(targetHex); + const inPublic = this._publicMutes.delete(targetHex); + changed = inPrivate || inPublic; + } else if (action === "mute") { + if (this._publicMutes.has(targetHex)) { + return { ok: true, already: true }; + } + this._publicMutes.add(targetHex); + changed = true; + } else if (action === "unmute") { + const inPublic = this._publicMutes.delete(targetHex); + changed = inPublic; + } + + if (!changed) { return { ok: true, already: true }; } - const snapshot = new Set(this.blockedPubkeys); - this.blockedPubkeys.add(targetHex); + this.blockedPubkeys = new Set([...this._privateBlocks, ...this._publicMutes]); const loggedInPubkey = normalizeHex(nostrClient?.pubkey); const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey); @@ -1843,192 +1920,39 @@ class UserBlockListManager { if (isSessionActor) { this._saveLocal(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { - action: "block", + action, targetPubkey: targetHex, actorPubkey: actorHex, }); - this._clearSeedRemoval(actorHex, targetHex); + if (action === "block" || action === "mute") { + this._clearSeedRemoval(actorHex, targetHex); + } else { + this._addSeedRemoval(actorHex, targetHex); + } return { ok: true }; } try { await this.publishBlockList(actorHex); this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { - action: "block", + action, targetPubkey: targetHex, actorPubkey: actorHex, }); - this._clearSeedRemoval(actorHex, targetHex); + if (action === "block" || action === "mute") { + this._clearSeedRemoval(actorHex, targetHex); + } else { + this._addSeedRemoval(actorHex, targetHex); + } return { ok: true }; } catch (err) { - this.blockedPubkeys = snapshot; + this._privateBlocks = snapshotPrivate; + this._publicMutes = snapshotPublic; + this.blockedPubkeys = snapshotUnion; throw err; } } - async removeBlock(targetPubkey, userPubkey) { - const actorHex = normalizeHex(userPubkey); - if (!actorHex) { - throw new Error("Invalid user pubkey."); - } - - const targetHex = normalizeHex(targetPubkey); - if (!targetHex) { - return { ok: true, already: true }; - } - - await this.ensureLoaded(actorHex); - - if (!this.blockedPubkeys.has(targetHex)) { - return { ok: true, already: true }; - } - - const snapshot = new Set(this.blockedPubkeys); - this.blockedPubkeys.delete(targetHex); - - const loggedInPubkey = normalizeHex(nostrClient?.pubkey); - const sessionPubkey = normalizeHex(nostrClient?.sessionActor?.pubkey); - const isSessionActor = - !!actorHex && !loggedInPubkey && actorHex === sessionPubkey; - - if (isSessionActor) { - this._saveLocal(actorHex); - this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { - action: "unblock", - targetPubkey: targetHex, - actorPubkey: actorHex, - }); - this._addSeedRemoval(actorHex, targetHex); - return { ok: true }; - } - - try { - await this.publishBlockList(actorHex); - this.emitter.emit(USER_BLOCK_EVENTS.CHANGE, { - action: "unblock", - targetPubkey: targetHex, - actorPubkey: actorHex, - }); - this._addSeedRemoval(actorHex, targetHex); - return { ok: true }; - } catch (err) { - this.blockedPubkeys = snapshot; - throw err; - } - } - - async publishMuteListSnapshot({ - signer, - ownerPubkey, - blockedPubkeys = [], - createdAt, - plaintext = "", - onStatus, - } = {}) { - // nostrClient imported from nostrClientFacade - if (isSessionActor(nostrClient)) { - const error = new Error( - "Publishing mute lists is not allowed for session actors." - ); - error.code = "session-actor-publish-blocked"; - throw error; - } - - const owner = normalizeHex(ownerPubkey); - if (!owner || !signer || typeof signer.signEvent !== "function") { - return null; - } - - const sanitizedTags = sanitizeMuteTags(blockedPubkeys, owner); - const pTags = sanitizedTags.map((tag) => tag[1]); - - const timestamp = Number.isFinite(createdAt) - ? Math.max(0, Math.floor(createdAt)) - : Math.floor(Date.now() / 1000); - - const payloadText = typeof plaintext === "string" ? plaintext : ""; - let encryptedContent = ""; - let encryptionTagValue = ""; - - if (payloadText) { - const encryptionCandidates = []; - - if (typeof signer.nip44Encrypt === "function") { - encryptionCandidates.push({ tag: "nip44_v2", encrypt: signer.nip44Encrypt }); - } - - if (typeof signer.nip04Encrypt === "function") { - encryptionCandidates.push({ tag: "nip04", encrypt: signer.nip04Encrypt }); - } - - for (const candidate of encryptionCandidates) { - try { - const encrypted = await candidate.encrypt(owner, payloadText); - if (typeof encrypted === "string" && encrypted) { - encryptedContent = encrypted; - encryptionTagValue = candidate.tag; - break; - } - } catch (error) { - // Ignore encryption failures and fall back to the next candidate. - } - } - } - - const event = buildMuteListEvent({ - pubkey: owner, - created_at: timestamp, - pTags, - content: encryptedContent, - encrypted: !!encryptionTagValue, - encryptionTag: encryptionTagValue, - }); - - onStatus?.({ status: "mute-publishing" }); - - let signedEvent; - try { - signedEvent = await signer.signEvent(event); - } catch (error) { - const wrapped = - error instanceof Error ? error : new Error(String(error || "mute-signature-failed")); - wrapped.code = wrapped.code || "mute-signature-failed"; - throw wrapped; - } - - const publishResults = await publishEventToRelays( - nostrClient.pool, - nostrClient.relays, - signedEvent, - ); - - const summary = assertAnyRelayAccepted(publishResults, { context: "mute list" }); - - if (summary.failed.length) { - summary.failed.forEach(({ url, error: relayError }) => { - const reason = - relayError instanceof Error - ? relayError.message - : relayError - ? String(relayError) - : "publish failed"; - userLogger.warn( - `[UserBlockList] Mute list not accepted by ${url}: ${reason}`, - relayError, - ); - }); - } - - this.muteEventId = signedEvent.id; - this.muteEventCreatedAt = Number.isFinite(signedEvent?.created_at) - ? signedEvent.created_at - : event.created_at; - - onStatus?.({ status: "mute-published", event: signedEvent }); - - return signedEvent; - } - async publishBlockList(userPubkey, options = {}) { if (isSessionActor(nostrClient)) { const error = new Error( @@ -2085,11 +2009,10 @@ class UserBlockListManager { throw new Error("Invalid user pubkey."); } - const sanitizedTags = sanitizeMuteTags(this.blockedPubkeys, normalized); - const payload = { - blockedPubkeys: sanitizedTags.map(([, target]) => target), - }; - const plaintext = serializeBlockListTagMatrix(sanitizedTags, normalized, { + // Encrypt private blocks + const privateBlockArray = Array.from(this._privateBlocks); + const sanitizedPrivateTags = sanitizeMuteTags(privateBlockArray, normalized); + const plaintext = serializeBlockListTagMatrix(sanitizedPrivateTags, normalized, { logDiscarded: false, }); @@ -2110,41 +2033,44 @@ class UserBlockListManager { registerEncryptor("nip04", (value) => signer.nip04Encrypt(normalized, value)); } - if (!encryptors.length) { - const err = new Error( - "An encryption-capable signer is required to update the block list." - ); - err.code = "block-list-missing-encryptor"; - throw err; - } - let cipherText = ""; let encryptionScheme = ""; const seenSchemes = new Set(); const encryptionErrors = []; - for (const candidate of encryptors) { - if (seenSchemes.has(candidate.scheme)) { - continue; + // Only attempt encryption if we have private blocks + if (privateBlockArray.length > 0) { + if (!encryptors.length) { + const err = new Error( + "An encryption-capable signer is required to update private blocks." + ); + err.code = "block-list-missing-encryptor"; + throw err; } - seenSchemes.add(candidate.scheme); - try { - const encrypted = await candidate.handler(plaintext); - if (typeof encrypted === "string" && encrypted) { - cipherText = encrypted; - encryptionScheme = candidate.scheme; - break; - } - } catch (error) { - encryptionErrors.push({ scheme: candidate.scheme, error }); - } - } - if (!cipherText) { - const err = new Error("Failed to encrypt block list."); - err.code = "block-list-encrypt-failed"; - err.cause = encryptionErrors; - throw err; + for (const candidate of encryptors) { + if (seenSchemes.has(candidate.scheme)) { + continue; + } + seenSchemes.add(candidate.scheme); + try { + const encrypted = await candidate.handler(plaintext); + if (typeof encrypted === "string" && encrypted) { + cipherText = encrypted; + encryptionScheme = candidate.scheme; + break; + } + } catch (error) { + encryptionErrors.push({ scheme: candidate.scheme, error }); + } + } + + if (!cipherText) { + const err = new Error("Failed to encrypt block list."); + err.code = "block-list-encrypt-failed"; + err.cause = encryptionErrors; + throw err; + } } const encryptionTagValue = @@ -2156,11 +2082,18 @@ class UserBlockListManager { ? "nip04" : undefined; - const event = buildBlockListEvent({ + // Build event with both public mutes (p tags) and private blocks (encrypted content) + const publicMuteArray = Array.from(this._publicMutes); + const sanitizedPublicTags = sanitizeMuteTags(publicMuteArray, normalized); + const pTags = sanitizedPublicTags.map((tag) => tag[1]); + + const event = buildMuteListEvent({ pubkey: normalized, created_at: Math.floor(Date.now() / 1000), content: cipherText, - encryption: encryptionTagValue, + pTags, + encrypted: !!encryptionTagValue, + encryptionTag: encryptionTagValue, }); const signedEvent = await signer.signEvent(event); @@ -2174,14 +2107,14 @@ class UserBlockListManager { let publishSummary; try { publishSummary = assertAnyRelayAccepted(publishResults, { - context: "block list", + context: "mute list", }); } catch (publishError) { if (publishError?.relayFailures?.length) { publishError.relayFailures.forEach( ({ url, error: relayError, reason }) => { userLogger.error( - `[UserBlockList] Block list rejected by ${url}: ${reason}`, + `[UserBlockList] Mute list rejected by ${url}: ${reason}`, relayError || reason ); } @@ -2199,7 +2132,7 @@ class UserBlockListManager { ? String(relayError) : "publish failed"; userLogger.warn( - `[UserBlockList] Block list not accepted by ${url}: ${reason}`, + `[UserBlockList] Mute list not accepted by ${url}: ${reason}`, relayError ); }); @@ -2212,19 +2145,6 @@ class UserBlockListManager { this.lastPublishedCreatedAt = this.blockEventCreatedAt; onStatus?.({ status: "published", event: signedEvent }); - try { - await this.publishMuteListSnapshot({ - signer, - ownerPubkey: normalized, - blockedPubkeys: payload.blockedPubkeys, - createdAt: event.created_at, - plaintext, - onStatus, - }); - } catch (muteError) { - userLogger.warn("[UserBlockList] Failed to publish mute list", muteError); - } - try { await this.loadBlocks(normalized, { since: this.lastPublishedCreatedAt ?? undefined, diff --git a/tests/moderation-service.test.mjs b/tests/moderation-service.test.mjs index c5fb2757..a4c0de13 100644 --- a/tests/moderation-service.test.mjs +++ b/tests/moderation-service.test.mjs @@ -394,10 +394,38 @@ test("viewer mute list publishes and updates aggregation", async (t) => { }), }; + const mockUserBlocks = { + async addMute(pubkey, viewer) { + // Simulate the publish that userBlocks would do + const event = { + kind: 10000, + pubkey: viewer, + tags: [["p", pubkey]], + content: "", + created_at: Math.floor(Date.now() / 1000), + }; + await nostrClient.pool.publish([], event); + return { ok: true }; + }, + async removeMute(pubkey, viewer) { + const event = { + kind: 10000, + pubkey: viewer, + tags: [], + content: "", + created_at: Math.floor(Date.now() / 1000), + }; + await nostrClient.pool.publish([], event); + return { ok: true }; + }, + on: () => () => {}, + }; + const service = new ModerationService({ logger: () => {}, nostrClient, userLogger: noopUserLogger, + userBlocks: mockUserBlocks, requestExtensionPermissions: async () => ({ ok: true }), }); diff --git a/tests/moderation/trusted-mute-lists.test.mjs b/tests/moderation/trusted-mute-lists.test.mjs index 0b3dca66..19f8b446 100644 --- a/tests/moderation/trusted-mute-lists.test.mjs +++ b/tests/moderation/trusted-mute-lists.test.mjs @@ -73,6 +73,8 @@ test("trusted mute lists from seeds hide authors for anonymous viewers", async ( userBlocks.loadBlocks = async () => {}; userBlocks.blockedPubkeys = new Set(); + userBlocks._privateBlocks = new Set(); + userBlocks._publicMutes = new Set(); userBlocks.blockEventId = null; userBlocks.blockEventCreatedAt = null; userBlocks.lastPublishedCreatedAt = null; @@ -103,7 +105,7 @@ test("trusted mute lists from seeds hide authors for anonymous viewers", async ( }, }); - await userBlocks.addBlock(mutedHex, seedHex); + await userBlocks.addMute(mutedHex, seedHex); const muteEvent = signedEvents.find( (event) => diff --git a/tests/more-menu-controller.test.mjs b/tests/more-menu-controller.test.mjs index b2f5d65e..a73c3e11 100644 --- a/tests/more-menu-controller.test.mjs +++ b/tests/more-menu-controller.test.mjs @@ -166,18 +166,18 @@ test('block-author updates user blocks, reloads videos, and refreshes feeds', as test('mute-author updates viewer mute list and refreshes feeds', async () => { const events = []; const moderationStub = { - addAuthorToViewerMuteList: async (target) => { - events.push(['addMute', target]); + isAuthorMutedByViewer: () => false, + }; + const userBlocksStub = { + addMute: async (target, viewer) => { + events.push(['addMute', target, viewer]); return { ok: true }; }, - removeAuthorFromViewerMuteList: async () => { - throw new Error('unreachable'); - }, - isAuthorMutedByViewer: () => false, }; const controller = new MoreMenuController({ moderationService: moderationStub, + userBlocks: userBlocksStub, callbacks: { getCurrentUserPubkey: () => 'viewerhex', getCurrentVideo: () => ({ pubkey: 'fallbackhex' }), @@ -199,7 +199,7 @@ test('mute-author updates viewer mute list and refreshes feeds', async () => { await controller.handleMoreMenuAction('mute-author', { author: targetHex }); assert.deepEqual(events, [ - ['addMute', targetHex], + ['addMute', targetHex, 'viewerhex'], ['success', 'Creator muted. Their videos will be downranked in your feeds.'], [ 'refreshAllVideoGrids', @@ -211,18 +211,18 @@ test('mute-author updates viewer mute list and refreshes feeds', async () => { test('unmute-author removes creators from viewer mute list', async () => { const events = []; const moderationStub = { - addAuthorToViewerMuteList: async () => { - throw new Error('unreachable'); - }, - removeAuthorFromViewerMuteList: async (target) => { - events.push(['removeMute', target]); + isAuthorMutedByViewer: () => true, + }; + const userBlocksStub = { + removeMute: async (target, viewer) => { + events.push(['removeMute', target, viewer]); return { ok: true }; }, - isAuthorMutedByViewer: () => true, }; const controller = new MoreMenuController({ moderationService: moderationStub, + userBlocks: userBlocksStub, callbacks: { getCurrentUserPubkey: () => 'viewerhex', getCurrentVideo: () => ({ pubkey: 'fallbackhex' }), @@ -244,7 +244,7 @@ test('unmute-author removes creators from viewer mute list', async () => { await controller.handleMoreMenuAction('unmute-author', { author: targetHex }); assert.deepEqual(events, [ - ['removeMute', targetHex], + ['removeMute', targetHex, 'viewerhex'], ['success', 'Creator removed from your mute list.'], [ 'refreshAllVideoGrids', diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index e6a1f43e..e9ee691d 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -88,6 +88,8 @@ await (async () => { ); userBlocks.blockedPubkeys = new Set(); + userBlocks._privateBlocks = new Set(); + userBlocks._publicMutes = new Set(); userBlocks.blockEventId = null; userBlocks.loaded = false; @@ -230,6 +232,8 @@ await (async () => { ); userBlocks.blockedPubkeys = new Set([initialBlocked]); + userBlocks._privateBlocks = new Set([initialBlocked]); + userBlocks._publicMutes = new Set(); userBlocks.blockEventId = "existing-event"; userBlocks.blockEventCreatedAt = 1_000; userBlocks.loaded = false; @@ -383,6 +387,10 @@ await (async () => { }; userBlocks.blockedPubkeys = new Set(); + userBlocks._privateBlocks = new Set(); + userBlocks._publicMutes = new Set(); + userBlocks._privateBlocks = new Set(); + userBlocks._publicMutes = new Set(); userBlocks.blockEventId = null; userBlocks.blockEventCreatedAt = null; userBlocks.lastPublishedCreatedAt = null; @@ -641,15 +649,15 @@ await (async () => { "loadBlocks should not request extension permissions when signer decryptor exists", ); - const blockResult = await userBlocks.addBlock(target, actor); - assert.equal(blockResult?.ok, true, "addBlock should succeed with direct signer"); + const blockResult = await userBlocks.addMute(target, actor); + assert.equal(blockResult?.ok, true, "addMute should succeed with direct signer"); assert(userBlocks.blockedPubkeys.has(target), "target should be in the block list"); - const unblockResult = await userBlocks.removeBlock(target, actor); + const unblockResult = await userBlocks.removeMute(target, actor); assert.equal( unblockResult?.ok, true, - "removeBlock should succeed with direct signer", + "removeMute should succeed with direct signer", ); assert.equal( userBlocks.blockedPubkeys.has(target), @@ -849,6 +857,10 @@ await (async () => { }); manager.blockedPubkeys = new Set([blocked]); + manager._privateBlocks = new Set([blocked]); + manager._publicMutes = new Set(); + manager._privateBlocks = new Set([blocked]); + manager._publicMutes = new Set(); manager.publishMuteListSnapshot = async () => null; manager.loadBlocks = async () => {}; @@ -931,6 +943,8 @@ await (async () => { }); manager.blockedPubkeys = new Set([blocked]); + manager._privateBlocks = new Set([blocked]); + manager._privateBlocks = new Set([blocked]); manager.publishMuteListSnapshot = async () => null; manager.loadBlocks = async () => {}; @@ -1096,7 +1110,7 @@ await (async () => { }, }); - manager.blockedPubkeys = new Set([ + const badInputs = new Set([ validOne, validTwoUpper, actor, @@ -1107,17 +1121,19 @@ await (async () => { ["e", validOne], null, ]); + manager.blockedPubkeys = new Set(badInputs); + manager._privateBlocks = new Set(badInputs); + manager._publicMutes = new Set(badInputs); manager.loadBlocks = async () => {}; try { await manager.publishBlockList(actor); - assert.equal(publishCalls.length, 2, "block and mute events should publish"); - assert.equal(encryptionCalls.length, 2, "both block and mute payloads should encrypt"); + assert.equal(publishCalls.length, 1, "only one merged event should publish"); + assert.equal(encryptionCalls.length, 1, "payload should encrypt once for private content"); - const [blockPayload, mutePayload] = encryptionCalls; + const [blockPayload] = encryptionCalls; assert.equal(blockPayload.pubkey, actor, "block payload should encrypt for the actor"); - assert.equal(mutePayload.pubkey, actor, "mute payload should encrypt for the actor"); const matrix = JSON.parse(blockPayload.plaintext); assert.deepEqual( @@ -1128,11 +1144,6 @@ await (async () => { ], "block payload should serialize only sanitized mute tags", ); - assert.equal( - mutePayload.plaintext, - blockPayload.plaintext, - "mute payload should reuse the sanitized block payload", - ); const muteEvent = signedEvents.find((event) => Array.isArray(event?.tags) && event.tags.some((tag) => Array.isArray(tag) && tag[0] === "p"), @@ -1162,8 +1173,8 @@ await (async () => { assert.equal( warningLines.length, - expectedReasons.length, - "every malformed entry should trigger a warning", + expectedReasons.length * 2, + "every malformed entry should trigger a warning (once for block processing, once for mute processing)", ); const actualReasons = warningLines @@ -1174,8 +1185,10 @@ await (async () => { .filter(Boolean) .sort(); + const uniqueActualReasons = [...new Set(actualReasons)].sort(); + assert.deepEqual( - actualReasons, + uniqueActualReasons, expectedReasons, "discarded entries should log their discard reasons", ); @@ -1281,6 +1294,7 @@ await (async () => { const UserBlockListManager = userBlocks.constructor; const manager = new UserBlockListManager(); manager.blockedPubkeys.add(previouslyBlocked); + manager._privateBlocks.add(previouslyBlocked); const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays; const originalPool = nostrClient.pool; @@ -1368,8 +1382,8 @@ await (async () => { await userBlocks.loadBlocks(actor); const blocked = userBlocks.getBlockedPubkeys(); - assert.ok(blocked.includes(taggedBlocked), "Should include pubkey from tagged list"); - assert.ok(blocked.includes(plainBlocked), "Should include pubkey from plain list (merged)"); + assert.ok(!blocked.includes(taggedBlocked), "Should NOT include pubkey from older tagged list (superseded)"); + assert.ok(blocked.includes(plainBlocked), "Should include pubkey from newer plain list"); } finally { relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays;