From f35d27d47145e2ae172beee103550b6be1a08e21 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 10:59:14 +0000 Subject: [PATCH] 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 <53631862+PR0M3TH3AN@users.noreply.github.com> --- context/CONTEXT_2026-02-14_10-32-37.md | 11 + ...2-14_10-57-56_decompose-agent_completed.md | 21 + js/ui/profileModal/ProfileAdminController.js | 885 +++++++ js/ui/profileModal/ProfileAdminRenderer.js | 283 +++ .../ProfileModerationController.js | 932 +++++++ js/ui/profileModalController.js | 2150 +---------------- todo/TODO_2026-02-14_10-32-37.md | 18 + 7 files changed, 2225 insertions(+), 2075 deletions(-) create mode 100644 context/CONTEXT_2026-02-14_10-32-37.md create mode 100644 docs/agents/task-logs/daily/2026-02-14_10-57-56_decompose-agent_completed.md create mode 100644 js/ui/profileModal/ProfileAdminController.js create mode 100644 js/ui/profileModal/ProfileAdminRenderer.js create mode 100644 js/ui/profileModal/ProfileModerationController.js create mode 100644 todo/TODO_2026-02-14_10-32-37.md diff --git a/context/CONTEXT_2026-02-14_10-32-37.md b/context/CONTEXT_2026-02-14_10-32-37.md new file mode 100644 index 00000000..1ba8af81 --- /dev/null +++ b/context/CONTEXT_2026-02-14_10-32-37.md @@ -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). diff --git a/docs/agents/task-logs/daily/2026-02-14_10-57-56_decompose-agent_completed.md b/docs/agents/task-logs/daily/2026-02-14_10-57-56_decompose-agent_completed.md new file mode 100644 index 00000000..bcd39671 --- /dev/null +++ b/docs/agents/task-logs/daily/2026-02-14_10-57-56_decompose-agent_completed.md @@ -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. diff --git a/js/ui/profileModal/ProfileAdminController.js b/js/ui/profileModal/ProfileAdminController.js new file mode 100644 index 00000000..11811c49 --- /dev/null +++ b/js/ui/profileModal/ProfileAdminController.js @@ -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, + ); + } +} diff --git a/js/ui/profileModal/ProfileAdminRenderer.js b/js/ui/profileModal/ProfileAdminRenderer.js new file mode 100644 index 00000000..c8945b75 --- /dev/null +++ b/js/ui/profileModal/ProfileAdminRenderer.js @@ -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); + } +} diff --git a/js/ui/profileModal/ProfileModerationController.js b/js/ui/profileModal/ProfileModerationController.js new file mode 100644 index 00000000..57f5fa13 --- /dev/null +++ b/js/ui/profileModal/ProfileModerationController.js @@ -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; + } +} diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index d6b42703..d46fef49 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -48,6 +48,8 @@ import { ProfileStorageController } from "./profileModal/ProfileStorageControlle import { ProfileDirectMessageController } from "./profileModal/ProfileDirectMessageController.js"; import { ProfileRelayController } from "./profileModal/ProfileRelayController.js"; import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js"; +import { ProfileAdminController } from "./profileModal/ProfileAdminController.js"; +import { ProfileModerationController } from "./profileModal/ProfileModerationController.js"; const noop = () => {}; @@ -182,6 +184,8 @@ export class ProfileModalController { this.dmController = new ProfileDirectMessageController(this); this.relayController = new ProfileRelayController(this); this.hashtagController = new ProfileHashtagController(this); + this.adminController = new ProfileAdminController(this); + this.moderationController = new ProfileModerationController(this); this.hashtagController.initialize(); this.dmController.enableNip17RelayWarning = resolvedEnableNip17RelayWarning; @@ -193,12 +197,6 @@ export class ProfileModalController { this.safeDecodeNpub = this.services.safeDecodeNpub; this.truncateMiddle = this.services.truncateMiddle; this.formatShortNpub = this.services.formatShortNpub; - this.moderationSettingsDefaults = createInternalDefaultModerationSettings(); - this.currentModerationSettings = createInternalDefaultModerationSettings(); - this.sendAdminListNotificationService = - typeof this.services.sendAdminListNotification === "function" - ? this.services.sendAdminListNotification - : null; this.describeAdminErrorService = this.services.describeAdminError; this.describeNotificationErrorService = this.services.describeNotificationError; @@ -437,46 +435,16 @@ export class ProfileModalController { this.profileFriendsRefreshBtn = null; this.profileBlockedRefreshBtn = null; this.relayController.profileRelayRefreshBtn = null; - 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.moderationHideControlsGroup = null; - this.moderationHideControlElements = []; - this.boundModerationOverridesUpdate = null; - this.moderatorSection = null; - this.moderatorEmpty = null; - this.adminModeratorList = null; - this.addModeratorButton = null; - this.moderatorInput = null; - this.adminModeratorsRefreshBtn = null; + // Legacy aliases managed via ProfileAdminController in cacheDomReferences this.adminModeratorsSection = null; this.adminModeratorsEmpty = null; this.adminAddModeratorButton = null; this.adminModeratorInput = null; - this.whitelistSection = null; - this.whitelistEmpty = null; - this.whitelistList = null; - this.addWhitelistButton = null; - this.whitelistInput = null; - this.adminWhitelistRefreshBtn = null; this.adminWhitelistSection = null; this.adminWhitelistEmpty = null; this.adminWhitelistList = null; this.adminAddWhitelistButton = null; this.adminWhitelistInput = null; - this.blacklistSection = null; - this.blacklistEmpty = null; - this.blacklistList = null; - this.addBlacklistButton = null; - this.blacklistInput = null; - this.adminBlacklistRefreshBtn = null; this.adminBlacklistSection = null; this.adminBlacklistEmpty = null; this.adminBlacklistList = null; @@ -608,7 +576,7 @@ export class ProfileModalController { ); } this.hashtagController.populateHashtagPreferences(); - this.refreshModerationSettingsUi(); + this.moderationController.refreshModerationSettingsUi(); const preserveMenu = this.isMobileLayoutActive(); this.selectPane(this.getActivePane(), { keepMenuView: preserveMenu }); @@ -838,89 +806,34 @@ export class ProfileModalController { this.blockListStatus = this.panes.blocked?.querySelector("[data-role=\"blocked-list-status\"]") || null; - 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(); - - 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.moderationController.cacheDomReferences(); + this.adminController.cacheDomReferences(); // Backwards-compatible aliases retained for application code that still - // mirrors DOM references from the controller. These should be removed once - // the application stops reaching through the controller. - this.adminModeratorsSection = this.moderatorSection; - this.adminModeratorsEmpty = this.moderatorEmpty; - this.adminAddModeratorButton = this.addModeratorButton; - this.adminModeratorInput = this.moderatorInput; - 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; + // mirrors DOM references from the controller. + this.adminModeratorsSection = this.adminController.moderatorSection; + this.adminModeratorsEmpty = this.adminController.moderatorEmpty; + this.adminAddModeratorButton = this.adminController.addModeratorButton; + this.adminModeratorInput = this.adminController.moderatorInput; + this.adminWhitelistSection = this.adminController.whitelistSection; + this.adminWhitelistEmpty = this.adminController.whitelistEmpty; + this.adminWhitelistList = this.adminController.whitelistList; + this.adminAddWhitelistButton = this.adminController.addWhitelistButton; + this.adminWhitelistInput = this.adminController.whitelistInput; + this.adminBlacklistSection = this.adminController.blacklistSection; + this.adminBlacklistEmpty = this.adminController.blacklistEmpty; + this.adminBlacklistList = this.adminController.blacklistList; + this.adminAddBlacklistButton = this.adminController.addBlacklistButton; + this.adminBlacklistInput = this.adminController.blacklistInput; + + // Direct aliases for tests that expect un-prefixed names + this.moderatorSection = this.adminController.moderatorSection; + this.adminModeratorList = this.adminController.adminModeratorList; + this.whitelistList = this.adminController.whitelistList; + this.whitelistInput = this.adminController.whitelistInput; + this.addWhitelistButton = this.adminController.addWhitelistButton; + this.addModeratorButton = this.adminController.addModeratorButton; + this.moderatorInput = this.adminController.moderatorInput; const ensureAriaLabel = (button, label) => { if (!(button instanceof HTMLElement)) { @@ -943,20 +856,6 @@ export class ProfileModalController { ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions"); ensureAriaLabel(this.profileFriendsRefreshBtn, "Refresh friends"); ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh muted & blocked users"); - ensureAriaLabel(this.adminModeratorsRefreshBtn, "Refresh moderators"); - ensureAriaLabel(this.adminWhitelistRefreshBtn, "Refresh whitelist"); - ensureAriaLabel(this.adminBlacklistRefreshBtn, "Refresh blacklist"); - - this.adminWhitelistSection = this.whitelistSection; - this.adminWhitelistEmpty = this.whitelistEmpty; - this.adminWhitelistList = this.whitelistList; - this.adminAddWhitelistButton = this.addWhitelistButton; - this.adminWhitelistInput = this.whitelistInput; - this.adminBlacklistSection = this.blacklistSection; - this.adminBlacklistEmpty = this.blacklistEmpty; - this.adminBlacklistList = this.blacklistList; - this.adminAddBlacklistButton = this.addBlacklistButton; - this.adminBlacklistInput = this.blacklistInput; this.editNameInput = document.getElementById("editNameInput") || null; this.editDisplayNameInput = document.getElementById("editDisplayNameInput") || null; @@ -1951,6 +1850,8 @@ export class ProfileModalController { this.bindDmRelayControls(); this.walletController.registerEventListeners(); this.storageController.registerEventListeners(); + this.adminController.registerEventListeners(); + this.moderationController.registerEventListeners(); if (this.dmController.profileMessageSendButton instanceof HTMLElement) { this.dmController.profileMessageSendButton.addEventListener("click", () => { @@ -2020,149 +1921,6 @@ export class ProfileModalController { }); } - 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, - ); - } - - if (this.addModeratorButton instanceof HTMLElement) { - this.addModeratorButton.addEventListener("click", () => { - void this.handleAddModerator(); - }); - } - - if (this.adminModeratorsRefreshBtn instanceof HTMLElement) { - this.adminModeratorsRefreshBtn.addEventListener("click", () => { - const service = this.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.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.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"); - } - }); - } if (this.profileEditBtn instanceof HTMLElement) { this.profileEditBtn.addEventListener("click", () => { @@ -5160,1735 +4918,23 @@ export class ProfileModalController { } - getModerationSettingsService() { - const service = this.services.moderationSettings; - if (!service || typeof service !== "object") { - return null; - } - return service; + + + + + + + // Legacy wrappers for backward compatibility and test stability + describeAdminError(...args) { + return this.adminController.describeAdminError(...args); } - 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; + describeNotificationError(...args) { + return this.adminController.describeNotificationError(...args); } - 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.services.getModerationOverrides !== "function") { - return []; - } - - try { - const entries = this.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.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.services.clearModerationOverride !== "function") { - return false; - } - - try { - await this.services.clearModerationOverride({ - eventId: entry.eventId, - authorPubkey: entry.authorPubkey, - }); - this.refreshModerationOverridesUi(); - this.showSuccess("Moderation override reset."); - return true; - } catch (error) { - this.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.services.getProfileCacheEntry(authorKey); - if (!cacheEntry) { - entriesNeedingFetch.add(authorKey); - } - } - - const summaryData = this.dmController.resolveProfileSummaryForPubkey(authorKey); - profileSummary = this.dmController.createCompactProfileSummary(summaryData); - - const actions = document.createElement("div"); - actions.className = "flex flex-wrap items-center justify-end gap-2"; - - const resetButton = this.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.truncateMiddle === "function" - ? this.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.services.batchFetchProfiles === "function" - ) { - this.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.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.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.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.showSuccess("Moderation settings saved."); - context.success = true; - context.reason = "saved"; - context.settings = normalized; - this.callbacks.onModerationSettingsChange({ - settings: normalized, - controller: this, - 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.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.showSuccess("Moderation defaults restored."); - context.success = true; - context.reason = "reset"; - context.settings = normalized; - this.callbacks.onModerationSettingsChange({ - settings: normalized, - controller: this, - 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.showError(detail); - context.error = detail; - context.reason = error?.code || "service-error"; - } - - return context; - } - - storeAdminEmptyMessages() { - const capture = (element) => { - if (element instanceof HTMLElement && !element.dataset.defaultMessage) { - element.dataset.defaultMessage = element.textContent || ""; - } - }; - - capture(this.moderatorEmpty); - capture(this.whitelistEmpty); - capture(this.blacklistEmpty); - } - - setAdminLoading(isLoading) { - this.storeAdminEmptyMessages(); - if (this.panes.admin instanceof HTMLElement) { - this.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(this.moderatorEmpty, "Loading moderators…"); - toggleMessage(this.whitelistEmpty, "Loading whitelist…"); - toggleMessage(this.blacklistEmpty, "Loading blacklist…"); - } - - clearAdminLists() { - this.storeAdminEmptyMessages(); - if (this.adminModeratorList) { - this.adminModeratorList.textContent = ""; - } - if (this.whitelistList) { - this.whitelistList.textContent = ""; - } - if (this.blacklistList) { - this.blacklistList.textContent = ""; - } - if (this.moderatorEmpty instanceof HTMLElement) { - this.moderatorEmpty.textContent = - this.moderatorEmpty.dataset.defaultMessage || - this.moderatorEmpty.textContent; - this.moderatorEmpty.classList.remove("hidden"); - } - if (this.whitelistEmpty instanceof HTMLElement) { - this.whitelistEmpty.textContent = - this.whitelistEmpty.dataset.defaultMessage || - this.whitelistEmpty.textContent; - this.whitelistEmpty.classList.remove("hidden"); - } - if (this.blacklistEmpty instanceof HTMLElement) { - this.blacklistEmpty.textContent = - this.blacklistEmpty.dataset.defaultMessage || - this.blacklistEmpty.textContent; - this.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.formatShortNpub === "function" - ? (value) => this.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.safeDecodeNpub(normalizedNpub) - : null; - const normalizedHex = - decodedHex && /^[0-9a-f]{64}$/i.test(decodedHex) - ? decodedHex.toLowerCase() - : null; - - let cachedProfile = null; - if (normalizedHex) { - const cacheEntry = this.services.getProfileCacheEntry(normalizedHex); - cachedProfile = cacheEntry?.profile || null; - if (!cacheEntry) { - entriesNeedingFetch.add(normalizedHex); - } - } - - const encodedNpub = - normalizedHex && typeof this.safeEncodeNpub === "function" - ? this.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.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.createViewChannelButton({ - targetNpub: encodedNpub, - displayNpub, - }); - if (viewButton) { - actions.appendChild(viewButton); - } - - const copyButton = this.createCopyNpubButton({ - targetNpub: encodedNpub, - displayNpub, - }); - if (copyButton) { - actions.appendChild(copyButton); - } - - if (removable && typeof onRemove === "function") { - const removeBtn = this.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.services.batchFetchProfiles === "function" - ) { - this.services.batchFetchProfiles(entriesNeedingFetch); - } - } - - populateAdminLists() { - const actorNpub = this.services.getCurrentUserNpub(); - if (!actorNpub || !this.services.accessControl.canEditAdminLists(actorNpub)) { - this.clearAdminLists(); - return; - } - - const isSuperAdmin = this.services.accessControl.isSuperAdmin(actorNpub); - const editors = this.normalizeAdminListEntries( - this.services.accessControl.getEditors(), - ).filter((npub) => npub && npub !== this.adminSuperNpub); - const whitelist = this.normalizeAdminListEntries( - this.services.accessControl.getWhitelist(), - ); - const blacklist = this.normalizeAdminListEntries( - this.services.accessControl.getBlacklist(), - ); - const normalizeForCompare = (value) => - this.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.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.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.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.navButtons.admin; - const adminPane = this.panes.admin; - - let loadError = null; - this.setAdminLoading(true); - this.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.services.getCurrentUserNpub(); - const canEdit = - !!actorNpub && this.services.accessControl.canEditAdminLists(actorNpub); - const isSuperAdmin = - !!actorNpub && this.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.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.showStatus(null); - this.showError("Unable to load moderation lists. Please try again."); - this.clearAdminLists(); - this.setAdminLoading(false); - return; - } - - if (!canEdit) { - this.clearAdminLists(); - this.showStatus(null); - this.setAdminLoading(false); - if ( - adminNav instanceof HTMLElement && - adminNav.dataset.state === "active" - ) { - this.selectPane("account"); - } - return; - } - - if (this.moderatorSection instanceof HTMLElement) { - this.moderatorSection.classList.toggle("hidden", !isSuperAdmin); - this.moderatorSection.setAttribute( - "aria-hidden", - (!isSuperAdmin).toString(), - ); - } - this.populateAdminLists(); - this.showStatus(null); - this.setAdminLoading(false); - } - - 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.normalizeHexPubkey(trimmed); - if (!normalizedHex) { - return null; - } - return this.safeEncodeNpub(normalizedHex); - } - - ensureAdminActor(requireSuperAdmin = false) { - const actorNpub = this.services.getCurrentUserNpub(); - if (!actorNpub) { - this.showError("Please login with a Nostr account to manage admin settings."); - return null; - } - if (!this.services.accessControl.canEditAdminLists(actorNpub)) { - this.showError("You do not have permission to manage bitvid moderation lists."); - return null; - } - if (requireSuperAdmin && !this.services.accessControl.isSuperAdmin(actorNpub)) { - this.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.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.showError(this.describeAdminError(preloadError.code || "storage-error")); - context.reason = preloadError.code || "storage-error"; - context.error = preloadError; - this.callbacks.onAdminAddModerator(context, this); - return context; - } - - const actorNpub = this.ensureAdminActor(true); - context.actorNpub = actorNpub; - if (!actorNpub || !input) { - context.reason = actorNpub ? "missing-input" : "unauthorized"; - this.callbacks.onAdminAddModerator(context, this); - return context; - } - - if (!trimmed) { - this.showError("Enter an npub to add as a moderator."); - context.reason = "empty"; - this.callbacks.onAdminAddModerator(context, this); - return context; - } - - if (!normalizedValue) { - this.showError("Enter a valid npub before adding it as a moderator."); - context.reason = "invalid"; - this.callbacks.onAdminAddModerator(context, this); - 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.showError(this.describeAdminError(errorCode || "service-error")); - context.reason = errorCode || "service-error"; - context.error = mutationResult?.error || mutationResult?.result || null; - return context; - } - - this.moderatorInput.value = ""; - this.showSuccess("Moderator added successfully."); - await this.services.onAccessControlUpdated(); - context.success = true; - context.reason = "added"; - } finally { - if (this.addModeratorButton) { - this.addModeratorButton.disabled = false; - this.addModeratorButton.removeAttribute("aria-busy"); - } - this.callbacks.onAdminAddModerator(context, this); - } - - return context; - } - - async handleRemoveModerator(npub, button) { - const context = { - npub, - normalizedNpub: this.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.showError("Unable to remove moderator: invalid npub."); - context.reason = "invalid"; - releaseButton(); - this.callbacks.onAdminRemoveModerator(context, this); - 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.showError(this.describeAdminError(preloadError.code || "storage-error")); - context.reason = preloadError.code || "storage-error"; - context.error = preloadError; - releaseButton(); - this.callbacks.onAdminRemoveModerator(context, this); - return context; - } - - const actorNpub = this.ensureAdminActor(true); - context.actorNpub = actorNpub; - if (!actorNpub) { - context.reason = "unauthorized"; - releaseButton(); - this.callbacks.onAdminRemoveModerator(context, this); - 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.showError(this.describeAdminError(errorCode || "service-error")); - context.reason = errorCode || "service-error"; - context.error = mutationResult?.error || mutationResult?.result || null; - releaseButton(); - this.callbacks.onAdminRemoveModerator(context, this); - return context; - } - - this.showSuccess("Moderator removed."); - await this.services.onAccessControlUpdated(); - context.success = true; - context.reason = "removed"; - - releaseButton(); - this.callbacks.onAdminRemoveModerator(context, this); - 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.callbacks.onAdminAddWhitelist, - remove: this.callbacks.onAdminRemoveWhitelist, - }, - blacklist: { - add: this.callbacks.onAdminAddBlacklist, - remove: this.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); - }; - - 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.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.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.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.showSuccess(successMessage); - await this.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.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; - } - - describeAdminError(code) { - if (typeof this.describeAdminErrorService === "function") { - const result = this.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.describeNotificationErrorService === "function") { - const result = this.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.sendAdminListNotificationService === "function") { - return this.sendAdminListNotificationService({ listType, actorNpub, targetNpub }); - } - - const normalizedTarget = this.normalizeNpubValue(targetNpub); - if (!normalizedTarget) { - return { ok: false, error: "invalid-target" }; - } - - const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); - if (!activeHex) { - return { ok: false, error: "missing-actor-pubkey" }; - } - - const fallbackActor = this.safeEncodeNpub(activeHex) || "a bitvid moderator"; - const actorDisplay = this.normalizeNpubValue(actorNpub) || fallbackActor; - const isWhitelist = listType === "whitelist"; - - const formatNpub = - typeof this.formatShortNpub === "function" - ? (value) => this.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.bitvidWebsiteUrl}).` - : `This hides your channel and prevents uploads across bitvid (${this.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.adminDmImageUrl})\n\n${messageBody}`; - - return this.services.nostrClient.sendDirectMessage( - normalizedTarget, - message, - activeHex, - ); + sendAdminListNotification(...args) { + return this.adminController.sendAdminListNotification(...args); } async requestSwitchProfile({ pubkey, entry, providerId } = {}) { @@ -7064,82 +5110,36 @@ export class ProfileModalController { } - async runAdminMutation(payload = {}) { - const callback = this.callbacks.onAdminMutation; - if (callback && callback !== noop) { - const result = await callback({ ...payload, controller: this }); - 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.services.accessControl.ensureReady(); - resultContext.ok = true; - break; - case "add-moderator": - resultContext.result = await this.services.accessControl.addModerator( - payload.actorNpub, - payload.targetNpub, - ); - resultContext.ok = !!resultContext.result?.ok; - break; - case "remove-moderator": - resultContext.result = - await this.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.services.accessControl.addToWhitelist( - payload.actorNpub, - payload.targetNpub, - ) - : await this.services.accessControl.removeFromWhitelist( - payload.actorNpub, - payload.targetNpub, - ); - } else { - resultContext.result = payload.mode === "add" - ? await this.services.accessControl.addToBlacklist( - payload.actorNpub, - payload.targetNpub, - ) - : await this.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; + runAdminMutation(...args) { + return this.adminController.runAdminMutation(...args); } - notifyAdminError(payload = {}) { - const callback = this.callbacks.onAdminNotifyError; - if (callback && callback !== noop) { - callback({ ...payload, controller: this }); - } + notifyAdminError(...args) { + return this.adminController.notifyAdminError(...args); + } + + refreshAdminPaneState(...args) { + return this.adminController.refreshAdminPaneState(...args); + } + + refreshModerationSettingsUi(...args) { + return this.moderationController.refreshModerationSettingsUi(...args); + } + + updateModerationTrustStats(...args) { + return this.moderationController.updateModerationTrustStats(...args); + } + + handleAddModerator(...args) { + return this.adminController.handleAddModerator(...args); + } + + handleRemoveModerator(...args) { + return this.adminController.handleRemoveModerator(...args); + } + + handleAdminListMutation(...args) { + return this.adminController.handleAdminListMutation(...args); } updateFocusTrap() { diff --git a/todo/TODO_2026-02-14_10-32-37.md b/todo/TODO_2026-02-14_10-32-37.md new file mode 100644 index 00000000..067fe814 --- /dev/null +++ b/todo/TODO_2026-02-14_10-32-37.md @@ -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.