diff --git a/js/submissions/submissionFacade.js b/js/submissions/submissionFacade.js index 60f19e36..4e8c448d 100644 --- a/js/submissions/submissionFacade.js +++ b/js/submissions/submissionFacade.js @@ -96,6 +96,40 @@ export async function fetchPendingSubmissions({ return [...byApplicant.values()]; } +/** + * Look up a single event by id and return its author's hex pubkey (or ""). + * The appeal-approval flow uses this to discover WHY a video is hidden — the + * event's own pubkey is the authoritative author, so we don't rely on the + * appellant to supply it. + * @param {{ eventId: string, client?: any }} opts + * @returns {Promise} lowercased author hex, or "" if not found + */ +export async function resolveEventAuthorHex({ eventId, client = nostrClient } = {}) { + const id = normHex(eventId); + if (!id) { + return ""; + } + const { manager, relays } = getManagerAndRelays(client); + if (!manager || typeof manager.list !== "function" || !relays.length) { + return ""; + } + try { + const events = await manager.list({ + relays, + filters: [{ ids: [id], limit: 1 }], + }); + const found = (Array.isArray(events) ? events : []).find( + (event) => normHex(event?.id) === id, + ); + return found && typeof found.pubkey === "string" + ? found.pubkey.trim().toLowerCase() + : ""; + } catch (error) { + devLogger.warn("[submissions] Failed to resolve event author:", error); + return ""; + } +} + async function fetchResolvedIds({ editorHexes, manager, relays }) { const authors = [...new Set(editorHexes.map(normHex).filter(Boolean))]; const ids = new Set(); @@ -201,4 +235,5 @@ export default { SUBMISSIONS_RESOLVED_DTAG, fetchPendingSubmissions, markSubmissionResolved, + resolveEventAuthorHex, }; diff --git a/js/ui/profileModal/submissionsSection.js b/js/ui/profileModal/submissionsSection.js index 285b3570..6483bbe4 100644 --- a/js/ui/profileModal/submissionsSection.js +++ b/js/ui/profileModal/submissionsSection.js @@ -9,10 +9,12 @@ import { fetchPendingSubmissions, markSubmissionResolved, + resolveEventAuthorHex, } from "../../submissions/submissionFacade.js"; import { SUBMISSION_TYPE_LABELS } from "../../submissions/submissionService.js"; import { FEATURE_SUBMISSIONS } from "../../constants.js"; -import { safeDecodeNpub } from "../../utils/nostrHelpers.js"; +import { safeDecodeNpub, safeEncodeNpub } from "../../utils/nostrHelpers.js"; +import { formatShortNpub } from "../../utils/formatters.js"; import { showConfirm } from "../confirmDialog.js"; import { devLogger } from "../../utils/logger.js"; @@ -202,7 +204,7 @@ function actionsForSubmission(submission) { actions.push({ action: "approve-appeal", label: "Approve & unblock", - hint: "Removes the video from the block list so it's visible again.", + hint: "Finds why it's hidden and lifts the block (author-level is confirmed separately).", primary: true, }); } @@ -288,9 +290,12 @@ async function approveSubmission(controller, submission) { } } -// Approve a content appeal: actually remove the named video from the per-event -// block list, then mark the appeal resolved. Requires a well-formed target id -// (parsed from the submission's `e` tag) — the button isn't shown without one. +// Diagnose WHY the appealed video is hidden, then apply the right fix. A video +// can be hidden by an event-level block (just that video) or an author-level +// block (the whole npub — hides ALL their content). We resolve the event's +// author from relays and branch: author-level unblock is a bigger hammer, so it +// gets its own explicit, strongly-worded confirmation and is never bundled into +// the default "unblock this video". async function approveAppeal(controller, submission) { const services = controller.mainController.services; const accessControl = services?.accessControl; @@ -299,38 +304,100 @@ async function approveAppeal(controller, submission) { if (!actorNpub || !accessControl || !targetEventId) { return; } - const ok = await showConfirm( - "Unblock this video? It'll be removed from the block list and become visible again.", - { title: "Approve appeal", confirmLabel: "Unblock" }, - ); - if (!ok) { - return; + + const eventBlocked = + typeof accessControl.isEventBlacklisted === "function" + ? Boolean(accessControl.isEventBlacklisted(targetEventId)) + : false; + let authorHex = ""; + try { + authorHex = await resolveEventAuthorHex({ eventId: targetEventId }); + } catch (error) { + authorHex = ""; } + const authorNpub = authorHex ? safeEncodeNpub(authorHex) || "" : ""; + const authorBlocked = + authorHex && typeof accessControl.isBlacklisted === "function" + ? Boolean(accessControl.isBlacklisted(authorHex)) + : false; + + // Pick the action + confirmation for the real block state. `apply` runs the + // unblock(s) after the admin confirms; `successMessage` reports what happened. + let apply = null; + let successMessage = ""; + if (authorBlocked) { + const who = authorNpub ? formatShortNpub(authorNpub) : "this creator"; + const confirmed = await showConfirm( + `This video is hidden because its author (${who}) is blocked platform-wide. Unblocking restores ALL of their content, not just this video. Unblock the author?`, + { title: "Author is blocked", confirmLabel: "Unblock author" }, + ); + if (!confirmed) { + return; + } + apply = async () => { + const result = await accessControl.removeFromBlacklist(actorNpub, authorNpub); + if (!result || result.ok === false) { + throw new Error(result?.error || "author-unblock-failed"); + } + // Also clear any event-level block so the video is fully visible. + if (eventBlocked && typeof accessControl.removeFromEventBlacklist === "function") { + await accessControl.removeFromEventBlacklist(actorNpub, targetEventId); + } + }; + successMessage = "Appeal approved — the author has been unblocked."; + } else if (eventBlocked) { + const confirmed = await showConfirm( + "Unblock this video? It'll be removed from the block list and become visible again.", + { title: "Approve appeal", confirmLabel: "Unblock" }, + ); + if (!confirmed) { + return; + } + apply = async () => { + const result = await accessControl.removeFromEventBlacklist( + actorNpub, + targetEventId, + ); + if (!result || result.ok === false) { + throw new Error(result?.error || "unblock-failed"); + } + }; + successMessage = "Appeal approved — the video has been unblocked."; + } else { + // Not on either block list. Distinguish "confirmed not blocked" from + // "couldn't verify" (event not found on connected relays). + const message = authorHex + ? "This video isn't on the event or author block list — there's nothing to unblock (it may already be unblocked, or hidden by trusted moderation reports). Mark the appeal as resolved?" + : "Couldn't find this video on connected relays to check its block status. If you've already lifted the block, mark the appeal as resolved — otherwise cancel and check the admin block lists."; + const confirmed = await showConfirm(message, { + title: authorHex ? "Nothing to unblock" : "Couldn't verify", + confirmLabel: "Mark resolved", + }); + if (!confirmed) { + return; + } + apply = async () => {}; + successMessage = "Appeal marked as resolved."; + } + controller.pendingSubmissions = (controller.pendingSubmissions || []).filter( (s) => s.eventId !== submission.eventId, ); renderSubmissions(controller); try { - const result = await accessControl.removeFromEventBlacklist( - actorNpub, - targetEventId, - ); - if (!result || result.ok === false) { - throw new Error(result?.error || "unblock-failed"); - } + await apply(); await markSubmissionResolved({ submission, status: "approved", actingHex: safeDecodeNpub(actorNpub), }); - controller.mainController.showSuccess?.( - "Appeal approved — the video has been unblocked.", - ); + controller.mainController.showSuccess?.(successMessage); + controller.populateAdminLists?.(); emitSubmissionsChanged(); } catch (error) { devLogger.warn("[profileModal] Approve appeal failed:", error); controller.mainController.showError?.( - "Couldn't unblock — please try again.", + "Couldn't complete the appeal — please try again.", ); void populateSubmissions(controller); } diff --git a/tests/submission-facade.test.mjs b/tests/submission-facade.test.mjs index 1622513a..d0fd9e39 100644 --- a/tests/submission-facade.test.mjs +++ b/tests/submission-facade.test.mjs @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { fetchPendingSubmissions, markSubmissionResolved, + resolveEventAuthorHex, RESOLVED_LIST_KIND, SUBMISSIONS_RESOLVED_DTAG, } from "../js/submissions/submissionFacade.js"; @@ -47,6 +48,7 @@ function mockClient({ events = [], onPublish } = {}) { list: async ({ filters }) => { const f = filters[0] || {}; return events.filter((ev) => { + if (f.ids && !f.ids.includes(ev.id)) return false; if (f.kinds && !f.kinds.includes(ev.kind)) return false; if (f.authors && !f.authors.includes(ev.pubkey)) return false; if (f["#p"]) { @@ -121,6 +123,30 @@ test("markSubmissionResolved appends to the acting moderator's resolved-set", as assert.equal(newTag[3], "npub-bob", "records applicant"); }); +test("resolveEventAuthorHex returns the blocked event's author (lowercased)", async () => { + const blockedEventId = "f".repeat(64); + const authorHex = "C".repeat(64); // stored upper-case to prove normalization + const events = [ + { id: blockedEventId, kind: 30078, pubkey: authorHex, tags: [], content: "" }, + ]; + const author = await resolveEventAuthorHex({ + eventId: blockedEventId, + client: mockClient({ events }), + }); + assert.equal(author, "c".repeat(64)); +}); + +test("resolveEventAuthorHex returns '' for a blank id or an event not found", async () => { + assert.equal(await resolveEventAuthorHex({ eventId: "", client: mockClient({}) }), ""); + assert.equal( + await resolveEventAuthorHex({ + eventId: "a".repeat(64), + client: mockClient({ events: [] }), + }), + "", + ); +}); + test("markSubmissionResolved is a no-op without an acting pubkey or event id", async () => { assert.equal( await markSubmissionResolved({ submission: { eventId: "x" }, actingHex: "", client: mockClient({}) }),