From 3e57ab2ea9bf8811444f2746d6e35f84108dd47d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:19:34 -0400 Subject: [PATCH] [moderation] #25: per-video admin block list (hide a single video, not the author) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/accessControl.js | 99 ++++++++++++++++ js/adminEventBlacklistHelpers.js | 80 +++++++++++++ js/adminListStore.js | 48 ++++---- js/app.js | 47 ++++++++ js/nostrEventSchemas.js | 20 ++++ js/ui/applicationBootstrap.js | 13 ++- js/ui/components/videoMenuRenderers.js | 6 + js/ui/moreMenu/blacklistEventAction.js | 56 ++++++++++ js/ui/moreMenuController.js | 24 ++-- tests/admin-event-blacklist.test.mjs | 149 +++++++++++++++++++++++++ todo/TODO_2026-06-20_pre-launch.md | 31 ++++- 11 files changed, 529 insertions(+), 44 deletions(-) create mode 100644 js/adminEventBlacklistHelpers.js create mode 100644 js/ui/moreMenu/blacklistEventAction.js create mode 100644 tests/admin-event-blacklist.test.mjs diff --git a/js/accessControl.js b/js/accessControl.js index f03f254c..cbadabdb 100644 --- a/js/accessControl.js +++ b/js/accessControl.js @@ -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" }; diff --git a/js/adminEventBlacklistHelpers.js b/js/adminEventBlacklistHelpers.js new file mode 100644 index 00000000..f89cf305 --- /dev/null +++ b/js/adminEventBlacklistHelpers.js @@ -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; +} diff --git a/js/adminListStore.js b/js/adminListStore.js index 140c1696..3059d9cd 100644 --- a/js/adminListStore.js +++ b/js/adminListStore.js @@ -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) { diff --git a/js/app.js b/js/app.js index 96f4d2f5..3954d30c 100644 --- a/js/app.js +++ b/js/app.js @@ -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"); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 9ae48f1c..1fbedef7 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -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; } diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 6a8ec5bd..55a0099e 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -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; diff --git a/js/ui/components/videoMenuRenderers.js b/js/ui/components/videoMenuRenderers.js index e71651bf..332eeae7 100644 --- a/js/ui/components/videoMenuRenderers.js +++ b/js/ui/components/videoMenuRenderers.js @@ -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, { diff --git a/js/ui/moreMenu/blacklistEventAction.js b/js/ui/moreMenu/blacklistEventAction.js new file mode 100644 index 00000000..2bb393b2 --- /dev/null +++ b/js/ui/moreMenu/blacklistEventAction.js @@ -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."); + } +} diff --git a/js/ui/moreMenuController.js b/js/ui/moreMenuController.js index da6bd78d..08b905b1 100644 --- a/js/ui/moreMenuController.js +++ b/js/ui/moreMenuController.js @@ -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) { diff --git a/tests/admin-event-blacklist.test.mjs b/tests/admin-event-blacklist.test.mjs new file mode 100644 index 00000000..ec5da7e6 --- /dev/null +++ b/tests/admin-event-blacklist.test.mjs @@ -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); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index a30a1c99..e6bee93f 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -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