diff --git a/context/CONTEXT_2026-02-19_19-58-00.md b/context/CONTEXT_2026-02-19_19-58-00.md new file mode 100644 index 00000000..9c5c1f71 --- /dev/null +++ b/context/CONTEXT_2026-02-19_19-58-00.md @@ -0,0 +1,29 @@ +# Decomposition Context + +- **Task:** Decompose `js/ui/profileModalController.js` (6258 lines). +- **Reason:** Largest grandfathered file, highly coupled UI logic. +- **Goal:** Extract "Block List" and "Edit Profile" logic into separate controllers to reduce line count by >200 lines and improve maintainability. +- **Target Branch:** `unstable` (via `agents/daily/...` branch). +- **Plan:** + 1. Extract `ProfileBlockListController` to manage blocked users list, add/remove blocks. + 2. Extract `ProfileEditController` to manage profile editing form, uploads, and saving. + 3. Update `ProfileModalController` to delegate to these new controllers. + +## Selected Blocks + +1. **Block List Management**: + - `populateBlockedList` + - `handleAddBlockedCreator` + - `handleRemoveBlockedCreator` + - `ensureBlockListStatusElement` + - `setBlockListLoadingState` + - `mutateBlocklist` + - Event listeners and DOM cache for block list elements. + +2. **Edit Profile Management**: + - `handleEditProfile` + - `populateEditPane` + - `checkStorageForUploads` + - `handleUpload` + - `handleSaveProfile` + - Event listeners and DOM cache for edit profile elements. diff --git a/decisions/DECISIONS_2026-02-19_19-58-00.md b/decisions/DECISIONS_2026-02-19_19-58-00.md new file mode 100644 index 00000000..3e1b6917 --- /dev/null +++ b/decisions/DECISIONS_2026-02-19_19-58-00.md @@ -0,0 +1,15 @@ +# Decomposition Decisions + +- **File:** `js/ui/profileModalController.js` +- **Extracted Controllers:** + - `js/ui/profileModal/ProfileBlockListController.js` (Block list logic) + - `js/ui/profileModal/ProfileEditController.js` (Edit profile logic) +- **Rationale:** + - Block list management is a distinct feature set (add, remove, list, refresh). + - Edit profile is a self-contained form handling workflow (populate, upload, save). + - Reducing the main controller size improves readability and separation of concerns. +- **Backwards Compatibility:** + - New controllers alias DOM references back to the main controller (`this.mainController.blockList = ...`) to maintain compatibility with tests and potentially other external code that expects these properties on the main controller. + - Delegate methods added to `ProfileModalController` to forward calls to the new controllers, preserving the public API. +- **Baseline Update:** + - `js/ui/profileModalController.js` reduced from 6258 to 5541 lines (-717 lines). diff --git a/docs/agents/task-logs/daily/2026-02-19_19-58-00_decompose-agent_completed.md b/docs/agents/task-logs/daily/2026-02-19_19-58-00_decompose-agent_completed.md new file mode 100644 index 00000000..c79f43d8 --- /dev/null +++ b/docs/agents/task-logs/daily/2026-02-19_19-58-00_decompose-agent_completed.md @@ -0,0 +1,11 @@ +# Decompose Agent Completion Log + +- **Agent:** decompose-agent +- **Date:** 2026-02-19 +- **Status:** Completed +- **Task:** Decompose `js/ui/profileModalController.js`. +- **Result:** + - Extracted `ProfileBlockListController.js` and `ProfileEditController.js`. + - Reduced `js/ui/profileModalController.js` by 717 lines (6258 -> 5541). + - Verified with lint and unit tests. + - Updated file size baseline. diff --git a/js/ui/profileModal/ProfileBlockListController.js b/js/ui/profileModal/ProfileBlockListController.js new file mode 100644 index 00000000..9cb23a38 --- /dev/null +++ b/js/ui/profileModal/ProfileBlockListController.js @@ -0,0 +1,613 @@ +import { devLogger, userLogger } from "../../utils/logger.js"; + +const FALLBACK_PROFILE_AVATAR = "assets/svg/default-profile.svg"; + +export class ProfileBlockListController { + constructor(mainController) { + this.mainController = mainController; + + this.blockList = null; + this.blockListEmpty = null; + this.blockListStatus = null; + this.blockListLoadingState = "idle"; + this.blockInput = null; + this.addBlockedButton = null; + this.profileBlockedRefreshBtn = null; + } + + cacheDomReferences() { + this.blockList = document.getElementById("blockedList") || null; + this.blockListEmpty = document.getElementById("blockedEmpty") || null; + this.blockInput = document.getElementById("blockedInput") || null; + this.addBlockedButton = document.getElementById("addBlockedBtn") || null; + this.profileBlockedRefreshBtn = document.getElementById("blockedRefreshBtn") || null; + + // Attempt to locate status element or create it if missing + this.blockListStatus = + (this.mainController.panes.blocked?.querySelector && + this.mainController.panes.blocked.querySelector("[data-role=\"blocked-list-status\"]")) || + null; + + // Backwards compatibility alias + this.mainController.blockList = this.blockList; + this.mainController.blockListEmpty = this.blockListEmpty; + this.mainController.blockInput = this.blockInput; + this.mainController.addBlockedButton = this.addBlockedButton; + + this.mainController.profileBlockedList = this.blockList; + this.mainController.profileBlockedEmpty = this.blockListEmpty; + this.mainController.profileBlockedInput = this.blockInput; + this.mainController.profileAddBlockedBtn = this.addBlockedButton; + this.mainController.profileBlockedRefreshBtn = this.profileBlockedRefreshBtn; + this.mainController.blockListStatus = this.blockListStatus; + } + + registerEventListeners() { + if (this.addBlockedButton instanceof HTMLElement) { + this.addBlockedButton.addEventListener("click", () => { + void this.handleAddBlockedCreator(); + }); + } + + if (this.profileBlockedRefreshBtn instanceof HTMLElement) { + this.profileBlockedRefreshBtn.addEventListener("click", () => { + const activeHex = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()); + if (!activeHex) { + return; + } + const blocksService = this.mainController.services.userBlocks; + if (!blocksService || typeof blocksService.loadBlocks !== "function") { + return; + } + void blocksService + .loadBlocks(activeHex) + .then(() => { + this.populateBlockedList(); + }) + .catch((error) => { + devLogger.warn("[profileModal] Failed to refresh blocked list:", error); + }); + }); + } + + if (this.blockInput instanceof HTMLElement) { + this.blockInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void this.handleAddBlockedCreator(); + } + }); + } + } + + ensureBlockListStatusElement() { + if (this.blockListStatus instanceof HTMLElement) { + return this.blockListStatus; + } + + const anchor = + this.blockList instanceof HTMLElement + ? this.blockList + : this.blockListEmpty instanceof HTMLElement + ? this.blockListEmpty + : null; + + if (!anchor || !(anchor.parentElement instanceof HTMLElement)) { + return null; + } + + const existing = anchor.parentElement.querySelector( + '[data-role="blocked-list-status"]', + ); + if (existing instanceof HTMLElement) { + this.blockListStatus = existing; + return existing; + } + + const status = document.createElement("div"); + status.dataset.role = "blocked-list-status"; + status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + + if (this.blockList instanceof HTMLElement) { + anchor.parentElement.insertBefore(status, this.blockList); + } else { + anchor.parentElement.appendChild(status); + } + + this.blockListStatus = status; + return status; + } + + setBlockListLoadingState(state = "idle", options = {}) { + const statusEl = this.ensureBlockListStatusElement(); + if (!statusEl) { + this.blockListLoadingState = state; + return; + } + + const message = + typeof options.message === "string" && options.message.trim() + ? options.message.trim() + : ""; + + statusEl.textContent = ""; + statusEl.classList.remove("text-status-warning"); + statusEl.classList.add("text-muted"); + statusEl.classList.add("hidden"); + + this.blockListLoadingState = state; + + if (state === "loading") { + if (this.blockListEmpty instanceof HTMLElement) { + this.blockListEmpty.classList.add("hidden"); + } + + const spinner = document.createElement("span"); + spinner.className = "status-spinner status-spinner--inline"; + spinner.setAttribute("aria-hidden", "true"); + + const text = document.createElement("span"); + text.textContent = message || "Loading blocked creators…"; + + statusEl.appendChild(spinner); + statusEl.appendChild(text); + statusEl.classList.remove("hidden"); + return; + } + + if (state === "error") { + statusEl.classList.remove("text-muted"); + statusEl.classList.add("text-status-warning"); + + if (this.blockListEmpty instanceof HTMLElement) { + this.blockListEmpty.classList.add("hidden"); + } + + const text = document.createElement("span"); + text.textContent = + message || "Blocked creators may be out of date. Try again later."; + + statusEl.appendChild(text); + statusEl.classList.remove("hidden"); + } + } + + populateBlockedList(blocked = null) { + if (!this.blockList || !this.blockListEmpty) { + if (this.blockListLoadingState === "loading") { + this.setBlockListLoadingState("idle"); + } + return; + } + + const sourceEntries = + Array.isArray(blocked) && blocked.length + ? blocked + : this.mainController.services.userBlocks.getBlockedPubkeys(); + + const normalizedEntries = []; + const pushEntry = (hex, label) => { + if (!hex || !label) { + return; + } + normalizedEntries.push({ hex, label }); + }; + + sourceEntries.forEach((entry) => { + if (typeof entry === "string") { + const trimmed = entry.trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("npub1")) { + const decoded = this.mainController.safeDecodeNpub(trimmed); + if (!decoded) { + return; + } + const label = this.mainController.safeEncodeNpub(decoded) || trimmed; + pushEntry(decoded, label); + return; + } + + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + const hex = trimmed.toLowerCase(); + const label = this.mainController.safeEncodeNpub(hex) || hex; + pushEntry(hex, label); + } + return; + } + + if (entry && typeof entry === "object") { + const candidateNpub = + typeof entry.npub === "string" ? entry.npub.trim() : ""; + const candidateHex = + typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; + + if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { + const normalizedHex = candidateHex.toLowerCase(); + const label = + candidateNpub && candidateNpub.startsWith("npub1") + ? candidateNpub + : this.mainController.safeEncodeNpub(normalizedHex) || normalizedHex; + pushEntry(normalizedHex, label); + return; + } + + if (candidateNpub && candidateNpub.startsWith("npub1")) { + const decoded = this.mainController.safeDecodeNpub(candidateNpub); + if (!decoded) { + return; + } + const label = this.mainController.safeEncodeNpub(decoded) || candidateNpub; + pushEntry(decoded, label); + } + } + }); + + const deduped = []; + const seenHex = new Set(); + normalizedEntries.forEach((entry) => { + if (!seenHex.has(entry.hex)) { + seenHex.add(entry.hex); + deduped.push(entry); + } + }); + + this.blockList.textContent = ""; + + if (!deduped.length) { + this.blockListEmpty.classList.remove("hidden"); + this.blockList.classList.add("hidden"); + if (this.blockListLoadingState === "loading") { + this.setBlockListLoadingState("idle"); + } + return; + } + + this.blockListEmpty.classList.add("hidden"); + this.blockList.classList.remove("hidden"); + + const formatNpub = + typeof this.mainController.formatShortNpub === "function" + ? (value) => this.mainController.formatShortNpub(value) + : (value) => (typeof value === "string" ? value : ""); + const entriesNeedingFetch = new Set(); + + deduped.forEach(({ hex, label }) => { + const item = document.createElement("li"); + item.className = + "card flex items-center justify-between gap-4 p-4"; + + let cachedProfile = null; + if (hex) { + const cacheEntry = this.mainController.services.getProfileCacheEntry(hex); + cachedProfile = cacheEntry?.profile || null; + if (!cacheEntry) { + entriesNeedingFetch.add(hex); + } + } + + const encodedNpub = + hex && typeof this.mainController.safeEncodeNpub === "function" + ? this.mainController.safeEncodeNpub(hex) + : label; + const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; + const displayName = + cachedProfile?.name?.trim() || displayNpub || "Blocked profile"; + const avatarSrc = + cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; + + const summary = this.mainController.dmController.createCompactProfileSummary({ + displayName, + displayNpub, + avatarSrc, + }); + + const actions = document.createElement("div"); + actions.className = "flex flex-wrap items-center justify-end gap-2"; + + 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); + } + + const removeButton = this.mainController.createRemoveButton({ + label: "Remove", + onRemove: () => this.handleRemoveBlockedCreator(hex), + }); + if (removeButton) { + removeButton.dataset.blockedHex = hex; + actions.appendChild(removeButton); + } + + item.appendChild(summary); + if (actions.childElementCount > 0) { + item.appendChild(actions); + } + + this.blockList.appendChild(item); + }); + + if (this.blockListLoadingState === "loading") { + this.setBlockListLoadingState("idle"); + } + + if ( + entriesNeedingFetch.size && + typeof this.mainController.services.batchFetchProfiles === "function" + ) { + this.mainController.services.batchFetchProfiles(entriesNeedingFetch); + } + } + + async handleAddBlockedCreator() { + const input = this.blockInput || null; + const rawValue = typeof input?.value === "string" ? input.value : ""; + const trimmed = rawValue.trim(); + + const context = { + input, + rawValue, + value: trimmed, + success: false, + reason: null, + error: null, + }; + + if (!input) { + context.reason = "missing-input"; + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + + if (!trimmed) { + this.mainController.showError("Enter an npub to block."); + context.reason = "empty"; + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + + const activePubkey = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()); + if (!activePubkey) { + this.mainController.showError("Please login to manage your block list."); + context.reason = "no-active-pubkey"; + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + + const actorHex = activePubkey; + let targetHex = ""; + + if (trimmed.startsWith("npub1")) { + targetHex = this.mainController.safeDecodeNpub(trimmed) || ""; + if (!targetHex) { + this.mainController.showError("Invalid npub. Please double-check and try again."); + context.reason = "invalid-npub"; + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + targetHex = trimmed.toLowerCase(); + } else { + this.mainController.showError("Enter a valid npub or hex pubkey."); + context.reason = "invalid-value"; + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + + if (targetHex === actorHex) { + this.mainController.showError("You cannot block yourself."); + context.reason = "self"; + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + + context.targetHex = targetHex; + + try { + const mutationResult = await this.mutateBlocklist({ + action: "add", + actorHex, + targetHex, + controller: this.mainController, + }); + + context.result = mutationResult; + + if (mutationResult?.ok) { + this.mainController.showSuccess( + "Creator blocked. You won't see their videos anymore.", + ); + context.success = true; + context.reason = mutationResult.reason || "blocked"; + } else if (mutationResult?.reason === "already-blocked") { + this.mainController.showSuccess("You already blocked this creator."); + context.reason = "already-blocked"; + } else { + const message = + mutationResult?.error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : mutationResult?.error?.code === + "extension-encryption-permission-denied" + ? "Your Nostr extension must allow encryption to update your mute/block list." + : mutationResult?.error?.message || + "Failed to update your mute/block list. Please try again."; + context.error = mutationResult?.error || null; + context.reason = mutationResult?.reason || "service-error"; + if (message) { + this.mainController.showError(message); + } + } + + if (this.blockInput) { + this.blockInput.value = ""; + } + this.populateBlockedList(); + } catch (error) { + userLogger.error("Failed to add creator to personal block list:", error); + context.error = error; + context.reason = error?.code || "service-error"; + const message = + error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : error?.code === "extension-encryption-permission-denied" + ? "Your Nostr extension must allow encryption to update your mute/block list." + : "Failed to update your mute/block list. Please try again."; + this.mainController.showError(message); + } + + this.mainController.callbacks.onAddBlocked(context, this.mainController); + return context; + } + + async handleRemoveBlockedCreator(candidate) { + const activePubkey = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()); + if (!activePubkey) { + this.mainController.showError("Please login to manage your block list."); + return; + } + + let targetHex = ""; + if (typeof candidate === "string") { + const trimmed = candidate.trim(); + if (!trimmed) { + return; + } + + if (trimmed.startsWith("npub1")) { + targetHex = this.mainController.safeDecodeNpub(trimmed) || ""; + } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { + targetHex = trimmed.toLowerCase(); + } + } + + if (!targetHex) { + userLogger.warn("No valid pubkey to remove from block list:", candidate); + return; + } + + try { + const mutationResult = await this.mutateBlocklist({ + action: "remove", + actorHex: activePubkey, + targetHex, + controller: this.mainController, + }); + + if (mutationResult?.ok) { + this.mainController.showSuccess("Creator removed from your mute/block list."); + } else if (mutationResult?.reason === "not-blocked") { + this.mainController.showSuccess("Creator already removed from your mute/block list."); + } else if (mutationResult?.error) { + const message = + mutationResult.error.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : mutationResult.error.code === + "extension-encryption-permission-denied" + ? "Your Nostr extension must allow encryption to update your mute/block list." + : mutationResult.error.message || + "Failed to update your mute/block list. Please try again."; + if (message) { + this.mainController.showError(message); + } + } + + this.populateBlockedList(); + } catch (error) { + userLogger.error( + "Failed to remove creator from personal block list:", + error, + ); + const message = + error?.code === "nip04-missing" + ? "Your Nostr extension must support NIP-04 to manage private lists." + : error?.code === "extension-encryption-permission-denied" + ? "Your Nostr extension must allow encryption to update your mute/block list." + : "Failed to update your mute/block list. Please try again."; + this.mainController.showError(message); + } + } + + async mutateBlocklist({ action, actorHex, targetHex } = {}) { + const callback = this.mainController.callbacks.onBlocklistMutation; + const noop = () => {}; + if (callback && callback !== noop) { + const result = await callback({ + controller: this.mainController, + action, + actorHex, + targetHex, + }); + if (result !== undefined) { + return result; + } + } + + const context = { ok: false, reason: null, error: null }; + if (!actorHex || !targetHex) { + context.reason = "invalid-target"; + return context; + } + + try { + await this.mainController.services.userBlocks.ensureLoaded(actorHex); + const isBlocked = this.mainController.services.userBlocks.isBlocked(targetHex); + + if (action === "add") { + if (isBlocked) { + context.reason = "already-blocked"; + return context; + } + await this.mainController.services.userBlocks.addBlock(targetHex, actorHex); + context.ok = true; + context.reason = "blocked"; + } else if (action === "remove") { + if (!isBlocked) { + context.reason = "not-blocked"; + return context; + } + await this.mainController.services.userBlocks.removeBlock(targetHex, actorHex); + context.ok = true; + context.reason = "unblocked"; + } else { + context.reason = "invalid-action"; + return context; + } + + if (context.ok) { + try { + await this.mainController.services.onVideosShouldRefresh({ + reason: `blocklist-${action}`, + actorHex, + targetHex, + }); + } catch (refreshError) { + userLogger.warn( + "[ProfileModalController] Failed to refresh videos after blocklist mutation:", + refreshError, + ); + } + } + + return context; + } catch (error) { + context.error = error; + context.reason = error?.code || "service-error"; + return context; + } + } +} diff --git a/js/ui/profileModal/ProfileEditController.js b/js/ui/profileModal/ProfileEditController.js new file mode 100644 index 00000000..a76ce4cb --- /dev/null +++ b/js/ui/profileModal/ProfileEditController.js @@ -0,0 +1,311 @@ +import { buildProfileMetadataEvent } from "../../nostrEventSchemas.js"; +import { buildPublicUrl, buildR2Key } from "../../r2.js"; +import { devLogger } from "../../utils/logger.js"; + +export class ProfileEditController { + constructor(mainController) { + this.mainController = mainController; + + this.profileEditBtn = null; + this.profileEditBackBtn = null; + this.editNameInput = null; + this.editDisplayNameInput = null; + this.editAboutInput = null; + this.editWebsiteInput = null; + this.editNip05Input = null; + this.editLud16Input = null; + this.editPictureInput = null; + this.editBannerInput = null; + + this.editPictureFile = null; + this.editPictureUploadBtn = null; + this.editPictureStorageHint = null; + this.editPictureConfigureLink = null; + + this.editBannerFile = null; + this.editBannerUploadBtn = null; + this.editBannerStorageHint = null; + this.editBannerConfigureLink = null; + + this.editSaveBtn = null; + this.editCancelBtn = null; + this.editStatusText = null; + } + + cacheDomReferences() { + this.profileEditBtn = document.getElementById("profileEditBtn") || null; + this.profileEditBackBtn = document.getElementById("profileEditBackBtn") || null; + + this.editNameInput = document.getElementById("editNameInput") || null; + this.editDisplayNameInput = document.getElementById("editDisplayNameInput") || null; + this.editAboutInput = document.getElementById("editAboutInput") || null; + this.editWebsiteInput = document.getElementById("editWebsiteInput") || null; + this.editNip05Input = document.getElementById("editNip05Input") || null; + this.editLud16Input = document.getElementById("editLud16Input") || null; + this.editPictureInput = document.getElementById("editPictureInput") || null; + this.editBannerInput = document.getElementById("editBannerInput") || null; + + this.editPictureFile = document.getElementById("editPictureFile") || null; + this.editPictureUploadBtn = document.getElementById("editPictureUploadBtn") || null; + this.editPictureStorageHint = document.getElementById("editPictureStorageHint") || null; + this.editPictureConfigureLink = document.getElementById("editPictureConfigureLink") || null; + + this.editBannerFile = document.getElementById("editBannerFile") || null; + this.editBannerUploadBtn = document.getElementById("editBannerUploadBtn") || null; + this.editBannerStorageHint = document.getElementById("editBannerStorageHint") || null; + this.editBannerConfigureLink = document.getElementById("editBannerConfigureLink") || null; + + this.editSaveBtn = document.getElementById("editSaveBtn") || null; + this.editCancelBtn = document.getElementById("editCancelBtn") || null; + this.editStatusText = document.getElementById("editStatusText") || null; + + // Backwards compatibility aliases + this.mainController.profileEditBtn = this.profileEditBtn; + this.mainController.profileEditBackBtn = this.profileEditBackBtn; + this.mainController.editNameInput = this.editNameInput; + this.mainController.editDisplayNameInput = this.editDisplayNameInput; + this.mainController.editAboutInput = this.editAboutInput; + this.mainController.editWebsiteInput = this.editWebsiteInput; + this.mainController.editNip05Input = this.editNip05Input; + this.mainController.editLud16Input = this.editLud16Input; + this.mainController.editPictureInput = this.editPictureInput; + this.mainController.editBannerInput = this.editBannerInput; + this.mainController.editPictureFile = this.editPictureFile; + this.mainController.editPictureUploadBtn = this.editPictureUploadBtn; + this.mainController.editPictureStorageHint = this.editPictureStorageHint; + this.mainController.editPictureConfigureLink = this.editPictureConfigureLink; + this.mainController.editBannerFile = this.editBannerFile; + this.mainController.editBannerUploadBtn = this.editBannerUploadBtn; + this.mainController.editBannerStorageHint = this.editBannerStorageHint; + this.mainController.editBannerConfigureLink = this.editBannerConfigureLink; + this.mainController.editSaveBtn = this.editSaveBtn; + this.mainController.editCancelBtn = this.editCancelBtn; + this.mainController.editStatusText = this.editStatusText; + } + + registerEventListeners() { + if (this.profileEditBtn instanceof HTMLElement) { + this.profileEditBtn.addEventListener("click", () => { + this.handleEditProfile(); + }); + } + + if (this.profileEditBackBtn instanceof HTMLElement) { + this.profileEditBackBtn.addEventListener("click", () => { + this.mainController.selectPane("account"); + }); + } + + if (this.editCancelBtn instanceof HTMLElement) { + this.editCancelBtn.addEventListener("click", () => { + this.mainController.selectPane("account"); + }); + } + + if (this.editSaveBtn instanceof HTMLElement) { + this.editSaveBtn.addEventListener("click", () => { + void this.handleSaveProfile(); + }); + } + + if (this.editPictureUploadBtn instanceof HTMLElement) { + this.editPictureUploadBtn.addEventListener("click", () => { + if (this.editPictureFile) this.editPictureFile.click(); + }); + } + + if (this.editPictureFile instanceof HTMLElement) { + this.editPictureFile.addEventListener("change", () => { + void this.handleUpload("picture"); + }); + } + + if (this.editBannerUploadBtn instanceof HTMLElement) { + this.editBannerUploadBtn.addEventListener("click", () => { + if (this.editBannerFile) this.editBannerFile.click(); + }); + } + + if (this.editBannerFile instanceof HTMLElement) { + this.editBannerFile.addEventListener("change", () => { + void this.handleUpload("banner"); + }); + } + + if (this.editPictureConfigureLink instanceof HTMLElement) { + this.editPictureConfigureLink.addEventListener("click", (e) => { + e.preventDefault(); + this.mainController.selectPane("storage"); + }); + } + + if (this.editBannerConfigureLink instanceof HTMLElement) { + this.editBannerConfigureLink.addEventListener("click", (e) => { + e.preventDefault(); + this.mainController.selectPane("storage"); + }); + } + } + + handleEditProfile() { + this.mainController.selectPane("edit"); + void this.populateEditPane(); + } + + async populateEditPane() { + const pubkey = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()); + if (!pubkey) { + return; + } + + const cacheEntry = this.mainController.services.getProfileCacheEntry(pubkey); + const profile = cacheEntry?.profile || {}; + + if (this.editNameInput) this.editNameInput.value = profile.name || ""; + if (this.editDisplayNameInput) + this.editDisplayNameInput.value = profile.display_name || ""; + if (this.editAboutInput) this.editAboutInput.value = profile.about || ""; + if (this.editWebsiteInput) + this.editWebsiteInput.value = profile.website || ""; + if (this.editNip05Input) this.editNip05Input.value = profile.nip05 || ""; + if (this.editLud16Input) this.editLud16Input.value = profile.lud16 || ""; + if (this.editPictureInput) + this.editPictureInput.value = profile.picture || ""; + if (this.editBannerInput) this.editBannerInput.value = profile.banner || ""; + + void this.checkStorageForUploads(pubkey); + } + + async checkStorageForUploads(pubkey) { + const r2Service = this.mainController.services.r2Service; + if (!r2Service) return; + + let hasStorage = false; + try { + const credentials = await r2Service.resolveConnection(pubkey); + hasStorage = !!credentials; + } catch (e) { + hasStorage = false; + } + + const updateUI = (uploadBtn, hint, has) => { + if (uploadBtn) { + uploadBtn.disabled = !has; + if (!has) uploadBtn.setAttribute("aria-disabled", "true"); + else uploadBtn.removeAttribute("aria-disabled"); + } + if (hint) { + if (has) hint.classList.add("hidden"); + else hint.classList.remove("hidden"); + } + }; + + updateUI( + this.editPictureUploadBtn, + this.editPictureStorageHint, + hasStorage, + ); + updateUI(this.editBannerUploadBtn, this.editBannerStorageHint, hasStorage); + } + + async handleUpload(type) { + const pubkey = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()); + if (!pubkey) return; + + const r2Service = this.mainController.services.r2Service; + if (!r2Service) return; + + const fileInput = + type === "picture" ? this.editPictureFile : this.editBannerFile; + const urlInput = + type === "picture" ? this.editPictureInput : this.editBannerInput; + const uploadBtn = + type === "picture" ? this.editPictureUploadBtn : this.editBannerUploadBtn; + + if (!fileInput || !fileInput.files.length) return; + const file = fileInput.files[0]; + + try { + if (uploadBtn) { + uploadBtn.disabled = true; + uploadBtn.textContent = "Uploading..."; + } + + const credentials = await r2Service.resolveConnection(pubkey); + if (!credentials) { + this.mainController.showError("Storage configuration missing."); + return; + } + + const key = buildR2Key(pubkey, file); + await r2Service.uploadFile({ + file, + ...credentials, + bucket: credentials.bucket, + key, + }); + + const url = buildPublicUrl(credentials.baseDomain, key); + if (urlInput) urlInput.value = url; + + fileInput.value = ""; + } catch (error) { + this.mainController.showError("Upload failed: " + (error.message || "Unknown error")); + devLogger.error("Upload error:", error); + } finally { + if (uploadBtn) { + uploadBtn.disabled = false; + uploadBtn.textContent = "Upload"; + } + } + } + + async handleSaveProfile() { + const pubkey = this.mainController.normalizeHexPubkey(this.mainController.getActivePubkey()); + if (!pubkey) return; + + const profile = { + name: this.editNameInput?.value?.trim() || "", + display_name: this.editDisplayNameInput?.value?.trim() || "", + about: this.editAboutInput?.value?.trim() || "", + website: this.editWebsiteInput?.value?.trim() || "", + nip05: this.editNip05Input?.value?.trim() || "", + lud16: this.editLud16Input?.value?.trim() || "", + picture: this.editPictureInput?.value?.trim() || "", + banner: this.editBannerInput?.value?.trim() || "", + }; + + if (this.editSaveBtn) { + this.editSaveBtn.disabled = true; + this.editSaveBtn.textContent = "Saving..."; + } + + try { + const event = buildProfileMetadataEvent({ + pubkey, + created_at: Math.floor(Date.now() / 1000), + metadata: profile, + }); + + const result = + await this.mainController.services.nostrClient.signAndPublishEvent(event); + + if (result && result.signedEvent) { + if (this.mainController.services.nostrClient.handleEvent) { + this.mainController.services.nostrClient.handleEvent(result.signedEvent); + } + } + + this.mainController.showSuccess("Profile updated!"); + this.mainController.selectPane("account"); + this.mainController.renderSavedProfiles(); + } catch (error) { + this.mainController.showError("Failed to save profile: " + error.message); + } finally { + if (this.editSaveBtn) { + this.editSaveBtn.disabled = false; + this.editSaveBtn.textContent = "Save Profile"; + } + } + } +} diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index bc5368c8..470338f8 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -24,8 +24,6 @@ import { getBreakpointLg } from "../designSystem/metrics.js"; import { getProviderMetadata } from "../services/authProviders/index.js"; import { AppShell } from "./dm/index.js"; import { devLogger, userLogger } from "../utils/logger.js"; -import { buildPublicUrl, buildR2Key } from "../r2.js"; -import { buildProfileMetadataEvent } from "../nostrEventSchemas.js"; import { describeAttachment, extractAttachmentsFromMessage, @@ -50,6 +48,8 @@ import { ProfileRelayController } from "./profileModal/ProfileRelayController.js import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js"; import { ProfileAdminController } from "./profileModal/ProfileAdminController.js"; import { ProfileModerationController } from "./profileModal/ProfileModerationController.js"; +import { ProfileBlockListController } from "./profileModal/ProfileBlockListController.js"; +import { ProfileEditController } from "./profileModal/ProfileEditController.js"; const noop = () => {}; @@ -211,6 +211,8 @@ export class ProfileModalController { this.hashtagController = new ProfileHashtagController(this); this.adminController = new ProfileAdminController(this); this.moderationController = new ProfileModerationController(this); + this.blockListController = new ProfileBlockListController(this); + this.editController = new ProfileEditController(this); this.hashtagController.initialize(); this.dmController.enableNip17RelayWarning = resolvedEnableNip17RelayWarning; @@ -726,12 +728,7 @@ export class ProfileModalController { this.profileFriendsRefreshBtn = document.getElementById("friendsRefreshBtn") || null; - this.blockList = document.getElementById("blockedList") || null; - this.blockListEmpty = document.getElementById("blockedEmpty") || null; - this.blockInput = document.getElementById("blockedInput") || null; - this.addBlockedButton = document.getElementById("addBlockedBtn") || null; - this.profileBlockedRefreshBtn = - document.getElementById("blockedRefreshBtn") || null; + this.blockListController.cacheDomReferences(); this.dmController.profileMessagesPane = document.getElementById("profilePaneMessages") || null; @@ -831,13 +828,6 @@ export class ProfileModalController { this.profileFriendsEmpty = this.friendListEmpty; this.profileFriendsInput = this.friendInput; this.profileAddFriendBtn = this.addFriendButton; - this.profileBlockedList = this.blockList; - this.profileBlockedEmpty = this.blockListEmpty; - this.profileBlockedInput = this.blockInput; - this.profileAddBlockedBtn = this.addBlockedButton; - this.blockListStatus = - this.panes.blocked?.querySelector("[data-role=\"blocked-list-status\"]") || - null; this.moderationController.cacheDomReferences(); this.adminController.cacheDomReferences(); @@ -887,31 +877,10 @@ export class ProfileModalController { ); ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions"); ensureAriaLabel(this.profileFriendsRefreshBtn, "Refresh friends"); + + this.editController.cacheDomReferences(); ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh muted & blocked users"); - this.editNameInput = document.getElementById("editNameInput") || null; - this.editDisplayNameInput = document.getElementById("editDisplayNameInput") || null; - this.editAboutInput = document.getElementById("editAboutInput") || null; - this.editWebsiteInput = document.getElementById("editWebsiteInput") || null; - this.editNip05Input = document.getElementById("editNip05Input") || null; - this.editLud16Input = document.getElementById("editLud16Input") || null; - this.editPictureInput = document.getElementById("editPictureInput") || null; - this.editBannerInput = document.getElementById("editBannerInput") || null; - - this.editPictureFile = document.getElementById("editPictureFile") || null; - this.editPictureUploadBtn = document.getElementById("editPictureUploadBtn") || null; - this.editPictureStorageHint = document.getElementById("editPictureStorageHint") || null; - this.editPictureConfigureLink = document.getElementById("editPictureConfigureLink") || null; - - this.editBannerFile = document.getElementById("editBannerFile") || null; - this.editBannerUploadBtn = document.getElementById("editBannerUploadBtn") || null; - this.editBannerStorageHint = document.getElementById("editBannerStorageHint") || null; - this.editBannerConfigureLink = document.getElementById("editBannerConfigureLink") || null; - - this.editSaveBtn = document.getElementById("editSaveBtn") || null; - this.editCancelBtn = document.getElementById("editCancelBtn") || null; - this.editStatusText = document.getElementById("editStatusText") || null; - if (this.createWatchHistoryRenderer) { this.ensureProfileHistoryRenderer(); } @@ -1692,41 +1661,7 @@ export class ProfileModalController { }); } - if (this.addBlockedButton instanceof HTMLElement) { - this.addBlockedButton.addEventListener("click", () => { - void this.handleAddBlockedCreator(); - }); - } - - if (this.profileBlockedRefreshBtn instanceof HTMLElement) { - this.profileBlockedRefreshBtn.addEventListener("click", () => { - const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); - if (!activeHex) { - return; - } - const blocksService = this.services.userBlocks; - if (!blocksService || typeof blocksService.loadBlocks !== "function") { - return; - } - void blocksService - .loadBlocks(activeHex) - .then(() => { - this.populateBlockedList(); - }) - .catch((error) => { - devLogger.warn("[profileModal] Failed to refresh blocked list:", error); - }); - }); - } - - if (this.blockInput instanceof HTMLElement) { - this.blockInput.addEventListener("keydown", (event) => { - if (event.key === "Enter") { - event.preventDefault(); - void this.handleAddBlockedCreator(); - } - }); - } + this.blockListController.registerEventListeners(); if (this.profileSubscriptionsRefreshBtn instanceof HTMLElement) { this.profileSubscriptionsRefreshBtn.addEventListener("click", () => { @@ -1954,67 +1889,7 @@ export class ProfileModalController { } - if (this.profileEditBtn instanceof HTMLElement) { - this.profileEditBtn.addEventListener("click", () => { - this.handleEditProfile(); - }); - } - - if (this.profileEditBackBtn instanceof HTMLElement) { - this.profileEditBackBtn.addEventListener("click", () => { - this.selectPane("account"); - }); - } - - if (this.editCancelBtn instanceof HTMLElement) { - this.editCancelBtn.addEventListener("click", () => { - this.selectPane("account"); - }); - } - - if (this.editSaveBtn instanceof HTMLElement) { - this.editSaveBtn.addEventListener("click", () => { - void this.handleSaveProfile(); - }); - } - - if (this.editPictureUploadBtn instanceof HTMLElement) { - this.editPictureUploadBtn.addEventListener("click", () => { - if (this.editPictureFile) this.editPictureFile.click(); - }); - } - - if (this.editPictureFile instanceof HTMLElement) { - this.editPictureFile.addEventListener("change", () => { - void this.handleUpload("picture"); - }); - } - - if (this.editBannerUploadBtn instanceof HTMLElement) { - this.editBannerUploadBtn.addEventListener("click", () => { - if (this.editBannerFile) this.editBannerFile.click(); - }); - } - - if (this.editBannerFile instanceof HTMLElement) { - this.editBannerFile.addEventListener("change", () => { - void this.handleUpload("banner"); - }); - } - - if (this.editPictureConfigureLink instanceof HTMLElement) { - this.editPictureConfigureLink.addEventListener("click", (e) => { - e.preventDefault(); - this.selectPane("storage"); - }); - } - - if (this.editBannerConfigureLink instanceof HTMLElement) { - this.editBannerConfigureLink.addEventListener("click", (e) => { - e.preventDefault(); - this.selectPane("storage"); - }); - } + this.editController.registerEventListeners(); this.dmController.updateMessagesReloadState(); } @@ -3390,98 +3265,8 @@ export class ProfileModalController { - ensureBlockListStatusElement() { - if (this.blockListStatus instanceof HTMLElement) { - return this.blockListStatus; - } - - const anchor = - this.blockList instanceof HTMLElement - ? this.blockList - : this.blockListEmpty instanceof HTMLElement - ? this.blockListEmpty - : null; - - if (!anchor || !(anchor.parentElement instanceof HTMLElement)) { - return null; - } - - const existing = anchor.parentElement.querySelector( - '[data-role="blocked-list-status"]', - ); - if (existing instanceof HTMLElement) { - this.blockListStatus = existing; - return existing; - } - - const status = document.createElement("div"); - status.dataset.role = "blocked-list-status"; - status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden"; - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - - if (this.blockList instanceof HTMLElement) { - anchor.parentElement.insertBefore(status, this.blockList); - } else { - anchor.parentElement.appendChild(status); - } - - this.blockListStatus = status; - return status; - } - - setBlockListLoadingState(state = "idle", options = {}) { - const statusEl = this.ensureBlockListStatusElement(); - if (!statusEl) { - this.blockListLoadingState = state; - return; - } - - const message = - typeof options.message === "string" && options.message.trim() - ? options.message.trim() - : ""; - - statusEl.textContent = ""; - statusEl.classList.remove("text-status-warning"); - statusEl.classList.add("text-muted"); - statusEl.classList.add("hidden"); - - this.blockListLoadingState = state; - - if (state === "loading") { - if (this.blockListEmpty instanceof HTMLElement) { - this.blockListEmpty.classList.add("hidden"); - } - - const spinner = document.createElement("span"); - spinner.className = "status-spinner status-spinner--inline"; - spinner.setAttribute("aria-hidden", "true"); - - const text = document.createElement("span"); - text.textContent = message || "Loading blocked creators…"; - - statusEl.appendChild(spinner); - statusEl.appendChild(text); - statusEl.classList.remove("hidden"); - return; - } - - if (state === "error") { - statusEl.classList.remove("text-muted"); - statusEl.classList.add("text-status-warning"); - - if (this.blockListEmpty instanceof HTMLElement) { - this.blockListEmpty.classList.add("hidden"); - } - - const text = document.createElement("span"); - text.textContent = - message || "Blocked creators may be out of date. Try again later."; - - statusEl.appendChild(text); - statusEl.classList.remove("hidden"); - } + setBlockListLoadingState(...args) { + return this.blockListController.setBlockListLoadingState(...args); } setPermissionPromptCtaState(nextState = {}) { @@ -3689,184 +3474,8 @@ export class ProfileModalController { this.refreshSubscriptionsBackgroundStatus(); } - populateBlockedList(blocked = null) { - if (!this.blockList || !this.blockListEmpty) { - if (this.blockListLoadingState === "loading") { - this.setBlockListLoadingState("idle"); - } - return; - } - - const sourceEntries = - Array.isArray(blocked) && blocked.length - ? blocked - : this.services.userBlocks.getBlockedPubkeys(); - - const normalizedEntries = []; - const pushEntry = (hex, label) => { - if (!hex || !label) { - return; - } - normalizedEntries.push({ hex, label }); - }; - - sourceEntries.forEach((entry) => { - if (typeof entry === "string") { - const trimmed = entry.trim(); - if (!trimmed) { - return; - } - - if (trimmed.startsWith("npub1")) { - const decoded = this.safeDecodeNpub(trimmed); - if (!decoded) { - return; - } - const label = this.safeEncodeNpub(decoded) || trimmed; - pushEntry(decoded, label); - return; - } - - if (/^[0-9a-f]{64}$/i.test(trimmed)) { - const hex = trimmed.toLowerCase(); - const label = this.safeEncodeNpub(hex) || hex; - pushEntry(hex, label); - } - return; - } - - if (entry && typeof entry === "object") { - const candidateNpub = - typeof entry.npub === "string" ? entry.npub.trim() : ""; - const candidateHex = - typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; - - if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { - const normalizedHex = candidateHex.toLowerCase(); - const label = - candidateNpub && candidateNpub.startsWith("npub1") - ? candidateNpub - : this.safeEncodeNpub(normalizedHex) || normalizedHex; - pushEntry(normalizedHex, label); - return; - } - - if (candidateNpub && candidateNpub.startsWith("npub1")) { - const decoded = this.safeDecodeNpub(candidateNpub); - if (!decoded) { - return; - } - const label = this.safeEncodeNpub(decoded) || candidateNpub; - pushEntry(decoded, label); - } - } - }); - - const deduped = []; - const seenHex = new Set(); - normalizedEntries.forEach((entry) => { - if (!seenHex.has(entry.hex)) { - seenHex.add(entry.hex); - deduped.push(entry); - } - }); - - this.blockList.textContent = ""; - - if (!deduped.length) { - this.blockListEmpty.classList.remove("hidden"); - this.blockList.classList.add("hidden"); - if (this.blockListLoadingState === "loading") { - this.setBlockListLoadingState("idle"); - } - return; - } - - this.blockListEmpty.classList.add("hidden"); - this.blockList.classList.remove("hidden"); - - const formatNpub = - typeof this.formatShortNpub === "function" - ? (value) => this.formatShortNpub(value) - : (value) => (typeof value === "string" ? value : ""); - const entriesNeedingFetch = new Set(); - - deduped.forEach(({ hex, label }) => { - const item = document.createElement("li"); - item.className = - "card flex items-center justify-between gap-4 p-4"; - - let cachedProfile = null; - if (hex) { - const cacheEntry = this.services.getProfileCacheEntry(hex); - cachedProfile = cacheEntry?.profile || null; - if (!cacheEntry) { - entriesNeedingFetch.add(hex); - } - } - - const encodedNpub = - hex && typeof this.safeEncodeNpub === "function" - ? this.safeEncodeNpub(hex) - : label; - const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; - const displayName = - cachedProfile?.name?.trim() || displayNpub || "Blocked profile"; - const avatarSrc = - cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; - - const summary = this.dmController.createCompactProfileSummary({ - displayName, - displayNpub, - avatarSrc, - }); - - const actions = document.createElement("div"); - actions.className = "flex flex-wrap items-center justify-end gap-2"; - - const viewButton = this.createViewChannelButton({ - targetNpub: encodedNpub, - displayNpub, - }); - if (viewButton) { - actions.appendChild(viewButton); - } - - const copyButton = this.createCopyNpubButton({ - targetNpub: encodedNpub, - displayNpub, - }); - if (copyButton) { - actions.appendChild(copyButton); - } - - const removeButton = this.createRemoveButton({ - label: "Remove", - onRemove: () => this.handleRemoveBlockedCreator(hex), - }); - if (removeButton) { - removeButton.dataset.blockedHex = hex; - actions.appendChild(removeButton); - } - - item.appendChild(summary); - if (actions.childElementCount > 0) { - item.appendChild(actions); - } - - this.blockList.appendChild(item); - }); - - if (this.blockListLoadingState === "loading") { - this.setBlockListLoadingState("idle"); - } - - if ( - entriesNeedingFetch.size && - typeof this.services.batchFetchProfiles === "function" - ) { - this.services.batchFetchProfiles(entriesNeedingFetch); - } + populateBlockedList(...args) { + return this.blockListController.populateBlockedList(...args); } async populateSubscriptionsList(subscriptions = null) { @@ -4556,354 +4165,28 @@ export class ProfileModalController { return { success: true, reason: successReason }; } - async handleAddBlockedCreator() { - const input = this.blockInput || null; - const rawValue = typeof input?.value === "string" ? input.value : ""; - const trimmed = rawValue.trim(); - - const context = { - input, - rawValue, - value: trimmed, - success: false, - reason: null, - error: null, - }; - - if (!input) { - context.reason = "missing-input"; - this.callbacks.onAddBlocked(context, this); - return context; - } - - if (!trimmed) { - this.showError("Enter an npub to block."); - context.reason = "empty"; - this.callbacks.onAddBlocked(context, this); - return context; - } - - const activePubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (!activePubkey) { - this.showError("Please login to manage your block list."); - context.reason = "no-active-pubkey"; - this.callbacks.onAddBlocked(context, this); - return context; - } - - const actorHex = activePubkey; - let targetHex = ""; - - if (trimmed.startsWith("npub1")) { - targetHex = this.safeDecodeNpub(trimmed) || ""; - if (!targetHex) { - this.showError("Invalid npub. Please double-check and try again."); - context.reason = "invalid-npub"; - this.callbacks.onAddBlocked(context, this); - return context; - } - } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { - targetHex = trimmed.toLowerCase(); - } else { - this.showError("Enter a valid npub or hex pubkey."); - context.reason = "invalid-value"; - this.callbacks.onAddBlocked(context, this); - return context; - } - - if (targetHex === actorHex) { - this.showError("You cannot block yourself."); - context.reason = "self"; - this.callbacks.onAddBlocked(context, this); - return context; - } - - context.targetHex = targetHex; - - try { - const mutationResult = await this.mutateBlocklist({ - action: "add", - actorHex, - targetHex, - controller: this, - }); - - context.result = mutationResult; - - if (mutationResult?.ok) { - this.showSuccess( - "Creator blocked. You won't see their videos anymore.", - ); - context.success = true; - context.reason = mutationResult.reason || "blocked"; - } else if (mutationResult?.reason === "already-blocked") { - this.showSuccess("You already blocked this creator."); - context.reason = "already-blocked"; - } else { - const message = - mutationResult?.error?.code === "nip04-missing" - ? "Your Nostr extension must support NIP-04 to manage private lists." - : mutationResult?.error?.code === - "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your mute/block list." - : mutationResult?.error?.message || - "Failed to update your mute/block list. Please try again."; - context.error = mutationResult?.error || null; - context.reason = mutationResult?.reason || "service-error"; - if (message) { - this.showError(message); - } - } - - if (this.blockInput) { - this.blockInput.value = ""; - } - this.populateBlockedList(); - } catch (error) { - userLogger.error("Failed to add creator to personal block list:", error); - context.error = error; - context.reason = error?.code || "service-error"; - const message = - error?.code === "nip04-missing" - ? "Your Nostr extension must support NIP-04 to manage private lists." - : error?.code === "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your mute/block list." - : "Failed to update your mute/block list. Please try again."; - this.showError(message); - } - - this.callbacks.onAddBlocked(context, this); - return context; + handleAddBlockedCreator(...args) { + return this.blockListController.handleAddBlockedCreator(...args); } - async handleRemoveBlockedCreator(candidate) { - const activePubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (!activePubkey) { - this.showError("Please login to manage your block list."); - return; - } - - let targetHex = ""; - if (typeof candidate === "string") { - const trimmed = candidate.trim(); - if (!trimmed) { - return; - } - - if (trimmed.startsWith("npub1")) { - targetHex = this.safeDecodeNpub(trimmed) || ""; - } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { - targetHex = trimmed.toLowerCase(); - } - } - - if (!targetHex) { - userLogger.warn("No valid pubkey to remove from block list:", candidate); - return; - } - - try { - const mutationResult = await this.mutateBlocklist({ - action: "remove", - actorHex: activePubkey, - targetHex, - controller: this, - }); - - if (mutationResult?.ok) { - this.showSuccess("Creator removed from your mute/block list."); - } else if (mutationResult?.reason === "not-blocked") { - this.showSuccess("Creator already removed from your mute/block list."); - } else if (mutationResult?.error) { - const message = - mutationResult.error.code === "nip04-missing" - ? "Your Nostr extension must support NIP-04 to manage private lists." - : mutationResult.error.code === - "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your mute/block list." - : mutationResult.error.message || - "Failed to update your mute/block list. Please try again."; - if (message) { - this.showError(message); - } - } - - this.populateBlockedList(); - } catch (error) { - userLogger.error( - "Failed to remove creator from personal block list:", - error, - ); - const message = - error?.code === "nip04-missing" - ? "Your Nostr extension must support NIP-04 to manage private lists." - : error?.code === "extension-encryption-permission-denied" - ? "Your Nostr extension must allow encryption to update your mute/block list." - : "Failed to update your mute/block list. Please try again."; - this.showError(message); - } + handleRemoveBlockedCreator(...args) { + return this.blockListController.handleRemoveBlockedCreator(...args); } - handleEditProfile() { - this.selectPane("edit"); - void this.populateEditPane(); + handleEditProfile(...args) { + return this.editController.handleEditProfile(...args); } - async populateEditPane() { - const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (!pubkey) { - return; - } - - const cacheEntry = this.services.getProfileCacheEntry(pubkey); - const profile = cacheEntry?.profile || {}; - - if (this.editNameInput) this.editNameInput.value = profile.name || ""; - if (this.editDisplayNameInput) - this.editDisplayNameInput.value = profile.display_name || ""; - if (this.editAboutInput) this.editAboutInput.value = profile.about || ""; - if (this.editWebsiteInput) - this.editWebsiteInput.value = profile.website || ""; - if (this.editNip05Input) this.editNip05Input.value = profile.nip05 || ""; - if (this.editLud16Input) this.editLud16Input.value = profile.lud16 || ""; - if (this.editPictureInput) - this.editPictureInput.value = profile.picture || ""; - if (this.editBannerInput) this.editBannerInput.value = profile.banner || ""; - - void this.checkStorageForUploads(pubkey); + populateEditPane(...args) { + return this.editController.populateEditPane(...args); } - async checkStorageForUploads(pubkey) { - const r2Service = this.services.r2Service; - if (!r2Service) return; - - let hasStorage = false; - try { - const credentials = await r2Service.resolveConnection(pubkey); - hasStorage = !!credentials; - } catch (e) { - hasStorage = false; - } - - const updateUI = (uploadBtn, hint, has) => { - if (uploadBtn) { - uploadBtn.disabled = !has; - if (!has) uploadBtn.setAttribute("aria-disabled", "true"); - else uploadBtn.removeAttribute("aria-disabled"); - } - if (hint) { - if (has) hint.classList.add("hidden"); - else hint.classList.remove("hidden"); - } - }; - - updateUI( - this.editPictureUploadBtn, - this.editPictureStorageHint, - hasStorage, - ); - updateUI(this.editBannerUploadBtn, this.editBannerStorageHint, hasStorage); + async handleUpload(...args) { + return this.editController.handleUpload(...args); } - async handleUpload(type) { - const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (!pubkey) return; - - const r2Service = this.services.r2Service; - if (!r2Service) return; - - const fileInput = - type === "picture" ? this.editPictureFile : this.editBannerFile; - const urlInput = - type === "picture" ? this.editPictureInput : this.editBannerInput; - const uploadBtn = - type === "picture" ? this.editPictureUploadBtn : this.editBannerUploadBtn; - - if (!fileInput || !fileInput.files.length) return; - const file = fileInput.files[0]; - - try { - if (uploadBtn) { - uploadBtn.disabled = true; - uploadBtn.textContent = "Uploading..."; - } - - const credentials = await r2Service.resolveConnection(pubkey); - if (!credentials) { - this.showError("Storage configuration missing."); - return; - } - - const key = buildR2Key(pubkey, file); - await r2Service.uploadFile({ - file, - ...credentials, - bucket: credentials.bucket, - key, - }); - - const url = buildPublicUrl(credentials.baseDomain, key); - if (urlInput) urlInput.value = url; - - fileInput.value = ""; - } catch (error) { - this.showError("Upload failed: " + (error.message || "Unknown error")); - devLogger.error("Upload error:", error); - } finally { - if (uploadBtn) { - uploadBtn.disabled = false; - uploadBtn.textContent = "Upload"; - } - } - } - - async handleSaveProfile() { - const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); - if (!pubkey) return; - - const profile = { - name: this.editNameInput?.value?.trim() || "", - display_name: this.editDisplayNameInput?.value?.trim() || "", - about: this.editAboutInput?.value?.trim() || "", - website: this.editWebsiteInput?.value?.trim() || "", - nip05: this.editNip05Input?.value?.trim() || "", - lud16: this.editLud16Input?.value?.trim() || "", - picture: this.editPictureInput?.value?.trim() || "", - banner: this.editBannerInput?.value?.trim() || "", - }; - - if (this.editSaveBtn) { - this.editSaveBtn.disabled = true; - this.editSaveBtn.textContent = "Saving..."; - } - - try { - const event = buildProfileMetadataEvent({ - pubkey, - created_at: Math.floor(Date.now() / 1000), - metadata: profile, - }); - - const result = - await this.services.nostrClient.signAndPublishEvent(event); - - if (result && result.signedEvent) { - if (this.services.nostrClient.handleEvent) { - this.services.nostrClient.handleEvent(result.signedEvent); - } - } - - this.showSuccess("Profile updated!"); - this.selectPane("account"); - this.renderSavedProfiles(); - } catch (error) { - this.showError("Failed to save profile: " + error.message); - } finally { - if (this.editSaveBtn) { - this.editSaveBtn.disabled = false; - this.editSaveBtn.textContent = "Save Profile"; - } - } + async handleSaveProfile(...args) { + return this.editController.handleSaveProfile(...args); } diff --git a/test_logs/TEST_LOG_2026-02-19_19-58-00.md b/test_logs/TEST_LOG_2026-02-19_19-58-00.md new file mode 100644 index 00000000..59a3f89f --- /dev/null +++ b/test_logs/TEST_LOG_2026-02-19_19-58-00.md @@ -0,0 +1,17 @@ +# Test Log + +## Commands Run + +1. `npm run lint` + - Result: Passed (ignoring assets check). +2. `node scripts/run-targeted-tests.mjs tests/profile-modal-controller.test.mjs` + - Result: Passed (18/18 tests). + - Initial failure due to missing alias for `this.blockList` on main controller. Fixed by adding aliases in `ProfileBlockListController.js`. +3. `node scripts/check-file-size.mjs --report` + - Result: `js/ui/profileModalController.js` is now 5541 lines. + +## Manual Verification Notes + +- Code inspection confirmed extracted logic matches original behavior. +- Delegate methods ensure API compatibility. +- Constructor instantiation order preserved (new controllers added before `hashtagController` initialization). diff --git a/todo/TODO_2026-02-19_19-58-00.md b/todo/TODO_2026-02-19_19-58-00.md new file mode 100644 index 00000000..29822133 --- /dev/null +++ b/todo/TODO_2026-02-19_19-58-00.md @@ -0,0 +1,26 @@ +# Decomposition Todo + +- [ ] Create `js/ui/profileModal/ProfileBlockListController.js` + - [ ] Import dependencies (`devLogger`, `userLogger`, `FALLBACK_PROFILE_AVATAR`). + - [ ] Implement `cacheDomReferences`. + - [ ] Implement `registerEventListeners`. + - [ ] Move `populateBlockedList` logic. + - [ ] Move `mutateBlocklist` logic. + - [ ] Move helper methods (`ensureBlockListStatusElement`, `setBlockListLoadingState`). +- [ ] Create `js/ui/profileModal/ProfileEditController.js` + - [ ] Import dependencies (`buildProfileMetadataEvent`, `buildPublicUrl`, `buildR2Key`). + - [ ] Implement `cacheDomReferences`. + - [ ] Implement `registerEventListeners`. + - [ ] Move `populateEditPane` logic. + - [ ] Move `handleSaveProfile` logic. + - [ ] Move `handleUpload` logic. +- [ ] Update `js/ui/profileModalController.js` + - [ ] Import new controllers. + - [ ] Instantiate in `constructor`. + - [ ] Call `cacheDomReferences` and `registerEventListeners`. + - [ ] Delegate method calls (wrappers or direct usage). + - [ ] Remove extracted code. +- [ ] Verification + - [ ] `npm run lint` + - [ ] `node scripts/run-targeted-tests.mjs tests/profile-modal-controller.test.mjs` + - [ ] Verify line count reduction.