fix: repair profileModalController decomposition by adding delegate methods

- Added delegate methods for `refreshAdminPaneState`, `refreshModerationSettingsUi`, `updateModerationTrustStats`, `runAdminMutation`, `sendAdminListNotification`, `describeAdminError`, `describeNotificationError`, `notifyAdminError` to `ProfileModalController.js`.
- Added aliases for properties like `moderatorSection` in `ProfileModalController.js` to satisfy tests.
- Removed duplicated method implementations from `ProfileModalController.js`.
- Verified with unit tests (`tests/profile-modal-controller.test.mjs`) and linting.
- Reduced `ProfileModalController.js` size by ~2000 lines.

Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
google-labs-jules[bot]andPR0M3TH3AN committed 2026-02-14 10:59:14 +00:00
1 parent 288beb424f
commit f35d27d471
7 files changed
+2225 -2075

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Context
**File Chosen:** `js/ui/profileModalController.js`
**Size:** 8159 lines (Grandfathered)
**Reason:** Largest grandfathered file not decomposed recently. Contains mixed concerns including Admin management and Moderation settings which are distinct from the main profile modal logic.
**Extraction Plan:**
1. **Admin Logic**: Extract methods related to Admin/Moderator management (add/remove moderators, whitelist, blacklist, notifications) into `js/ui/profileModal/ProfileAdminController.js`.
2. **Moderation Logic**: Extract methods related to Moderation settings (blur threshold, autoplay, mute/hide logic) into `js/ui/profileModal/ProfileModerationController.js`.
**Goal:** Reduce `js/ui/profileModalController.js` by at least 200 lines (likely much more).
@@ -0,0 +1,21 @@
# Decompose Agent Completion Log
**Date:** 2026-02-14
**Agent:** decompose-agent
**Target:** js/ui/profileModalController.js
## Actions Taken
1. Identified `js/ui/profileModalController.js` as the largest grandfathered file (8159 lines).
2. Extracted `js/ui/profileModal/ProfileAdminController.js` (Admin logic).
3. Extracted `js/ui/profileModal/ProfileModerationController.js` (Moderation logic).
4. Extracted `js/ui/profileModal/ProfileAdminRenderer.js` (Admin UI rendering logic) to satisfy file size limits.
5. Refactored `js/ui/profileModalController.js` to delegate to these new controllers.
6. Implemented backward-compatibility wrappers and property aliases in `ProfileModalController.js` to ensure existing tests pass.
## Verification
- **Unit Tests:** `npm run test:unit tests/profile-modal-controller.test.mjs` passed (18/18 tests).
- **Linting:** `npm run lint` passed. File sizes are compliant.
- **Reduction:** `js/ui/profileModalController.js` reduced from ~8159 to ~6158 lines (-2001 lines).
## Status
Success.
@@ -0,0 +1,885 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { isDevMode } from "../../config.js";
import { ProfileAdminRenderer } from "./ProfileAdminRenderer.js";
const noop = () => {};
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;
}
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;
}
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");
}
});
}
}
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",
},
);
}
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;
}
if (this.moderatorSection instanceof HTMLElement) {
this.moderatorSection.classList.toggle("hidden", !isSuperAdmin);
this.moderatorSection.setAttribute(
"aria-hidden",
(!isSuperAdmin).toString(),
);
}
this.populateAdminLists();
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,
);
}
}
+283
View File
@@ -0,0 +1,283 @@
import { devLogger } from "../../utils/logger.js";
const FALLBACK_PROFILE_AVATAR = "assets/svg/default-profile.svg";
export class ProfileAdminRenderer {
constructor(mainController) {
this.mainController = mainController;
}
storeAdminEmptyMessages(controller) {
const capture = (element) => {
if (element instanceof HTMLElement && !element.dataset.defaultMessage) {
element.dataset.defaultMessage = element.textContent || "";
}
};
capture(controller.moderatorEmpty);
capture(controller.whitelistEmpty);
capture(controller.blacklistEmpty);
}
setAdminLoading(controller, isLoading) {
this.storeAdminEmptyMessages(controller);
if (this.mainController.panes.admin instanceof HTMLElement) {
this.mainController.panes.admin.setAttribute("aria-busy", isLoading ? "true" : "false");
}
const toggleMessage = (element, message) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (isLoading) {
element.textContent = message;
element.classList.remove("hidden");
} else {
element.textContent = element.dataset.defaultMessage || element.textContent;
}
};
toggleMessage(controller.moderatorEmpty, "Loading moderators…");
toggleMessage(controller.whitelistEmpty, "Loading whitelist…");
toggleMessage(controller.blacklistEmpty, "Loading blacklist…");
}
clearAdminLists(controller) {
this.storeAdminEmptyMessages(controller);
if (controller.adminModeratorList) {
controller.adminModeratorList.textContent = "";
}
if (controller.whitelistList) {
controller.whitelistList.textContent = "";
}
if (controller.blacklistList) {
controller.blacklistList.textContent = "";
}
if (controller.moderatorEmpty instanceof HTMLElement) {
controller.moderatorEmpty.textContent =
controller.moderatorEmpty.dataset.defaultMessage ||
controller.moderatorEmpty.textContent;
controller.moderatorEmpty.classList.remove("hidden");
}
if (controller.whitelistEmpty instanceof HTMLElement) {
controller.whitelistEmpty.textContent =
controller.whitelistEmpty.dataset.defaultMessage ||
controller.whitelistEmpty.textContent;
controller.whitelistEmpty.classList.remove("hidden");
}
if (controller.blacklistEmpty instanceof HTMLElement) {
controller.blacklistEmpty.textContent =
controller.blacklistEmpty.dataset.defaultMessage ||
controller.blacklistEmpty.textContent;
controller.blacklistEmpty.classList.remove("hidden");
}
}
normalizeAdminListEntries(entries) {
const collected = [];
const seen = new Set();
const append = (value) => {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed || seen.has(trimmed)) {
return;
}
seen.add(trimmed);
collected.push(trimmed);
};
if (Array.isArray(entries)) {
entries.forEach(append);
} else if (entries && typeof entries?.[Symbol.iterator] === "function") {
for (const entry of entries) {
append(entry);
}
} else if (entries && typeof entries === "object") {
Object.values(entries).forEach(append);
}
try {
collected.sort((a, b) => a.localeCompare(b));
} catch (error) {
devLogger.warn(
"[profileModal] Failed to sort admin list entries, using fallback order.",
error,
);
}
return collected;
}
renderAdminList(listEl, emptyEl, entries, options = {}) {
if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
return;
}
const {
onRemove,
removeLabel = "Remove",
confirmMessage,
removable = true,
overlapSet,
overlapLabel,
} = options;
const formatNpub =
typeof this.mainController.formatShortNpub === "function"
? (value) => this.mainController.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const entriesNeedingFetch = new Set();
listEl.textContent = "";
const values = this.normalizeAdminListEntries(entries);
const toggleHiddenState = (element, shouldHide) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (shouldHide) {
element.classList.add("hidden");
element.setAttribute("hidden", "");
} else {
element.classList.remove("hidden");
element.removeAttribute("hidden");
}
};
if (!values.length) {
toggleHiddenState(emptyEl, false);
toggleHiddenState(listEl, true);
return;
}
toggleHiddenState(emptyEl, true);
toggleHiddenState(listEl, false);
values.forEach((npub) => {
const item = document.createElement("li");
item.className =
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
const normalizedNpub = typeof npub === "string" ? npub.trim() : "";
const comparableNpub =
this.normalizeNpubValue(normalizedNpub) || normalizedNpub;
const decodedHex =
normalizedNpub && normalizedNpub.startsWith("npub1")
? this.mainController.safeDecodeNpub(normalizedNpub)
: null;
const normalizedHex =
decodedHex && /^[0-9a-f]{64}$/i.test(decodedHex)
? decodedHex.toLowerCase()
: null;
let cachedProfile = null;
if (normalizedHex) {
const cacheEntry = this.mainController.services.getProfileCacheEntry(normalizedHex);
cachedProfile = cacheEntry?.profile || null;
if (!cacheEntry) {
entriesNeedingFetch.add(normalizedHex);
}
}
const encodedNpub =
normalizedHex && typeof this.mainController.safeEncodeNpub === "function"
? this.mainController.safeEncodeNpub(normalizedHex)
: normalizedNpub;
const displayNpub = formatNpub(encodedNpub) || encodedNpub || normalizedNpub;
const displayName =
cachedProfile?.name?.trim() || displayNpub || "Unknown profile";
const avatarSrc =
cachedProfile?.picture || FALLBACK_PROFILE_AVATAR;
const summary = this.mainController.dmController.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
if (
summary &&
overlapLabel &&
overlapSet instanceof Set &&
comparableNpub &&
overlapSet.has(comparableNpub)
) {
const overlapBadge = document.createElement("span");
overlapBadge.className = "badge whitespace-nowrap";
overlapBadge.dataset.variant = "warning";
overlapBadge.textContent = overlapLabel;
summary.appendChild(overlapBadge);
}
const actions = document.createElement("div");
actions.className =
"flex flex-wrap items-center justify-end gap-2 sm:flex-none";
const viewButton = this.mainController.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.mainController.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
if (removable && typeof onRemove === "function") {
const removeBtn = this.mainController.createRemoveButton({
label: removeLabel,
confirmMessage,
confirmValue: displayNpub,
onRemove: (button) => onRemove(npub, button),
});
if (removeBtn) {
actions.appendChild(removeBtn);
}
}
item.appendChild(summary);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
listEl.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.mainController.services.batchFetchProfiles === "function"
) {
this.mainController.services.batchFetchProfiles(entriesNeedingFetch);
}
}
normalizeNpubValue(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
const normalizedHex = this.mainController.normalizeHexPubkey(trimmed);
if (!normalizedHex) {
return null;
}
return this.mainController.safeEncodeNpub(normalizedHex);
}
}
@@ -0,0 +1,932 @@
import { devLogger, userLogger } from "../../utils/logger.js";
import { RUNTIME_FLAGS } from "../../constants.js";
import {
DEFAULT_INTERNAL_MODERATION_SETTINGS,
createInternalDefaultModerationSettings,
} from "../profileModalContract.js";
const noop = () => {};
const TRUSTED_MUTE_HIDE_HELPER_TEXT =
"Reaching this count hides cards (with “Show anyway”); lower signals only blur thumbnails or block autoplay.";
const FALLBACK_PROFILE_AVATAR = "assets/svg/default-profile.svg";
export class ProfileModerationController {
constructor(mainController) {
this.mainController = mainController;
this.moderationSettingsCard = null;
this.moderationBlurInput = null;
this.moderationAutoplayInput = null;
this.moderationMuteHideInput = null;
this.moderationSpamHideInput = null;
this.moderationSaveButton = null;
this.moderationResetButton = null;
this.moderationStatusText = null;
this.moderationOverridesList = null;
this.moderationOverridesEmpty = null;
this.moderationTrustedContactsCount = null;
this.moderationTrustedMuteCount = null;
this.moderationTrustedReportCount = null;
this.moderationSeedOnlyIndicator = null;
this.moderationHideControlsGroup = null;
this.moderationHideControlElements = [];
this.boundModerationOverridesUpdate = null;
this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
this.currentModerationSettings = createInternalDefaultModerationSettings();
}
cacheDomReferences() {
this.moderationSettingsCard = document.getElementById("profileModerationSettings") || null;
this.moderationBlurInput = document.getElementById("profileModerationBlurThreshold") || null;
this.moderationAutoplayInput = document.getElementById("profileModerationAutoplayThreshold") || null;
this.moderationMuteHideInput = document.getElementById("profileModerationMuteHideThreshold") || null;
this.moderationSpamHideInput = document.getElementById("profileModerationSpamHideThreshold") || null;
this.moderationSaveButton = document.getElementById("profileModerationSave") || null;
this.moderationResetButton = document.getElementById("profileModerationReset") || null;
this.moderationStatusText = document.getElementById("profileModerationStatus") || null;
this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null;
this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || null;
this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null;
this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null;
this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || null;
this.moderationSeedOnlyIndicator = document.getElementById("profileModerationSeedOnlyIndicator") || null;
this.moderationHideControlsGroup =
this.moderationSettingsCard?.querySelector(
"[data-role=\"trusted-hide-controls\"]",
) || null;
this.moderationHideControlElements = Array.from(
this.moderationSettingsCard?.querySelectorAll(
"[data-role=\"trusted-hide-control\"]",
) || [],
);
this.updateTrustedMuteHideHelperCopy();
}
registerEventListeners() {
if (this.moderationBlurInput instanceof HTMLElement) {
this.moderationBlurInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationAutoplayInput instanceof HTMLElement) {
this.moderationAutoplayInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationMuteHideInput instanceof HTMLElement) {
this.moderationMuteHideInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationSpamHideInput instanceof HTMLElement) {
this.moderationSpamHideInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationSaveButton instanceof HTMLElement) {
this.moderationSaveButton.addEventListener("click", () => {
void this.handleModerationSettingsSave();
});
}
if (this.moderationResetButton instanceof HTMLElement) {
this.moderationResetButton.addEventListener("click", () => {
void this.handleModerationSettingsReset();
});
}
if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") {
this.boundModerationOverridesUpdate = () => {
this.refreshModerationOverridesUi();
};
document.addEventListener(
"video:moderation-override",
this.boundModerationOverridesUpdate,
);
document.addEventListener(
"video:moderation-hide",
this.boundModerationOverridesUpdate,
);
document.addEventListener(
"video:moderation-block",
this.boundModerationOverridesUpdate,
);
}
}
getModerationSettingsService() {
const service = this.mainController.services.moderationSettings;
if (!service || typeof service !== "object") {
return null;
}
return service;
}
getModerationSettingsDefaults() {
const service = this.getModerationSettingsService();
let defaults = null;
if (service && typeof service.getDefaultModerationSettings === "function") {
try {
defaults = service.getDefaultModerationSettings();
} catch (error) {
devLogger.info("[profileModal] moderation defaults fallback used", error);
}
}
if (!defaults || typeof defaults !== "object") {
defaults = createInternalDefaultModerationSettings();
}
const sanitized = {
blurThreshold: Math.max(
0,
Math.floor(
Number(
defaults.blurThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.blurThreshold,
),
),
),
autoplayBlockThreshold: Math.max(
0,
Math.floor(
Number(
defaults.autoplayBlockThreshold ??
DEFAULT_INTERNAL_MODERATION_SETTINGS.autoplayBlockThreshold,
),
),
),
trustedMuteHideThreshold: Math.max(
0,
Math.floor(
Number(
defaults.trustedMuteHideThreshold ??
DEFAULT_INTERNAL_MODERATION_SETTINGS.trustedMuteHideThreshold,
),
),
),
trustedSpamHideThreshold: Math.max(
0,
Math.floor(
Number(
defaults.trustedSpamHideThreshold ??
DEFAULT_INTERNAL_MODERATION_SETTINGS.trustedSpamHideThreshold,
),
),
),
};
return sanitized;
}
normalizeModerationSettings(settings = null) {
const defaults = this.getModerationSettingsDefaults();
const blur = Number.isFinite(settings?.blurThreshold)
? Math.max(0, Math.floor(settings.blurThreshold))
: defaults.blurThreshold;
const autoplay = Number.isFinite(settings?.autoplayBlockThreshold)
? Math.max(0, Math.floor(settings.autoplayBlockThreshold))
: defaults.autoplayBlockThreshold;
const muteHide = Number.isFinite(settings?.trustedMuteHideThreshold)
? Math.max(0, Math.floor(settings.trustedMuteHideThreshold))
: defaults.trustedMuteHideThreshold;
const spamHide = Number.isFinite(settings?.trustedSpamHideThreshold)
? Math.max(0, Math.floor(settings.trustedSpamHideThreshold))
: defaults.trustedSpamHideThreshold;
return {
blurThreshold: blur,
autoplayBlockThreshold: autoplay,
trustedMuteHideThreshold: muteHide,
trustedSpamHideThreshold: spamHide,
};
}
readModerationInputs() {
const defaults = this.getModerationSettingsDefaults();
const parse = (input, fallback) => {
if (!(input instanceof HTMLInputElement)) {
return { value: fallback, override: null, valid: true };
}
const raw = typeof input.value === "string" ? input.value.trim() : "";
if (!raw) {
return { value: fallback, override: null, valid: true };
}
const numeric = Number(raw);
if (!Number.isFinite(numeric)) {
return { value: fallback, override: null, valid: false };
}
const sanitized = Math.max(0, Math.floor(numeric));
return { value: sanitized, override: sanitized, valid: true };
};
const blur = parse(this.moderationBlurInput, defaults.blurThreshold);
const autoplay = parse(
this.moderationAutoplayInput,
defaults.autoplayBlockThreshold,
);
const muteHide = parse(
this.moderationMuteHideInput,
defaults.trustedMuteHideThreshold,
);
const spamHide = parse(
this.moderationSpamHideInput,
defaults.trustedSpamHideThreshold,
);
const valid = blur.valid && autoplay.valid && muteHide.valid && spamHide.valid;
const values = {
blurThreshold: blur.value,
autoplayBlockThreshold: autoplay.value,
trustedMuteHideThreshold: muteHide.value,
trustedSpamHideThreshold: spamHide.value,
};
const overrides = {
blurThreshold: blur.override,
autoplayBlockThreshold: autoplay.override,
trustedMuteHideThreshold: muteHide.override,
trustedSpamHideThreshold: spamHide.override,
};
return { defaults, values, overrides, valid };
}
applyModerationSettingsControlState({ resetStatus = false } = {}) {
const result = this.readModerationInputs();
const button = this.moderationSaveButton;
if (button instanceof HTMLElement) {
const baseline = this.currentModerationSettings || this.normalizeModerationSettings();
const isDirty =
result.valid &&
(baseline.blurThreshold !== result.values.blurThreshold ||
baseline.autoplayBlockThreshold !== result.values.autoplayBlockThreshold ||
baseline.trustedMuteHideThreshold !==
result.values.trustedMuteHideThreshold ||
baseline.trustedSpamHideThreshold !==
result.values.trustedSpamHideThreshold);
button.disabled = !(result.valid && isDirty);
if (button.disabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
}
if (resetStatus) {
this.updateModerationSettingsStatus("", "info");
}
return result;
}
areTrustedHideControlsEnabled() {
if (
RUNTIME_FLAGS &&
typeof RUNTIME_FLAGS === "object" &&
RUNTIME_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS === false
) {
return false;
}
return true;
}
updateTrustedHideControlsVisibility() {
const shouldShow = this.areTrustedHideControlsEnabled();
const targets = new Set();
if (this.moderationHideControlsGroup instanceof HTMLElement) {
targets.add(this.moderationHideControlsGroup);
}
if (Array.isArray(this.moderationHideControlElements)) {
for (const element of this.moderationHideControlElements) {
if (element instanceof HTMLElement) {
targets.add(element);
}
}
}
targets.forEach((element) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (shouldShow) {
element.classList.remove("hidden");
element.removeAttribute("hidden");
element.removeAttribute("aria-hidden");
} else {
element.classList.add("hidden");
element.setAttribute("hidden", "");
element.setAttribute("aria-hidden", "true");
}
});
}
updateModerationSettingsStatus(message = "", variant = "info") {
if (!(this.moderationStatusText instanceof HTMLElement)) {
return;
}
const text = typeof message === "string" ? message : "";
this.moderationStatusText.textContent = text;
if (text) {
this.moderationStatusText.dataset.variant = variant || "info";
} else if (this.moderationStatusText.dataset.variant) {
delete this.moderationStatusText.dataset.variant;
}
}
updateTrustedMuteHideHelperCopy() {
if (!(this.moderationMuteHideInput instanceof HTMLInputElement)) {
return;
}
const label = this.moderationMuteHideInput.closest("label");
if (!(label instanceof HTMLElement)) {
return;
}
const helper = label.querySelector("span.text-xs");
if (!(helper instanceof HTMLElement)) {
return;
}
helper.textContent = TRUSTED_MUTE_HIDE_HELPER_TEXT;
}
getModerationOverrideEntries() {
if (typeof this.mainController.services.getModerationOverrides !== "function") {
return [];
}
try {
const entries = this.mainController.services.getModerationOverrides();
return Array.isArray(entries) ? entries : [];
} catch (error) {
devLogger.info(
"[profileModal] moderation overrides fallback used",
error,
);
return [];
}
}
normalizeModerationOverrideEntries(entries = []) {
const normalized = [];
const seen = new Set();
entries.forEach((entry) => {
if (!entry || typeof entry !== "object") {
return;
}
const eventId =
typeof entry.eventId === "string"
? entry.eventId.trim().toLowerCase()
: "";
if (!eventId) {
return;
}
const author =
typeof entry.authorPubkey === "string"
? entry.authorPubkey.trim()
: "";
const normalizedAuthor = author ? this.mainController.normalizeHexPubkey(author) || author : "";
const key = `${normalizedAuthor || ""}:${eventId}`;
if (seen.has(key)) {
return;
}
seen.add(key);
normalized.push({
eventId,
authorPubkey: normalizedAuthor || "",
updatedAt: Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: 0,
});
});
normalized.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return normalized;
}
formatModerationOverrideTimestamp(updatedAt) {
const numeric = Number(updatedAt);
if (!Number.isFinite(numeric) || numeric <= 0) {
return { display: "", iso: "" };
}
try {
const date = new Date(numeric);
return {
display: date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
iso: date.toISOString(),
};
} catch (error) {
return { display: "", iso: "" };
}
}
async handleModerationOverrideReset(entry) {
if (!entry || typeof entry !== "object") {
return false;
}
if (typeof this.mainController.services.clearModerationOverride !== "function") {
return false;
}
try {
await this.mainController.services.clearModerationOverride({
eventId: entry.eventId,
authorPubkey: entry.authorPubkey,
});
this.refreshModerationOverridesUi();
this.mainController.showSuccess("Moderation override reset.");
return true;
} catch (error) {
this.mainController.showError("Unable to reset this moderation override.");
return false;
}
}
refreshModerationOverridesUi() {
if (
!(this.moderationOverridesList instanceof HTMLElement) ||
!(this.moderationOverridesEmpty instanceof HTMLElement)
) {
return;
}
const entries = this.normalizeModerationOverrideEntries(
this.getModerationOverrideEntries(),
);
this.moderationOverridesList.textContent = "";
if (!entries.length) {
this.moderationOverridesEmpty.classList.remove("hidden");
this.moderationOverridesList.classList.add("hidden");
return;
}
this.moderationOverridesEmpty.classList.add("hidden");
this.moderationOverridesList.classList.remove("hidden");
const entriesNeedingFetch = new Set();
entries.forEach((entry) => {
const item = document.createElement("li");
item.className = "card space-y-2 p-4";
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-4";
const authorKey = entry.authorPubkey;
let profileSummary = null;
if (authorKey) {
const cacheEntry = this.mainController.services.getProfileCacheEntry(authorKey);
if (!cacheEntry) {
entriesNeedingFetch.add(authorKey);
}
}
const summaryData = this.mainController.dmController.resolveProfileSummaryForPubkey(authorKey);
profileSummary = this.mainController.dmController.createCompactProfileSummary(summaryData);
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const resetButton = this.mainController.createRemoveButton({
label: "Reset",
onRemove: () => this.handleModerationOverrideReset(entry),
});
if (resetButton) {
actions.appendChild(resetButton);
}
if (profileSummary) {
row.appendChild(profileSummary);
}
if (actions.childElementCount > 0) {
row.appendChild(actions);
}
const meta = document.createElement("div");
meta.className = "flex flex-wrap items-center gap-3 text-2xs text-muted";
const contentId = document.createElement("span");
contentId.className = "font-mono text-2xs text-muted";
const shortId =
typeof this.mainController.truncateMiddle === "function"
? this.mainController.truncateMiddle(entry.eventId, 16)
: entry.eventId;
contentId.textContent = `Content ${shortId}`;
contentId.title = entry.eventId;
meta.appendChild(contentId);
const timestamp = this.formatModerationOverrideTimestamp(entry.updatedAt);
if (timestamp.display) {
const time = document.createElement("time");
time.className = "text-2xs text-muted";
time.dateTime = timestamp.iso;
time.textContent = `Updated ${timestamp.display}`;
meta.appendChild(time);
}
item.appendChild(row);
item.appendChild(meta);
this.moderationOverridesList.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.mainController.services.batchFetchProfiles === "function"
) {
this.mainController.services.batchFetchProfiles(entriesNeedingFetch);
}
}
refreshModerationSettingsUi() {
const service = this.getModerationSettingsService();
if (!service) {
this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
this.currentModerationSettings = createInternalDefaultModerationSettings();
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
return;
}
let active = null;
if (typeof service.getActiveModerationSettings === "function") {
try {
active = service.getActiveModerationSettings();
} catch (error) {
devLogger.info("[profileModal] moderation settings fallback used", error);
}
}
const defaults = this.getModerationSettingsDefaults();
this.moderationSettingsDefaults = defaults;
const normalized = this.normalizeModerationSettings(active);
this.currentModerationSettings = normalized;
if (this.moderationBlurInput instanceof HTMLInputElement) {
this.moderationBlurInput.value = String(normalized.blurThreshold);
}
if (this.moderationAutoplayInput instanceof HTMLInputElement) {
this.moderationAutoplayInput.value = String(
normalized.autoplayBlockThreshold,
);
}
if (this.moderationMuteHideInput instanceof HTMLInputElement) {
this.moderationMuteHideInput.value = String(
normalized.trustedMuteHideThreshold,
);
}
if (this.moderationSpamHideInput instanceof HTMLInputElement) {
this.moderationSpamHideInput.value = String(
normalized.trustedSpamHideThreshold,
);
}
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
}
getModerationTrustStats() {
const summary = {
trustedContactsCount: 0,
trustedMuteContributors: 0,
trustedReportContributors: 0,
trustedSeedOnly: false,
};
const service = this.mainController.moderationService;
if (!service) {
return summary;
}
if (typeof service.isTrustedSeedOnly === "function") {
summary.trustedSeedOnly = service.isTrustedSeedOnly();
} else if (typeof service.trustedSeedOnly === "boolean") {
summary.trustedSeedOnly = service.trustedSeedOnly;
}
const trustedContacts =
service.trustedContacts instanceof Set
? service.trustedContacts
: Array.isArray(service.trustedContacts)
? new Set(service.trustedContacts)
: new Set();
summary.trustedContactsCount = trustedContacts.size;
const adminSnapshot =
typeof service.getAdminListSnapshot === "function"
? service.getAdminListSnapshot()
: null;
const resolveStatus = (candidate) => {
if (typeof service.getAccessControlStatus === "function") {
return service.getAccessControlStatus(candidate, adminSnapshot);
}
return {
hex: this.mainController.normalizeHexPubkey(candidate),
whitelisted: false,
blacklisted: false,
};
};
const isBlocked = (pubkey) =>
typeof service.isPubkeyBlockedByViewer === "function"
? service.isPubkeyBlockedByViewer(pubkey)
: false;
const isTrustedCandidate = (status) => {
if (!status || !status.hex) {
return false;
}
if (status.blacklisted) {
return false;
}
if (isBlocked(status.hex)) {
return false;
}
return Boolean(status.whitelisted || trustedContacts.has(status.hex));
};
if (service.trustedMuteLists instanceof Map) {
const trustedMuteOwners = new Set();
for (const owner of service.trustedMuteLists.keys()) {
const status = resolveStatus(owner);
if (isTrustedCandidate(status)) {
trustedMuteOwners.add(status.hex);
}
}
summary.trustedMuteContributors = trustedMuteOwners.size;
}
if (service.reportEvents instanceof Map) {
const trustedReporters = new Set();
for (const eventReports of service.reportEvents.values()) {
if (!(eventReports instanceof Map)) {
continue;
}
for (const reporter of eventReports.keys()) {
const status = resolveStatus(reporter);
if (!isTrustedCandidate(status)) {
continue;
}
trustedReporters.add(status.hex);
}
}
summary.trustedReportContributors = trustedReporters.size;
}
return summary;
}
updateModerationTrustStats() {
if (
!(this.moderationTrustedContactsCount instanceof HTMLElement) &&
!(this.moderationTrustedMuteCount instanceof HTMLElement) &&
!(this.moderationTrustedReportCount instanceof HTMLElement) &&
!(this.moderationSeedOnlyIndicator instanceof HTMLElement)
) {
return;
}
const summary = this.getModerationTrustStats();
if (this.moderationTrustedContactsCount instanceof HTMLElement) {
this.moderationTrustedContactsCount.textContent = String(
summary.trustedContactsCount,
);
}
if (this.moderationTrustedMuteCount instanceof HTMLElement) {
this.moderationTrustedMuteCount.textContent = String(
summary.trustedMuteContributors,
);
}
if (this.moderationTrustedReportCount instanceof HTMLElement) {
this.moderationTrustedReportCount.textContent = String(
summary.trustedReportContributors,
);
}
if (this.moderationSeedOnlyIndicator instanceof HTMLElement) {
this.moderationSeedOnlyIndicator.hidden = !summary.trustedSeedOnly;
}
}
async handleModerationSettingsSave() {
const service = this.getModerationSettingsService();
const context = {
success: false,
reason: null,
error: null,
settings: null,
};
if (!service) {
return context;
}
const inputState = this.applyModerationSettingsControlState();
if (!inputState.valid) {
const message =
"Enter non-negative whole numbers for moderation thresholds.";
this.updateModerationSettingsStatus(message, "error");
this.mainController.showError(message);
context.reason = "invalid-input";
context.error = message;
return context;
}
const payload = {};
if (Object.prototype.hasOwnProperty.call(inputState.overrides, "blurThreshold")) {
payload.blurThreshold = inputState.overrides.blurThreshold;
}
if (
Object.prototype.hasOwnProperty.call(
inputState.overrides,
"autoplayBlockThreshold",
)
) {
payload.autoplayBlockThreshold = inputState.overrides.autoplayBlockThreshold;
}
if (
Object.prototype.hasOwnProperty.call(
inputState.overrides,
"trustedMuteHideThreshold",
)
) {
payload.trustedMuteHideThreshold =
inputState.overrides.trustedMuteHideThreshold;
}
if (
Object.prototype.hasOwnProperty.call(
inputState.overrides,
"trustedSpamHideThreshold",
)
) {
payload.trustedSpamHideThreshold =
inputState.overrides.trustedSpamHideThreshold;
}
try {
const updated =
typeof service.updateModerationSettings === "function"
? await service.updateModerationSettings(payload)
: inputState.values;
const normalized = this.normalizeModerationSettings(updated);
this.currentModerationSettings = normalized;
if (this.moderationBlurInput instanceof HTMLInputElement) {
this.moderationBlurInput.value = String(normalized.blurThreshold);
}
if (this.moderationAutoplayInput instanceof HTMLInputElement) {
this.moderationAutoplayInput.value = String(
normalized.autoplayBlockThreshold,
);
}
if (this.moderationMuteHideInput instanceof HTMLInputElement) {
this.moderationMuteHideInput.value = String(
normalized.trustedMuteHideThreshold,
);
}
if (this.moderationSpamHideInput instanceof HTMLInputElement) {
this.moderationSpamHideInput.value = String(
normalized.trustedSpamHideThreshold,
);
}
this.applyModerationSettingsControlState();
this.updateModerationSettingsStatus("Moderation settings saved.", "success");
this.mainController.showSuccess("Moderation settings saved.");
context.success = true;
context.reason = "saved";
context.settings = normalized;
this.mainController.callbacks.onModerationSettingsChange({
settings: normalized,
controller: this.mainController,
reason: "saved",
});
} catch (error) {
const fallbackMessage = "Failed to update moderation settings.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
this.updateModerationSettingsStatus(detail, "error");
this.mainController.showError(detail);
context.error = detail;
context.reason = error?.code || "service-error";
}
return context;
}
async handleModerationSettingsReset() {
const service = this.getModerationSettingsService();
const context = {
success: false,
reason: null,
error: null,
settings: null,
};
if (!service) {
return context;
}
try {
const updated =
typeof service.resetModerationSettings === "function"
? await service.resetModerationSettings()
: createInternalDefaultModerationSettings();
const normalized = this.normalizeModerationSettings(updated);
this.currentModerationSettings = normalized;
if (this.moderationBlurInput instanceof HTMLInputElement) {
this.moderationBlurInput.value = String(normalized.blurThreshold);
}
if (this.moderationAutoplayInput instanceof HTMLInputElement) {
this.moderationAutoplayInput.value = String(
normalized.autoplayBlockThreshold,
);
}
if (this.moderationMuteHideInput instanceof HTMLInputElement) {
this.moderationMuteHideInput.value = String(
normalized.trustedMuteHideThreshold,
);
}
if (this.moderationSpamHideInput instanceof HTMLInputElement) {
this.moderationSpamHideInput.value = String(
normalized.trustedSpamHideThreshold,
);
}
this.updateTrustedHideControlsVisibility();
this.applyModerationSettingsControlState({ resetStatus: true });
this.updateModerationSettingsStatus(
"Moderation defaults restored.",
"success",
);
this.mainController.showSuccess("Moderation defaults restored.");
context.success = true;
context.reason = "reset";
context.settings = normalized;
this.mainController.callbacks.onModerationSettingsChange({
settings: normalized,
controller: this.mainController,
reason: "reset",
});
} catch (error) {
const fallbackMessage = "Failed to restore moderation defaults.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
this.updateModerationSettingsStatus(detail, "error");
this.mainController.showError(detail);
context.error = detail;
context.reason = error?.code || "service-error";
}
return context;
}
}
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
# Todo
- [ ] Extract Admin Logic to `js/ui/profileModal/ProfileAdminController.js`
- [ ] Move methods: `populateAdminLists`, `renderAdminList`, `normalizeAdminListEntries`, `handleAddModerator`, `handleRemoveModerator`, `handleAdminListMutation`, `runAdminMutation`, `refreshAdminPaneState`, `setAdminLoading`, `clearAdminLists`, `ensureAdminActor`, `storeAdminEmptyMessages`, `normalizeNpubValue`, `describeAdminError`, `notifyAdminError`, `describeNotificationError`, `sendAdminListNotification`.
- [ ] Move DOM references: `moderatorSection`, `moderatorEmpty`, `adminModeratorList`, `addModeratorButton`, `moderatorInput`, `adminModeratorsRefreshBtn`, `whitelistSection`, `whitelistEmpty`, `whitelistList`, `addWhitelistButton`, `whitelistInput`, `adminWhitelistRefreshBtn`, `blacklistSection`, `blacklistEmpty`, `blacklistList`, `addBlacklistButton`, `blacklistInput`, `adminBlacklistRefreshBtn`.
- [ ] Update imports in `ProfileAdminController.js`.
- [ ] Update `ProfileModalController.js` to use `ProfileAdminController`.
- [ ] Extract Moderation Logic to `js/ui/profileModal/ProfileModerationController.js`
- [ ] Move methods: `refreshModerationSettingsUi`, `getModerationSettingsService`, `getModerationSettingsDefaults`, `normalizeModerationSettings`, `readModerationInputs`, `applyModerationSettingsControlState`, `areTrustedHideControlsEnabled`, `updateTrustedHideControlsVisibility`, `updateModerationSettingsStatus`, `updateTrustedMuteHideHelperCopy`, `getModerationOverrideEntries`, `normalizeModerationOverrideEntries`, `formatModerationOverrideTimestamp`, `handleModerationOverrideReset`, `refreshModerationOverridesUi`, `getModerationTrustStats`, `updateModerationTrustStats`, `handleModerationSettingsSave`, `handleModerationSettingsReset`.
- [ ] Move DOM references: `moderationSettingsCard`, `moderationBlurInput`, `moderationAutoplayInput`, `moderationMuteHideInput`, `moderationSpamHideInput`, `moderationSaveButton`, `moderationResetButton`, `moderationStatusText`, `moderationOverridesList`, `moderationOverridesEmpty`, `moderationTrustedContactsCount`, `moderationTrustedMuteCount`, `moderationTrustedReportCount`, `moderationSeedOnlyIndicator`, `moderationHideControlsGroup`, `moderationHideControlElements`.
- [ ] Update imports in `ProfileModerationController.js`.
- [ ] Update `ProfileModalController.js` to use `ProfileModerationController`.
- [ ] Verify
- [ ] Run `npm run lint`.
- [ ] Run `npm run test:unit`.
- [ ] Manual check.