Files
bitvid/js/ui/profileModal/ProfileAdminController.js
T
thePR0M3TH3ANandClaude Opus 4.8 cf4ce5c3b8 [admin] Add sub-tab toolbar + Blocked-videos tool to the admin pane (#25 follow-up)
The admin pane stacked every list (Moderators / Whitelist / Blacklist) in one
scroll, and the per-event block list had no admin UI (block-only via the ⋯ menu,
no view/unblock). This reorganizes the pane into a sub-tab toolbar and adds the
missing Blocked-videos tool.

- Sub-tabs: a role="tablist" toolbar in #profilePaneAdmin switches between
  Whitelist (default), Blacklist, Blocked videos, and Moderators. Moderators is
  super-admin-only — its tab button is hidden otherwise. selectAdminSubtab()
  shows one section at a time (ARIA + data-state), falling back to the default
  when a hidden tab is requested.
- Blocked videos: view the per-event admin block list, unblock per row (confirm
  → removeFromEventBlacklist), and add by nevent/hex id. Rows show the video
  title+author when it's in the local cache, else the shortened id. Reuses the
  existing accessControl event-blacklist API + onEventBlacklistChange (live
  re-render) and refreshes the grids on change. No backend changes.
- Row-building/render logic in js/ui/profileModal/blockedVideosSection.js
  (keeps ProfileAdminController under its size cap; unit-testable without a DOM).

Spec correction: profile-modal-controller.test's admin assertion updated for the
sub-tab layout (Moderators section now hidden until its tab is active; its tab
button is shown to a super admin; selecting it reveals the section).

Tests: tests/profile-blocked-videos.test.mjs (7 — rows dedupe/enrich/fallback,
render, unblock wiring); profile-modal-controller admin test updated.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 10:56:38 -04:00

1152 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { ProfileAdminRenderer } from "./ProfileAdminRenderer.js";
import { getApplication } from "../../applicationContext.js";
import {
buildBlockedVideoRows,
renderBlockedVideosList,
} from "./blockedVideosSection.js";
import { showConfirm } from "../confirmDialog.js";
const noop = () => {};
// Admin pane sub-tabs, in toolbar order. "moderators" is only shown to the
// super admin (its tab button is hidden otherwise).
const ADMIN_SUBTABS = ["whitelist", "blacklist", "blockedVideos", "moderators"];
const DEFAULT_ADMIN_SUBTAB = "whitelist";
export class ProfileAdminController {
constructor(mainController) {
this.mainController = mainController;
this.renderer = new ProfileAdminRenderer(mainController);
this.moderatorSection = null;
this.moderatorEmpty = null;
this.adminModeratorList = null;
this.addModeratorButton = null;
this.moderatorInput = null;
this.adminModeratorsRefreshBtn = null;
this.whitelistSection = null;
this.whitelistEmpty = null;
this.whitelistList = null;
this.addWhitelistButton = null;
this.whitelistInput = null;
this.adminWhitelistRefreshBtn = null;
this.blacklistSection = null;
this.blacklistEmpty = null;
this.blacklistList = null;
this.addBlacklistButton = null;
this.blacklistInput = null;
this.adminBlacklistRefreshBtn = null;
// Blocked videos (per-event admin block list) sub-tab.
this.blockedVideosSection = null;
this.blockedVideosEmpty = null;
this.blockedVideosList = null;
this.addBlockedVideoButton = null;
this.blockedVideoInput = null;
this.blockedVideosRefreshBtn = null;
// Sub-tab toolbar.
this.subtabBar = null;
this.subtabButtons = {};
this.subtabSections = {};
this.activeSubtab = DEFAULT_ADMIN_SUBTAB;
this.eventBlacklistUnsub = null;
}
cacheDomReferences() {
this.moderatorSection = document.getElementById("adminModeratorsSection") || null;
this.moderatorEmpty = document.getElementById("adminModeratorsEmpty") || null;
this.adminModeratorList = document.getElementById("adminModeratorList") || null;
this.addModeratorButton = document.getElementById("adminAddModeratorBtn") || null;
this.moderatorInput = document.getElementById("adminModeratorInput") || null;
this.adminModeratorsRefreshBtn = document.getElementById("adminModeratorsRefreshBtn") || null;
this.whitelistSection = document.getElementById("adminWhitelistSection") || null;
this.whitelistEmpty = document.getElementById("adminWhitelistEmpty") || null;
this.whitelistList = document.getElementById("adminWhitelistList") || null;
this.addWhitelistButton = document.getElementById("adminAddWhitelistBtn") || null;
this.whitelistInput = document.getElementById("adminWhitelistInput") || null;
this.adminWhitelistRefreshBtn = document.getElementById("adminWhitelistRefreshBtn") || null;
this.blacklistSection = document.getElementById("adminBlacklistSection") || null;
this.blacklistEmpty = document.getElementById("adminBlacklistEmpty") || null;
this.blacklistList = document.getElementById("adminBlacklistList") || null;
this.addBlacklistButton = document.getElementById("adminAddBlacklistBtn") || null;
this.blacklistInput = document.getElementById("adminBlacklistInput") || null;
this.adminBlacklistRefreshBtn = document.getElementById("adminBlacklistRefreshBtn") || null;
this.blockedVideosSection = document.getElementById("adminBlockedVideosSection") || null;
this.blockedVideosEmpty = document.getElementById("adminBlockedVideosEmpty") || null;
this.blockedVideosList = document.getElementById("adminBlockedVideosList") || null;
this.addBlockedVideoButton = document.getElementById("adminAddBlockedVideoBtn") || null;
this.blockedVideoInput = document.getElementById("adminBlockedVideoInput") || null;
this.blockedVideosRefreshBtn = document.getElementById("adminBlockedVideosRefreshBtn") || null;
this.subtabBar = document.getElementById("adminSubtabBar") || null;
this.subtabButtons = {
whitelist: document.getElementById("adminSubtabWhitelist") || null,
blacklist: document.getElementById("adminSubtabBlacklist") || null,
blockedVideos: document.getElementById("adminSubtabBlockedVideos") || null,
moderators: document.getElementById("adminSubtabModerators") || null,
};
this.subtabSections = {
whitelist: this.whitelistSection,
blacklist: this.blacklistSection,
blockedVideos: this.blockedVideosSection,
moderators: this.moderatorSection,
};
}
registerEventListeners() {
const ensureAriaLabel = (button, label) => {
if (!(button instanceof HTMLElement)) {
return;
}
if (!button.getAttribute("aria-label")) {
button.setAttribute("aria-label", label);
}
};
ensureAriaLabel(this.adminModeratorsRefreshBtn, "Refresh moderators");
ensureAriaLabel(this.adminWhitelistRefreshBtn, "Refresh whitelist");
ensureAriaLabel(this.adminBlacklistRefreshBtn, "Refresh blacklist");
if (this.addModeratorButton instanceof HTMLElement) {
this.addModeratorButton.addEventListener("click", () => {
void this.handleAddModerator();
});
}
if (this.adminModeratorsRefreshBtn instanceof HTMLElement) {
this.adminModeratorsRefreshBtn.addEventListener("click", () => {
const service = this.mainController.services.accessControl;
if (!service || typeof service.refresh !== "function") {
return;
}
void service
.refresh()
.then(() => this.refreshAdminPaneState())
.catch((error) => {
devLogger.warn("[profileModal] Failed to refresh moderators:", error);
});
});
}
if (this.moderatorInput instanceof HTMLElement) {
this.moderatorInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddModerator();
}
});
}
if (this.addWhitelistButton instanceof HTMLElement) {
this.addWhitelistButton.addEventListener("click", () => {
void this.handleAdminListMutation("whitelist", "add");
});
}
if (this.adminWhitelistRefreshBtn instanceof HTMLElement) {
this.adminWhitelistRefreshBtn.addEventListener("click", () => {
const service = this.mainController.services.accessControl;
if (!service || typeof service.refresh !== "function") {
return;
}
void service
.refresh()
.then(() => this.refreshAdminPaneState())
.catch((error) => {
devLogger.warn("[profileModal] Failed to refresh whitelist:", error);
});
});
}
if (this.whitelistInput instanceof HTMLElement) {
this.whitelistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAdminListMutation("whitelist", "add");
}
});
}
if (this.addBlacklistButton instanceof HTMLElement) {
this.addBlacklistButton.addEventListener("click", () => {
void this.handleAdminListMutation("blacklist", "add");
});
}
if (this.adminBlacklistRefreshBtn instanceof HTMLElement) {
this.adminBlacklistRefreshBtn.addEventListener("click", () => {
const service = this.mainController.services.accessControl;
if (!service || typeof service.refresh !== "function") {
return;
}
void service
.refresh()
.then(() => this.refreshAdminPaneState())
.catch((error) => {
devLogger.warn("[profileModal] Failed to refresh blacklist:", error);
});
});
}
if (this.blacklistInput instanceof HTMLElement) {
this.blacklistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAdminListMutation("blacklist", "add");
}
});
}
ensureAriaLabel(this.blockedVideosRefreshBtn, "Refresh blocked videos");
for (const name of ADMIN_SUBTABS) {
const button = this.subtabButtons?.[name];
if (button instanceof HTMLElement) {
button.addEventListener("click", () => this.selectAdminSubtab(name));
}
}
if (this.addBlockedVideoButton instanceof HTMLElement) {
this.addBlockedVideoButton.addEventListener("click", () => {
void this.handleAddBlockedVideo();
});
}
if (this.blockedVideoInput instanceof HTMLElement) {
this.blockedVideoInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddBlockedVideo();
}
});
}
if (this.blockedVideosRefreshBtn instanceof HTMLElement) {
this.blockedVideosRefreshBtn.addEventListener("click", () => {
const service = this.mainController.services.accessControl;
if (!service || typeof service.refresh !== "function") {
return;
}
void service
.refresh()
.then(() => this.populateBlockedVideos())
.catch((error) => {
devLogger.warn("[profileModal] Failed to refresh blocked videos:", error);
});
});
}
// Re-render the blocked-videos list whenever the published list changes
// (e.g. an admin blocks a video from the ⋯ menu while this pane is open).
const accessControl = this.mainController.services?.accessControl;
if (accessControl && typeof accessControl.onEventBlacklistChange === "function") {
if (typeof this.eventBlacklistUnsub === "function") {
this.eventBlacklistUnsub();
}
this.eventBlacklistUnsub = accessControl.onEventBlacklistChange(() => {
this.populateBlockedVideos();
});
}
}
// Show one admin sub-tab's section and hide the rest, syncing button state.
// Falls back to the default tab when asked for a hidden one (e.g. Moderators
// for a non-super-admin).
selectAdminSubtab(name) {
let target = ADMIN_SUBTABS.includes(name) ? name : DEFAULT_ADMIN_SUBTAB;
const button = this.subtabButtons?.[target];
if (button instanceof HTMLElement && button.classList.contains("hidden")) {
target = DEFAULT_ADMIN_SUBTAB;
}
this.activeSubtab = target;
for (const key of ADMIN_SUBTABS) {
const isActive = key === target;
const tabButton = this.subtabButtons?.[key];
const section = this.subtabSections?.[key];
if (tabButton instanceof HTMLElement) {
tabButton.setAttribute("aria-selected", isActive ? "true" : "false");
if (isActive) {
tabButton.dataset.state = "active";
} else {
delete tabButton.dataset.state;
}
}
if (section instanceof HTMLElement) {
section.classList.toggle("hidden", !isActive);
section.setAttribute("aria-hidden", (!isActive).toString());
}
}
}
populateAdminLists() {
const actorNpub = this.mainController.services.getCurrentUserNpub();
if (!actorNpub || !this.mainController.services.accessControl.canEditAdminLists(actorNpub)) {
this.renderer.clearAdminLists(this);
return;
}
const isSuperAdmin = this.mainController.services.accessControl.isSuperAdmin(actorNpub);
const editors = this.renderer.normalizeAdminListEntries(
this.mainController.services.accessControl.getEditors(),
).filter((npub) => npub && npub !== this.mainController.adminSuperNpub);
const whitelist = this.renderer.normalizeAdminListEntries(
this.mainController.services.accessControl.getWhitelist(),
);
const blacklist = this.renderer.normalizeAdminListEntries(
this.mainController.services.accessControl.getBlacklist(),
);
const normalizeForCompare = (value) =>
this.renderer.normalizeNpubValue(value) ||
(typeof value === "string" ? value.trim() : "");
const whitelistCompare = new Set(
whitelist.map(normalizeForCompare).filter(Boolean),
);
const blacklistCompare = new Set(
blacklist.map(normalizeForCompare).filter(Boolean),
);
this.renderer.renderAdminList(
this.adminModeratorList,
this.moderatorEmpty,
editors,
{
onRemove: (npub, button) => this.handleRemoveModerator(npub, button),
removeLabel: "Remove",
confirmMessage:
"Remove moderator {npub}? They will immediately lose access to the admin panel.",
removable: isSuperAdmin,
},
);
this.renderer.renderAdminList(
this.whitelistList,
this.whitelistEmpty,
whitelist,
{
onRemove: (npub, button) =>
this.handleAdminListMutation("whitelist", "remove", npub, button),
removeLabel: "Remove",
confirmMessage: "Remove {npub} from the whitelist?",
removable: true,
overlapSet: blacklistCompare,
overlapLabel: "Also blacklisted",
},
);
this.renderer.renderAdminList(
this.blacklistList,
this.blacklistEmpty,
blacklist,
{
onRemove: (npub, button) =>
this.handleAdminListMutation("blacklist", "remove", npub, button),
removeLabel: "Unblock",
confirmMessage: "Remove {npub} from the blacklist?",
removable: true,
overlapSet: whitelistCompare,
overlapLabel: "Also whitelisted",
},
);
}
// Best-effort lookup of a blocked video from the shared feed cache so rows can
// show a title/author; returns null when the video isn't cached (id-only row).
resolveBlockedVideo(id) {
try {
const app = getApplication();
const service = app?.nostrService || null;
if (service && typeof service.getVideoByEventId === "function") {
const video = service.getVideoByEventId(id);
if (video) return video;
}
const client = service?.nostrClient || null;
if (client?.allEvents && typeof client.allEvents.get === "function") {
return client.allEvents.get(id) || null;
}
} catch (error) {
// Non-fatal — fall back to an id-only row.
}
return null;
}
populateBlockedVideos() {
if (!(this.blockedVideosList instanceof HTMLElement)) {
return;
}
const actorNpub = this.mainController.services.getCurrentUserNpub();
const accessControl = this.mainController.services.accessControl;
if (!actorNpub || !accessControl?.canEditAdminLists?.(actorNpub)) {
renderBlockedVideosList(this.blockedVideosList, this.blockedVideosEmpty, []);
return;
}
const ids =
typeof accessControl.getEventBlacklist === "function"
? accessControl.getEventBlacklist()
: [];
const rows = buildBlockedVideoRows(ids, (id) => this.resolveBlockedVideo(id));
renderBlockedVideosList(this.blockedVideosList, this.blockedVideosEmpty, rows, {
onUnblock: (id, button) => this.handleRemoveBlockedVideo(id, button),
formatAuthor: (hex) =>
typeof hex === "string" && hex.length > 12
? `by ${hex.slice(0, 10)}…`
: "",
});
}
async handleAddBlockedVideo() {
const input = this.blockedVideoInput || null;
const rawValue = typeof input?.value === "string" ? input.value.trim() : "";
if (!rawValue) {
this.mainController.showError("Enter an event id or nevent to block.");
return;
}
const actorNpub = this.ensureAdminActor();
if (!actorNpub) {
return;
}
const accessControl = this.mainController.services.accessControl;
try {
const result = await accessControl.addToEventBlacklist(actorNpub, rawValue);
if (result?.ok) {
if (input) input.value = "";
this.mainController.showSuccess("Video added to the block list.");
this.populateBlockedVideos();
this.refreshGridsAfterEventBlacklistChange();
} else {
this.mainController.showError(
result?.error === "forbidden"
? "You do not have permission to block videos."
: result?.error === "invalid event id"
? "That doesn't look like a valid event id or nevent."
: "Failed to block the video. Please try again.",
);
}
} catch (error) {
userLogger.error("[profileModal] Failed to add blocked video:", error);
this.mainController.showError("Failed to block the video. Please try again.");
}
}
async handleRemoveBlockedVideo(id, button) {
const actorNpub = this.ensureAdminActor();
if (!actorNpub) {
return;
}
const confirmed = await showConfirm(
"Unblock this video? It will become visible across bitvid again.",
{ title: "Unblock video", confirmLabel: "Unblock", cancelLabel: "Cancel" },
);
if (!confirmed) {
return;
}
if (button instanceof HTMLElement) {
button.disabled = true;
}
const accessControl = this.mainController.services.accessControl;
try {
const result = await accessControl.removeFromEventBlacklist(actorNpub, id);
if (result?.ok) {
this.mainController.showSuccess("Video unblocked.");
this.populateBlockedVideos();
this.refreshGridsAfterEventBlacklistChange();
} else {
if (button instanceof HTMLElement) button.disabled = false;
this.mainController.showError(
result?.error === "forbidden"
? "You do not have permission to unblock videos."
: "Failed to unblock the video. Please try again.",
);
}
} catch (error) {
if (button instanceof HTMLElement) button.disabled = false;
userLogger.error("[profileModal] Failed to remove blocked video:", error);
this.mainController.showError("Failed to unblock the video. Please try again.");
}
}
refreshGridsAfterEventBlacklistChange() {
try {
const app = getApplication();
if (app && typeof app.refreshAllVideoGrids === "function") {
void app
.refreshAllVideoGrids({
reason: "admin-event-blacklist-update",
forceMainReload: true,
})
.catch(() => {});
}
} catch (error) {
// Non-fatal.
}
}
async refreshAdminPaneState() {
const adminNav = this.mainController.navButtons.admin;
const adminPane = this.mainController.panes.admin;
let loadError = null;
this.renderer.setAdminLoading(this, true);
this.mainController.showStatus("Fetching moderation filters…");
try {
const ensureResult = await this.runAdminMutation({
action: "ensure-ready",
});
if (ensureResult?.error && ensureResult.ok === false) {
loadError = ensureResult.error;
}
} catch (error) {
loadError = error;
}
const actorNpub = this.mainController.services.getCurrentUserNpub();
const canEdit =
!!actorNpub && this.mainController.services.accessControl.canEditAdminLists(actorNpub);
const isSuperAdmin =
!!actorNpub && this.mainController.services.accessControl.isSuperAdmin(actorNpub);
if (adminNav instanceof HTMLElement) {
adminNav.classList.toggle("hidden", !canEdit);
if (!canEdit) {
adminNav.setAttribute("aria-selected", "false");
}
}
if (adminPane instanceof HTMLElement) {
if (!canEdit) {
adminPane.classList.add("hidden");
adminPane.setAttribute("aria-hidden", "true");
} else {
const isActive = this.mainController.getActivePane() === "admin";
adminPane.classList.toggle("hidden", !isActive);
adminPane.setAttribute("aria-hidden", (!isActive).toString());
}
}
if (loadError) {
if (loadError?.code === "nostr-unavailable") {
devLogger.info("Moderation lists are still syncing with relays.");
return;
}
userLogger.error("Failed to load admin lists:", loadError);
this.mainController.showStatus(null);
this.mainController.showError("Unable to load moderation lists. Please try again.");
this.renderer.clearAdminLists(this);
this.renderer.setAdminLoading(this, false);
return;
}
if (!canEdit) {
this.renderer.clearAdminLists(this);
this.mainController.showStatus(null);
this.renderer.setAdminLoading(this, false);
if (
adminNav instanceof HTMLElement &&
adminNav.dataset.state === "active"
) {
this.mainController.selectPane("account");
}
return;
}
// Moderators is super-admin-only: hide its sub-tab button entirely for
// everyone else (its section visibility is then governed by selectAdminSubtab).
const moderatorsTab = this.subtabButtons?.moderators;
if (moderatorsTab instanceof HTMLElement) {
moderatorsTab.classList.toggle("hidden", !isSuperAdmin);
if (!isSuperAdmin) {
moderatorsTab.setAttribute("aria-selected", "false");
}
}
this.populateAdminLists();
this.populateBlockedVideos();
// Re-apply the current sub-tab so exactly one section is visible (and so a
// now-hidden Moderators tab falls back to the default).
this.selectAdminSubtab(this.activeSubtab);
this.mainController.showStatus(null);
this.renderer.setAdminLoading(this, false);
}
ensureAdminActor(requireSuperAdmin = false) {
const actorNpub = this.mainController.services.getCurrentUserNpub();
if (!actorNpub) {
this.mainController.showError("Please login with a Nostr account to manage admin settings.");
return null;
}
if (!this.mainController.services.accessControl.canEditAdminLists(actorNpub)) {
this.mainController.showError("You do not have permission to manage bitvid moderation lists.");
return null;
}
if (requireSuperAdmin && !this.mainController.services.accessControl.isSuperAdmin(actorNpub)) {
this.mainController.showError("Only the Super Admin can manage moderators or whitelist mode.");
return null;
}
return actorNpub;
}
async handleAddModerator() {
const input = this.moderatorInput || null;
const rawValue = typeof input?.value === "string" ? input.value : "";
const trimmed = rawValue.trim();
const normalizedValue = this.renderer.normalizeNpubValue(trimmed);
const context = {
input,
rawValue,
value: trimmed,
normalizedValue,
actorNpub: null,
success: false,
reason: null,
error: null,
result: null,
};
let preloadError = null;
try {
const ensureResult = await this.runAdminMutation({ action: "ensure-ready" });
if (ensureResult?.error && ensureResult.ok === false) {
preloadError = ensureResult.error;
}
} catch (error) {
preloadError = error;
userLogger.error("Failed to load admin lists before adding moderator:", error);
}
if (preloadError) {
this.mainController.showError(this.describeAdminError(preloadError.code || "storage-error"));
context.reason = preloadError.code || "storage-error";
context.error = preloadError;
this.mainController.callbacks.onAdminAddModerator(context, this.mainController);
return context;
}
const actorNpub = this.ensureAdminActor(true);
context.actorNpub = actorNpub;
if (!actorNpub || !input) {
context.reason = actorNpub ? "missing-input" : "unauthorized";
this.mainController.callbacks.onAdminAddModerator(context, this.mainController);
return context;
}
if (!trimmed) {
this.mainController.showError("Enter an npub to add as a moderator.");
context.reason = "empty";
this.mainController.callbacks.onAdminAddModerator(context, this.mainController);
return context;
}
if (!normalizedValue) {
this.mainController.showError("Enter a valid npub before adding it as a moderator.");
context.reason = "invalid";
this.mainController.callbacks.onAdminAddModerator(context, this.mainController);
return context;
}
if (this.addModeratorButton) {
this.addModeratorButton.disabled = true;
this.addModeratorButton.setAttribute("aria-busy", "true");
}
try {
const mutationResult = await this.runAdminMutation({
action: "add-moderator",
actorNpub,
targetNpub: normalizedValue,
});
context.result = mutationResult?.result || null;
if (!mutationResult?.ok) {
const errorCode = mutationResult?.result?.error || mutationResult?.error?.code;
this.mainController.showError(this.describeAdminError(errorCode || "service-error"));
context.reason = errorCode || "service-error";
context.error = mutationResult?.error || mutationResult?.result || null;
return context;
}
this.moderatorInput.value = "";
this.mainController.showSuccess("Moderator added successfully.");
await this.mainController.services.onAccessControlUpdated();
context.success = true;
context.reason = "added";
} finally {
if (this.addModeratorButton) {
this.addModeratorButton.disabled = false;
this.addModeratorButton.removeAttribute("aria-busy");
}
this.mainController.callbacks.onAdminAddModerator(context, this.mainController);
}
return context;
}
async handleRemoveModerator(npub, button) {
const context = {
npub,
normalizedNpub: this.renderer.normalizeNpubValue(npub),
button,
actorNpub: null,
success: false,
reason: null,
error: null,
result: null,
};
const releaseButton = () => {
if (button instanceof HTMLElement) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
};
if (!context.normalizedNpub) {
this.mainController.showError("Unable to remove moderator: invalid npub.");
context.reason = "invalid";
releaseButton();
this.mainController.callbacks.onAdminRemoveModerator(context, this.mainController);
return context;
}
let preloadError = null;
try {
const ensureResult = await this.runAdminMutation({ action: "ensure-ready" });
if (ensureResult?.error && ensureResult.ok === false) {
preloadError = ensureResult.error;
}
} catch (error) {
preloadError = error;
userLogger.error("Failed to load admin lists before removing moderator:", error);
}
if (preloadError) {
this.mainController.showError(this.describeAdminError(preloadError.code || "storage-error"));
context.reason = preloadError.code || "storage-error";
context.error = preloadError;
releaseButton();
this.mainController.callbacks.onAdminRemoveModerator(context, this.mainController);
return context;
}
const actorNpub = this.ensureAdminActor(true);
context.actorNpub = actorNpub;
if (!actorNpub) {
context.reason = "unauthorized";
releaseButton();
this.mainController.callbacks.onAdminRemoveModerator(context, this.mainController);
return context;
}
const mutationResult = await this.runAdminMutation({
action: "remove-moderator",
actorNpub,
targetNpub: context.normalizedNpub,
});
context.result = mutationResult?.result || null;
if (!mutationResult?.ok) {
const errorCode = mutationResult?.result?.error || mutationResult?.error?.code;
this.mainController.showError(this.describeAdminError(errorCode || "service-error"));
context.reason = errorCode || "service-error";
context.error = mutationResult?.error || mutationResult?.result || null;
releaseButton();
this.mainController.callbacks.onAdminRemoveModerator(context, this.mainController);
return context;
}
this.mainController.showSuccess("Moderator removed.");
await this.mainController.services.onAccessControlUpdated();
context.success = true;
context.reason = "removed";
releaseButton();
this.mainController.callbacks.onAdminRemoveModerator(context, this.mainController);
return context;
}
async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
const isWhitelist = listType === "whitelist";
const input = isWhitelist ? this.whitelistInput : this.blacklistInput;
const addButton = isWhitelist ? this.addWhitelistButton : this.addBlacklistButton;
const isAdd = action === "add";
let buttonToToggle = sourceButton || (isAdd ? addButton : null);
const context = {
listType,
action,
explicitNpub,
sourceButton,
actorNpub: null,
targetNpub: null,
success: false,
reason: null,
error: null,
result: null,
notificationResult: null,
notificationError: null,
};
const callbackMap = {
whitelist: {
add: this.mainController.callbacks.onAdminAddWhitelist,
remove: this.mainController.callbacks.onAdminRemoveWhitelist,
},
blacklist: {
add: this.mainController.callbacks.onAdminAddBlacklist,
remove: this.mainController.callbacks.onAdminRemoveBlacklist,
},
};
const adminCallback = callbackMap[listType]?.[action] || noop;
const setBusy = (element, busy) => {
if (!(element instanceof HTMLElement)) {
return;
}
element.disabled = !!busy;
if (busy) {
element.setAttribute("aria-busy", "true");
} else {
element.removeAttribute("aria-busy");
}
};
const finalize = () => {
setBusy(buttonToToggle, false);
adminCallback(context, this.mainController);
};
let preloadError = null;
try {
const ensureResult = await this.runAdminMutation({ action: "ensure-ready" });
if (ensureResult?.error && ensureResult.ok === false) {
preloadError = ensureResult.error;
}
} catch (error) {
preloadError = error;
userLogger.error("Failed to load admin lists before updating entries:", error);
}
if (preloadError) {
this.mainController.showError(this.describeAdminError(preloadError.code || "storage-error"));
context.reason = preloadError.code || "storage-error";
context.error = preloadError;
finalize();
return context;
}
const actorNpub = this.ensureAdminActor(false);
context.actorNpub = actorNpub;
if (!actorNpub) {
context.reason = "unauthorized";
finalize();
return context;
}
let target = typeof explicitNpub === "string" ? explicitNpub.trim() : "";
if (!target && input instanceof HTMLInputElement) {
target = input.value.trim();
}
context.targetNpub = target;
if (isAdd && !target) {
this.mainController.showError("Enter an npub before adding it to the list.");
context.reason = "empty";
finalize();
return context;
}
buttonToToggle = buttonToToggle || (isAdd ? addButton : null);
setBusy(buttonToToggle, true);
const mutationResult = await this.runAdminMutation({
action: "list-mutation",
listType,
mode: action,
actorNpub,
targetNpub: target,
});
context.result = mutationResult?.result || null;
if (!mutationResult?.ok) {
const errorCode = mutationResult?.result?.error || mutationResult?.error?.code;
this.mainController.showError(this.describeAdminError(errorCode || "service-error"));
context.reason = errorCode || "service-error";
context.error = mutationResult?.error || mutationResult?.result || null;
finalize();
return context;
}
if (isAdd && input instanceof HTMLInputElement) {
input.value = "";
}
const successMessage = isWhitelist
? isAdd
? "Added to the whitelist."
: "Removed from the whitelist."
: isAdd
? "Added to the blacklist."
: "Removed from the blacklist.";
this.mainController.showSuccess(successMessage);
await this.mainController.services.onAccessControlUpdated();
context.success = true;
context.reason = isAdd ? "added" : "removed";
if (isAdd) {
try {
const notifyResult = await this.sendAdminListNotification({
listType,
actorNpub,
targetNpub: target,
});
context.notificationResult = notifyResult;
if (!notifyResult?.ok) {
const errorMessage = this.describeNotificationError(notifyResult?.error);
if (errorMessage) {
this.mainController.showError(errorMessage);
}
if (isDevMode && notifyResult?.error) {
userLogger.warn(
"[admin] Failed to send list notification DM:",
notifyResult,
);
}
this.notifyAdminError({
listType,
action,
actorNpub,
targetNpub: target,
error: notifyResult?.error || null,
result: notifyResult,
});
}
} catch (error) {
context.notificationError = error;
userLogger.error("Failed to send list notification DM:", error);
devLogger.warn(
"List update succeeded, but DM notification threw an unexpected error.",
error,
);
this.notifyAdminError({
listType,
action,
actorNpub,
targetNpub: target,
error,
});
}
}
finalize();
return context;
}
async runAdminMutation(payload = {}) {
const callback = this.mainController.callbacks.onAdminMutation;
if (callback && callback !== noop) {
const result = await callback({ ...payload, controller: this.mainController });
if (result !== undefined) {
return result;
}
}
const action = payload?.action;
const resultContext = { ok: false, error: null, result: null };
try {
switch (action) {
case "ensure-ready":
await this.mainController.services.accessControl.ensureReady();
resultContext.ok = true;
break;
case "add-moderator":
resultContext.result = await this.mainController.services.accessControl.addModerator(
payload.actorNpub,
payload.targetNpub,
);
resultContext.ok = !!resultContext.result?.ok;
break;
case "remove-moderator":
resultContext.result =
await this.mainController.services.accessControl.removeModerator(
payload.actorNpub,
payload.targetNpub,
);
resultContext.ok = !!resultContext.result?.ok;
break;
case "list-mutation":
if (payload.listType === "whitelist") {
resultContext.result = payload.mode === "add"
? await this.mainController.services.accessControl.addToWhitelist(
payload.actorNpub,
payload.targetNpub,
)
: await this.mainController.services.accessControl.removeFromWhitelist(
payload.actorNpub,
payload.targetNpub,
);
} else {
resultContext.result = payload.mode === "add"
? await this.mainController.services.accessControl.addToBlacklist(
payload.actorNpub,
payload.targetNpub,
)
: await this.mainController.services.accessControl.removeFromBlacklist(
payload.actorNpub,
payload.targetNpub,
);
}
resultContext.ok = !!resultContext.result?.ok;
break;
default:
resultContext.error = Object.assign(
new Error("Unknown admin mutation."),
{ code: "invalid-action" },
);
}
} catch (error) {
resultContext.error = error;
return resultContext;
}
return resultContext;
}
notifyAdminError(payload = {}) {
const callback = this.mainController.callbacks.onAdminNotifyError;
if (callback && callback !== noop) {
callback({ ...payload, controller: this.mainController });
}
}
describeAdminError(code) {
if (typeof this.mainController.describeAdminErrorService === "function") {
const result = this.mainController.describeAdminErrorService(code);
if (typeof result === "string" && result) {
return result;
}
}
switch (code) {
case "invalid npub":
return "Please provide a valid npub address.";
case "immutable":
return "That account cannot be modified.";
case "self":
return "You cannot blacklist yourself.";
case "forbidden":
return "You do not have permission to perform that action.";
case "nostr-unavailable":
return "Unable to reach the configured Nostr relays. Please retry once your connection is restored.";
case "nostr-extension-missing":
return "Connect a Nostr extension before editing moderation lists.";
case "signature-failed":
return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again.";
case "publish-failed":
return "Failed to publish the update to Nostr relays. Please try again.";
case "storage-error":
return "Unable to update moderation settings. Please try again.";
default:
return "Unable to update moderation settings. Please try again.";
}
}
describeNotificationError(code) {
if (typeof this.mainController.describeNotificationErrorService === "function") {
const result = this.mainController.describeNotificationErrorService(code);
if (typeof result === "string") {
return result;
}
}
switch (code) {
case "nostr-extension-missing":
return "List updated, but the DM notification failed because no Nostr extension is connected.";
case "nostr-uninitialized":
return "List updated, but the DM notification system is still connecting to Nostr relays. Please try again in a moment.";
case "nip04-unavailable":
return "List updated, but your Nostr extension does not support NIP-04 encryption, so the DM notification was not sent.";
case "sign-event-unavailable":
return "List updated, but your Nostr extension could not sign the DM notification.";
case "missing-actor-pubkey":
return "List updated, but we could not determine your public key to send the DM notification.";
case "publish-failed":
return "List updated, but the DM notification could not be delivered to any relay.";
case "encryption-failed":
case "signature-failed":
return "List updated, but the DM notification failed while preparing the encrypted message.";
case "invalid-target":
case "empty-message":
return "";
default:
return "List updated, but the DM notification could not be sent.";
}
}
async sendAdminListNotification({ listType, actorNpub, targetNpub }) {
if (typeof this.mainController.sendAdminListNotificationService === "function") {
return this.mainController.sendAdminListNotificationService({ listType, actorNpub, targetNpub });
}
const normalizedTarget = this.renderer.normalizeNpubValue(targetNpub);
if (!normalizedTarget) {
return { ok: false, error: "invalid-target" };
}
const activeHex = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey());
if (!activeHex) {
return { ok: false, error: "missing-actor-pubkey" };
}
const fallbackActor = this.mainController.safeEncodeNpub(activeHex) || "a bitvid moderator";
const actorDisplay = this.renderer.normalizeNpubValue(actorNpub) || fallbackActor;
const isWhitelist = listType === "whitelist";
const formatNpub =
typeof this.mainController.formatShortNpub === "function"
? (value) => this.mainController.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const displayTarget = formatNpub(normalizedTarget) || normalizedTarget;
const displayActor = formatNpub(actorDisplay) || actorDisplay;
const introLine = isWhitelist
? `Great news—your npub ${displayTarget} has been added to the bitvid whitelist by ${displayActor}.`
: `We wanted to let you know that your npub ${displayTarget} has been placed on the bitvid blacklist by ${displayActor}.`;
const statusLine = isWhitelist
? `You now have full creator access across bitvid (${this.mainController.bitvidWebsiteUrl}).`
: `This hides your channel and prevents uploads across bitvid (${this.mainController.bitvidWebsiteUrl}) for now.`;
const followUpLine = isWhitelist
? "Please take a moment to review our community guidelines (https://bitvid.network/#view=community-guidelines), and reply to this DM if you have any questions."
: "Please review our community guidelines (https://bitvid.network/#view=community-guidelines). If you believe this was a mistake, you can submit an appeal at https://bitvid.network/?modal=appeals to request reinstatement, or reply to this DM with any questions.";
const messageBody = [
"Hi there,",
"",
introLine,
"",
statusLine,
"",
followUpLine,
"",
"— the bitvid team",
].join("\n");
const message = `![bitvid status update](${this.mainController.adminDmImageUrl})\n\n${messageBody}`;
return this.mainController.services.nostrClient.sendDirectMessage(
normalizedTarget,
message,
activeHex,
);
}
}