diff --git a/CONTEXT.md b/CONTEXT.md index 47d91f60..ea5a00d0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,14 +1,13 @@ # Context -**Goal:** Execute the `bitvid-const-refactor-agent` task (daily scheduler). -**Scope:** Identify and refactor duplicated numeric constants in `js/` to use canonical named constants. -**Focus:** Refactor `5000` (timeout) to `SHORT_TIMEOUT_MS` and `60000` (timeout) to `LONG_TIMEOUT_MS` in selected files. +**Goal:** Execute the `bitvid-decompose-agent` task (daily scheduler). +**Scope:** Decompose `js/ui/profileModalController.js` by extracting hashtag preference logic into `js/ui/profileModal/ProfileHashtagController.js`. +**Focus:** Reduce file size by moving cohesive hashtag-related methods and state. **Definition of Done:** -- [ ] Identify candidate duplicates. -- [ ] Refactor `js/ui/engagementController.js` (5000 -> SHORT_TIMEOUT_MS). -- [ ] Refactor `js/ui/applicationBootstrap.js` (5000 -> SHORT_TIMEOUT_MS). -- [ ] Refactor `js/webtorrent.js` (60000 -> LONG_TIMEOUT_MS). +- [ ] Create `js/ui/profileModal/ProfileHashtagController.js`. +- [ ] Move methods: `normalizeHashtagTag`, `formatHashtagTag`, `getResolvedHashtagPreferences`, `setHashtagStatus`, `refreshHashtagBackgroundStatus`, `populateHashtagPreferences`, `renderHashtagList`, `sanitizeHashtagList`, `createHashtagListItem`, `persistHashtagPreferences`, `handleAddHashtagPreference`, `handleRemoveHashtagPreference`, `handleHashtagPreferencesChange`, `describeHashtagPreferencesError`. +- [ ] Integrate into `js/ui/profileModalController.js`. - [ ] Verify changes with `npm run lint` and `npm run test:unit`. -- [ ] Update documentation (`DECISIONS.md`, `TEST_LOG.md`). +- [ ] Update `scripts/check-file-size.mjs` baseline. - [ ] Update `docs/agents/AGENT_TASK_LOG.csv`. diff --git a/docs/agents/AGENT_TASK_LOG.csv b/docs/agents/AGENT_TASK_LOG.csv index b1c4fe56..46369e07 100644 --- a/docs/agents/AGENT_TASK_LOG.csv +++ b/docs/agents/AGENT_TASK_LOG.csv @@ -2,3 +2,5 @@ date,agent_name,prompt_file,status,branch,summary 2026-02-12,audit-agent,bitvid-audit-agent.md,completed,unstable,"Ran audit scripts, created initial reports" 2026-02-12,ci-health-agent,bitvid-ci-health-agent.md,completed,ai/ci-health-fix-watch-history-flake,"Fixed flaky test in watch-history.test.mjs using waitFor" 2026-02-12,const-refactor-agent,bitvid-const-refactor-agent.md,completed,ai/constants-refactor/daily-run,"Refactored timeouts in UI controllers and webtorrent.js to use canonical constants" +2026-02-13,decompose-agent,bitvid-decompose-agent.md,started,ai/decompose/daily-run,"Task claimed — execution beginning" +2026-02-13,decompose-agent,bitvid-decompose-agent.md,completed,ai/decompose/daily-run,"Extracted ProfileHashtagController from profileModalController.js, reducing file size by ~600 lines" diff --git a/js/ui/profileModal/ProfileHashtagController.js b/js/ui/profileModal/ProfileHashtagController.js new file mode 100644 index 00000000..d5d5eec2 --- /dev/null +++ b/js/ui/profileModal/ProfileHashtagController.js @@ -0,0 +1,598 @@ +import { + normalizeHashtag, + formatHashtag, +} from "../../utils/hashtagNormalization.js"; +import { devLogger } from "../../utils/logger.js"; + +const noop = () => {}; + +export class ProfileHashtagController { + constructor(mainController) { + this.mainController = mainController; + this.hashtagStatusText = null; + this.hashtagBackgroundLoading = false; + this.hashtagInterestList = null; + this.hashtagInterestEmpty = null; + this.hashtagInterestInput = null; + this.addHashtagInterestButton = null; + this.profileHashtagInterestRefreshBtn = null; + this.hashtagDisinterestList = null; + this.hashtagDisinterestEmpty = null; + this.hashtagDisinterestInput = null; + this.addHashtagDisinterestButton = null; + this.profileHashtagDisinterestRefreshBtn = null; + + this.hashtagPreferencesService = (this.mainController.services ? this.mainController.services.hashtagPreferences : null); + this.describeHashtagPreferencesErrorService = + (this.mainController.services ? this.mainController.services.describeHashtagPreferencesError : null); + this.getHashtagPreferencesSnapshotService = + (this.mainController.services ? this.mainController.services.getHashtagPreferences : null); + this.hashtagPreferencesPublishInFlight = false; + this.hashtagPreferencesPublishPromise = null; + this.hashtagPreferencesUnsubscribe = null; + } + + initialize() { + if ( + this.hashtagPreferencesService && + typeof this.hashtagPreferencesService.on === "function" + ) { + this.hashtagPreferencesUnsubscribe = this.hashtagPreferencesService.on( + "change", + (detail) => { + this.handleHashtagPreferencesChange({ + action: + typeof detail?.action === "string" ? detail.action : "change", + preferences: detail, + }); + }, + ); + } + } + + cacheDomReferences() { + this.hashtagStatusText = + document.getElementById("profileHashtagStatus") || null; + this.hashtagInterestList = + document.getElementById("profileHashtagInterestList") || null; + this.hashtagInterestEmpty = + document.getElementById("profileHashtagInterestEmpty") || null; + this.hashtagInterestInput = + document.getElementById("profileHashtagInterestInput") || null; + this.addHashtagInterestButton = + document.getElementById("profileAddHashtagInterestBtn") || null; + this.profileHashtagInterestRefreshBtn = + document.getElementById("profileHashtagInterestRefreshBtn") || null; + + this.hashtagDisinterestList = + document.getElementById("profileHashtagDisinterestList") || null; + this.hashtagDisinterestEmpty = + document.getElementById("profileHashtagDisinterestEmpty") || null; + this.hashtagDisinterestInput = + document.getElementById("profileHashtagDisinterestInput") || null; + this.addHashtagDisinterestButton = + document.getElementById("profileAddHashtagDisinterestBtn") || null; + this.profileHashtagDisinterestRefreshBtn = + document.getElementById("profileHashtagDisinterestRefreshBtn") || null; + } + + normalizeHashtagTag(value) { + return normalizeHashtag(value); + } + + formatHashtagTag(value) { + return formatHashtag(value); + } + + sanitizeHashtagList(list) { + if (!Array.isArray(list)) { + return []; + } + return list + .map((entry) => { + const tag = this.normalizeHashtagTag(entry); + return tag ? tag : null; + }) + .filter((tag) => typeof tag === "string"); + } + + getResolvedHashtagPreferences(preferences = null) { + const fallback = { + interests: [], + disinterests: [], + lastLoadError: null, + dataReady: false, + uiReady: false, + loadedFromCache: false, + }; + + const getSnapshot = this.getHashtagPreferencesSnapshotService; + + if (typeof getSnapshot !== "function") { + return fallback; + } + + // Use passed preferences or get fresh snapshot + const source = + preferences && typeof preferences === "object" + ? preferences + : getSnapshot(); + + if (!source || typeof source !== "object") { + return fallback; + } + + const interestsSource = Array.isArray(source.interests) + ? source.interests + : []; + const disinterestsSource = Array.isArray(source.disinterests) + ? source.disinterests + : []; + + return { + interests: this.sanitizeHashtagList(interestsSource), + disinterests: this.sanitizeHashtagList(disinterestsSource), + lastLoadError: source.lastLoadError || null, + dataReady: source.dataReady === true, + uiReady: source.uiReady === true, + loadedFromCache: source.loadedFromCache === true, + }; + } + + setHashtagStatus(message = "", tone = "muted") { + if (!this.hashtagStatusText) { + return; + } + + this.hashtagStatusText.textContent = message; + this.hashtagStatusText.className = "text-xs mt-2 empty:hidden"; + + switch (tone) { + case "info": + this.hashtagStatusText.classList.add("text-accent"); + break; + case "success": + this.hashtagStatusText.classList.add("text-status-success"); + break; + case "warning": + this.hashtagStatusText.classList.add("text-status-warning"); + break; + case "danger": + this.hashtagStatusText.classList.add("text-status-danger"); + break; + case "muted": + default: + this.hashtagStatusText.classList.add("text-muted"); + break; + } + } + + refreshHashtagBackgroundStatus() { + if (!this.hashtagBackgroundLoading) { + return; + } + + const statusText = this.hashtagStatusText?.textContent?.trim?.() || ""; + if (!statusText) { + this.setHashtagStatus("Loading in background…", "info"); + return; + } + + if (statusText === "Loading in background…") { + // already showing spinner message + return; + } + } + + clearHashtagInputs() { + if (this.hashtagInterestInput instanceof HTMLInputElement) { + this.hashtagInterestInput.value = ""; + } + if (this.hashtagDisinterestInput instanceof HTMLInputElement) { + this.hashtagDisinterestInput.value = ""; + } + } + + populateHashtagPreferences(preferences = null) { + const snapshot = this.getResolvedHashtagPreferences(preferences); + + if (!snapshot.uiReady) { + this.setHashtagStatus("Loading hashtag preferences…", "info"); + this.renderHashtagList("interest", []); + this.renderHashtagList("disinterest", []); + this.refreshHashtagBackgroundStatus(); + return; + } + + if (!snapshot.dataReady) { + const message = snapshot.lastLoadError + ? "Couldn’t sync hashtag preferences. Retry to load your lists." + : "Hashtag preferences are unavailable right now. Retry to sync."; + this.setHashtagStatus(message, "warning"); + this.renderHashtagList("interest", []); + this.renderHashtagList("disinterest", []); + this.refreshHashtagBackgroundStatus(); + return; + } + + this.renderHashtagList("interest", snapshot.interests); + this.renderHashtagList("disinterest", snapshot.disinterests); + + if (!snapshot.interests.length && !snapshot.disinterests.length) { + if (snapshot.loadedFromCache) { + this.setHashtagStatus( + "No hashtag preferences yet (showing cached state).", + "info", + ); + } else { + this.setHashtagStatus("", "muted"); + } + } + this.refreshHashtagBackgroundStatus(); + } + + renderHashtagList(type, tags) { + const list = + type === "interest" ? this.hashtagInterestList : this.hashtagDisinterestList; + const empty = + type === "interest" ? this.hashtagInterestEmpty : this.hashtagDisinterestEmpty; + + if (!(list instanceof HTMLElement) || !(empty instanceof HTMLElement)) { + return; + } + + list.textContent = ""; + + const normalized = this.sanitizeHashtagList(tags); + if (!normalized.length) { + empty.classList.remove("hidden"); + list.classList.add("hidden"); + return; + } + + empty.classList.add("hidden"); + list.classList.remove("hidden"); + + normalized.forEach((tag) => { + const item = this.createHashtagListItem(type, tag); + if (item) { + list.appendChild(item); + } + }); + } + + createHashtagListItem(type, tag) { + const normalized = this.normalizeHashtagTag(tag); + if (!normalized) { + return null; + } + + const item = document.createElement("li"); + item.className = "profile-hashtag-item"; + item.dataset.hashtagType = type; + item.dataset.tag = normalized; + + const label = document.createElement("span"); + label.className = "profile-hashtag-label"; + label.textContent = this.formatHashtagTag(normalized); + item.appendChild(label); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "profile-hashtag-remove focus-ring"; + removeButton.dataset.hashtagType = type; + removeButton.dataset.tag = normalized; + removeButton.setAttribute( + "aria-label", + type === "interest" + ? `Remove ${this.formatHashtagTag(normalized)} from interests` + : `Remove ${this.formatHashtagTag(normalized)} from disinterests`, + ); + const icon = document.createElement("span"); + icon.setAttribute("aria-hidden", "true"); + icon.textContent = "\u00D7"; + removeButton.appendChild(icon); + removeButton.addEventListener("click", () => { + void this.handleRemoveHashtagPreference(type, normalized); + }); + + item.appendChild(removeButton); + + return item; + } + + async persistHashtagPreferences(options = {}) { + const service = this.hashtagPreferencesService; + const publish = + service && typeof service.publish === "function" ? service.publish : null; + + if (!publish) { + const message = this.describeHashtagPreferencesError(null, { + fallbackMessage: "Hashtag preferences are unavailable right now.", + }); + if (message) { + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + } + const error = new Error( + message || "Hashtag preferences are unavailable right now.", + ); + error.code = "service-unavailable"; + throw error; + } + + if (this.hashtagPreferencesPublishInFlight) { + return this.hashtagPreferencesPublishPromise; + } + + const { successMessage, pubkey, progressMessage } = + options && typeof options === "object" ? options : {}; + + const resolvedPubkeyCandidate = + typeof pubkey === "string" && pubkey.trim() + ? pubkey + : this.mainController.getActivePubkey(); + const normalizedPubkey = this.mainController.normalizeHexPubkey(resolvedPubkeyCandidate); + + const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {}; + + const pendingMessage = + typeof progressMessage === "string" && progressMessage.trim() + ? progressMessage.trim() + : "Saving hashtag preferences…"; + const finalMessage = + typeof successMessage === "string" && successMessage.trim() + ? successMessage.trim() + : "Hashtag preferences saved."; + + this.hashtagPreferencesPublishInFlight = true; + this.setHashtagStatus(pendingMessage, "info"); + + const publishPromise = (async () => { + try { + const result = await publish.call(service, payload); + this.setHashtagStatus(finalMessage, "success"); + return result; + } catch (error) { + const failure = + error instanceof Error ? error : new Error(String(error || "")); + if (!failure.code) { + failure.code = "hashtag-preferences-publish-failed"; + } + const message = this.describeHashtagPreferencesError(failure, { + fallbackMessage: + "Failed to update hashtag preferences. Please try again.", + }); + if (message) { + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + } + throw failure; + } finally { + this.hashtagPreferencesPublishInFlight = false; + this.hashtagPreferencesPublishPromise = null; + } + })(); + + this.hashtagPreferencesPublishPromise = publishPromise; + return publishPromise; + } + + async handleAddHashtagPreference(type) { + const isInterest = type === "interest"; + const input = isInterest + ? this.hashtagInterestInput + : this.hashtagDisinterestInput; + + const rawValue = + input instanceof HTMLInputElement ? input.value || "" : ""; + const normalized = this.normalizeHashtagTag(rawValue); + + if (!(input instanceof HTMLInputElement)) { + return { success: false, reason: "missing-input" }; + } + + if (!normalized) { + const message = "Enter a hashtag to add."; + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + return { success: false, reason: "empty" }; + } + + const service = this.hashtagPreferencesService; + const addMethod = isInterest + ? service?.addInterest + : service?.addDisinterest; + + if (typeof addMethod !== "function") { + const message = "Hashtag preferences are unavailable right now."; + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + return { success: false, reason: "service-unavailable" }; + } + + const snapshot = this.getResolvedHashtagPreferences(); + const alreadyInTarget = isInterest + ? snapshot.interests.includes(normalized) + : snapshot.disinterests.includes(normalized); + const inOpposite = isInterest + ? snapshot.disinterests.includes(normalized) + : snapshot.interests.includes(normalized); + + let result = false; + try { + result = addMethod.call(service, normalized); + } catch (error) { + const message = this.describeHashtagPreferencesError(error, { + fallbackMessage: "Failed to update hashtag preferences. Please try again.", + }); + if (message) { + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + } + return { success: false, reason: error?.code || "service-error", error }; + } finally { + if (input) { + input.value = ""; + } + } + + if (result) { + const actionMessage = inOpposite + ? `${this.formatHashtagTag(normalized)} moved to ${ + isInterest ? "interests" : "disinterests" + }.` + : `${this.formatHashtagTag(normalized)} added to ${ + isInterest ? "interests" : "disinterests" + }.`; + this.populateHashtagPreferences(); + try { + await this.persistHashtagPreferences({ + successMessage: actionMessage, + }); + this.mainController.showSuccess(actionMessage); + return { success: true, reason: inOpposite ? "moved" : "added" }; + } catch (error) { + return { + success: false, + reason: error?.code || "publish-failed", + error, + }; + } finally { + this.populateHashtagPreferences(); + } + } + + if (alreadyInTarget) { + const message = `${this.formatHashtagTag(normalized)} is already in your ${ + isInterest ? "interests" : "disinterests" + }.`; + this.mainController.showStatus(message); + this.setHashtagStatus(message, "info"); + this.populateHashtagPreferences(); + return { success: false, reason: "duplicate" }; + } + + const fallbackMessage = this.describeHashtagPreferencesError(null, { + fallbackMessage: `Failed to add ${this.formatHashtagTag(normalized)}.`, + }); + if (fallbackMessage) { + this.mainController.showError(fallbackMessage); + this.setHashtagStatus(fallbackMessage, "warning"); + } + this.populateHashtagPreferences(); + return { success: false, reason: "no-change" }; + } + + async handleRemoveHashtagPreference(type, candidate) { + const normalized = this.normalizeHashtagTag(candidate); + if (!normalized) { + return { success: false, reason: "invalid" }; + } + + const service = this.hashtagPreferencesService; + const removeMethod = + type === "interest" + ? service?.removeInterest + : service?.removeDisinterest; + + if (typeof removeMethod !== "function") { + const message = "Hashtag preferences are unavailable right now."; + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + return { success: false, reason: "service-unavailable" }; + } + + let removed = false; + try { + removed = removeMethod.call(service, normalized); + } catch (error) { + const message = this.describeHashtagPreferencesError(error, { + fallbackMessage: `Failed to remove ${this.formatHashtagTag(normalized)}.`, + }); + if (message) { + this.mainController.showError(message); + this.setHashtagStatus(message, "warning"); + } + this.populateHashtagPreferences(); + return { success: false, reason: error?.code || "service-error", error }; + } + + if (removed) { + const message = `${this.formatHashtagTag(normalized)} removed from ${ + type === "interest" ? "interests" : "disinterests" + }.`; + this.populateHashtagPreferences(); + try { + await this.persistHashtagPreferences({ successMessage: message }); + this.mainController.showSuccess(message); + } catch (error) { + return { + success: false, + reason: error?.code || "publish-failed", + error, + }; + } finally { + this.populateHashtagPreferences(); + } + } else { + const message = `${this.formatHashtagTag(normalized)} is already removed.`; + this.mainController.showStatus(message); + this.setHashtagStatus(message, "info"); + } + + this.populateHashtagPreferences(); + return { success: removed, reason: removed ? "removed" : "already-removed" }; + } + + handleHashtagPreferencesChange(detail = {}) { + const preferences = + detail && typeof detail.preferences === "object" + ? detail.preferences + : detail; + const action = typeof detail?.action === "string" ? detail.action : ""; + + if (action === "background-loading") { + this.hashtagBackgroundLoading = true; + this.setHashtagStatus("Loading in background…", "info"); + } else if ( + (action === "sync" || action === "background-loaded" || action === "reset") && + this.hashtagBackgroundLoading + ) { + const statusText = this.hashtagStatusText?.textContent?.trim?.() || ""; + if (statusText === "Loading in background…") { + this.setHashtagStatus("", "muted"); + } + this.hashtagBackgroundLoading = false; + } + + this.populateHashtagPreferences(preferences); + this.refreshHashtagBackgroundStatus(); + } + + describeHashtagPreferencesError(error, options = {}) { + const describe = this.describeHashtagPreferencesErrorService; + + if (typeof describe !== "function") { + const fallback = + options && typeof options.fallbackMessage === "string" + ? options.fallbackMessage + : "An error occurred with hashtag preferences."; + return fallback; + } + + try { + return describe(error, options); + } catch (err) { + devLogger.warn( + "[ProfileModalController] describeHashtagPreferencesError service threw:", + err, + ); + return ( + options && typeof options.fallbackMessage === "string" + ? options.fallbackMessage + : "An error occurred." + ); + } + } +} diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 549036ae..083a5d96 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -24,12 +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 { - normalizeHashtag, - formatHashtag, -} from "../utils/hashtagNormalization.js"; -import { formatTimeAgo } from "../utils/formatters.js"; -import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { buildPublicUrl, buildR2Key } from "../r2.js"; import { buildProfileMetadataEvent } from "../nostrEventSchemas.js"; import { @@ -53,6 +47,7 @@ import { ProfileWalletController } from "./profileModal/ProfileWalletController. import { ProfileStorageController } from "./profileModal/ProfileStorageController.js"; import { ProfileDirectMessageController } from "./profileModal/ProfileDirectMessageController.js"; import { ProfileRelayController } from "./profileModal/ProfileRelayController.js"; +import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js"; const noop = () => {}; @@ -103,6 +98,8 @@ export class ProfileModalController { this.dmController = new ProfileDirectMessageController(this); this.relayController = new ProfileRelayController(this); + this.hashtagController = new ProfileHashtagController(this); + this.hashtagController.initialize(); const resolvedMaxWalletDefaultZap = typeof providedConstants.MAX_WALLET_DEFAULT_ZAP === "number" && @@ -264,29 +261,6 @@ export class ProfileModalController { } } - this.hashtagPreferencesService = this.services.hashtagPreferences; - this.describeHashtagPreferencesErrorService = - this.services.describeHashtagPreferencesError; - this.getHashtagPreferencesSnapshotService = - this.services.getHashtagPreferences; - this.hashtagPreferencesPublishInFlight = false; - this.hashtagPreferencesPublishPromise = null; - - if ( - this.hashtagPreferencesService && - typeof this.hashtagPreferencesService.on === "function" - ) { - this.hashtagPreferencesUnsubscribe = this.hashtagPreferencesService.on( - "change", - (detail) => { - this.handleHashtagPreferencesChange({ - action: - typeof detail?.action === "string" ? detail.action : "change", - preferences: detail, - }); - }, - ); - } this.dmController.initializeDirectMessagesService(); @@ -446,18 +420,6 @@ export class ProfileModalController { this.walletDisconnectButton = null; this.walletStatusText = null; this.profileWalletStatusText = null; - this.hashtagStatusText = null; - this.hashtagBackgroundLoading = false; - this.hashtagInterestList = null; - this.hashtagInterestEmpty = null; - this.hashtagInterestInput = null; - this.addHashtagInterestButton = null; - this.profileHashtagInterestRefreshBtn = null; - this.hashtagDisinterestList = null; - this.hashtagDisinterestEmpty = null; - this.hashtagDisinterestInput = null; - this.addHashtagDisinterestButton = null; - this.profileHashtagDisinterestRefreshBtn = null; this.subscriptionsStatusText = null; this.subscriptionsBackgroundLoading = false; this.permissionPromptCta = null; @@ -645,7 +607,7 @@ export class ProfileModalController { this.authLoadingStateListener, ); } - this.populateHashtagPreferences(); + this.hashtagController.populateHashtagPreferences(); this.refreshModerationSettingsUi(); const preserveMenu = this.isMobileLayoutActive(); this.selectPane(this.getActivePane(), { keepMenuView: preserveMenu }); @@ -854,28 +816,7 @@ export class ProfileModalController { this.dmController.populateDmRelayPreferences(); this.dmController.syncDmPrivacySettingsUi(); - this.hashtagStatusText = - document.getElementById("profileHashtagStatus") || null; - this.hashtagInterestList = - document.getElementById("profileHashtagInterestList") || null; - this.hashtagInterestEmpty = - document.getElementById("profileHashtagInterestEmpty") || null; - this.hashtagInterestInput = - document.getElementById("profileHashtagInterestInput") || null; - this.addHashtagInterestButton = - document.getElementById("profileAddHashtagInterestBtn") || null; - this.profileHashtagInterestRefreshBtn = - document.getElementById("profileHashtagInterestRefreshBtn") || null; - this.hashtagDisinterestList = - document.getElementById("profileHashtagDisinterestList") || null; - this.hashtagDisinterestEmpty = - document.getElementById("profileHashtagDisinterestEmpty") || null; - this.hashtagDisinterestInput = - document.getElementById("profileHashtagDisinterestInput") || null; - this.addHashtagDisinterestButton = - document.getElementById("profileAddHashtagDisinterestBtn") || null; - this.profileHashtagDisinterestRefreshBtn = - document.getElementById("profileHashtagDisinterestRefreshBtn") || null; + this.hashtagController.cacheDomReferences(); this.subscriptionsStatusText = document.getElementById("subscriptionsStatus") || null; this.applyPermissionPromptCtaState(); @@ -992,11 +933,11 @@ export class ProfileModalController { ensureAriaLabel(this.relayController.profileRelayRefreshBtn, "Refresh relay list"); ensureAriaLabel( - this.profileHashtagInterestRefreshBtn, + this.hashtagController.profileHashtagInterestRefreshBtn, "Refresh interest hashtags", ); ensureAriaLabel( - this.profileHashtagDisinterestRefreshBtn, + this.hashtagController.profileHashtagDisinterestRefreshBtn, "Refresh disinterest hashtags", ); ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions"); @@ -1906,9 +1847,9 @@ export class ProfileModalController { }); } - if (this.addHashtagInterestButton instanceof HTMLElement) { - this.addHashtagInterestButton.addEventListener("click", () => { - void this.handleAddHashtagPreference("interest"); + if (this.hashtagController.addHashtagInterestButton instanceof HTMLElement) { + this.hashtagController.addHashtagInterestButton.addEventListener("click", () => { + void this.hashtagController.handleAddHashtagPreference("interest"); }); } @@ -1917,7 +1858,7 @@ export class ProfileModalController { if (!activeHex) { return; } - const service = this.hashtagPreferencesService; + const service = this.services.hashtagPreferences; if (!service || typeof service.load !== "function") { return; } @@ -1931,40 +1872,40 @@ export class ProfileModalController { }); }; - if (this.profileHashtagInterestRefreshBtn instanceof HTMLElement) { - this.profileHashtagInterestRefreshBtn.addEventListener( + if (this.hashtagController.profileHashtagInterestRefreshBtn instanceof HTMLElement) { + this.hashtagController.profileHashtagInterestRefreshBtn.addEventListener( "click", handleHashtagRefresh, ); } - if (this.profileHashtagDisinterestRefreshBtn instanceof HTMLElement) { - this.profileHashtagDisinterestRefreshBtn.addEventListener( + if (this.hashtagController.profileHashtagDisinterestRefreshBtn instanceof HTMLElement) { + this.hashtagController.profileHashtagDisinterestRefreshBtn.addEventListener( "click", handleHashtagRefresh, ); } - if (this.hashtagInterestInput instanceof HTMLElement) { - this.hashtagInterestInput.addEventListener("keydown", (event) => { + if (this.hashtagController.hashtagInterestInput instanceof HTMLElement) { + this.hashtagController.hashtagInterestInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); - void this.handleAddHashtagPreference("interest"); + void this.hashtagController.handleAddHashtagPreference("interest"); } }); } - if (this.addHashtagDisinterestButton instanceof HTMLElement) { - this.addHashtagDisinterestButton.addEventListener("click", () => { - void this.handleAddHashtagPreference("disinterest"); + if (this.hashtagController.addHashtagDisinterestButton instanceof HTMLElement) { + this.hashtagController.addHashtagDisinterestButton.addEventListener("click", () => { + void this.hashtagController.handleAddHashtagPreference("disinterest"); }); } - if (this.hashtagDisinterestInput instanceof HTMLElement) { - this.hashtagDisinterestInput.addEventListener("keydown", (event) => { + if (this.hashtagController.hashtagDisinterestInput instanceof HTMLElement) { + this.hashtagController.hashtagDisinterestInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); - void this.handleAddHashtagPreference("disinterest"); + void this.hashtagController.handleAddHashtagPreference("disinterest"); } }); } @@ -2312,40 +2253,6 @@ export class ProfileModalController { return fallback; } - describeHashtagPreferencesError(error, options = {}) { - const describe = this.describeHashtagPreferencesErrorService; - const { fallbackMessage, operation } = - options && typeof options === "object" ? options : {}; - const normalizedOperation = - typeof operation === "string" && operation.trim() - ? operation.trim().toLowerCase() - : "update"; - - const fallback = - typeof fallbackMessage === "string" && fallbackMessage.trim() - ? fallbackMessage.trim() - : normalizedOperation === "load" - ? "Failed to load hashtag preferences. Please try again." - : normalizedOperation === "reset" - ? "Failed to reset hashtag preferences." - : "Failed to update hashtag preferences. Please try again."; - - if (typeof describe === "function") { - try { - const message = describe(error, fallback); - if (typeof message === "string" && message.trim()) { - return message.trim(); - } - } catch (describeError) { - devLogger.warn( - "[ProfileModalController] describeHashtagPreferencesError service threw:", - describeError, - ); - } - } - - return fallback; - } setAddAccountLoading(isLoading) { if (!(this.addAccountButton instanceof HTMLElement)) { @@ -3643,13 +3550,13 @@ export class ProfileModalController { } else if (target === "storage") { this.storageController.populateStoragePane(); } else if (target === "hashtags") { - this.populateHashtagPreferences(); - if (activeHex && this.hashtagPreferencesService) { - this.hashtagPreferencesService + this.hashtagController.populateHashtagPreferences(); + if (activeHex && this.services.hashtagPreferences) { + this.services.hashtagPreferences .load(activeHex, { allowPermissionPrompt: true }) .catch(noop); } - this.refreshHashtagBackgroundStatus(); + this.hashtagController.refreshHashtagBackgroundStatus(); } else if (target === "subscriptions") { if (activeHex && this.subscriptionsService) { this.subscriptionsService @@ -3777,91 +3684,6 @@ export class ProfileModalController { } } - normalizeHashtagTag(value) { - return normalizeHashtag(value); - } - - formatHashtagTag(value) { - return formatHashtag(value); - } - - sanitizeHashtagList(list) { - if (!Array.isArray(list)) { - return []; - } - - const seen = new Set(); - const normalized = []; - - list.forEach((entry) => { - if (typeof entry !== "string") { - return; - } - const tag = this.normalizeHashtagTag(entry); - if (!tag || seen.has(tag)) { - return; - } - seen.add(tag); - normalized.push(tag); - }); - - normalized.sort((a, b) => a.localeCompare(b)); - return normalized; - } - - getResolvedHashtagPreferences(preferences = null) { - const candidate = - preferences && typeof preferences === "object" ? preferences : null; - - let snapshot = null; - if (candidate) { - snapshot = candidate; - } else if (typeof this.getHashtagPreferencesSnapshotService === "function") { - try { - const resolved = this.getHashtagPreferencesSnapshotService(); - if (resolved && typeof resolved === "object") { - snapshot = resolved; - } - } catch (error) { - devLogger.warn( - "[ProfileModalController] Failed to read hashtag preferences snapshot:", - error, - ); - } - } - - const service = this.hashtagPreferencesService || {}; - const loadState = - typeof service.getLoadState === "function" ? service.getLoadState() : null; - - const interestsSource = Array.isArray(snapshot?.interests) - ? snapshot.interests - : typeof service.getInterests === "function" - ? service.getInterests() - : []; - const disinterestsSource = Array.isArray(snapshot?.disinterests) - ? snapshot.disinterests - : typeof service.getDisinterests === "function" - ? service.getDisinterests() - : []; - - return { - interests: this.sanitizeHashtagList(interestsSource), - disinterests: this.sanitizeHashtagList(disinterestsSource), - uiReady: - loadState?.uiReady === true || - service.uiReady === true || - (!loadState && service.uiReady !== false), - dataReady: - loadState?.dataReady === true || - service.dataReady === true || - (!loadState && service.dataReady !== false), - lastLoadError: loadState?.lastLoadError || service.lastLoadError || null, - loadedFromCache: - loadState?.loadedFromCache === true || service.loadedFromCache === true, - }; - } - setPermissionPromptCtaState(nextState = {}) { if (!nextState || typeof nextState !== "object") { return; @@ -3938,7 +3760,7 @@ export class ProfileModalController { "Subscriptions loaded from cache; sync is retrying in background.", "warning", ); - this.setHashtagStatus( + this.hashtagController.setHashtagStatus( "Hashtag preferences loaded from cache; sync is retrying in background.", "warning", ); @@ -3950,7 +3772,7 @@ export class ProfileModalController { "Some profile lists failed to sync. Use Retry sync.", "warning", ); - this.setHashtagStatus( + this.hashtagController.setHashtagStatus( "Some profile lists failed to sync. Use Retry sync.", "warning", ); @@ -3983,49 +3805,6 @@ export class ProfileModalController { } } - setHashtagStatus(message = "", tone = "muted") { - if (!(this.hashtagStatusText instanceof HTMLElement)) { - return; - } - - const classList = this.hashtagStatusText.classList; - classList.remove( - "text-status-success", - "text-status-warning", - "text-status-danger", - "text-status-info", - "text-muted", - ); - - const normalized = - typeof message === "string" && message.trim() ? message.trim() : ""; - - if (!normalized) { - this.hashtagStatusText.textContent = ""; - this.hashtagStatusText.classList.add("text-muted", "hidden"); - return; - } - - this.hashtagStatusText.textContent = normalized; - this.hashtagStatusText.classList.remove("hidden"); - - switch (tone) { - case "success": - classList.add("text-status-success"); - break; - case "warning": - case "error": - classList.add("text-status-warning"); - break; - case "info": - classList.add("text-status-info"); - break; - default: - classList.add("text-muted"); - break; - } - } - setSubscriptionsStatus(message = "", tone = "muted") { if (!(this.subscriptionsStatusText instanceof HTMLElement)) { return; @@ -4069,26 +3848,6 @@ export class ProfileModalController { } } - refreshHashtagBackgroundStatus() { - const isBackground = this.hashtagPreferencesService?.backgroundLoading === true; - const statusText = this.hashtagStatusText?.textContent?.trim?.() || ""; - - if (isBackground && !this.hashtagBackgroundLoading) { - this.hashtagBackgroundLoading = true; - if (!statusText) { - this.setHashtagStatus("Loading in background…", "info"); - } - return; - } - - if (!isBackground && this.hashtagBackgroundLoading) { - if (statusText === "Loading in background…") { - this.setHashtagStatus("", "muted"); - } - this.hashtagBackgroundLoading = false; - } - } - refreshSubscriptionsBackgroundStatus() { const isBackground = this.subscriptionsService?.backgroundLoading === true; const statusText = this.subscriptionsStatusText?.textContent?.trim?.() || ""; @@ -4109,391 +3868,6 @@ export class ProfileModalController { } } - clearHashtagInputs() { - if (this.hashtagInterestInput instanceof HTMLInputElement) { - this.hashtagInterestInput.value = ""; - } - if (this.hashtagDisinterestInput instanceof HTMLInputElement) { - this.hashtagDisinterestInput.value = ""; - } - } - - populateHashtagPreferences(preferences = null) { - const snapshot = this.getResolvedHashtagPreferences(preferences); - - if (!snapshot.uiReady) { - this.setHashtagStatus("Loading hashtag preferences…", "info"); - this.renderHashtagList("interest", []); - this.renderHashtagList("disinterest", []); - this.refreshHashtagBackgroundStatus(); - return; - } - - if (!snapshot.dataReady) { - const message = snapshot.lastLoadError - ? "Couldn’t sync hashtag preferences. Retry to load your lists." - : "Hashtag preferences are unavailable right now. Retry to sync."; - this.setHashtagStatus(message, "warning"); - this.renderHashtagList("interest", []); - this.renderHashtagList("disinterest", []); - this.refreshHashtagBackgroundStatus(); - return; - } - - this.renderHashtagList("interest", snapshot.interests); - this.renderHashtagList("disinterest", snapshot.disinterests); - - if (!snapshot.interests.length && !snapshot.disinterests.length) { - if (snapshot.loadedFromCache) { - this.setHashtagStatus( - "No hashtag preferences yet (showing cached state).", - "info", - ); - } else { - this.setHashtagStatus("", "muted"); - } - } - this.refreshHashtagBackgroundStatus(); - } - - renderHashtagList(type, tags) { - const list = - type === "interest" ? this.hashtagInterestList : this.hashtagDisinterestList; - const empty = - type === "interest" ? this.hashtagInterestEmpty : this.hashtagDisinterestEmpty; - - if (!(list instanceof HTMLElement) || !(empty instanceof HTMLElement)) { - return; - } - - list.textContent = ""; - - const normalized = this.sanitizeHashtagList(tags); - if (!normalized.length) { - empty.classList.remove("hidden"); - list.classList.add("hidden"); - return; - } - - empty.classList.add("hidden"); - list.classList.remove("hidden"); - - normalized.forEach((tag) => { - const item = this.createHashtagListItem(type, tag); - if (item) { - list.appendChild(item); - } - }); - } - - createHashtagListItem(type, tag) { - const normalized = this.normalizeHashtagTag(tag); - if (!normalized) { - return null; - } - - const item = document.createElement("li"); - item.className = "profile-hashtag-item"; - item.dataset.hashtagType = type; - item.dataset.tag = normalized; - - const label = document.createElement("span"); - label.textContent = this.formatHashtagTag(normalized); - item.appendChild(label); - - const removeButton = document.createElement("button"); - removeButton.type = "button"; - removeButton.className = "profile-hashtag-remove focus-ring"; - removeButton.dataset.hashtagType = type; - removeButton.dataset.tag = normalized; - removeButton.setAttribute( - "aria-label", - type === "interest" - ? `Remove ${this.formatHashtagTag(normalized)} from interests` - : `Remove ${this.formatHashtagTag(normalized)} from disinterests`, - ); - const icon = document.createElement("span"); - icon.setAttribute("aria-hidden", "true"); - icon.textContent = "\u00D7"; - removeButton.appendChild(icon); - removeButton.addEventListener("click", () => { - void this.handleRemoveHashtagPreference(type, normalized); - }); - - item.appendChild(removeButton); - - return item; - } - - async persistHashtagPreferences(options = {}) { - const service = this.hashtagPreferencesService; - const publish = - service && typeof service.publish === "function" ? service.publish : null; - - if (!publish) { - const message = this.describeHashtagPreferencesError(null, { - fallbackMessage: "Hashtag preferences are unavailable right now.", - }); - if (message) { - this.showError(message); - this.setHashtagStatus(message, "warning"); - } - const error = new Error( - message || "Hashtag preferences are unavailable right now.", - ); - error.code = "service-unavailable"; - throw error; - } - - if (this.hashtagPreferencesPublishInFlight) { - return this.hashtagPreferencesPublishPromise; - } - - const { successMessage, pubkey, progressMessage } = - options && typeof options === "object" ? options : {}; - - const resolvedPubkeyCandidate = - typeof pubkey === "string" && pubkey.trim() - ? pubkey - : this.getActivePubkey(); - const normalizedPubkey = this.normalizeHexPubkey(resolvedPubkeyCandidate); - - const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {}; - - const pendingMessage = - typeof progressMessage === "string" && progressMessage.trim() - ? progressMessage.trim() - : "Saving hashtag preferences…"; - const finalMessage = - typeof successMessage === "string" && successMessage.trim() - ? successMessage.trim() - : "Hashtag preferences saved."; - - this.hashtagPreferencesPublishInFlight = true; - this.setHashtagStatus(pendingMessage, "info"); - - const publishPromise = (async () => { - try { - const result = await publish.call(service, payload); - this.setHashtagStatus(finalMessage, "success"); - return result; - } catch (error) { - const failure = - error instanceof Error ? error : new Error(String(error || "")); - if (!failure.code) { - failure.code = "hashtag-preferences-publish-failed"; - } - const message = this.describeHashtagPreferencesError(failure, { - fallbackMessage: - "Failed to update hashtag preferences. Please try again.", - }); - if (message) { - this.showError(message); - this.setHashtagStatus(message, "warning"); - } - throw failure; - } finally { - this.hashtagPreferencesPublishInFlight = false; - this.hashtagPreferencesPublishPromise = null; - } - })(); - - this.hashtagPreferencesPublishPromise = publishPromise; - return publishPromise; - } - - async handleAddHashtagPreference(type) { - const isInterest = type === "interest"; - const input = isInterest - ? this.hashtagInterestInput - : this.hashtagDisinterestInput; - - const rawValue = - input instanceof HTMLInputElement ? input.value || "" : ""; - const normalized = this.normalizeHashtagTag(rawValue); - - if (!(input instanceof HTMLInputElement)) { - return { success: false, reason: "missing-input" }; - } - - if (!normalized) { - const message = "Enter a hashtag to add."; - this.showError(message); - this.setHashtagStatus(message, "warning"); - return { success: false, reason: "empty" }; - } - - const service = this.hashtagPreferencesService; - const addMethod = isInterest - ? service?.addInterest - : service?.addDisinterest; - - if (typeof addMethod !== "function") { - const message = "Hashtag preferences are unavailable right now."; - this.showError(message); - this.setHashtagStatus(message, "warning"); - return { success: false, reason: "service-unavailable" }; - } - - const snapshot = this.getResolvedHashtagPreferences(); - const alreadyInTarget = isInterest - ? snapshot.interests.includes(normalized) - : snapshot.disinterests.includes(normalized); - const inOpposite = isInterest - ? snapshot.disinterests.includes(normalized) - : snapshot.interests.includes(normalized); - - let result = false; - try { - result = addMethod.call(service, normalized); - } catch (error) { - const message = this.describeHashtagPreferencesError(error, { - fallbackMessage: "Failed to update hashtag preferences. Please try again.", - }); - if (message) { - this.showError(message); - this.setHashtagStatus(message, "warning"); - } - return { success: false, reason: error?.code || "service-error", error }; - } finally { - if (input) { - input.value = ""; - } - } - - if (result) { - const actionMessage = inOpposite - ? `${this.formatHashtagTag(normalized)} moved to ${ - isInterest ? "interests" : "disinterests" - }.` - : `${this.formatHashtagTag(normalized)} added to ${ - isInterest ? "interests" : "disinterests" - }.`; - this.populateHashtagPreferences(); - try { - await this.persistHashtagPreferences({ - successMessage: actionMessage, - }); - this.showSuccess(actionMessage); - return { success: true, reason: inOpposite ? "moved" : "added" }; - } catch (error) { - return { - success: false, - reason: error?.code || "publish-failed", - error, - }; - } finally { - this.populateHashtagPreferences(); - } - } - - if (alreadyInTarget) { - const message = `${this.formatHashtagTag(normalized)} is already in your ${ - isInterest ? "interests" : "disinterests" - }.`; - this.showStatus(message); - this.setHashtagStatus(message, "info"); - this.populateHashtagPreferences(); - return { success: false, reason: "duplicate" }; - } - - const fallbackMessage = this.describeHashtagPreferencesError(null, { - fallbackMessage: `Failed to add ${this.formatHashtagTag(normalized)}.`, - }); - if (fallbackMessage) { - this.showError(fallbackMessage); - this.setHashtagStatus(fallbackMessage, "warning"); - } - this.populateHashtagPreferences(); - return { success: false, reason: "no-change" }; - } - - async handleRemoveHashtagPreference(type, candidate) { - const normalized = this.normalizeHashtagTag(candidate); - if (!normalized) { - return { success: false, reason: "invalid" }; - } - - const service = this.hashtagPreferencesService; - const removeMethod = - type === "interest" - ? service?.removeInterest - : service?.removeDisinterest; - - if (typeof removeMethod !== "function") { - const message = "Hashtag preferences are unavailable right now."; - this.showError(message); - this.setHashtagStatus(message, "warning"); - return { success: false, reason: "service-unavailable" }; - } - - let removed = false; - try { - removed = removeMethod.call(service, normalized); - } catch (error) { - const message = this.describeHashtagPreferencesError(error, { - fallbackMessage: `Failed to remove ${this.formatHashtagTag(normalized)}.`, - }); - if (message) { - this.showError(message); - this.setHashtagStatus(message, "warning"); - } - this.populateHashtagPreferences(); - return { success: false, reason: error?.code || "service-error", error }; - } - - if (removed) { - const message = `${this.formatHashtagTag(normalized)} removed from ${ - type === "interest" ? "interests" : "disinterests" - }.`; - this.populateHashtagPreferences(); - try { - await this.persistHashtagPreferences({ successMessage: message }); - this.showSuccess(message); - } catch (error) { - return { - success: false, - reason: error?.code || "publish-failed", - error, - }; - } finally { - this.populateHashtagPreferences(); - } - } else { - const message = `${this.formatHashtagTag(normalized)} is already removed.`; - this.showStatus(message); - this.setHashtagStatus(message, "info"); - } - - this.populateHashtagPreferences(); - return { success: removed, reason: removed ? "removed" : "already-removed" }; - } - - handleHashtagPreferencesChange(detail = {}) { - const preferences = - detail && typeof detail.preferences === "object" - ? detail.preferences - : detail; - const action = typeof detail?.action === "string" ? detail.action : ""; - - if (action === "background-loading") { - this.hashtagBackgroundLoading = true; - this.setHashtagStatus("Loading in background…", "info"); - } else if ( - (action === "sync" || action === "background-loaded" || action === "reset") && - this.hashtagBackgroundLoading - ) { - const statusText = this.hashtagStatusText?.textContent?.trim?.() || ""; - if (statusText === "Loading in background…") { - this.setHashtagStatus("", "muted"); - } - this.hashtagBackgroundLoading = false; - } - - this.populateHashtagPreferences(preferences); - this.refreshHashtagBackgroundStatus(); - } - handleSubscriptionsChange(detail = {}) { const action = typeof detail?.action === "string" ? detail.action : ""; @@ -8424,7 +7798,7 @@ export class ProfileModalController { void this.populateFriendsList(); this.relayController.populateProfileRelays(); this.walletController.refreshWalletPaneState(); - this.populateHashtagPreferences(); + this.hashtagController.populateHashtagPreferences(); void this.storageController.populateStoragePane(); this.handleActiveDmIdentityChanged(activePubkey); void this.refreshDmRelayPreferences({ force: true }); @@ -8438,7 +7812,7 @@ export class ProfileModalController { void this.populateFriendsList(); this.relayController.populateProfileRelays(); this.walletController.refreshWalletPaneState(); - this.populateHashtagPreferences(); + this.hashtagController.populateHashtagPreferences(); void this.storageController.populateStoragePane(); void this.refreshDmRelayPreferences({ force: true }); }) @@ -8504,10 +7878,10 @@ export class ProfileModalController { this.clearFriendsList(); this.relayController.populateProfileRelays(); this.walletController.refreshWalletPaneState(); - this.populateHashtagPreferences(); + this.hashtagController.populateHashtagPreferences(); void this.storageController.populateStoragePane(); - this.clearHashtagInputs(); - this.setHashtagStatus("", "muted"); + this.hashtagController.clearHashtagInputs(); + this.hashtagController.setHashtagStatus("", "muted"); this.handleActiveDmIdentityChanged(null); this.setMessagesUnreadIndicator(false); this.dmController.populateDmRelayPreferences(); diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index e4b9bb34..525940d7 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -26,7 +26,7 @@ const GROWTH_MARGIN = 50; // lines — allowlisted files may grow by this much f // These files are known to be oversized and are decomposition targets. // They must NOT grow beyond (recorded + GROWTH_MARGIN). const GRANDFATHERED = { - "js/ui/profileModalController.js": 8753, + "js/ui/profileModalController.js": 8130, "js/ui/components/VideoModal.js": 6284, "js/app.js": 5602, "js/channelProfile.js": 5529,