From 53540a205d61b311f6f907302b9daaf72aaa8cc8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:07:00 -0400 Subject: [PATCH] Align moderation reports with NIP-56 --- js/services/moderationService.js | 33 ++++++-- js/ui/components/videoMenuRenderers.js | 6 +- js/ui/moreMenuController.js | 83 ++++++++++++++++---- tests/helpers/moderation-test-helpers.mjs | 20 ++++- tests/moderation/submit-report.test.mjs | 95 +++++++++++++++++++++++ 5 files changed, 213 insertions(+), 24 deletions(-) create mode 100644 tests/moderation/submit-report.test.mjs diff --git a/js/services/moderationService.js b/js/services/moderationService.js index f6cfae75..efe0d923 100644 --- a/js/services/moderationService.js +++ b/js/services/moderationService.js @@ -2095,7 +2095,13 @@ export class ModerationService { return Number.isFinite(entry.trusted) ? entry.trusted : 0; } - async submitReport({ eventId, type, targetPubkey = "", content = "" } = {}) { + async submitReport({ + eventId, + type, + targetPubkey = "", + relayHint = "", + content = "", + } = {}) { const normalizedEventId = normalizeEventId(eventId); if (!normalizedEventId) { const error = new Error("invalid-event-id"); @@ -2117,6 +2123,13 @@ export class ModerationService { throw error; } + const normalizedTarget = normalizeHex(targetPubkey); + if (!normalizedTarget) { + const error = new Error("invalid-target-pubkey"); + error.code = "invalid-target-pubkey"; + throw error; + } + const extension = typeof window !== "undefined" ? window.nostr : null; if (!extension) { const error = new Error("nostr-extension-missing"); @@ -2137,17 +2150,21 @@ export class ModerationService { } } + const sanitizedRelayHint = + typeof relayHint === "string" ? relayHint.trim() : ""; + + const eventTag = ["e", normalizedEventId]; + if (sanitizedRelayHint) { + eventTag.push(sanitizedRelayHint); + } + eventTag.push(normalizedType); + const tags = [ - ["e", normalizedEventId, "", normalizedType], - ["report", normalizedType], + eventTag, + ["p", normalizedTarget, normalizedType], ["t", normalizedType], ]; - const normalizedTarget = normalizeHex(targetPubkey); - if (normalizedTarget) { - tags.push(["p", normalizedTarget]); - } - const event = { kind: 1984, created_at: Math.floor(Date.now() / 1000), diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index 093be888..3e1f6dbc 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -288,7 +288,11 @@ export function createVideoMoreMenuPanel({ appendMenuAction(doc, list, { text: "Report", action: "report", - dataset: { eventId: metadata.id }, + dataset: { + eventId: metadata.id, + author: metadata.pubkey, + pointerRelay: pointer && pointer.relay ? pointer.relay : "", + }, }); return panel; diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index 36a303bf..09053832 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -348,7 +348,12 @@ export default class MoreMenuController { return modal; } - openReportModal({ eventId, authorPubkey = "", trigger = null } = {}) { + openReportModal({ + eventId, + authorPubkey = "", + relayHint = "", + trigger = null, + } = {}) { const modal = this.ensureReportModal(); if (!modal) { this.callbacks.showError( @@ -357,9 +362,17 @@ export default class MoreMenuController { return false; } + const normalizedEventId = + typeof eventId === "string" ? eventId.trim() : ""; + const normalizedAuthor = + typeof authorPubkey === "string" ? authorPubkey.trim() : ""; + const normalizedRelayHint = + typeof relayHint === "string" ? relayHint.trim() : ""; + this.activeReportContext = { - eventId: typeof eventId === "string" ? eventId : "", - authorPubkey: typeof authorPubkey === "string" ? authorPubkey : "", + eventId: normalizedEventId, + authorPubkey: normalizedAuthor, + relayHint: normalizedRelayHint, }; this.isReportSubmitting = false; this.setReportModalBusy(false); @@ -496,6 +509,15 @@ export default class MoreMenuController { return; } + const targetPubkey = + typeof context.authorPubkey === "string" + ? context.authorPubkey.trim() + : ""; + if (!targetPubkey) { + this.showReportError("Unable to determine which creator to report."); + return; + } + const viewerPubkey = this.callbacks.getCurrentUserPubkey?.(); if (!viewerPubkey) { this.closeReportModal(); @@ -518,7 +540,11 @@ export default class MoreMenuController { await service.submitReport({ eventId, type: category, - targetPubkey: context.authorPubkey || "", + targetPubkey, + relayHint: + typeof context.relayHint === "string" + ? context.relayHint.trim() + : "", }); this.callbacks.showSuccess( @@ -537,6 +563,8 @@ export default class MoreMenuController { message = "Connect a Nostr account before reporting videos."; } else if (code === "service-unavailable") { message = "Reporting is unavailable right now. Please try again later."; + } else if (code === "invalid-target-pubkey") { + message = "Unable to determine which creator to report."; } this.showReportError(message); @@ -1505,11 +1533,14 @@ export default class MoreMenuController { } const eventId = - dataset.eventId || - (context === "modal" ? currentVideo?.id : "") || - currentVideo?.id || ""; + (typeof dataset.eventId === "string" ? dataset.eventId : "") || + (context === "modal" && typeof currentVideo?.id === "string" + ? currentVideo.id + : "") || + (typeof currentVideo?.id === "string" ? currentVideo.id : ""); - if (!eventId) { + const normalizedEventId = eventId ? eventId.trim() : ""; + if (!normalizedEventId) { this.callbacks.showError( "Unable to determine which video to report.", ); @@ -1517,15 +1548,41 @@ export default class MoreMenuController { } const authorPubkey = - dataset.author || - (context === "modal" && currentVideo?.pubkey + (typeof dataset.author === "string" ? dataset.author : "") || + (context === "modal" && typeof currentVideo?.pubkey === "string" ? currentVideo.pubkey : "") || - currentVideo?.pubkey || ""; + (typeof currentVideo?.pubkey === "string" ? currentVideo.pubkey : ""); + + const normalizedAuthor = authorPubkey ? authorPubkey.trim() : ""; + if (!normalizedAuthor) { + this.callbacks.showError( + "Unable to determine which creator to report.", + ); + break; + } + + let relayHint = + (typeof dataset.pointerRelay === "string" + ? dataset.pointerRelay + : "") || ""; + if (relayHint) { + relayHint = relayHint.trim(); + } + if (!relayHint && context === "modal") { + const pointer = Array.isArray(currentVideo?.pointer) + ? currentVideo.pointer + : null; + const hintCandidate = pointer && pointer.length > 2 ? pointer[2] : ""; + if (typeof hintCandidate === "string" && hintCandidate.trim()) { + relayHint = hintCandidate.trim(); + } + } this.openReportModal({ - eventId, - authorPubkey, + eventId: normalizedEventId, + authorPubkey: normalizedAuthor, + relayHint, trigger: dataset.triggerElement || null, }); break; diff --git a/tests/helpers/moderation-test-helpers.mjs b/tests/helpers/moderation-test-helpers.mjs index a03e6f0b..3d1a8f87 100644 --- a/tests/helpers/moderation-test-helpers.mjs +++ b/tests/helpers/moderation-test-helpers.mjs @@ -3,6 +3,7 @@ import { ModerationService } from "../../js/services/moderationService.js"; import { getDefaultModerationSettings } from "../../js/state/cache.js"; const DEFAULT_CONTACT_OWNER = "f".repeat(64); +const DEFAULT_REPORT_TARGET = "e".repeat(64); export function withMockedNostrTools(t) { const hadWindow = typeof globalThis.window !== "undefined"; @@ -99,6 +100,8 @@ export function createReportEvent({ id, reporter, eventId, + targetPubkey = DEFAULT_REPORT_TARGET, + relayHint = "", createdAt = Math.floor(Date.now() / 1000), type = "nudity", } = {}) { @@ -108,7 +111,19 @@ export function createReportEvent({ : `${Math.random().toString(16).slice(2)}${"0".repeat(64)}`.slice(0, 64); const reporterPubkey = typeof reporter === "string" && reporter ? reporter : ""; const targetEventId = typeof eventId === "string" && eventId ? eventId : ""; + const targetAuthor = + typeof targetPubkey === "string" && targetPubkey + ? targetPubkey + : DEFAULT_REPORT_TARGET; const normalizedType = typeof type === "string" && type ? type : "nudity"; + const normalizedRelayHint = + typeof relayHint === "string" && relayHint ? relayHint : ""; + + const eventTag = ["e", targetEventId]; + if (normalizedRelayHint) { + eventTag.push(normalizedRelayHint); + } + eventTag.push(normalizedType); return { kind: 1984, @@ -116,8 +131,9 @@ export function createReportEvent({ pubkey: reporterPubkey, created_at: Number.isFinite(createdAt) ? Math.floor(createdAt) : Math.floor(Date.now() / 1000), tags: [ - ["e", targetEventId], - ["report", normalizedType], + eventTag, + ["p", targetAuthor, normalizedType], + ["t", normalizedType], ], content: "fixture report", }; diff --git a/tests/moderation/submit-report.test.mjs b/tests/moderation/submit-report.test.mjs new file mode 100644 index 00000000..a4c53390 --- /dev/null +++ b/tests/moderation/submit-report.test.mjs @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { ModerationService } from "../../js/services/moderationService.js"; +import { withMockedNostrTools } from "../helpers/moderation-test-helpers.mjs"; + +function createRelayPublishingPool(publishCalls) { + return { + publish(urls, event) { + publishCalls.push({ urls, event }); + return { + on(eventName, handler) { + if (eventName === "ok") { + setTimeout(() => handler(), 0); + return true; + } + return false; + }, + }; + }, + }; +} + +test("submitReport emits NIP-56 compliant report tags", async (t) => { + withMockedNostrTools(t); + + const reporterPubkey = "a".repeat(64); + const targetPubkey = "b".repeat(64); + const eventId = "c".repeat(64); + const relayHint = "wss://relay.example"; + + const extension = { + async signEvent(event) { + return { ...event, id: "signed-event", sig: "sig" }; + }, + }; + + const previousExtension = globalThis.window.nostr; + globalThis.window.nostr = extension; + t.after(() => { + if (typeof previousExtension === "undefined") { + delete globalThis.window.nostr; + } else { + globalThis.window.nostr = previousExtension; + } + }); + + const publishCalls = []; + const nostrClient = { + pubkey: reporterPubkey, + relays: ["wss://relay.one"], + ensurePool: async () => {}, + ensureExtensionPermissions: async () => ({ ok: true }), + pool: createRelayPublishingPool(publishCalls), + }; + + const service = new ModerationService({ nostrClient, logger: () => {} }); + + const { event, ok } = await service.submitReport({ + eventId, + type: "malware", + targetPubkey, + relayHint, + }); + + assert.equal(ok, true, "submitReport should resolve with ok: true"); + + const eTag = event.tags.find((tag) => Array.isArray(tag) && tag[0] === "e"); + assert.deepEqual(eTag, ["e", eventId, relayHint, "malware"], "e tag should include relay hint before type"); + + const pTag = event.tags.find((tag) => Array.isArray(tag) && tag[0] === "p"); + assert.deepEqual(pTag, ["p", targetPubkey, "malware"], "p tag should include reported author and type"); + + assert.equal( + event.tags.filter((tag) => Array.isArray(tag) && tag[0] === "report").length, + 0, + "legacy report tag should be omitted", + ); + + const followupId = "d".repeat(64); + const { event: followup } = await service.submitReport({ + eventId: followupId, + type: "spam", + targetPubkey, + }); + + const followupETag = followup.tags.find((tag) => Array.isArray(tag) && tag[0] === "e"); + assert.deepEqual( + followupETag, + ["e", followupId, "spam"], + "e tag without relay hint should collapse to type as third entry", + ); + + assert.ok(publishCalls.length >= 2, "submitReport should publish each report"); +});