From e07d4c4caba81023236e10df7f5e05ad0e8ad191 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 5 Oct 2025 13:52:31 -0400 Subject: [PATCH] Add profile modal controller skeleton --- js/ui/profileModalController.js | 503 ++++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 js/ui/profileModalController.js diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js new file mode 100644 index 00000000..223ec69a --- /dev/null +++ b/js/ui/profileModalController.js @@ -0,0 +1,503 @@ +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;