[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

+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) {