diff --git a/components/iframe_forms/iframe-content-appeals-form.html b/components/iframe_forms/iframe-content-appeals-form.html index 8afc1ab7..96b23c89 100644 --- a/components/iframe_forms/iframe-content-appeals-form.html +++ b/components/iframe_forms/iframe-content-appeals-form.html @@ -45,6 +45,18 @@ placeholder="Enter the magnet link" /> + + +
+ Required — open the blocked video's ⋯ menu, choose “Copy link”, and + paste it here so a moderator can locate and unblock the exact video. +
+ @@ -202,6 +214,36 @@ ]; const pool = new SimplePool(); + // Validate + normalize the blocked-video reference to a 64-char hex + // event id. Accepts a bitvid share link (which embeds an nevent), a raw + // nevent1…/note1… pointer, or a bare hex id. Returns "" when nothing + // valid is found so the caller can block submission. + const HEX_EVENT_ID = /^[0-9a-f]{64}$/i; + function extractEventHex(input) { + const trimmed = String(input || "").trim(); + if (!trimmed) { + return ""; + } + if (HEX_EVENT_ID.test(trimmed)) { + return trimmed.toLowerCase(); + } + // Pull an nevent/note token out of a URL or free text. + const match = trimmed.match(/(nevent1[0-9a-z]+|note1[0-9a-z]+)/i); + const token = match ? match[1] : trimmed; + try { + const decoded = nip19.decode(token); + if (decoded.type === "nevent" && HEX_EVENT_ID.test(decoded.data?.id || "")) { + return decoded.data.id.toLowerCase(); + } + if (decoded.type === "note" && HEX_EVENT_ID.test(decoded.data || "")) { + return decoded.data.toLowerCase(); + } + } catch (err) { + // not a decodable pointer + } + return ""; + } + document .getElementById("dm-form") .addEventListener("submit", async (ev) => { @@ -220,6 +262,17 @@ const magnetLink = document .getElementById("magnetLink") .value.trim(); + const eventPointer = document + .getElementById("eventPointer") + .value.trim(); + const targetEventHex = extractEventHex(eventPointer); + if (!targetEventHex) { + log( + "Please paste a valid video share link, nevent1…, note1…, or 64-char event id so a moderator can locate the blocked video.", + "error" + ); + return; + } const submissionDate = document .getElementById("submissionDate") .value.trim(); @@ -261,6 +314,7 @@ **2. Content Details** - **Title of the Video:** ${videoTitle} - **Magnet Link:** ${magnetLink} +- **Blocked Event ID:** ${targetEventHex} - **Date of Content Submission:** ${submissionDate} **3. Reason for Appeal** @@ -315,10 +369,9 @@ For further questions, reach out through bitvid’s Nostr support channels. const ephemeralPubHex = getPublicKey(ephemeralPriv); log("Ephemeral pubkey: " + ephemeralPubHex.slice(0, 16) + "..."); - // Encrypt the appeal content. - log("Encrypting appeal content (nip04)..."); // Build a structured bitvid submission (kind 30083, PUBLIC — no // encryption). Published from the ephemeral key. + log("Building appeal submission (kind 30083, public)..."); const now = Math.floor(Date.now() / 1000); const submissionId = "sub-" + @@ -332,6 +385,7 @@ For further questions, reach out through bitvid’s Nostr support channels. ["d", submissionId], ["k", "appeal"], ["p", targetPubHex], + ["e", targetEventHex], ["t", "bitvid-submission"], ["client", "bitvid"], ], diff --git a/css/tailwind.source.css b/css/tailwind.source.css index ce715fe1..f1d9f539 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3231,7 +3231,24 @@ .submission-card__actions { display: flex; - gap: var(--space-2xs); + flex-wrap: wrap; + gap: var(--space-sm); + align-items: flex-start; + } + + /* Each action pairs its button with a one-line description so a moderator + knows exactly what the control does before clicking. */ + .submission-card__action { + display: flex; + flex-direction: column; + gap: var(--space-3xs); + min-inline-size: 0; + } + + .submission-card__action-hint { + font-size: var(--font-size-2xs); + color: var(--color-text-muted); + max-inline-size: 22ch; } /* #43: always-visible active-identity pill. Rendered after the header in @@ -4531,6 +4548,12 @@ margin-top: var(--space-xs); } + body.iframe-form .form-hint { + margin-top: var(--space-xs); + font-size: var(--font-size-2xs); + color: var(--color-text-muted); + } + body.iframe-form .checkbox-group, body.iframe-form .radio-group { display: flex; diff --git a/js/submissions/submissionService.js b/js/submissions/submissionService.js index 56fcb8d2..b7915ff6 100644 --- a/js/submissions/submissionService.js +++ b/js/submissions/submissionService.js @@ -38,13 +38,31 @@ function firstTagValue(tags, name) { return tag ? tag[1] : ""; } +const HEX_EVENT_ID_REGEX = /^[0-9a-f]{64}$/i; + +// The `e` tag on an appeal points at the blocked video's event id. We only trust +// a well-formed 64-char hex id — the admin approve path feeds it straight into +// the event block list. +function firstEventTarget(tags) { + for (const tag of tags) { + if (Array.isArray(tag) && tag[0] === "e" && typeof tag[1] === "string") { + const id = tag[1].trim().toLowerCase(); + if (HEX_EVENT_ID_REGEX.test(id)) { + return id; + } + } + } + return ""; +} + /** * Parse a kind-30083 event into a structured submission. Returns null for a * non-submission event or one missing a `d` identifier. * @param {Object} event * @returns {null | { * id: string, eventId: string, type: string, applicant: string, - * recipient: string, content: string, createdAt: number, pubkey: string, + * targetEventId: string, recipient: string, content: string, + * createdAt: number, pubkey: string, * }} */ export function parseSubmissionEvent(event) { @@ -65,6 +83,7 @@ export function parseSubmissionEvent(event) { eventId: typeof event.id === "string" ? event.id : "", type: KNOWN_TYPES.has(rawType) ? rawType : "other", applicant: firstTagValue(tags, "applicant").trim(), + targetEventId: firstEventTarget(tags), recipient: firstTagValue(tags, "p").trim(), content: typeof event.content === "string" ? event.content : "", createdAt: Number.isFinite(event.created_at) ? event.created_at : 0, diff --git a/js/ui/profileModal/submissionsSection.js b/js/ui/profileModal/submissionsSection.js index e32e4dbd..285b3570 100644 --- a/js/ui/profileModal/submissionsSection.js +++ b/js/ui/profileModal/submissionsSection.js @@ -149,28 +149,81 @@ function renderSubmissionRow(submission) { const actions = document.createElement("div"); actions.className = "submission-card__actions"; - // Approve (add to whitelist) only applies to whitelist applications. - if (submission.type === "application" && submission.applicant) { - const approve = document.createElement("button"); - approve.type = "button"; - approve.className = "btn focus-ring"; - approve.dataset.size = "sm"; - approve.dataset.submissionAction = "approve"; - approve.textContent = "Approve"; - actions.appendChild(approve); + for (const action of actionsForSubmission(submission)) { + actions.appendChild(renderAction(action)); } - const deny = document.createElement("button"); - deny.type = "button"; - deny.className = "btn-ghost focus-ring"; - deny.dataset.size = "sm"; - deny.dataset.submissionAction = "deny"; - deny.textContent = submission.type === "application" ? "Deny" : "Dismiss"; - actions.appendChild(deny); li.appendChild(actions); return li; } +// Build a labeled button + one-line description explaining what it does. +function renderAction({ action, label, hint, primary }) { + const wrap = document.createElement("div"); + wrap.className = "submission-card__action"; + const button = document.createElement("button"); + button.type = "button"; + button.className = `${primary ? "btn" : "btn-ghost"} focus-ring`; + button.dataset.size = "sm"; + button.dataset.submissionAction = action; + button.textContent = label; + wrap.appendChild(button); + if (hint) { + const caption = document.createElement("span"); + caption.className = "submission-card__action-hint"; + caption.textContent = hint; + wrap.appendChild(caption); + } + return wrap; +} + +// The action set depends on the submission type so each button's effect is +// explicit. Only whitelist applications add to the whitelist; only appeals that +// name a real event can unblock it; everything else is just "mark handled". +function actionsForSubmission(submission) { + if (submission.type === "application" && submission.applicant) { + return [ + { + action: "approve", + label: "Approve", + hint: "Adds the applicant to the whitelist.", + primary: true, + }, + { + action: "deny", + label: "Deny", + hint: "Rejects the application — nothing is whitelisted.", + }, + ]; + } + if (submission.type === "appeal") { + const actions = []; + if (submission.targetEventId) { + actions.push({ + action: "approve-appeal", + label: "Approve & unblock", + hint: "Removes the video from the block list so it's visible again.", + primary: true, + }); + } + actions.push({ + action: "deny", + label: submission.targetEventId ? "Keep blocked" : "Dismiss", + hint: submission.targetEventId + ? "Dismisses the appeal — the video stays blocked." + : "Marks the appeal as handled (no event linked to unblock).", + }); + return actions; + } + return [ + { + action: "deny", + label: "Mark handled", + hint: "Marks this submission as reviewed.", + }, + ]; +} + export function handleSubmissionsClick(controller, event) { const button = event.target instanceof Element @@ -187,8 +240,11 @@ export function handleSubmissionsClick(controller, event) { if (!submission) { return; } - if (button.dataset.submissionAction === "approve") { + const action = button.dataset.submissionAction; + if (action === "approve") { void approveSubmission(controller, submission); + } else if (action === "approve-appeal") { + void approveAppeal(controller, submission); } else { void denySubmission(controller, submission); } @@ -232,6 +288,54 @@ 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. +async function approveAppeal(controller, submission) { + const services = controller.mainController.services; + const accessControl = services?.accessControl; + const actorNpub = services?.getCurrentUserNpub?.() || ""; + const targetEventId = submission.targetEventId; + 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; + } + 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 markSubmissionResolved({ + submission, + status: "approved", + actingHex: safeDecodeNpub(actorNpub), + }); + controller.mainController.showSuccess?.( + "Appeal approved — the video has been unblocked.", + ); + emitSubmissionsChanged(); + } catch (error) { + devLogger.warn("[profileModal] Approve appeal failed:", error); + controller.mainController.showError?.( + "Couldn't unblock — please try again.", + ); + void populateSubmissions(controller); + } +} + async function denySubmission(controller, submission) { const actorNpub = controller.mainController.services?.getCurrentUserNpub?.() || ""; diff --git a/tests/submission-service.test.mjs b/tests/submission-service.test.mjs index 48cb9d9e..f8a8a837 100644 --- a/tests/submission-service.test.mjs +++ b/tests/submission-service.test.mjs @@ -73,6 +73,48 @@ test("parseSubmissionEvent structures a submission", () => { assert.equal(parsed.pubkey, EPHEMERAL); }); +test("parseSubmissionEvent surfaces an appeal's blocked-event target from the e tag", () => { + const target = "b".repeat(64); + const parsed = parseSubmissionEvent({ + kind: SUBMISSION_KIND, + pubkey: EPHEMERAL, + created_at: 10, + tags: [ + ["d", "appeal-1"], + ["k", "appeal"], + ["p", ADMIN], + ["e", target.toUpperCase()], + ], + }); + assert.equal(parsed.type, "appeal"); + // lowercased, ready to feed straight into the event block list + assert.equal(parsed.targetEventId, target); +}); + +test("parseSubmissionEvent ignores a malformed e tag and defaults target to ''", () => { + const parsed = parseSubmissionEvent({ + kind: SUBMISSION_KIND, + pubkey: EPHEMERAL, + created_at: 10, + tags: [ + ["d", "appeal-2"], + ["k", "appeal"], + ["e", "not-a-real-event-id"], + ], + }); + assert.equal(parsed.targetEventId, ""); +}); + +test("parseSubmissionEvent leaves targetEventId empty when no e tag is present", () => { + const parsed = parseSubmissionEvent({ + kind: SUBMISSION_KIND, + pubkey: EPHEMERAL, + created_at: 10, + tags: [["d", "x"], ["k", "application"]], + }); + assert.equal(parsed.targetEventId, ""); +}); + test("parseSubmissionEvent normalizes an unknown type to 'other'", () => { const parsed = parseSubmissionEvent({ kind: SUBMISSION_KIND,