diff --git a/js/userBlocks.js b/js/userBlocks.js index b2627978..1052377d 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1214,10 +1214,10 @@ class UserBlockListManager { return; } - const applyEvents = async ( - events, - { skipIfEmpty = false, source = "fast" } = {}, - ) => { + const applyEvents = async ( + events, + { skipIfEmpty = false, source = "fast" } = {}, + ) => { const previousState = { blockedPubkeys: new Set(this.blockedPubkeys), privateBlocks: new Set(this._privateBlocks), @@ -1302,15 +1302,43 @@ class UserBlockListManager { this.blockEventId = newestOverall?.id || null; this.blockEventCreatedAt = newestCreatedAt; + const looksLikeJsonPayload = (value) => { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + const first = trimmed[0]; + return first === "[" || first === "{"; + }; + // Helper to decrypt a single event const decryptEvent = async (ev, meta = {}) => { if (!ev) return []; - const isContentEmpty = !ev.content || !ev.content.trim(); + const trimmedContent = + typeof ev.content === "string" ? ev.content.trim() : ""; + const isContentEmpty = !trimmedContent; if (isContentEmpty) { return []; } + const hasEncryptionHints = extractEncryptionHints(ev).length > 0; + const isLegacyBlockFormat = isTaggedBlockListEvent(ev) || ev.kind === 30002; + const optionalDecrypt = !hasEncryptionHints && !isLegacyBlockFormat; + + if ( + !hasEncryptionHints && + !isLegacyBlockFormat && + looksLikeJsonPayload(trimmedContent) + ) { + return parseBlockListPlaintext(trimmedContent, normalized, { + limit: MAX_BLOCKLIST_ENTRIES, + }); + } + const { decryptors, order, sources, permissionError, signerStatus } = await resolveDecryptors(ev); meta.signerStatus = signerStatus; @@ -1319,6 +1347,12 @@ class UserBlockListManager { ); if (!decryptors.size) { + if (optionalDecrypt) { + // NIP-51 allows descriptive plaintext in content for standard + // mute lists. If decryptors are unavailable and encryption was + // not explicitly indicated, keep public tags and continue. + return []; + } if (permissionError) { throw permissionError; } @@ -1436,12 +1470,14 @@ class UserBlockListManager { timeoutPromise, ]); - // Only apply the newest list to avoid zombie data from stale lists - if (newestOverall === newestStandard && newestStandard) { + // Merge the newest standard mute snapshot with legacy/private lists so + // users retain full cross-client compatibility during migration. + if (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) { + } + if (newestLegacy) { legacyDecrypted.forEach((pk) => nextPrivateBlocks.add(pk)); } diff --git a/tests/dm-block-filter.test.mjs b/tests/dm-block-filter.test.mjs index 3ca1b276..0a6fbf6a 100644 --- a/tests/dm-block-filter.test.mjs +++ b/tests/dm-block-filter.test.mjs @@ -1,6 +1,14 @@ +import "./test-helpers/setup-localstorage.mjs"; import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert/strict"; import nostrService from "../js/services/nostrService.js"; +import { userBlocks } from "../js/userBlocks.js"; +import { nostrClient } from "../js/nostrClientFacade.js"; +import { relayManager } from "../js/relayManager.js"; +import { + BLOCK_LIST_IDENTIFIER, + KIND_MUTE_LIST, +} from "../js/nostrEventSchemas.js"; const ACTOR_PUBKEY = "aaaa".repeat(16); const BLOCKED_PUBKEY = "bbbb".repeat(16); @@ -170,4 +178,150 @@ describe("DM block/mute filtering", () => { assert.equal(nostrService._isDmRemoteBlocked(message), false); }); }); + + describe("integration with loaded user block list", () => { + it("should block DM contact for pubkeys loaded from both standard and legacy lists", async () => { + const actor = ACTOR_PUBKEY; + const legacyBlockedPubkey = "dddd".repeat(16); + const standardBlockedPubkey = "eeee".repeat(16); + + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalNostr = globalThis.window?.nostr; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalFetchListIncrementally = nostrClient.fetchListIncrementally; + const originalRelayEntries = relayManager.getEntries(); + + const relayUrls = ["wss://dm-block-regression.example"]; + relayManager.setEntries( + relayUrls.map((url) => ({ url, mode: "both" })), + { allowEmpty: false, updateClient: false }, + ); + nostrClient.relays = relayUrls; + + if (typeof globalThis.window === "undefined") { + globalThis.window = {}; + } + + globalThis.window.nostr = { + ...(originalNostr || {}), + nip04: { + ...((originalNostr && originalNostr.nip04) || {}), + decrypt: async () => + JSON.stringify({ + blockedPubkeys: [legacyBlockedPubkey], + }), + }, + }; + + const standardEvent = { + id: "event-standard-mute", + kind: KIND_MUTE_LIST, + created_at: 2_000, + pubkey: actor, + content: "", + tags: [["p", standardBlockedPubkey]], + }; + + const legacyEvent = { + id: "event-legacy-blocks", + kind: KIND_MUTE_LIST, + created_at: 1_000, + pubkey: actor, + content: "legacy-encrypted", + tags: [["d", BLOCK_LIST_IDENTIFIER]], + }; + + nostrClient.fetchListIncrementally = async ({ + kind, + dTag, + } = {}) => { + if (kind === KIND_MUTE_LIST && dTag === BLOCK_LIST_IDENTIFIER) { + return [legacyEvent]; + } + if (kind === KIND_MUTE_LIST && !dTag) { + return [standardEvent]; + } + if (kind === 30002 && dTag === BLOCK_LIST_IDENTIFIER) { + return []; + } + return []; + }; + + try { + await manager.loadBlocks(actor, { allowPermissionPrompt: false }); + + const waitForMergedBlocks = async (timeoutMs = 250) => { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if ( + manager.isBlocked(legacyBlockedPubkey) && + manager.isBlocked(standardBlockedPubkey) + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + await waitForMergedBlocks(); + + nostrService.setDmBlockChecker((pubkey) => manager.isBlocked(pubkey)); + + nostrService.applyDirectMessage( + makeDmMessage({ + senderPubkey: legacyBlockedPubkey, + receiverPubkey: actor, + eventId: "blocked-legacy", + direction: "incoming", + }), + { reason: "integration-test" }, + ); + + nostrService.applyDirectMessage( + makeDmMessage({ + senderPubkey: standardBlockedPubkey, + receiverPubkey: actor, + eventId: "blocked-standard", + direction: "incoming", + }), + { reason: "integration-test" }, + ); + + nostrService.applyDirectMessage( + makeDmMessage({ + senderPubkey: ALLOWED_PUBKEY, + receiverPubkey: actor, + eventId: "allowed-contact", + direction: "incoming", + }), + { reason: "integration-test" }, + ); + + assert.equal( + nostrService.dmMessages.length, + 1, + "only non-blocked contacts should remain in DM message state", + ); + assert.equal( + nostrService.dmMessages[0]?.remotePubkey, + ALLOWED_PUBKEY, + "allowed contact should pass block filter", + ); + } finally { + relayManager.setEntries(originalRelayEntries, { + allowEmpty: false, + updateClient: false, + }); + nostrClient.relays = originalRelays; + nostrClient.fetchListIncrementally = originalFetchListIncrementally; + if (typeof globalThis.window === "undefined") { + globalThis.window = {}; + } + globalThis.window.nostr = originalNostr; + } + }); + }); }); diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index 55a7bdd8..fc0d764b 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -1333,7 +1333,7 @@ await (async () => { })(); await (async () => { - // Test: Merge 'd=user-blocks' tagged lists with plain lists + // Test: Merge legacy tagged/private lists with standard mute lists const actor = "a".repeat(64); const taggedBlocked = "b".repeat(64); const plainBlocked = "c".repeat(64); @@ -1386,8 +1386,14 @@ await (async () => { await userBlocks.loadBlocks(actor); const blocked = userBlocks.getBlockedPubkeys(); - 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"); + assert.ok( + blocked.includes(taggedBlocked), + "should include pubkey from legacy tagged/private list", + ); + assert.ok( + blocked.includes(plainBlocked), + "should include pubkey from standard mute list", + ); } finally { relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays; @@ -1395,3 +1401,62 @@ await (async () => { window.nostr = originalNostr; } })(); + +await (async () => { + // Test: standard mute lists may contain non-encrypted text content + const actor = "d".repeat(64); + const blockedHex = "e".repeat(64); + + const UserBlockListManager = userBlocks.constructor; + const manager = new UserBlockListManager(); + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : nostrClient.relays; + const originalPool = nostrClient.pool; + const originalNostr = window.nostr; + + const relayUrls = ["wss://plain-content-mute.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries( + relayUrls.map((url) => ({ url, mode: "both" })), + { allowEmpty: false, updateClient: false }, + ); + + window.nostr = { + ...(originalNostr || {}), + nip04: undefined, + nip44: undefined, + }; + + nostrClient.relays = relayUrls; + nostrClient.pool = { + list: async () => [ + { + id: "event-standard-note", + kind: 10000, + created_at: 13_000, + pubkey: actor, + content: "mute list synced from another client", + tags: [["p", blockedHex]], + }, + ], + }; + + try { + await manager.loadBlocks(actor, { allowPermissionPrompt: false }); + assert.equal( + manager.blockedPubkeys.has(blockedHex), + true, + "non-encrypted content should not block p-tag mute ingestion", + ); + } finally { + relayManager.setEntries( + originalRelayEntries, + { allowEmpty: false, updateClient: false }, + ); + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + window.nostr = originalNostr; + } +})();