[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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-30 15:19:34 -04:00
1 parent 2e7e2016d3
commit 3e57ab2ea9
11 files changed
+529 -44

No files matched your search

+99
View File
@@ -14,6 +14,10 @@ import {
readCachedAdminState,
} from "./adminListStore.js";
import { ensureNostrTools } from "./nostr/toolkit.js";
import {
normalizeEventId,
normalizeEventIdList,
} from "./adminEventBlacklistHelpers.js";
import { userLogger } from "./utils/logger.js";
const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i;
@@ -109,6 +113,8 @@ class AccessControl {
this.whitelistPubkeys = new Set();
this.blacklist = new Set();
this.blacklistPubkeys = new Set();
// Per-event (per-video) block list (#25): hex event ids hidden everywhere.
this.eventBlacklist = new Set();
this.whitelistEnabled = getWhitelistMode();
this.hasLoaded = false;
this.lastError = null;
@@ -118,6 +124,7 @@ class AccessControl {
this._whitelistListeners = new Set();
this._editorListeners = new Set();
this._blacklistListeners = new Set();
this._eventBlacklistListeners = new Set();
this._scheduleHydrateFromCache();
}
@@ -248,6 +255,9 @@ class AccessControl {
const editors = Array.isArray(state?.editors) ? state.editors : [];
const whitelist = Array.isArray(state?.whitelist) ? state.whitelist : [];
const blacklist = Array.isArray(state?.blacklist) ? state.blacklist : [];
const eventBlacklist = Array.isArray(state?.eventBlacklist)
? state.eventBlacklist
: [];
const previousEditors =
this.editors instanceof Set ? new Set(this.editors) : new Set();
@@ -255,6 +265,8 @@ class AccessControl {
this.whitelist instanceof Set ? new Set(this.whitelist) : new Set();
const previousBlacklist =
this.blacklist instanceof Set ? new Set(this.blacklist) : new Set();
const previousEventBlacklist =
this.eventBlacklist instanceof Set ? new Set(this.eventBlacklist) : new Set();
this.editors = new Set(
dedupeNpubs([...ADMIN_EDITORS_NPUBS, ...editors])
@@ -282,6 +294,12 @@ class AccessControl {
this.blacklist = new Set(sanitizedBlacklist);
const blacklistChanged = !areSetsEqual(previousBlacklist, this.blacklist);
this.eventBlacklist = new Set(normalizeEventIdList(eventBlacklist));
const eventBlacklistChanged = !areSetsEqual(
previousEventBlacklist,
this.eventBlacklist,
);
const toolkitCandidate =
(typeof window !== "undefined" ? window?.NostrTools : null) ||
(typeof globalThis !== "undefined" ? globalThis?.NostrTools : null);
@@ -330,6 +348,9 @@ class AccessControl {
if (blacklistChanged) {
this._emitBlacklistChange(Array.from(this.blacklist));
}
if (eventBlacklistChanged) {
this._emitEventBlacklistChange(Array.from(this.eventBlacklist));
}
}
_emitWhitelistChange(whitelistValues) {
@@ -368,6 +389,24 @@ class AccessControl {
}
}
_emitEventBlacklistChange(eventValues) {
if (!this._eventBlacklistListeners.size) {
return;
}
const snapshot = Array.isArray(eventValues)
? [...eventValues]
: this.getEventBlacklist();
for (const listener of Array.from(this._eventBlacklistListeners)) {
try {
listener(snapshot);
} catch (error) {
userLogger.error("accessControl event-blacklist listener failed", error);
}
}
}
_emitEditorsChange(editorsValues) {
if (!this._editorListeners.size) {
return;
@@ -405,6 +444,7 @@ class AccessControl {
this.whitelistPubkeys.clear();
this.blacklist.clear();
this.blacklistPubkeys.clear();
this.eventBlacklist.clear();
}
throw error;
} finally {
@@ -542,6 +582,65 @@ class AccessControl {
};
}
// --- Per-event (per-video) admin block list (#25) ---
getEventBlacklist() {
return Array.from(this.eventBlacklist);
}
isEventBlacklisted(idOrPointer) {
const id = normalizeEventId(idOrPointer);
return Boolean(id) && this.eventBlacklist.has(id);
}
onEventBlacklistChange(listener) {
if (typeof listener !== "function") {
return () => {};
}
this._eventBlacklistListeners.add(listener);
return () => {
this._eventBlacklistListeners.delete(listener);
};
}
async _persistEventBlacklist(actorNpub, nextIds) {
try {
await persistAdminState(actorNpub, { eventBlacklist: nextIds });
await this.refresh();
return { ok: true };
} catch (error) {
return { ok: false, error: error?.code || "storage-error" };
}
}
async addToEventBlacklist(actorNpub, idOrPointer) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const id = normalizeEventId(idOrPointer);
if (!id) {
return { ok: false, error: "invalid event id" };
}
return this._persistEventBlacklist(
actorNpub,
normalizeEventIdList([...this.getEventBlacklist(), id]),
);
}
async removeFromEventBlacklist(actorNpub, idOrPointer) {
if (!this.canEditAdminLists(actorNpub)) {
return { ok: false, error: "forbidden" };
}
const id = normalizeEventId(idOrPointer);
if (!id) {
return { ok: false, error: "invalid event id" };
}
return this._persistEventBlacklist(
actorNpub,
this.getEventBlacklist().filter((value) => value !== id),
);
}
async addModerator(requestorNpub, moderatorNpub) {
if (!this.isSuperAdmin(requestorNpub)) {
return { ok: false, error: "forbidden" };
+80
View File
@@ -0,0 +1,80 @@
// 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;
}
+24 -24
View File
@@ -21,6 +21,7 @@ import {
NOTE_TYPES,
} from "./nostrEventSchemas.js";
import { CACHE_POLICIES, STORAGE_TIERS } from "./nostr/cachePolicies.js";
import { sanitizeEventIdList, extractEventIdsFromEvent, buildEventBlacklistEvent } from "./adminEventBlacklistHelpers.js";
import { publishEventToRelay } from "./nostrPublish.js";
import { fetchBatchedReferenceEvents } from "./adminListBatch.js";
@@ -224,6 +225,7 @@ function sanitizeAdminState(state = {}) {
editors: sanitizedEditors,
whitelist: sanitizedWhitelist,
blacklist: sanitizedBlacklist,
eventBlacklist: sanitizeEventIdList(state.eventBlacklist || []),
};
}
@@ -231,7 +233,8 @@ function hasAnyEntries(state = {}) {
return Boolean(
(Array.isArray(state.editors) && state.editors.length) ||
(Array.isArray(state.whitelist) && state.whitelist.length) ||
(Array.isArray(state.blacklist) && state.blacklist.length)
(Array.isArray(state.blacklist) && state.blacklist.length) ||
(Array.isArray(state.eventBlacklist) && state.eventBlacklist.length)
);
}
@@ -551,15 +554,9 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
? filter["#d"][0]
: "";
// Note: fetchListIncrementally expects single kind, pubkey, dTag.
// The existing fetchLatestListEvent was a bit generic but admin lists are specific.
// If we can't extract specific identifiers, fall back to pool list (no incremental).
// fetchListIncrementally needs a single kind/pubkey/dTag (admin lists always have them).
if (!pubkey) {
devLogger.warn(`[adminListStore] Cannot use incremental fetch for ${contextLabel} without author.`);
// Fallback to old behavior if pubkey missing (e.g. search scenario? unlikely for admin lists)
// Actually admin lists always have specific d-tag and author (editor list, whitelist, blacklist, community lists)
// So we should be fine.
}
let events = [];
@@ -577,20 +574,14 @@ async function fetchLatestListEvent(filter, contextLabel = "admin-list") {
pubkey,
dTag,
relayUrls: relays,
// Force a full fetch: admin lists are replaceable events whose
// created_at doesn't advance, so a persisted lastSeen would gate
// since=lastSeen+1 and perpetually hide the current event across
// reloads. See tests/nostr/relayBatchFetcherReplaceable.test.mjs.
// Force a full fetch: admin lists are replaceable (see
// tests/nostr/relayBatchFetcherReplaceable.test.mjs).
since: 0,
}),
timeoutPromise,
]);
} else {
// Fallback for non-specific queries
const normalizedFilter = {
kinds: [kind],
limit: 50,
};
const normalizedFilter = { kinds: [kind], limit: 50 };
if (dTag) normalizedFilter["#d"] = [dTag];
if (filter.authors) normalizedFilter.authors = filter.authors;
events = await Promise.race([
@@ -639,17 +630,19 @@ async function loadNostrList(identifier) {
}
const event = await fetchLatestListEvent(filter, identifier);
return event ? extractNpubsFromEvent(event) : [];
const extractor = identifier === ADMIN_LIST_IDENTIFIERS.eventBlacklist ? extractEventIdsFromEvent : extractNpubsFromEvent;
return event ? extractor(event) : [];
}
async function loadNostrState() {
const [editors, whitelist, blacklist] = await Promise.all([
const [editors, whitelist, blacklist, eventBlacklist] = await Promise.all([
loadNostrList(ADMIN_LIST_IDENTIFIERS.editors),
loadNostrList(ADMIN_LIST_IDENTIFIERS.whitelist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.blacklist),
loadNostrList(ADMIN_LIST_IDENTIFIERS.eventBlacklist),
]);
return sanitizeAdminState({ editors, whitelist, blacklist });
return sanitizeAdminState({ editors, whitelist, blacklist, eventBlacklist });
}
async function loadCommunityBlacklistEntries() {
@@ -689,9 +682,8 @@ async function loadCommunityBlacklistEntries() {
return [];
}
// Batch all curator blacklists into ONE REQ instead of one-per-curator — this
// was the cold-start kind-30000 relay storm. Best-effort: on failure we return
// nothing and the cached admin state (readCachedAdminState) carries over.
// Batch all curator blacklists into ONE REQ (was the cold-start kind-30000 storm).
// Best-effort: on failure the cached admin state carries over.
try {
const matched = await fetchBatchedReferenceEvents({
references,
@@ -709,7 +701,11 @@ async function loadCommunityBlacklistEntries() {
}
}
function buildListEvent(listKey, npubs, actorHex) {
function buildListEvent(listKey, entries, actorHex) {
if (listKey === "eventBlacklist") {
return buildEventBlacklistEvent(actorHex, entries); // `e`-tag list, hex event ids
}
const npubs = entries;
const hexPubkeys = Array.from(
new Set(npubs.map((npub) => {
try {
@@ -938,6 +934,9 @@ async function persistNostrState(actorNpub, updates = {}) {
);
}
if (Array.isArray(updates.eventBlacklist)) {
sanitizedUpdates.eventBlacklist = sanitizeEventIdList(updates.eventBlacklist);
}
const entries = Object.entries(sanitizedUpdates).filter(([, value]) =>
Array.isArray(value)
);
@@ -1026,6 +1025,7 @@ export async function loadAdminState() {
editors: baseEditors,
whitelist: baseWhitelist,
blacklist: [...baseBlacklist, ...communityEntries],
eventBlacklist: nostrState?.eventBlacklist || [],
});
}
} catch (error) {
+47
View File
@@ -1071,7 +1071,54 @@ class Application {
// Placeholder for session actor initialization if needed
}
// Merge the static operator list (_staticBlacklistedEventIds) with the dynamic admin
// event-blacklist (#25) into the live set every consumer reads. Reassign (not mutate)
// so snapshots taken by feed/grid/search paths pick up the new set.
_rebuildBlacklistedEventIds() {
const base =
this._staticBlacklistedEventIds instanceof Set
? this._staticBlacklistedEventIds
: new Set();
let adminIds = [];
try {
adminIds =
typeof accessControl.getEventBlacklist === "function"
? accessControl.getEventBlacklist()
: [];
} catch (error) {
adminIds = [];
}
this.blacklistedEventIds = new Set([...base, ...adminIds]);
return this.blacklistedEventIds;
}
_initAccessControlListeners() {
if (typeof accessControl.onEventBlacklistChange === "function") {
accessControl.onEventBlacklistChange(() => {
this._rebuildBlacklistedEventIds();
const refreshReason = "admin-event-blacklist-change";
this.refreshAllVideoGrids({
reason: refreshReason,
forceMainReload: true,
}).catch((error) => {
devLogger.warn(
"[app.init()] Failed to refresh video grids after admin event-blacklist change:",
error,
);
});
if (typeof this.refreshVisibleModerationUi === "function") {
try {
this.refreshVisibleModerationUi({ reason: refreshReason });
} catch (error) {
devLogger.warn(
"[app.init()] Failed to refresh moderation UI after event-blacklist change:",
error,
);
}
}
});
}
if (typeof accessControl.onBlacklistChange === "function") {
accessControl.onBlacklistChange(() => {
this._syncSessionActorBlacklist("blacklist-change");
+20
View File
@@ -56,6 +56,7 @@ export const NOTE_TYPES = Object.freeze({
ADMIN_MODERATION_LIST: "adminModerationList",
ADMIN_BLACKLIST: "adminBlacklist",
ADMIN_WHITELIST: "adminWhitelist",
ADMIN_EVENT_BLACKLIST: "adminEventBlacklist",
PROFILE_METADATA: "profileMetadata",
MUTE_LIST: "muteList",
DELETION: "deletion",
@@ -82,6 +83,7 @@ export const ADMIN_LIST_IDENTIFIERS = Object.freeze({
editors: "editors",
whitelist: "whitelist",
blacklist: "blacklist",
eventBlacklist: "event-blacklist",
communityBlacklistSources: ADMIN_COMMUNITY_BLACKLIST_SOURCES,
});
@@ -635,6 +637,21 @@ const BASE_SCHEMAS = {
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
// Per-event (per-video) admin block list (#25): hides a SINGLE offending event
// without blocking its author. Same kind-30000 NIP-51 shape as the author lists,
// but the entries are EVENT ids carried as `e` tags (not `p` pubkey tags).
[NOTE_TYPES.ADMIN_EVENT_BLACKLIST]: {
type: NOTE_TYPES.ADMIN_EVENT_BLACKLIST,
label: "Admin event blacklist",
kind: 30000,
identifierTag: {
name: "d",
value: `${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.eventBlacklist}`,
},
participantTagName: "e",
appendTags: DEFAULT_APPEND_TAGS,
content: { format: "empty", description: "Content field unused." },
},
[NOTE_TYPES.PROFILE_METADATA]: {
type: NOTE_TYPES.PROFILE_METADATA,
label: "Profile metadata",
@@ -2977,6 +2994,9 @@ function resolveAdminNoteType(listKey) {
return NOTE_TYPES.ADMIN_WHITELIST;
case "blacklist":
return NOTE_TYPES.ADMIN_BLACKLIST;
case "eventBlacklist":
case "event-blacklist":
return NOTE_TYPES.ADMIN_EVENT_BLACKLIST;
default:
return null;
}
+11 -2
View File
@@ -1209,7 +1209,10 @@ export default class ApplicationBootstrap {
});
app.appChromeController.initialize();
app.blacklistedEventIds = new Set();
// Static, build-time per-event block list (operator config). Kept separate from
// the dynamic admin list (#25) so app._rebuildBlacklistedEventIds() can re-merge
// both whenever the published admin event-blacklist changes.
app._staticBlacklistedEventIds = new Set();
if (this.window?.NostrTools?.nip19?.decode) {
for (const neventStr of ADMIN_INITIAL_EVENT_BLACKLIST) {
if (!neventStr || neventStr.trim().length < 8) {
@@ -1218,7 +1221,7 @@ export default class ApplicationBootstrap {
try {
const decoded = this.window.NostrTools.nip19.decode(neventStr);
if (decoded?.type === "nevent" && decoded.data?.id) {
app.blacklistedEventIds.add(decoded.data.id);
app._staticBlacklistedEventIds.add(decoded.data.id);
}
} catch (err) {
devLogger.error(
@@ -1229,6 +1232,12 @@ export default class ApplicationBootstrap {
}
}
}
app.blacklistedEventIds = new Set(app._staticBlacklistedEventIds);
if (typeof app._rebuildBlacklistedEventIds === "function") {
// Fold in any already-loaded admin event-blacklist (accessControl may have
// hydrated from cache synchronously before this point).
app._rebuildBlacklistedEventIds();
}
app.unsubscribeFromPubkeyState = null;
app.unsubscribeFromCurrentUserState = null;
+6
View File
@@ -282,6 +282,12 @@ export function createVideoMoreMenuPanel({
variant: "critical",
dataset: { author: metadata.pubkey },
});
appendMenuAction(doc, list, {
text: "Block this video",
action: "blacklist-event",
variant: "critical",
dataset: { eventId: metadata.id },
});
}
appendMenuAction(doc, list, {
+56
View File
@@ -0,0 +1,56 @@
// Per-event (per-video) admin block action (#25) for the ⋯ menu. Editor-gated; adds
// the video's event id to the published admin event-blacklist and refreshes the grids.
// Extracted from moreMenuController so that large file stays under its size cap.
import { userLogger } from "../../utils/logger.js";
export async function handleBlacklistEventAction({
accessControl,
callbacks,
dataset = {},
currentVideo = null,
} = {}) {
const actorNpub = callbacks.getCurrentUserNpub?.();
if (!actorNpub) {
callbacks.showError?.("Please login as a moderator to block a video.");
return;
}
try {
await accessControl?.ensureReady?.();
} catch (error) {
userLogger.warn("Failed to refresh moderation state before event block:", error);
}
if (!accessControl?.canEditAdminLists?.(actorNpub)) {
callbacks.showError?.("Only moderators can block a video.");
return;
}
const eventId =
(typeof dataset.eventId === "string" && dataset.eventId.trim()) ||
currentVideo?.id ||
"";
if (!eventId) {
callbacks.showError?.("Unable to determine which video to block.");
return;
}
try {
const result = await accessControl.addToEventBlacklist(actorNpub, eventId);
if (result?.ok) {
callbacks.showSuccess?.("Video added to the block list.");
await callbacks
.refreshAllVideoGrids?.({
reason: "admin-event-blacklist-update",
forceMainReload: true,
})
.catch(() => {});
} else {
callbacks.showError?.(
result?.error === "forbidden"
? "Only moderators can block a video."
: "Failed to block the video. Please try again.",
);
}
} catch (error) {
userLogger.error("Failed to add video to event blacklist:", error);
callbacks.showError?.("Failed to block the video. Please try again.");
}
}
+12 -12
View File
@@ -5,6 +5,7 @@ import {
createVideoMoreMenuPanel,
} from "./components/videoMenuRenderers.js";
import createPopover from "./overlay/popoverEngine.js";
import { handleBlacklistEventAction } from "./moreMenu/blacklistEventAction.js";
import {
prepareStaticModal,
openStaticModal,
@@ -1019,9 +1020,7 @@ export default class MoreMenuController {
event.preventDefault();
event.stopPropagation();
// The modal ⋯ trigger declares its context via `data-more-dropdown`
// ("modal"), not `data-context` — reading only the latter made it
// default to "card" and skip the modal video resolution below.
// Modal ⋯ context comes from `data-more-dropdown`, not `data-context`.
const context =
button.getAttribute("data-context") ||
button.getAttribute("data-more-dropdown") ||
@@ -1034,8 +1033,7 @@ export default class MoreMenuController {
if (context === "modal") {
video = this.callbacks.getCurrentVideo();
// currentVideo can be null when the modal opened without playback
// starting (e.g. a deep link); fall back to the modal's activeVideo.
// currentVideo can be null on a deep link; fall back to activeVideo.
if (!video && this.videoModal?.activeVideo) {
video = this.videoModal.activeVideo;
}
@@ -1175,9 +1173,6 @@ export default class MoreMenuController {
break;
}
case "view-stats": {
// Prefer the card's own video; fall back to resolving it from the menu
// item's eventId (card popovers don't always carry the object), and only
// use the modal's current video for the modal context.
let targetVideo = video || null;
if (!targetVideo && dataset?.eventId) {
targetVideo =
@@ -1190,7 +1185,6 @@ export default class MoreMenuController {
this.callbacks.showError("No video selected.");
break;
}
// Dismiss the menu, then open the public popularity chart (lazy-loaded).
try {
this.activePopover?.close?.();
} catch (error) {
@@ -1469,6 +1463,15 @@ export default class MoreMenuController {
}
break;
}
case "blacklist-event": {
await handleBlacklistEventAction({
accessControl: this.accessControl,
callbacks: this.callbacks,
dataset,
currentVideo,
});
break;
}
case "block-author": {
const currentUserPubkey = this.callbacks.getCurrentUserPubkey();
if (!currentUserPubkey) {
@@ -1625,13 +1628,10 @@ export default class MoreMenuController {
break;
}
case "event-details": {
// Card context passes the card's video; modal context falls back to the
// active video (the dispatch path doesn't carry the video object).
let targetVideo = video;
if (!targetVideo && context === "modal") {
targetVideo = currentVideo;
}
// Dismiss the menu before opening the details modal.
try {
this.activePopover?.close?.();
} catch (error) {
+149
View File
@@ -0,0 +1,149 @@
// #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);
});
+25 -6
View File
@@ -590,12 +590,31 @@ toward freshness and looked identical. Gave each a structural identity:
allow-override after the WoT mute/block checks.
### 25. Per-video / per-event admin block list (granular moderation)
- [ ] Today admin moderation is essentially a "ban hammer" (author/community level).
Add a **per-event (per-video) block list** so an admin can hide a SINGLE
offending video without blocking the whole author. Define the per-event block
record (kind/addressable + the event id / address it targets), wire it into the
render-time filter alongside the existing author/community blacklist, and expose
it in the moderation UI (and eventually the #23 admin tab).
- [x] **DONE 2026-06-30 (full vertical slice).** Admins (any editor — same gate as the
author blacklist) can now hide a SINGLE video without blocking its author.
- **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; `participantTagName: "e"`).
Loaded/persisted through the existing admin-list machinery
(`adminListStore` loadNostrState/persistNostrState) and cached like the others.
- **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. Now `app._rebuildBlacklistedEventIds()`
merges that static base with the dynamic admin list, and an
`onEventBlacklistChange` listener rebuilds + refreshes the grids on change.
- **UI:** a "Block this video" item in the per-video ⋯ menu (editor-only, via
`videoMenuRenderers`), 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 — record helpers + editor-gated action,
fallbacks, refusal, publish-failure). Build + lint clean; admin/access/moderation
suites 56/56.
- [ ] **Follow-up:** surface the per-event list (view/unblock) in the Admin pane /
future #23 admin tab; today removal is via `removeFromEventBlacklist` (no UI yet).
### 26. Video popularity / view-count chart (public, three-dots menu) — DONE 2026-06-24
- [x] **Shipped.** A "Popularity" item in the ⋯ menu opens a public views-over-time