mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Admin moderation was author/community-level only. Admins (any editor — same gate as the author blacklist) can now hide a SINGLE offending video. - Record: a published kind-30000 NIP-51 list under d-tag bitvid:admin:event-blacklist, carrying blocked event ids as `e` tags (NOTE_TYPES.ADMIN_EVENT_BLACKLIST schema). Loaded/persisted through the existing admin-list machinery (adminListStore) and cached like whitelist/blacklist. - accessControl: eventBlacklist Set + getEventBlacklist/isEventBlacklisted/ addToEventBlacklist/removeFromEventBlacklist (editor-gated; normalizes hex or nevent/note) + onEventBlacklistChange. - Render filter already existed (app.blacklistedEventIds, honored by every feed/grid/ search/playback path) but was fed only by the static ADMIN_INITIAL_EVENT_BLACKLIST config. app._rebuildBlacklistedEventIds() now merges that static base with the dynamic admin list, and an onEventBlacklistChange listener rebuilds + refreshes grids on change. - UI: a "Block this video" item in the per-video ⋯ menu (editor-only), handled by handleBlacklistEventAction (js/ui/moreMenu/blacklistEventAction.js). Helpers extracted to js/adminEventBlacklistHelpers.js to keep adminListStore / accessControl / moreMenuController under their size caps. Tests: tests/admin-event-blacklist.test.mjs (9). Build + lint clean; admin/access/moderation suites 56/56. Co-Authored-By: Claude Opus 4.8 <[email protected]>
150 lines
4.9 KiB
JavaScript
150 lines
4.9 KiB
JavaScript
// #25 per-event (per-video) admin block list: an admin can hide a SINGLE video without
|
|
// blocking its author. The published list is a kind-30000 NIP-51 event carrying the
|
|
// blocked event ids as `e` tags. These tests cover the pure record helpers and the
|
|
// ⋯-menu action handler (editor-gated add + grid refresh).
|
|
|
|
import test, { afterEach } from "node:test";
|
|
import { strict as assert } from "node:assert";
|
|
import {
|
|
sanitizeEventIdList,
|
|
extractEventIdsFromEvent,
|
|
normalizeEventId,
|
|
buildEventBlacklistEvent,
|
|
} from "../js/adminEventBlacklistHelpers.js";
|
|
import { handleBlacklistEventAction } from "../js/ui/moreMenu/blacklistEventAction.js";
|
|
|
|
const HEX_A = "a".repeat(64);
|
|
const HEX_B = "b".repeat(64);
|
|
|
|
afterEach(() => {
|
|
delete globalThis.window;
|
|
delete globalThis.NostrTools;
|
|
});
|
|
|
|
// --- record helpers ---
|
|
|
|
test("sanitizeEventIdList keeps valid hex ids, lowercased + deduped", () => {
|
|
assert.deepEqual(
|
|
sanitizeEventIdList([HEX_A.toUpperCase(), HEX_A, HEX_B, "nope", "", 123]),
|
|
[HEX_A, HEX_B],
|
|
);
|
|
});
|
|
|
|
test("extractEventIdsFromEvent reads `e` tags only", () => {
|
|
const event = {
|
|
tags: [
|
|
["d", "bitvid:admin:event-blacklist"],
|
|
["e", HEX_A],
|
|
["p", HEX_B], // a pubkey tag must NOT be treated as a blocked event
|
|
["e", HEX_B],
|
|
],
|
|
};
|
|
assert.deepEqual(extractEventIdsFromEvent(event), [HEX_A, HEX_B]);
|
|
});
|
|
|
|
test("normalizeEventId accepts hex and decodes nevent/note pointers", () => {
|
|
assert.equal(normalizeEventId(HEX_A.toUpperCase()), HEX_A);
|
|
assert.equal(normalizeEventId("not-an-id"), "");
|
|
globalThis.NostrTools = {
|
|
nip19: {
|
|
decode: (v) =>
|
|
v === "nevent1xyz"
|
|
? { type: "nevent", data: { id: HEX_B } }
|
|
: { type: "note", data: HEX_A },
|
|
},
|
|
};
|
|
assert.equal(normalizeEventId("nevent1xyz"), HEX_B);
|
|
assert.equal(normalizeEventId("note1abc"), HEX_A);
|
|
});
|
|
|
|
test("buildEventBlacklistEvent builds a kind-30000 list with the event d-tag + `e` tags", () => {
|
|
const event = buildEventBlacklistEvent(HEX_A, [HEX_A, HEX_B, "junk"]);
|
|
assert.equal(event.kind, 30000);
|
|
const dTag = event.tags.find((t) => t[0] === "d");
|
|
assert.equal(dTag[1], "bitvid:admin:event-blacklist");
|
|
const eTags = event.tags.filter((t) => t[0] === "e").map((t) => t[1]);
|
|
assert.deepEqual(eTags, [HEX_A, HEX_B]); // junk dropped
|
|
assert.ok(!event.tags.some((t) => t[0] === "p"), "no pubkey tags");
|
|
});
|
|
|
|
// --- ⋯-menu action handler ---
|
|
|
|
function makeDeps({ canEdit = true, npub = "npub1mod", addResult = { ok: true } } = {}) {
|
|
const calls = { added: [], success: [], error: [], refresh: 0 };
|
|
const accessControl = {
|
|
ensureReady: async () => {},
|
|
canEditAdminLists: () => canEdit,
|
|
addToEventBlacklist: async (actor, id) => {
|
|
calls.added.push({ actor, id });
|
|
return addResult;
|
|
},
|
|
};
|
|
const callbacks = {
|
|
getCurrentUserNpub: () => npub,
|
|
showError: (m) => calls.error.push(m),
|
|
showSuccess: (m) => calls.success.push(m),
|
|
refreshAllVideoGrids: async () => {
|
|
calls.refresh += 1;
|
|
},
|
|
};
|
|
return { accessControl, callbacks, calls };
|
|
}
|
|
|
|
test("blocks the video's event id (editor) and refreshes the grids", async () => {
|
|
const { accessControl, callbacks, calls } = makeDeps();
|
|
await handleBlacklistEventAction({
|
|
accessControl,
|
|
callbacks,
|
|
dataset: { eventId: HEX_A },
|
|
currentVideo: { id: HEX_B },
|
|
});
|
|
assert.deepEqual(calls.added, [{ actor: "npub1mod", id: HEX_A }], "uses dataset eventId");
|
|
assert.equal(calls.success.length, 1);
|
|
assert.equal(calls.refresh, 1);
|
|
assert.equal(calls.error.length, 0);
|
|
});
|
|
|
|
test("falls back to the current video's id when the dataset has none", async () => {
|
|
const { accessControl, callbacks, calls } = makeDeps();
|
|
await handleBlacklistEventAction({
|
|
accessControl,
|
|
callbacks,
|
|
dataset: {},
|
|
currentVideo: { id: HEX_B },
|
|
});
|
|
assert.deepEqual(calls.added, [{ actor: "npub1mod", id: HEX_B }]);
|
|
});
|
|
|
|
test("a non-editor is refused and nothing is published", async () => {
|
|
const { accessControl, callbacks, calls } = makeDeps({ canEdit: false });
|
|
await handleBlacklistEventAction({
|
|
accessControl,
|
|
callbacks,
|
|
dataset: { eventId: HEX_A },
|
|
});
|
|
assert.equal(calls.added.length, 0);
|
|
assert.equal(calls.refresh, 0);
|
|
assert.match(calls.error[0], /moderators/i);
|
|
});
|
|
|
|
test("a logged-out user is prompted to login, no publish", async () => {
|
|
const { accessControl, callbacks, calls } = makeDeps({ npub: null });
|
|
await handleBlacklistEventAction({ accessControl, callbacks, dataset: { eventId: HEX_A } });
|
|
assert.equal(calls.added.length, 0);
|
|
assert.match(calls.error[0], /login/i);
|
|
});
|
|
|
|
test("a publish failure surfaces an error and does not claim success", async () => {
|
|
const { accessControl, callbacks, calls } = makeDeps({
|
|
addResult: { ok: false, error: "storage-error" },
|
|
});
|
|
await handleBlacklistEventAction({
|
|
accessControl,
|
|
callbacks,
|
|
dataset: { eventId: HEX_A },
|
|
});
|
|
assert.equal(calls.success.length, 0);
|
|
assert.equal(calls.refresh, 0);
|
|
assert.equal(calls.error.length, 1);
|
|
});
|