const noop = () => {}; export class ProfileModalController { constructor(options = {}) { const { modalContainer = null, removeTrackingScripts = noop, createWatchHistoryRenderer = null, setGlobalModalState = noop, showError = noop, showSuccess = noop, showStatus = noop, callbacks = {}, } = options; this.modalContainer = modalContainer; this.removeTrackingScripts = removeTrackingScripts; this.createWatchHistoryRenderer = createWatchHistoryRenderer; this.setGlobalModalState = setGlobalModalState; this.showError = showError; this.showSuccess = showSuccess; this.showStatus = showStatus; this.callbacks = { onClose: callbacks.onClose || noop, onLogout: callbacks.onLogout || noop, onChannelLink: callbacks.onChannelLink || noop, onAddAccount: callbacks.onAddAccount || noop, onSelectPane: callbacks.onSelectPane || noop, onPaneShown: callbacks.onPaneShown || noop, onAddRelay: callbacks.onAddRelay || noop, onRestoreRelays: callbacks.onRestoreRelays || noop, onAddBlocked: callbacks.onAddBlocked || noop, onWalletSave: callbacks.onWalletSave || noop, onWalletTest: callbacks.onWalletTest || noop, onWalletDisconnect: callbacks.onWalletDisconnect || noop, onAdminAddModerator: callbacks.onAdminAddModerator || noop, onAdminAddWhitelist: callbacks.onAdminAddWhitelist || noop, onAdminAddBlacklist: callbacks.onAdminAddBlacklist || noop, onAdminRemoveModerator: callbacks.onAdminRemoveModerator || noop, onAdminRemoveWhitelist: callbacks.onAdminRemoveWhitelist || noop, onAdminRemoveBlacklist: callbacks.onAdminRemoveBlacklist || noop, onHistoryReady: callbacks.onHistoryReady || noop, }; this.profileModal = null; this.closeButton = null; this.logoutButton = null; this.channelLink = null; this.addAccountButton = null; this.navButtons = { account: null, relays: null, wallet: null, blocked: null, history: null, admin: null, }; this.panes = { account: null, relays: null, wallet: null, blocked: null, history: null, admin: null, }; this.relayList = null; this.relayInput = null; this.relayAddButton = null; this.relayRestoreButton = null; this.blockList = null; this.blockInput = null; this.blockAddButton = null; this.walletUriInput = null; this.walletDefaultZapInput = null; this.walletSaveButton = null; this.walletTestButton = null; this.walletDisconnectButton = null; this.adminAddModeratorButton = null; this.adminModeratorInput = null; this.adminAddWhitelistButton = null; this.adminWhitelistInput = null; this.adminAddBlacklistButton = null; this.adminBlacklistInput = null; this.profileHistoryRenderer = null; this.boundKeydown = null; this.boundFocusIn = null; this.focusableElements = []; this.activeProfilePane = "account"; this.isWalletPaneBusy = false; this.adminEmptyMessages = new Map(); } async load() { if (!(this.modalContainer instanceof HTMLElement)) { throw new Error("profile modal container missing"); } const response = await fetch("components/profile-modal.html"); if (!response.ok) { throw new Error(`Failed to load profile modal HTML (${response.status})`); } const html = await response.text(); const wrapper = document.createElement("div"); wrapper.innerHTML = html; this.removeTrackingScripts(wrapper); this.modalContainer.appendChild(wrapper); this.cacheDomReferences(); this.registerEventListeners(); this.updateFocusTrap(); this.callbacks.onPaneShown(this.activeProfilePane, { controller: this }); return true; } cacheDomReferences() { this.profileModal = document.getElementById("profileModal") || null; this.closeButton = document.getElementById("closeProfileModal") || null; this.logoutButton = document.getElementById("profileLogoutBtn") || null; this.channelLink = document.getElementById("profileChannelLink") || null; this.addAccountButton = document.getElementById("profileAddAccountBtn") || null; this.navButtons.account = document.getElementById("profileNavAccount") || null; this.navButtons.relays = document.getElementById("profileNavRelays") || null; this.navButtons.wallet = document.getElementById("profileNavWallet") || null; this.navButtons.blocked = document.getElementById("profileNavBlocked") || null; this.navButtons.history = document.getElementById("profileNavHistory") || null; this.navButtons.admin = document.getElementById("profileNavAdmin") || null; this.panes.account = document.getElementById("profilePaneAccount") || null; this.panes.relays = document.getElementById("profilePaneRelays") || null; this.panes.wallet = document.getElementById("profilePaneWallet") || null; this.panes.blocked = document.getElementById("profilePaneBlocked") || null; this.panes.history = document.getElementById("profilePaneHistory") || null; this.panes.admin = document.getElementById("profilePaneAdmin") || null; this.relayList = document.getElementById("relayList") || null; this.relayInput = document.getElementById("relayInput") || null; this.relayAddButton = document.getElementById("addRelayBtn") || null; this.relayRestoreButton = document.getElementById("restoreRelaysBtn") || null; this.blockList = document.getElementById("blockedList") || null; this.blockInput = document.getElementById("blockedInput") || null; this.blockAddButton = document.getElementById("addBlockedBtn") || null; this.walletUriInput = document.getElementById("profileWalletUri") || null; this.walletDefaultZapInput = document.getElementById("profileWalletDefaultZap") || null; this.walletSaveButton = document.getElementById("profileWalletSave") || null; this.walletTestButton = document.getElementById("profileWalletTest") || null; this.walletDisconnectButton = document.getElementById("profileWalletDisconnect") || null; this.adminAddModeratorButton = document.getElementById("adminAddModeratorBtn") || null; this.adminModeratorInput = document.getElementById("adminModeratorInput") || null; this.adminAddWhitelistButton = document.getElementById("adminAddWhitelistBtn") || null; this.adminWhitelistInput = document.getElementById("adminWhitelistInput") || null; this.adminAddBlacklistButton = document.getElementById("adminAddBlacklistBtn") || null; this.adminBlacklistInput = document.getElementById("adminBlacklistInput") || null; if (!this.profileHistoryRenderer && this.createWatchHistoryRenderer) { this.profileHistoryRenderer = this.createWatchHistoryRenderer({ viewSelector: "#profilePaneHistory", gridSelector: "#profileHistoryGrid", loadingSelector: "#profileHistoryLoading", statusSelector: "#profileHistoryStatus", emptySelector: "#profileHistoryEmpty", sentinelSelector: "#profileHistorySentinel", scrollContainerSelector: "#profileHistoryScroll", errorBannerSelector: "#profileHistoryError", clearButtonSelector: "#profileHistoryClear", republishButtonSelector: "#profileHistoryRepublish", featureBannerSelector: "#profileHistoryFeatureBanner", toastRegionSelector: "#profileHistoryToastRegion", sessionWarningSelector: "#profileHistorySessionWarning", metadataToggleSelector: "#profileHistoryMetadataToggle", metadataThumbSelector: "#profileHistoryMetadataThumb", metadataLabelSelector: "#profileHistoryMetadataLabel", metadataDescriptionSelector: "#profileHistoryMetadataDescription", remove: (payload) => this.callbacks.onHistoryReady(payload, this), }); } } registerEventListeners() { if (this.closeButton instanceof HTMLElement) { this.closeButton.addEventListener("click", () => { this.hide(); this.callbacks.onClose(this); }); } if (this.logoutButton instanceof HTMLElement) { this.logoutButton.addEventListener("click", async () => { try { await this.callbacks.onLogout(this); } catch (error) { this.showError("Failed to logout. Please try again."); } this.hide(); }); } if (this.channelLink instanceof HTMLElement) { this.channelLink.addEventListener("click", (event) => { event.preventDefault(); this.callbacks.onChannelLink(this.channelLink, this); }); } if (this.addAccountButton instanceof HTMLElement) { this.addAccountButton.addEventListener("click", () => { this.callbacks.onAddAccount(this); }); } Object.entries(this.navButtons).forEach(([name, button]) => { if (button instanceof HTMLElement) { button.addEventListener("click", () => { this.selectPane(name); }); } }); if (this.relayAddButton instanceof HTMLElement) { this.relayAddButton.addEventListener("click", () => { this.callbacks.onAddRelay(this.relayInput, this); }); } if (this.relayRestoreButton instanceof HTMLElement) { this.relayRestoreButton.addEventListener("click", () => { this.callbacks.onRestoreRelays(this); }); } if (this.blockAddButton instanceof HTMLElement) { this.blockAddButton.addEventListener("click", () => { this.callbacks.onAddBlocked(this.blockInput, this); }); } if (this.walletUriInput instanceof HTMLElement) { this.walletUriInput.addEventListener("input", () => { this.callbacks.onWalletInputChange?.(this.walletUriInput, this); }); } if (this.walletDefaultZapInput instanceof HTMLElement) { this.walletDefaultZapInput.addEventListener("input", () => { this.callbacks.onWalletInputChange?.(this.walletDefaultZapInput, this); }); } if (this.walletSaveButton instanceof HTMLElement) { this.walletSaveButton.addEventListener("click", () => { this.callbacks.onWalletSave(this); }); } if (this.walletTestButton instanceof HTMLElement) { this.walletTestButton.addEventListener("click", () => { this.callbacks.onWalletTest(this); }); } if (this.walletDisconnectButton instanceof HTMLElement) { this.walletDisconnectButton.addEventListener("click", () => { this.callbacks.onWalletDisconnect(this); }); } if (this.adminAddModeratorButton instanceof HTMLElement) { this.adminAddModeratorButton.addEventListener("click", () => { this.callbacks.onAdminAddModerator(this.adminModeratorInput, this); }); } if (this.adminModeratorInput instanceof HTMLElement) { this.adminModeratorInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); this.callbacks.onAdminAddModerator(this.adminModeratorInput, this); } }); } if (this.adminAddWhitelistButton instanceof HTMLElement) { this.adminAddWhitelistButton.addEventListener("click", () => { this.callbacks.onAdminAddWhitelist(this.adminWhitelistInput, this); }); } if (this.adminWhitelistInput instanceof HTMLElement) { this.adminWhitelistInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); this.callbacks.onAdminAddWhitelist(this.adminWhitelistInput, this); } }); } if (this.adminAddBlacklistButton instanceof HTMLElement) { this.adminAddBlacklistButton.addEventListener("click", () => { this.callbacks.onAdminAddBlacklist(this.adminBlacklistInput, this); }); } if (this.adminBlacklistInput instanceof HTMLElement) { this.adminBlacklistInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); this.callbacks.onAdminAddBlacklist(this.adminBlacklistInput, this); } }); } } selectPane(name = "account") { const normalized = typeof name === "string" ? name.toLowerCase() : "account"; if (this.activeProfilePane === normalized) { this.callbacks.onSelectPane(normalized, { controller: this }); return; } Object.entries(this.panes).forEach(([key, pane]) => { if (pane instanceof HTMLElement) { const isActive = key === normalized; pane.classList.toggle("hidden", !isActive); pane.setAttribute("aria-hidden", String(!isActive)); } }); Object.entries(this.navButtons).forEach(([key, button]) => { if (button instanceof HTMLElement) { const isActive = key === normalized; button.setAttribute("aria-selected", isActive ? "true" : "false"); button.classList.toggle("bg-gray-800", isActive); button.classList.toggle("text-white", isActive); button.classList.toggle("text-gray-400", !isActive); } }); this.activeProfilePane = normalized; this.updateFocusTrap(); this.callbacks.onSelectPane(normalized, { controller: this }); this.callbacks.onPaneShown(normalized, { controller: this }); } updateFocusTrap() { if (!(this.profileModal instanceof HTMLElement)) { this.focusableElements = []; return; } const selector = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; const nodes = Array.from( this.profileModal.querySelectorAll(selector), ); this.focusableElements = nodes.filter((node) => { if (!(node instanceof HTMLElement)) { return false; } const style = window.getComputedStyle(node); if (style.display === "none" || style.visibility === "hidden") { return false; } if (node.hasAttribute("disabled")) { return false; } if (node.getAttribute("aria-hidden") === "true") { return false; } return true; }); this.bindFocusTrap(); } bindFocusTrap() { if (!(this.profileModal instanceof HTMLElement)) { return; } if (!this.boundKeydown) { this.boundKeydown = (event) => { if (event.key === "Escape") { event.preventDefault(); this.hide(); this.callbacks.onClose(this); return; } if (event.key !== "Tab") { return; } if (!this.focusableElements.length) { event.preventDefault(); if (typeof this.profileModal.focus === "function") { this.profileModal.focus(); } return; } const first = this.focusableElements[0]; const last = this.focusableElements[this.focusableElements.length - 1]; const active = document.activeElement; if (event.shiftKey) { if (active === first || !this.profileModal.contains(active)) { event.preventDefault(); if (typeof last?.focus === "function") { last.focus(); } } return; } if (active === last) { event.preventDefault(); if (typeof first?.focus === "function") { first.focus(); } } }; } if (!this.boundFocusIn) { this.boundFocusIn = (event) => { if ( !this.profileModal || this.profileModal.classList.contains("hidden") || this.profileModal.contains(event.target) ) { return; } const target = this.focusableElements[0] || this.profileModal; if (typeof target?.focus === "function") { target.focus(); } }; } this.profileModal.addEventListener("keydown", this.boundKeydown); document.addEventListener("focusin", this.boundFocusIn); } open(pane = "account") { if (!(this.profileModal instanceof HTMLElement)) { return; } this.profileModal.classList.remove("hidden"); this.profileModal.setAttribute("aria-hidden", "false"); this.setGlobalModalState("profile", true); this.selectPane(pane); const focusTarget = this.focusableElements[0] || this.profileModal; window.requestAnimationFrame(() => { if (typeof focusTarget?.focus === "function") { focusTarget.focus(); } }); } hide() { if (!(this.profileModal instanceof HTMLElement)) { return; } this.profileModal.classList.add("hidden"); this.profileModal.setAttribute("aria-hidden", "true"); this.setGlobalModalState("profile", false); if (this.boundKeydown) { this.profileModal.removeEventListener("keydown", this.boundKeydown); } if (this.boundFocusIn) { document.removeEventListener("focusin", this.boundFocusIn); } } } export default ProfileModalController;