// js/ui/profileModal/submissionsSection.js // // The admin pane's "Submissions" sub-tab (#23) logic, extracted from // ProfileAdminController to keep that file under the size cap (mirrors // blockedVideosSection.js). Each function takes the ProfileAdminController as // `controller` and uses its cached DOM refs + services. Gated by // FEATURE_SUBMISSIONS. import { fetchPendingSubmissions, markSubmissionResolved, resolveEventAuthorHex, } from "../../submissions/submissionFacade.js"; import { SUBMISSION_TYPE_LABELS } from "../../submissions/submissionService.js"; import { FEATURE_SUBMISSIONS } from "../../constants.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"; function setLoading(controller, value) { if (controller.submissionsLoading) { controller.submissionsLoading.classList.toggle("hidden", !value); } } // Let the app recompute the profile-button "pending submissions" dot after a // moderator approves/denies one. function emitSubmissionsChanged() { try { document.dispatchEvent(new CustomEvent("bitvid:submissions-changed")); } catch (error) { // best effort } } export async function populateSubmissions(controller) { if (!FEATURE_SUBMISSIONS || !controller.submissionsList) { return; } const services = controller.mainController.services; const accessControl = services?.accessControl; const actorNpub = services?.getCurrentUserNpub?.() || ""; if (!actorNpub || !accessControl?.canEditAdminLists?.(actorNpub)) { controller.pendingSubmissions = []; renderSubmissions(controller); return; } const adminHex = safeDecodeNpub(controller.mainController.adminSuperNpub); // getEditors() may return a Set (or array) — coerce before mapping. const editorHexes = Array.from(accessControl.getEditors?.() || []) .map((npub) => safeDecodeNpub(npub)) .filter(Boolean); const whitelistNpubs = Array.from(accessControl.getWhitelist?.() || []); setLoading(controller, true); let pending = []; try { pending = await fetchPendingSubmissions({ adminHex, editorHexes, whitelistNpubs }); } catch (error) { devLogger.warn("[profileModal] Failed to load submissions:", error); } controller.pendingSubmissions = pending; setLoading(controller, false); renderSubmissions(controller); } export function renderSubmissions(controller) { if (!controller.submissionsList) { return; } controller.submissionsList.replaceChildren(); const list = Array.isArray(controller.pendingSubmissions) ? controller.pendingSubmissions : []; if (controller.submissionsEmpty) { controller.submissionsEmpty.classList.toggle("hidden", list.length > 0); } for (const submission of list) { controller.submissionsList.appendChild(renderSubmissionRow(submission)); } } // Render `**bold**` segments as ; everything else as plain text — never // parse HTML from the content (submissions are untrusted public events). function appendInline(el, text) { const parts = String(text).split("**"); parts.forEach((part, i) => { if (!part) { return; } if (i % 2 === 1) { const strong = document.createElement("strong"); strong.textContent = part; el.appendChild(strong); } else { el.appendChild(document.createTextNode(part)); } }); } // Tiny, SAFE markdown-lite renderer for the form-generated body: `#…###` // headings become bold lines, leading `- `/`* ` bullet markers are stripped, // `**bold**` is honored inline. No HTML is ever parsed from the content. function renderSubmissionBody(container, content) { for (const raw of String(content).split("\n")) { const line = raw.replace(/\s+$/, ""); if (!line.trim()) { continue; } if (/^#{1,6}\s/.test(line)) { const heading = document.createElement("p"); heading.className = "submission-card__heading"; appendInline(heading, line.replace(/^#{1,6}\s+/, "")); container.appendChild(heading); } else { const p = document.createElement("p"); p.className = "submission-card__line"; appendInline(p, line.replace(/^[-*]\s+/, "")); container.appendChild(p); } } } function renderSubmissionRow(submission) { const li = document.createElement("li"); li.className = "submission-card"; li.dataset.submissionId = submission.eventId; const head = document.createElement("div"); head.className = "submission-card__head"; const badge = document.createElement("span"); badge.className = "submission-card__type"; badge.textContent = SUBMISSION_TYPE_LABELS[submission.type] || SUBMISSION_TYPE_LABELS.other; head.appendChild(badge); if (submission.applicant) { const who = document.createElement("span"); who.className = "submission-card__applicant"; who.textContent = submission.applicant; head.appendChild(who); } li.appendChild(head); if (submission.content) { const body = document.createElement("div"); body.className = "submission-card__body"; renderSubmissionBody(body, submission.content); li.appendChild(body); } const actions = document.createElement("div"); actions.className = "submission-card__actions"; for (const action of actionsForSubmission(submission)) { actions.appendChild(renderAction(action)); } 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: "Finds why it's hidden and lifts the block (author-level is confirmed separately).", 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 ? event.target.closest("[data-submission-action]") : null; if (!button) { return; } const card = button.closest("[data-submission-id]"); const id = card?.dataset?.submissionId || ""; const submission = (controller.pendingSubmissions || []).find( (s) => s.eventId === id, ); if (!submission) { return; } 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); } } async function approveSubmission(controller, submission) { const services = controller.mainController.services; const accessControl = services?.accessControl; const actorNpub = services?.getCurrentUserNpub?.() || ""; const applicant = submission.applicant; if (!actorNpub || !accessControl || !applicant) { return; } const ok = await showConfirm( `Approve ${applicant}? They'll be added to the whitelist.`, { title: "Approve application", confirmLabel: "Approve" }, ); if (!ok) { return; } controller.pendingSubmissions = (controller.pendingSubmissions || []).filter( (s) => s.eventId !== submission.eventId, ); renderSubmissions(controller); try { await accessControl.addToWhitelist(actorNpub, applicant); await markSubmissionResolved({ submission, status: "approved", actingHex: safeDecodeNpub(actorNpub), }); controller.mainController.showSuccess?.( `Approved — ${applicant} added to the whitelist.`, ); controller.populateAdminLists(); emitSubmissionsChanged(); } catch (error) { devLogger.warn("[profileModal] Approve submission failed:", error); controller.mainController.showError?.("Couldn't approve — please try again."); void populateSubmissions(controller); } } // 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; const actorNpub = services?.getCurrentUserNpub?.() || ""; const targetEventId = submission.targetEventId; if (!actorNpub || !accessControl || !targetEventId) { 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 { await apply(); await markSubmissionResolved({ submission, status: "approved", actingHex: safeDecodeNpub(actorNpub), }); controller.mainController.showSuccess?.(successMessage); controller.populateAdminLists?.(); emitSubmissionsChanged(); } catch (error) { devLogger.warn("[profileModal] Approve appeal failed:", error); controller.mainController.showError?.( "Couldn't complete the appeal — please try again.", ); void populateSubmissions(controller); } } async function denySubmission(controller, submission) { const actorNpub = controller.mainController.services?.getCurrentUserNpub?.() || ""; if (!actorNpub) { return; } controller.pendingSubmissions = (controller.pendingSubmissions || []).filter( (s) => s.eventId !== submission.eventId, ); renderSubmissions(controller); try { await markSubmissionResolved({ submission, status: "denied", actingHex: safeDecodeNpub(actorNpub), }); controller.mainController.showSuccess?.("Submission dismissed."); emitSubmissionsChanged(); } catch (error) { devLogger.warn("[profileModal] Deny submission failed:", error); controller.mainController.showError?.("Couldn't dismiss — please try again."); void populateSubmissions(controller); } }