mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
288beb424f
commit
f35d27d471
7 files changed
+2225
-2075
No files matched your search
@@ -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 = `\n\n${messageBody}`;
|
||||
|
||||
return this.mainController.services.nostrClient.sendDirectMessage(
|
||||
normalizedTarget,
|
||||
message,
|
||||
activeHex,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
-2075
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
Reference in new issue
Block a user