diff --git a/js/app.js b/js/app.js index 8c4074dd..598574ca 100644 --- a/js/app.js +++ b/js/app.js @@ -628,6 +628,15 @@ class Application { this.authService.hydrateFromStorage(); this.renderSavedProfiles(); + if ( + this.nostrService && + typeof this.nostrService.setDmBlockChecker === "function" + ) { + this.nostrService.setDmBlockChecker((pubkey) => + this.isAuthorBlocked(pubkey), + ); + } + loadModerationOverridesFromStorage(); loadModerationSettingsFromStorage(); loadDmPrivacySettingsFromStorage(); diff --git a/js/services/nostrService.js b/js/services/nostrService.js index 9170378f..ea2fb0c7 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -635,6 +635,7 @@ export class NostrService { logger: userLogger, }); this.dmPreviewCache = new LRUCache({ maxSize: DM_PREVIEW_CACHE_LIMIT }); + this._dmBlockChecker = null; try { if (this.moderationService && typeof this.moderationService.setNostrClient === "function") { this.moderationService.setNostrClient(this.nostrClient); @@ -644,6 +645,28 @@ export class NostrService { } } + setDmBlockChecker(checker) { + this._dmBlockChecker = + typeof checker === "function" ? checker : null; + } + + _isDmRemoteBlocked(message, actorPubkey) { + if (typeof this._dmBlockChecker !== "function") { + return false; + } + try { + const actor = normalizeHexPubkey(actorPubkey || this.dmActorPubkey || ""); + const remote = resolveDirectMessageRemotePubkey(message, actor); + if (!remote) { + return false; + } + return this._dmBlockChecker(remote); + } catch (error) { + devLogger.warn("[nostrService] Block check failed for DM", error); + return false; + } + } + log(...args) { try { this.logger(...args); @@ -810,6 +833,13 @@ export class NostrService { return null; } + if (this._isDmRemoteBlocked(message, actorPubkey)) { + devLogger.log("[nostrService] Skipping persist for blocked DM sender", { + id: record.id, + }); + return null; + } + try { await writeMessages(record); const notificationResult = await this.dmNotificationManager.recordMessage({ @@ -876,6 +906,11 @@ export class NostrService { return; } + if (this._isDmRemoteBlocked(normalized)) { + devLogger.log("[nostrService] Dropping DM from blocked user", { eventId }); + return; + } + this.dmMessageIndex.set(eventId, normalized); const actor = normalizeHexPubkey( diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 88e847cf..15e75d05 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -798,8 +798,28 @@ export default class ApplicationBootstrap { try { const summaries = await app.nostrService.listDirectMessageConversationSummaries(); + const actorHex = + typeof app.normalizeHexPubkey === "function" && app.pubkey + ? app.normalizeHexPubkey(app.pubkey) + : ""; + const canCheckBlock = + userBlocks && typeof userBlocks.isBlocked === "function"; const totalUnseen = Array.isArray(summaries) ? summaries.reduce((sum, summary) => { + if (canCheckBlock && actorHex && summary?.conversation_id) { + const parts = summary.conversation_id.split(":"); + const remote = + parts.length === 3 && parts[0] === "dm" + ? parts[1] === actorHex + ? parts[2] + : parts[2] === actorHex + ? parts[1] + : "" + : ""; + if (remote && userBlocks.isBlocked(remote)) { + return sum; + } + } const count = Number(summary?.unseen_count); return sum + (Number.isFinite(count) ? count : 0); }, 0) diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index cc07eebf..2ff1bdd4 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -4197,7 +4197,15 @@ export class ProfileModalController { }; } - const threads = this.groupDirectMessages(messages, actor); + const allThreads = this.groupDirectMessages(messages, actor); + const blocksService = this.services.userBlocks; + const isRemoteBlocked = + blocksService && typeof blocksService.isBlocked === "function" + ? (pubkey) => blocksService.isBlocked(pubkey) + : () => false; + const threads = allThreads.filter( + (thread) => !thread.remoteHex || !isRemoteBlocked(thread.remoteHex), + ); const remoteKeys = new Set(); threads.forEach((thread) => { if (thread.remoteHex) { diff --git a/tests/dm-block-filter.test.mjs b/tests/dm-block-filter.test.mjs new file mode 100644 index 00000000..3ca1b276 --- /dev/null +++ b/tests/dm-block-filter.test.mjs @@ -0,0 +1,173 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import nostrService from "../js/services/nostrService.js"; + +const ACTOR_PUBKEY = "aaaa".repeat(16); +const BLOCKED_PUBKEY = "bbbb".repeat(16); +const ALLOWED_PUBKEY = "cccc".repeat(16); + +function makeDmMessage({ senderPubkey, receiverPubkey, eventId, direction }) { + return { + ok: true, + direction: direction || "incoming", + sender: { pubkey: senderPubkey }, + recipients: [{ pubkey: receiverPubkey }], + remotePubkey: + direction === "outgoing" ? receiverPubkey : senderPubkey, + actorPubkey: direction === "outgoing" ? senderPubkey : receiverPubkey, + timestamp: Math.floor(Date.now() / 1000), + plaintext: "Hello", + event: { + id: eventId || `evt-${Math.random().toString(36).slice(2)}`, + kind: 4, + pubkey: senderPubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", receiverPubkey]], + }, + message: { + kind: 4, + pubkey: senderPubkey, + tags: [["p", receiverPubkey]], + }, + }; +} + +describe("DM block/mute filtering", () => { + beforeEach(() => { + nostrService.dmMessages = []; + nostrService.dmMessageIndex = new Map(); + nostrService.dmActorPubkey = ACTOR_PUBKEY; + nostrService.dmHydratedFromSnapshot = false; + }); + + afterEach(() => { + nostrService.setDmBlockChecker(null); + nostrService.dmMessages = []; + nostrService.dmMessageIndex = new Map(); + nostrService.dmActorPubkey = null; + mock.reset(); + }); + + describe("setDmBlockChecker", () => { + it("should accept a function", () => { + nostrService.setDmBlockChecker(() => false); + assert.equal(typeof nostrService._dmBlockChecker, "function"); + }); + + it("should clear checker when passed null", () => { + nostrService.setDmBlockChecker(() => true); + nostrService.setDmBlockChecker(null); + assert.equal(nostrService._dmBlockChecker, null); + }); + + it("should clear checker when passed non-function", () => { + nostrService.setDmBlockChecker(() => true); + nostrService.setDmBlockChecker("not-a-function"); + assert.equal(nostrService._dmBlockChecker, null); + }); + }); + + describe("applyDirectMessage with block checker", () => { + it("should drop messages from blocked senders", () => { + nostrService.setDmBlockChecker((pubkey) => pubkey === BLOCKED_PUBKEY); + + const message = makeDmMessage({ + senderPubkey: BLOCKED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + eventId: "blocked-msg-1", + direction: "incoming", + }); + + nostrService.applyDirectMessage(message, { reason: "test" }); + + assert.equal(nostrService.dmMessages.length, 0); + assert.equal(nostrService.dmMessageIndex.has("blocked-msg-1"), false); + }); + + it("should allow messages from non-blocked senders", () => { + nostrService.setDmBlockChecker((pubkey) => pubkey === BLOCKED_PUBKEY); + + const message = makeDmMessage({ + senderPubkey: ALLOWED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + eventId: "allowed-msg-1", + direction: "incoming", + }); + + nostrService.applyDirectMessage(message, { reason: "test" }); + + assert.equal(nostrService.dmMessages.length, 1); + }); + + it("should allow all messages when no block checker is set", () => { + const message = makeDmMessage({ + senderPubkey: BLOCKED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + eventId: "no-checker-msg-1", + direction: "incoming", + }); + + nostrService.applyDirectMessage(message, { reason: "test" }); + + assert.equal(nostrService.dmMessages.length, 1); + }); + + it("should block outgoing messages to blocked recipients", () => { + nostrService.setDmBlockChecker((pubkey) => pubkey === BLOCKED_PUBKEY); + + const message = makeDmMessage({ + senderPubkey: ACTOR_PUBKEY, + receiverPubkey: BLOCKED_PUBKEY, + eventId: "outgoing-blocked-1", + direction: "outgoing", + }); + + nostrService.applyDirectMessage(message, { reason: "test" }); + + assert.equal(nostrService.dmMessages.length, 0); + }); + }); + + describe("_isDmRemoteBlocked", () => { + it("should return false when no checker is set", () => { + const message = makeDmMessage({ + senderPubkey: BLOCKED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + direction: "incoming", + }); + assert.equal(nostrService._isDmRemoteBlocked(message), false); + }); + + it("should return true for blocked remote pubkey", () => { + nostrService.setDmBlockChecker((pubkey) => pubkey === BLOCKED_PUBKEY); + const message = makeDmMessage({ + senderPubkey: BLOCKED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + direction: "incoming", + }); + assert.equal(nostrService._isDmRemoteBlocked(message), true); + }); + + it("should return false for allowed remote pubkey", () => { + nostrService.setDmBlockChecker((pubkey) => pubkey === BLOCKED_PUBKEY); + const message = makeDmMessage({ + senderPubkey: ALLOWED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + direction: "incoming", + }); + assert.equal(nostrService._isDmRemoteBlocked(message), false); + }); + + it("should handle checker that throws", () => { + nostrService.setDmBlockChecker(() => { + throw new Error("checker error"); + }); + const message = makeDmMessage({ + senderPubkey: BLOCKED_PUBKEY, + receiverPubkey: ACTOR_PUBKEY, + direction: "incoming", + }); + assert.equal(nostrService._isDmRemoteBlocked(message), false); + }); + }); +});