Submissions: labeled actions + real appeal-driven unblock

Make each admin action self-explanatory and give content appeals a real
effect instead of a dead-end "Dismiss".

- submissionsSection.js: per-type action sets, each button paired with a
  one-line description. Applications show Approve (adds to whitelist) / Deny;
  appeals that name a real event show "Approve & unblock" (removes the video
  from the event block list) / "Keep blocked"; other types show "Mark handled".
- submissionService.js: parse an appeal's `e` tag into targetEventId (trusted
  only when it's a well-formed 64-char hex id).
- iframe-content-appeals-form.html: add a required "Blocked video link or
  event ID" field; regex-validate + normalize a share link / nevent / note /
  hex to the hex event id, tag it as `e`, and include it in the body. Block
  submission when no valid reference is provided.
- CSS: stacked action + hint styling; iframe-form .form-hint.
- tests: cover targetEventId parsing (valid, malformed, absent).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-07 09:52:04 -04:00
1 parent ef4191538c
commit 2803f6b56f
5 files changed
+263 -21

No files matched your search

@@ -45,6 +45,18 @@
placeholder="Enter the magnet link"
/>
<label for="eventPointer">Blocked video link or event ID:</label>
<input
class="form-control"
type="text"
id="eventPointer"
placeholder="Paste the video's share link, nevent1…, note1…, or 64-char event id"
/>
<p class="form-hint">
Required — open the blocked video's ⋯ menu, choose “Copy link”, and
paste it here so a moderator can locate and unblock the exact video.
</p>
<label for="submissionDate">Date of Content Submission:</label>
<input class="form-control" type="date" id="submissionDate" />
@@ -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"],
],
+24 -1
View File
@@ -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;
+20 -1
View File
@@ -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,
+121 -17
View File
@@ -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?.() || "";
+42
View File
@@ -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,