mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.() || "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user