Files
bitvid/js/adminEventBlacklistHelpers.js
T
thePR0M3TH3ANandClaude Opus 4.8 3e57ab2ea9 [moderation] #25: per-video admin block list (hide a single video, not the author)
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]>
2026-06-30 15:19:34 -04:00

81 lines
2.5 KiB
JavaScript

// Helpers for the per-event (per-video) admin block list (#25). Entries are 64-char
// hex event ids carried as `e` tags on a kind-30000 NIP-51 list — not npubs.
import { buildAdminListEvent } from "./nostrEventSchemas.js";
export const HEX_EVENT_ID_REGEX = /^[0-9a-f]{64}$/i;
// Unsigned kind-30000 event for the per-event block list (`e` tags via its schema).
export function buildEventBlacklistEvent(actorHex, eventIds) {
return buildAdminListEvent("eventBlacklist", {
pubkey: actorHex,
created_at: Math.floor(Date.now() / 1000),
hexPubkeys: sanitizeEventIdList(eventIds),
});
}
// Deduped, lowercased hex event ids; anything malformed is dropped.
export function sanitizeEventIdList(values) {
const out = [];
const seen = new Set();
for (const value of Array.isArray(values) ? values : []) {
const id = typeof value === "string" ? value.trim().toLowerCase() : "";
if (HEX_EVENT_ID_REGEX.test(id) && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}
export function extractEventIdsFromEvent(event) {
if (!event || !Array.isArray(event.tags)) {
return [];
}
return sanitizeEventIdList(
event.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "e" && tag[1])
.map((tag) => tag[1]),
);
}
// Normalize a per-event block entry to a 64-char hex event id. Accepts a raw hex id or
// a NIP-19 nevent/note string (the ⋯ menu passes the video's hex id directly).
export function normalizeEventId(value) {
if (typeof value !== "string") {
return "";
}
const trimmed = value.trim();
if (HEX_EVENT_ID_REGEX.test(trimmed)) {
return trimmed.toLowerCase();
}
try {
const tools =
(typeof window !== "undefined" ? window?.NostrTools : null) ||
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null);
const decoded = tools?.nip19?.decode?.(trimmed);
if (decoded?.type === "nevent" && HEX_EVENT_ID_REGEX.test(decoded.data?.id || "")) {
return decoded.data.id.toLowerCase();
}
if (decoded?.type === "note" && HEX_EVENT_ID_REGEX.test(decoded.data || "")) {
return decoded.data.toLowerCase();
}
} catch (error) {
// not a decodable pointer
}
return "";
}
export function normalizeEventIdList(values) {
const out = [];
const seen = new Set();
for (const value of Array.isArray(values) ? values : []) {
const id = normalizeEventId(value);
if (id && !seen.has(id)) {
seen.add(id);
out.push(id);
}
}
return out;
}