From 715087992ea993ea908075f459403aa04a4b4b71 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 6 Oct 2025 21:37:21 -0400 Subject: [PATCH] Refine profile modal controller wiring --- js/app.js | 134 +++++++++++ js/ui/profileModalController.js | 382 +++++++++++++++++++++++++++++++- 2 files changed, 511 insertions(+), 5 deletions(-) diff --git a/js/app.js b/js/app.js index 008aadf6..454ef286 100644 --- a/js/app.js +++ b/js/app.js @@ -87,6 +87,7 @@ import { UploadModal } from "./ui/components/UploadModal.js"; import { EditModal } from "./ui/components/EditModal.js"; import { RevertModal } from "./ui/components/RevertModal.js"; import { VideoListView } from "./ui/views/VideoListView.js"; +import ProfileModalController from "./ui/profileModalController.js"; import { MediaLoader } from "./utils/mediaLoader.js"; import { pointerArrayToKey } from "./utils/pointer.js"; import { fakeDecrypt } from "./utils/fakeDecrypt.js"; @@ -326,6 +327,18 @@ class Application { this.profileWalletDisconnectButton = null; this.profileWalletStatusText = null; this.isWalletPaneBusy = false; + this.profileModalCachedSelection = null; + this.profileModalController = null; + this.profileModalStateCache = { + activePane: + typeof this.activeProfilePane === "string" && this.activeProfilePane + ? this.activeProfilePane + : "account", + cachedSelection: null, + walletBusy: Boolean(this.isWalletPaneBusy), + }; + this.profileModalStateAdapter = null; + this.profileModalServiceAdapter = null; this.adminModeratorInput = null; this.adminAddModeratorBtn = null; this.adminModeratorList = null; @@ -1530,6 +1543,19 @@ class Application { if (!modalContainer) { throw new Error("Modal container element not found!"); } + if (!this.profileModalController) { + this.profileModalController = new ProfileModalController({ + modalContainer, + removeTrackingScripts, + createWatchHistoryRenderer, + setGlobalModalState, + showError: (message) => this.showError(message), + showSuccess: (message) => this.showSuccess(message), + showStatus: (message) => this.showStatus(message), + services: this.getProfileModalServices(), + state: this.getProfileModalStateBridge(), + }); + } const wrapper = document.createElement("div"); wrapper.innerHTML = html; removeTrackingScripts(wrapper); @@ -3417,6 +3443,114 @@ class Application { return this.currentUserNpub; } + getProfileModalServices() { + if (!this.profileModalServiceAdapter) { + this.profileModalServiceAdapter = { + normalizeHexPubkey: (value) => this.normalizeHexPubkey(value), + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + safeDecodeNpub: (npub) => this.safeDecodeNpub(npub), + truncateMiddle: (value, maxLength) => truncateMiddle(value, maxLength), + getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), + batchFetchProfiles: (authorSet) => this.batchFetchProfiles(authorSet), + switchProfile: (pubkey) => this.switchProfile(pubkey), + relayManager, + userBlocks, + nostrClient, + accessControl, + getCurrentUserNpub: () => this.getCurrentUserNpub(), + getActiveNwcSettings: () => this.getActiveNwcSettings(), + updateActiveNwcSettings: (partial) => + this.updateActiveNwcSettings(partial), + createDefaultNwcSettings: () => createDefaultNwcSettings(), + ensureWallet: (options) => this.ensureWallet(options), + loadVideos: (forceFetch) => this.loadVideos(forceFetch), + sendAdminListNotification: (payload) => + this.sendAdminListNotification(payload), + describeAdminError: (code) => this.describeAdminError(code), + describeNotificationError: (code) => + this.describeNotificationError(code), + onAccessControlUpdated: () => this.onAccessControlUpdated(), + }; + } + + return this.profileModalServiceAdapter; + } + + getProfileModalStateBridge() { + if (!this.profileModalStateCache) { + this.profileModalStateCache = { + activePane: + typeof this.activeProfilePane === "string" && this.activeProfilePane + ? this.activeProfilePane + : "account", + cachedSelection: this.profileModalCachedSelection || null, + walletBusy: Boolean(this.isWalletPaneBusy), + }; + } else { + this.profileModalStateCache.activePane = + typeof this.activeProfilePane === "string" && this.activeProfilePane + ? this.activeProfilePane + : "account"; + this.profileModalStateCache.walletBusy = Boolean(this.isWalletPaneBusy); + this.profileModalStateCache.cachedSelection = + this.profileModalCachedSelection || null; + } + + if (!this.profileModalStateAdapter) { + const cache = this.profileModalStateCache; + this.profileModalStateAdapter = { + getSavedProfiles: () => getSavedProfiles(), + setSavedProfiles: (profiles, options) => + setSavedProfiles(Array.isArray(profiles) ? profiles : [], options), + persistSavedProfiles: (options) => persistSavedProfiles(options), + getActivePubkey: () => this.activeProfilePubkey, + setActivePubkey: (pubkey, options) => { + setStoredActiveProfilePubkey(pubkey, options); + return this.activeProfilePubkey; + }, + getCachedSelection: () => cache.cachedSelection, + setCachedSelection: (value) => { + const normalized = + typeof value === "string" && value.trim() + ? value.trim() + : null; + cache.cachedSelection = normalized; + this.profileModalCachedSelection = normalized; + return cache.cachedSelection; + }, + getActivePane: () => { + const pane = + typeof this.activeProfilePane === "string" && this.activeProfilePane + ? this.activeProfilePane + : "account"; + cache.activePane = pane; + return pane; + }, + setActivePane: (pane) => { + const normalized = + typeof pane === "string" && pane.trim() + ? pane.trim().toLowerCase() + : "account"; + this.activeProfilePane = normalized; + cache.activePane = normalized; + return this.activeProfilePane; + }, + getWalletBusy: () => { + const busy = Boolean(this.isWalletPaneBusy); + cache.walletBusy = busy; + return busy; + }, + setWalletBusy: (flag) => { + this.isWalletPaneBusy = Boolean(flag); + cache.walletBusy = this.isWalletPaneBusy; + return cache.walletBusy; + }, + }; + } + + return this.profileModalStateAdapter; + } + canCurrentUserManageBlacklist() { const actorNpub = this.getCurrentUserNpub(); if (!actorNpub) { diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 223ec69a..84ef5cab 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -1,5 +1,298 @@ const noop = () => {}; +const SERVICE_CONTRACT = [ + { + key: "normalizeHexPubkey", + type: "function", + description: + "Normalizes a provided pubkey (hex or npub) so profile lookups are stable.", + }, + { + key: "safeEncodeNpub", + type: "function", + description: + "Encodes a hex pubkey as an npub for display. Expected to be resilient to bad input.", + }, + { + key: "safeDecodeNpub", + type: "function", + description: + "Decodes an npub back to its hex form. Should return null/undefined for invalid payloads.", + }, + { + key: "truncateMiddle", + type: "function", + description: + "Utility used for shortening long identifiers in the modal without losing context.", + }, + { + key: "getProfileCacheEntry", + type: "function", + description: + "Returns the cached profile metadata for a normalized pubkey (if available).", + }, + { + key: "batchFetchProfiles", + type: "function", + description: + "Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.", + }, + { + key: "switchProfile", + type: "function", + description: + "Switches the active application profile to the provided pubkey and updates state accordingly.", + }, + { + key: "relayManager", + type: "object", + description: + "Shared relay manager instance responsible for the user\'s relay configuration.", + }, + { + key: "userBlocks", + type: "object", + description: "User blocks helper used to read and mutate the local block list.", + }, + { + key: "nostrClient", + type: "object", + description: "Reference to the nostr client powering subscriptions and profile fetches.", + }, + { + key: "accessControl", + type: "object", + description: + "Access control module required for admin list permission checks and updates.", + }, + { + key: "getCurrentUserNpub", + type: "function", + description: "Returns the active user\'s npub so UI actions can target the correct actor.", + }, + { + key: "getActiveNwcSettings", + type: "function", + description: + "Reads the active Nostr Wallet Connect settings (uri/default zap) for the logged in profile.", + }, + { + key: "updateActiveNwcSettings", + type: "function", + description: "Persists updates to the active wallet settings and returns the new state.", + }, + { + key: "createDefaultNwcSettings", + type: "function", + description: "Creates a baseline wallet settings object when none exists.", + }, + { + key: "ensureWallet", + type: "function", + description: + "Guarantees a wallet connection exists before issuing wallet related operations (connect/test).", + }, + { + key: "loadVideos", + type: "function", + description: "Triggers a video reload so UI reflects profile or permission changes.", + }, + { + key: "sendAdminListNotification", + type: "function", + description: + "Sends an administrative notification when moderators/whitelist/blacklist entries change.", + }, + { + key: "describeAdminError", + type: "function", + description: + "Maps low-level admin errors to readable copy for surfaced error messages.", + }, + { + key: "describeNotificationError", + type: "function", + description: + "Maps notification send errors to human readable descriptions for toast/banner messaging.", + }, + { + key: "onAccessControlUpdated", + type: "function", + description: + "Callback invoked after access control mutations so the app can refresh dependent UI.", + }, +]; + +const STATE_CONTRACT = [ + { + key: "getSavedProfiles", + type: "function", + description: + "Returns the saved profile entries that populate the account switcher UI.", + fallback: (internal) => () => internal.savedProfiles.slice(), + }, + { + key: "setSavedProfiles", + type: "function", + description: + "Replaces the saved profile entries and returns the newly stored collection.", + fallback: (internal) => (profiles = []) => { + internal.savedProfiles = Array.isArray(profiles) + ? profiles.slice() + : []; + return internal.savedProfiles; + }, + }, + { + key: "persistSavedProfiles", + type: "function", + description: + "Persists the saved profile collection (and optionally the active profile) to storage.", + fallback: () => noop, + }, + { + key: "getActivePubkey", + type: "function", + description: "Returns the currently active profile pubkey.", + fallback: (internal) => () => internal.activePubkey, + }, + { + key: "setActivePubkey", + type: "function", + description: "Updates the active profile pubkey and returns the stored value.", + fallback: (internal) => (pubkey) => { + if (typeof pubkey === "string") { + const trimmed = pubkey.trim(); + internal.activePubkey = trimmed ? trimmed : null; + } else { + internal.activePubkey = null; + } + return internal.activePubkey; + }, + }, + { + key: "getCachedSelection", + type: "function", + description: + "Reads the cached profile selection used to restore switcher focus after reloads.", + fallback: (internal) => () => internal.cachedSelection, + }, + { + key: "setCachedSelection", + type: "function", + description: + "Caches the last selected profile identifier and returns the stored value.", + fallback: (internal) => (value) => { + if (typeof value === "string") { + const trimmed = value.trim(); + internal.cachedSelection = trimmed || null; + } else { + internal.cachedSelection = null; + } + return internal.cachedSelection; + }, + }, + { + key: "getActivePane", + type: "function", + description: "Returns the identifier for the currently visible profile modal pane.", + fallback: (internal) => () => internal.activePane, + }, + { + key: "setActivePane", + type: "function", + description: "Updates the active pane identifier and returns the stored value.", + fallback: (internal) => (pane) => { + if (typeof pane === "string") { + const trimmed = pane.trim().toLowerCase(); + internal.activePane = trimmed || "account"; + } else { + internal.activePane = "account"; + } + return internal.activePane; + }, + }, + { + key: "getWalletBusy", + type: "function", + description: + "Indicates whether wallet-related actions are currently in-flight (disables UI as needed).", + fallback: (internal) => () => internal.walletBusy, + }, + { + key: "setWalletBusy", + type: "function", + description: "Updates the wallet busy flag and returns the new boolean state.", + fallback: (internal) => (isBusy) => { + internal.walletBusy = Boolean(isBusy); + return internal.walletBusy; + }, + }, +]; + +function buildServicesContract(services = {}) { + const resolved = {}; + const missing = []; + + SERVICE_CONTRACT.forEach((definition) => { + const value = services[definition.key]; + if (value == null) { + missing.push(`- ${definition.key}: ${definition.description}`); + return; + } + + if (definition.type === "function" && typeof value !== "function") { + throw new TypeError( + `Expected service \"${definition.key}\" to be a function. Received ${typeof value}.`, + ); + } + if (definition.type === "object" && (typeof value !== "object" || value === null)) { + throw new TypeError( + `Expected service \"${definition.key}\" to be an object. Received ${typeof value}.`, + ); + } + + resolved[definition.key] = value; + }); + + if (missing.length) { + throw new Error( + [ + "[ProfileModalController] Missing required services for profile modal controller:", + ...missing, + ].join("\n"), + ); + } + + return Object.freeze({ ...services, ...resolved }); +} + +function buildStateContract(state = {}, internalState) { + const resolved = {}; + + STATE_CONTRACT.forEach((definition) => { + const value = state[definition.key]; + if (typeof value === definition.type) { + resolved[definition.key] = value; + return; + } + + if (definition.fallback) { + const fallback = definition.fallback(internalState); + if (typeof fallback === definition.type) { + resolved[definition.key] = fallback; + return; + } + } + + throw new Error( + `[ProfileModalController] Missing state handler \"${definition.key}\" (${definition.description}).`, + ); + }); + + return { ...state, ...resolved }; +} + export class ProfileModalController { constructor(options = {}) { const { @@ -11,6 +304,8 @@ export class ProfileModalController { showSuccess = noop, showStatus = noop, callbacks = {}, + services = {}, + state = {}, } = options; this.modalContainer = modalContainer; @@ -21,6 +316,39 @@ export class ProfileModalController { this.showSuccess = showSuccess; this.showStatus = showStatus; + this.internalState = { + savedProfiles: [], + activePubkey: null, + cachedSelection: null, + activePane: "account", + walletBusy: false, + }; + + this.services = buildServicesContract(services); + this.state = buildStateContract(state, this.internalState); + + this.normalizeHexPubkey = this.services.normalizeHexPubkey; + this.safeEncodeNpub = this.services.safeEncodeNpub; + this.safeDecodeNpub = this.services.safeDecodeNpub; + this.truncateMiddle = this.services.truncateMiddle; + this.getProfileCacheEntry = this.services.getProfileCacheEntry; + this.batchFetchProfiles = this.services.batchFetchProfiles; + this.switchProfile = this.services.switchProfile; + this.relayManager = this.services.relayManager; + this.userBlocks = this.services.userBlocks; + this.nostrClient = this.services.nostrClient; + this.accessControl = this.services.accessControl; + this.getCurrentUserNpub = this.services.getCurrentUserNpub; + this.getActiveNwcSettings = this.services.getActiveNwcSettings; + this.updateActiveNwcSettings = this.services.updateActiveNwcSettings; + this.createDefaultNwcSettings = this.services.createDefaultNwcSettings; + this.ensureWallet = this.services.ensureWallet; + this.loadVideos = this.services.loadVideos; + this.sendAdminListNotification = this.services.sendAdminListNotification; + this.describeAdminError = this.services.describeAdminError; + this.describeNotificationError = this.services.describeNotificationError; + this.onAccessControlUpdated = this.services.onAccessControlUpdated; + this.callbacks = { onClose: callbacks.onClose || noop, onLogout: callbacks.onLogout || noop, @@ -87,8 +415,8 @@ export class ProfileModalController { this.boundKeydown = null; this.boundFocusIn = null; this.focusableElements = []; - this.activeProfilePane = "account"; - this.isWalletPaneBusy = false; + this.setActivePane(this.getActivePane()); + this.setWalletPaneBusy(this.isWalletBusy()); this.adminEmptyMessages = new Map(); } @@ -111,7 +439,7 @@ export class ProfileModalController { this.cacheDomReferences(); this.registerEventListeners(); this.updateFocusTrap(); - this.callbacks.onPaneShown(this.activeProfilePane, { controller: this }); + this.callbacks.onPaneShown(this.getActivePane(), { controller: this }); return true; } @@ -334,7 +662,7 @@ export class ProfileModalController { selectPane(name = "account") { const normalized = typeof name === "string" ? name.toLowerCase() : "account"; - if (this.activeProfilePane === normalized) { + if (this.getActivePane() === normalized) { this.callbacks.onSelectPane(normalized, { controller: this }); return; } @@ -357,7 +685,7 @@ export class ProfileModalController { } }); - this.activeProfilePane = normalized; + this.setActivePane(normalized); this.updateFocusTrap(); this.callbacks.onSelectPane(normalized, { controller: this }); this.callbacks.onPaneShown(normalized, { controller: this }); @@ -498,6 +826,50 @@ export class ProfileModalController { document.removeEventListener("focusin", this.boundFocusIn); } } + + getSavedProfiles() { + return this.state.getSavedProfiles(); + } + + setSavedProfiles(...args) { + return this.state.setSavedProfiles(...args); + } + + persistSavedProfiles(...args) { + return this.state.persistSavedProfiles(...args); + } + + getActivePubkey() { + return this.state.getActivePubkey(); + } + + setActivePubkey(...args) { + return this.state.setActivePubkey(...args); + } + + getCachedProfileSelection() { + return this.state.getCachedSelection(); + } + + setCachedProfileSelection(...args) { + return this.state.setCachedSelection(...args); + } + + getActivePane() { + return this.state.getActivePane(); + } + + setActivePane(...args) { + return this.state.setActivePane(...args); + } + + isWalletBusy() { + return Boolean(this.state.getWalletBusy()); + } + + setWalletPaneBusy(isBusy) { + return this.state.setWalletBusy(Boolean(isBusy)); + } } export default ProfileModalController;