mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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:
1 parent
2e7e2016d3
commit
3e57ab2ea9
11 files changed
+529
-44
No files matched your search
@@ -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" };
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user