From 319e2f4c9e963d1f42bf818e0ed7d8ff414fcb84 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 04:04:55 +0000 Subject: [PATCH] [decompose] Extract profile modal logic Extract contracts, default settings, and helpers from `js/ui/profileModalController.js` to `js/ui/profileModalContract.js`. This reduces the file size of `js/ui/profileModalController.js` by 884 lines. - Moved `SERVICE_CONTRACT`, `STATE_CONTRACT` and their builders. - Moved internal settings helpers (`createInternalDefaultNwcSettings`, etc). - Moved badge helpers (`resolveProviderBadgeClass`). - Updated `scripts/check-file-size.mjs` with new line count. - Cleaned up imports in `profileModalController.js`. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/ui/profileModalContract.js | 916 ++++++++++++++++++++++++++++++++ js/ui/profileModalController.js | 910 +------------------------------ scripts/check-file-size.mjs | 2 +- 3 files changed, 930 insertions(+), 898 deletions(-) create mode 100644 js/ui/profileModalContract.js diff --git a/js/ui/profileModalContract.js b/js/ui/profileModalContract.js new file mode 100644 index 00000000..84b340e4 --- /dev/null +++ b/js/ui/profileModalContract.js @@ -0,0 +1,916 @@ +import { + DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + DEFAULT_BLUR_THRESHOLD, + DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, + DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, +} from "../constants.js"; + +const noop = () => {}; + +export const NWC_URI_SCHEME = "nostr+walletconnect://"; +const PROVIDER_BADGE_BASE_CLASS = + "text-3xs font-semibold uppercase tracking-extra-wide"; +const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({ + info: "text-status-info", + success: "text-status-success", + warning: "text-status-warning", + danger: "text-status-danger", + neutral: "text-muted", + accent: "text-accent", + primary: "text-accent", +}); + +function resolveProviderBadgeClass(variant) { + if (typeof variant === "string" && variant.trim()) { + const normalized = variant.trim().toLowerCase(); + if (Object.prototype.hasOwnProperty.call(PROVIDER_BADGE_VARIANT_CLASS_MAP, normalized)) { + return PROVIDER_BADGE_VARIANT_CLASS_MAP[normalized]; + } + } + + return PROVIDER_BADGE_VARIANT_CLASS_MAP.neutral; +} + +const DEFAULT_INTERNAL_NWC_SETTINGS = Object.freeze({ + nwcUri: "", + defaultZap: null, + lastChecked: null, +}); + +function createInternalDefaultNwcSettings() { + return { ...DEFAULT_INTERNAL_NWC_SETTINGS }; +} + +const DEFAULT_INTERNAL_MODERATION_SETTINGS = Object.freeze({ + blurThreshold: DEFAULT_BLUR_THRESHOLD, + autoplayBlockThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, + trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, + trustedSpamHideThreshold: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, +}); + +function createInternalDefaultModerationSettings() { + return { ...DEFAULT_INTERNAL_MODERATION_SETTINGS }; +} + +const DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS = Object.freeze({ + readReceiptsEnabled: false, + typingIndicatorsEnabled: false, +}); + +function createInternalDefaultDmPrivacySettings() { + return { ...DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS }; +} + +function ensureInternalModerationSettings(internalState) { + if (!internalState || typeof internalState !== "object") { + return createInternalDefaultModerationSettings(); + } + + if ( + !internalState.moderationSettings || + typeof internalState.moderationSettings !== "object" || + internalState.moderationSettings === null + ) { + internalState.moderationSettings = createInternalDefaultModerationSettings(); + } + + return internalState.moderationSettings; +} + +function ensureInternalWalletSettings(internalState) { + if (!internalState || typeof internalState !== "object") { + return createInternalDefaultNwcSettings(); + } + + if ( + !internalState.walletSettings || + typeof internalState.walletSettings !== "object" || + internalState.walletSettings === null + ) { + internalState.walletSettings = createInternalDefaultNwcSettings(); + } + + return internalState.walletSettings; +} + +const SERVICE_CONTRACT = [ + { + key: "normalizeHexPubkey", + type: "function", + description: + "Normalizes a provided pubkey (hex or npub) so profile lookups are stable.", + fallback: () => (value) => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase(); + } + return trimmed; + }, + }, + { + key: "safeEncodeNpub", + type: "function", + description: + "Encodes a hex pubkey as an npub for display. Expected to be resilient to bad input.", + fallback: () => (pubkey) => { + if (typeof pubkey === "string" && pubkey.trim()) { + return pubkey.trim(); + } + return null; + }, + }, + { + key: "safeDecodeNpub", + type: "function", + description: + "Decodes an npub back to its hex form. Should return null/undefined for invalid payloads.", + fallback: () => () => null, + }, + { + key: "truncateMiddle", + type: "function", + description: + "Utility used for shortening long identifiers in the modal without losing context.", + fallback: () => (value) => value, + }, + { + key: "formatShortNpub", + type: "function", + description: + "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).", + fallback: () => (value) => (typeof value === "string" ? value : ""), + }, + { + key: "requestAddProfileLogin", + type: "function", + description: + "Opens the login modal and resolves with the authentication result for the add-profile flow.", + fallback: () => async () => { + throw new Error("Login service unavailable."); + }, + }, + { + key: "describeLoginError", + type: "function", + description: + "Maps authentication errors to human-readable strings for add-profile messaging.", + fallback: () => (_, fallbackMessage) => + typeof fallbackMessage === "string" && fallbackMessage.trim() + ? fallbackMessage.trim() + : "Failed to login. Please try again.", + }, + { + key: "r2Service", + type: "object", + description: "Service for handling file uploads (R2/S3).", + optional: true, + }, + { + key: "nostrService", + type: "object", + description: + "Provides encrypted direct message helpers for loading and subscribing to inbox updates.", + optional: true, + fallback: () => ({ + getDirectMessages: () => [], + loadDirectMessages: async () => [], + ensureDirectMessageSubscription: () => null, + stopDirectMessageSubscription: () => {}, + clearDirectMessages: () => {}, + on: () => () => {}, + }), + }, + { + key: "hashtagPreferences", + type: "object", + description: + "Service providing access to interest and disinterest hashtag collections.", + fallback: () => ({ + getInterests: () => [], + getDisinterests: () => [], + addInterest: () => false, + removeInterest: () => false, + addDisinterest: () => false, + removeDisinterest: () => false, + publish: async () => false, + on: () => () => {}, + }), + }, + { + key: "getHashtagPreferences", + type: "function", + description: + "Returns the cached hashtag preferences snapshot (interests, disinterests, metadata).", + fallback: ({ resolved }) => () => { + const service = resolved.hashtagPreferences || {}; + const interests = + typeof service.getInterests === "function" + ? service.getInterests() + : []; + const disinterests = + typeof service.getDisinterests === "function" + ? service.getDisinterests() + : []; + return { + interests, + disinterests, + eventId: null, + createdAt: null, + loaded: false, + }; + }, + }, + { + key: "describeHashtagPreferencesError", + type: "function", + description: + "Maps hashtag preference errors to user-facing status messages for the modal.", + fallback: () => (_, fallbackMessage) => + typeof fallbackMessage === "string" && fallbackMessage.trim() + ? fallbackMessage.trim() + : "Failed to update hashtag preferences. Please try again.", + }, + { + key: "getProfileCacheEntry", + type: "function", + description: + "Returns the cached profile metadata for a normalized pubkey (if available).", + fallback: () => () => null, + }, + { + key: "batchFetchProfiles", + type: "function", + description: + "Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.", + fallback: () => async () => [], + }, + { + key: "fetchDmRelayHints", + type: "function", + description: + "Fetches DM relay hints (kind 10050) for a given pubkey.", + fallback: () => async () => [], + }, + { + key: "switchProfile", + type: "function", + description: + "Switches the active application profile to the provided pubkey and updates state accordingly.", + fallback: () => async () => ({ switched: false }), + }, + { + key: "removeSavedProfile", + type: "function", + description: + "Removes a saved profile entry and persists the updated collection to storage.", + fallback: () => () => ({ removed: false }), + }, + { + key: "relayManager", + type: "object", + description: + "Shared relay manager instance responsible for the user\'s relay configuration.", + }, + { + key: "userBlocks", + type: "object", + description: "User blocks helper used to read and mutate the local block list.", + }, + { + key: "nostrClient", + type: "object", + description: "Reference to the nostr client powering subscriptions and profile fetches.", + }, + { + key: "subscriptions", + type: "object", + optional: true, + description: + "Subscriptions manager used by the profile modal to inspect or refresh the viewer's channel subscriptions.", + }, + { + key: "moderation", + type: "object", + optional: true, + description: + "Moderation service exposing the viewer's Nostr contacts and trust graph metadata.", + }, + { + key: "accessControl", + type: "object", + description: + "Access control module required for admin list permission checks and updates.", + }, + { + key: "getCurrentUserNpub", + type: "function", + description: "Returns the active user\'s npub so UI actions can target the correct actor.", + fallback: () => () => null, + }, + { + key: "nwcSettings", + type: "object", + description: + "Service that manages Nostr Wallet Connect settings and interactions for the active profile.", + fallback: ({ internalState }) => { + const ensure = () => ensureInternalWalletSettings(internalState); + const service = {}; + + service.createDefaultNwcSettings = () => createInternalDefaultNwcSettings(); + + service.getActiveNwcSettings = () => { + const current = ensure(); + return { ...current }; + }; + + service.updateActiveNwcSettings = (partial = {}) => { + const current = ensure(); + const next = { + ...current, + ...(partial && typeof partial === "object" ? partial : {}), + }; + internalState.walletSettings = next; + return { ...next }; + }; + + service.hydrateNwcSettingsForPubkey = async () => { + return service.getActiveNwcSettings(); + }; + + service.handleProfileWalletPersist = async (options = {}) => { + const { nwcUri, defaultZap, lastChecked } = + options && typeof options === "object" ? options : {}; + const partial = {}; + if (nwcUri !== undefined) { + partial.nwcUri = nwcUri; + } + if (defaultZap !== undefined) { + partial.defaultZap = defaultZap; + } + if (lastChecked !== undefined) { + partial.lastChecked = lastChecked; + } + if (!Object.keys(partial).length) { + return service.getActiveNwcSettings(); + } + return service.updateActiveNwcSettings(partial); + }; + + service.hasActiveWalletConnection = () => { + const settings = ensure(); + const candidate = + typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; + return candidate.length > 0; + }; + + service.validateWalletUri = (uri, { requireValue = false } = {}) => { + const value = typeof uri === "string" ? uri.trim() : ""; + + if (!value) { + if (requireValue) { + return { + valid: false, + sanitized: "", + message: "Enter a wallet connect URI before continuing.", + }; + } + return { valid: true, sanitized: "" }; + } + + if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) { + return { + valid: false, + sanitized: value, + message: `Wallet URI must start with ${NWC_URI_SCHEME}.`, + }; + } + + return { valid: true, sanitized: value }; + }; + + service.ensureWallet = async ({ nwcUri } = {}) => { + const settings = service.getActiveNwcSettings(); + const candidate = + typeof nwcUri === "string" && nwcUri.trim() + ? nwcUri.trim() + : settings.nwcUri || ""; + const { valid, sanitized, message } = service.validateWalletUri( + candidate, + { requireValue: true }, + ); + if (!valid) { + throw new Error(message || "Invalid wallet URI provided."); + } + return { ...settings, nwcUri: sanitized }; + }; + + service.clearStoredNwcSettings = async () => true; + + service.onLogin = async () => service.getActiveNwcSettings(); + + service.onLogout = async () => service.createDefaultNwcSettings(); + + service.onIdentityChanged = () => { + internalState.walletSettings = createInternalDefaultNwcSettings(); + }; + + return service; + }, + }, + { + key: "moderationSettings", + type: "object", + description: + "Service that manages Safety & Moderation threshold overrides for the active profile.", + fallback: ({ internalState }) => { + const ensure = () => ensureInternalModerationSettings(internalState); + const sanitize = (value, fallback) => { + if (value === null || value === undefined) { + return fallback; + } + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return fallback; + } + return Math.max(0, Math.floor(numeric)); + }; + + const service = {}; + + service.getDefaultModerationSettings = () => + createInternalDefaultModerationSettings(); + + service.getActiveModerationSettings = () => { + const current = ensure(); + const defaults = service.getDefaultModerationSettings(); + return { + blurThreshold: sanitize(current.blurThreshold, defaults.blurThreshold), + autoplayBlockThreshold: sanitize( + current.autoplayBlockThreshold, + defaults.autoplayBlockThreshold, + ), + }; + }; + + service.updateModerationSettings = (partial = {}) => { + const defaults = service.getDefaultModerationSettings(); + const current = ensure(); + const next = { ...current }; + + if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) { + const value = partial.blurThreshold; + next.blurThreshold = + value === null + ? defaults.blurThreshold + : sanitize(value, defaults.blurThreshold); + } + + if ( + Object.prototype.hasOwnProperty.call( + partial, + "autoplayBlockThreshold", + ) + ) { + const value = partial.autoplayBlockThreshold; + next.autoplayBlockThreshold = + value === null + ? defaults.autoplayBlockThreshold + : sanitize(value, defaults.autoplayBlockThreshold); + } + + internalState.moderationSettings = next; + return service.getActiveModerationSettings(); + }; + + service.resetModerationSettings = () => { + internalState.moderationSettings = createInternalDefaultModerationSettings(); + return service.getActiveModerationSettings(); + }; + + return service; + }, + }, + { + key: "getModerationOverrides", + type: "function", + description: + "Returns a list of stored moderation overrides for the active viewer.", + fallback: () => () => [], + }, + { + key: "clearModerationOverride", + type: "function", + description: + "Clears a specific moderation override when the viewer resets a decision.", + fallback: () => () => false, + }, + { + key: "loadVideos", + type: "function", + description: "Triggers a video reload so UI reflects profile or permission changes.", + fallback: () => async () => undefined, + }, + { + key: "onVideosShouldRefresh", + type: "function", + description: + "Signals that video listings should be refreshed after a profile-driven mutation.", + fallback: ({ services, resolved }) => async (context = {}) => { + const loader = + typeof services.loadVideos === "function" + ? services.loadVideos + : typeof resolved.loadVideos === "function" + ? resolved.loadVideos + : null; + if (loader) { + return loader(true, context); + } + return undefined; + }, + }, + { + key: "sendAdminListNotification", + type: "function", + description: + "Sends an administrative notification when moderators/whitelist/blacklist entries change.", + optional: true, + }, + { + key: "describeAdminError", + type: "function", + description: + "Maps low-level admin errors to readable copy for surfaced error messages.", + fallback: () => () => "", + }, + { + key: "describeNotificationError", + type: "function", + description: + "Maps notification send errors to human readable descriptions for toast/banner messaging.", + fallback: () => () => "", + }, + { + key: "onAccessControlUpdated", + type: "function", + description: + "Callback invoked after access control mutations so the app can refresh dependent UI.", + fallback: () => async () => undefined, + }, + { + key: "persistSavedProfiles", + type: "function", + description: + "Persists the saved profile collection (and optionally the active profile) to storage.", + fallback: () => () => undefined, + }, +]; + +const STATE_CONTRACT = [ + { + key: "getSavedProfiles", + type: "function", + description: + "Returns the saved profile entries that populate the account switcher UI.", + fallback: (internal) => () => internal.savedProfiles.slice(), + }, + { + key: "setSavedProfiles", + type: "function", + description: + "Replaces the saved profile entries and returns the newly stored collection.", + fallback: (internal) => (profiles = []) => { + internal.savedProfiles = Array.isArray(profiles) + ? profiles.slice() + : []; + return internal.savedProfiles; + }, + }, + { + key: "persistSavedProfiles", + type: "function", + description: + "Persists the saved profile collection (and optionally the active profile) to storage.", + fallback: () => noop, + }, + { + key: "getActivePubkey", + type: "function", + description: "Returns the currently active profile pubkey.", + fallback: (internal) => () => internal.activePubkey, + }, + { + key: "setActivePubkey", + type: "function", + description: "Updates the active profile pubkey and returns the stored value.", + fallback: (internal) => (pubkey) => { + if (typeof pubkey === "string") { + const trimmed = pubkey.trim(); + internal.activePubkey = trimmed ? trimmed : null; + } else { + internal.activePubkey = null; + } + return internal.activePubkey; + }, + }, + { + key: "getCachedSelection", + type: "function", + description: + "Reads the cached profile selection used to restore switcher focus after reloads.", + fallback: (internal) => () => internal.cachedSelection, + }, + { + key: "setCachedSelection", + type: "function", + description: + "Caches the last selected profile identifier and returns the stored value.", + fallback: (internal) => (value) => { + if (typeof value === "string") { + const trimmed = value.trim(); + internal.cachedSelection = trimmed || null; + } else { + internal.cachedSelection = null; + } + return internal.cachedSelection; + }, + }, + { + key: "getActivePane", + type: "function", + description: "Returns the identifier for the currently visible profile modal pane.", + fallback: (internal) => () => internal.activePane, + }, + { + key: "setActivePane", + type: "function", + description: "Updates the active pane identifier and returns the stored value.", + fallback: (internal) => (pane) => { + if (typeof pane === "string") { + const trimmed = pane.trim().toLowerCase(); + internal.activePane = trimmed || "account"; + } else { + internal.activePane = "account"; + } + return internal.activePane; + }, + }, + { + key: "getWalletBusy", + type: "function", + description: + "Indicates whether wallet-related actions are currently in-flight (disables UI as needed).", + fallback: (internal) => () => internal.walletBusy, + }, + { + key: "setWalletBusy", + type: "function", + description: "Updates the wallet busy flag and returns the new boolean state.", + fallback: (internal) => (isBusy) => { + internal.walletBusy = Boolean(isBusy); + return internal.walletBusy; + }, + }, + { + key: "getDmRecipient", + type: "function", + description: + "Returns the currently selected direct message recipient pubkey.", + fallback: (internal) => () => internal.dmRecipient, + }, + { + key: "setDmRecipient", + type: "function", + description: + "Updates the selected direct message recipient pubkey and returns the stored value.", + fallback: (internal) => (pubkey) => { + if (typeof pubkey === "string") { + const trimmed = pubkey.trim(); + internal.dmRecipient = trimmed || null; + } else { + internal.dmRecipient = null; + } + return internal.dmRecipient; + }, + }, + { + key: "getDmRelayHints", + type: "function", + description: + "Returns the cached DM relay hints for a given pubkey.", + fallback: (internal) => (pubkey) => { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized || !(internal.dmRelayHints instanceof Map)) { + return []; + } + const hints = internal.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + }, + }, + { + key: "setDmRelayHints", + type: "function", + description: + "Stores DM relay hints for a given pubkey and returns the stored list.", + fallback: (internal) => (pubkey, hints = []) => { + if (!(internal.dmRelayHints instanceof Map)) { + internal.dmRelayHints = new Map(); + } + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + const stored = Array.isArray(hints) ? hints.slice() : []; + internal.dmRelayHints.set(normalized, stored); + return stored; + }, + }, + { + key: "getDmRelayPreferences", + type: "function", + description: + "Returns the cached DM relay hints for a given pubkey (publisher view).", + fallback: (internal) => (pubkey) => { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized || !(internal.dmRelayHints instanceof Map)) { + return []; + } + const hints = internal.dmRelayHints.get(normalized); + return Array.isArray(hints) ? hints.slice() : []; + }, + }, + { + key: "setDmRelayPreferences", + type: "function", + description: + "Stores DM relay hints for a given pubkey (publisher view).", + fallback: (internal) => (pubkey, hints = []) => { + if (!(internal.dmRelayHints instanceof Map)) { + internal.dmRelayHints = new Map(); + } + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if (!normalized) { + return []; + } + const stored = Array.isArray(hints) ? hints.slice() : []; + internal.dmRelayHints.set(normalized, stored); + return stored; + }, + }, + { + key: "getDmPrivacySettings", + type: "function", + description: + "Returns the DM privacy settings (read receipts and typing indicators) for the active profile.", + fallback: (internal) => () => { + if (!(internal.dmPrivacySettings instanceof Map)) { + internal.dmPrivacySettings = new Map(); + } + const settings = internal.dmPrivacySettings.get("default"); + return settings ? { ...settings } : createInternalDefaultDmPrivacySettings(); + }, + }, + { + key: "setDmPrivacySettings", + type: "function", + description: + "Updates the DM privacy settings (read receipts and typing indicators) for the active profile.", + fallback: (internal) => (settings = {}) => { + if (!(internal.dmPrivacySettings instanceof Map)) { + internal.dmPrivacySettings = new Map(); + } + const base = createInternalDefaultDmPrivacySettings(); + const normalized = { + readReceiptsEnabled: + typeof settings.readReceiptsEnabled === "boolean" + ? settings.readReceiptsEnabled + : base.readReceiptsEnabled, + typingIndicatorsEnabled: + typeof settings.typingIndicatorsEnabled === "boolean" + ? settings.typingIndicatorsEnabled + : base.typingIndicatorsEnabled, + }; + internal.dmPrivacySettings.set("default", normalized); + return { ...normalized }; + }, + }, +]; + +function buildServicesContract(services = {}, internalState) { + const resolved = {}; + const missing = []; + + SERVICE_CONTRACT.forEach((definition) => { + const provided = services[definition.key]; + + if (provided == null) { + if (typeof definition.fallback === "function") { + const fallbackValue = definition.fallback({ + services, + resolved, + internalState, + definition, + }); + + if (definition.type === "function" && typeof fallbackValue === "function") { + resolved[definition.key] = fallbackValue; + return; + } + if ( + definition.type === "object" && + typeof fallbackValue === "object" && + fallbackValue !== null + ) { + resolved[definition.key] = fallbackValue; + return; + } + } + + if (definition.optional) { + return; + } + + missing.push(`- ${definition.key}: ${definition.description}`); + return; + } + + if (definition.type === "function" && typeof provided !== "function") { + throw new TypeError( + `Expected service \"${definition.key}\" to be a function. Received ${typeof provided}.`, + ); + } + if ( + definition.type === "object" && + (typeof provided !== "object" || provided === null) + ) { + throw new TypeError( + `Expected service \"${definition.key}\" to be an object. Received ${typeof provided}.`, + ); + } + + resolved[definition.key] = provided; + }); + + if (missing.length) { + throw new Error( + [ + "[ProfileModalController] Missing required services for profile modal controller:", + ...missing, + ].join("\n"), + ); + } + + return Object.freeze({ ...services, ...resolved }); +} + +function buildStateContract(state = {}, internalState) { + const resolved = {}; + + STATE_CONTRACT.forEach((definition) => { + const value = state[definition.key]; + if (typeof value === definition.type) { + resolved[definition.key] = value; + return; + } + + if (definition.fallback) { + const fallback = definition.fallback(internalState); + if (typeof fallback === definition.type) { + resolved[definition.key] = fallback; + return; + } + } + + throw new Error( + `[ProfileModalController] Missing state handler \"${definition.key}\" (${definition.description}).`, + ); + }); + + return { ...state, ...resolved }; +} + + +export { + PROVIDER_BADGE_BASE_CLASS, + PROVIDER_BADGE_VARIANT_CLASS_MAP, + DEFAULT_INTERNAL_NWC_SETTINGS, + DEFAULT_INTERNAL_MODERATION_SETTINGS, + DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS, + resolveProviderBadgeClass, + createInternalDefaultNwcSettings, + createInternalDefaultModerationSettings, + createInternalDefaultDmPrivacySettings, + ensureInternalModerationSettings, + ensureInternalWalletSettings, + buildServicesContract, + buildStateContract +}; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 4afbb5c6..f3871dd1 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -6,13 +6,20 @@ import { MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP, } from "../config.js"; import { normalizeDesignSystemContext } from "../designSystem.js"; +import { RUNTIME_FLAGS } from "../constants.js"; import { - DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, - DEFAULT_BLUR_THRESHOLD, - DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, - DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, - RUNTIME_FLAGS, -} from "../constants.js"; + PROVIDER_BADGE_BASE_CLASS, + DEFAULT_INTERNAL_MODERATION_SETTINGS, + resolveProviderBadgeClass, + createInternalDefaultNwcSettings, + createInternalDefaultModerationSettings, + createInternalDefaultDmPrivacySettings, + ensureInternalModerationSettings, + ensureInternalWalletSettings, + buildServicesContract, + buildStateContract, + NWC_URI_SCHEME +} from "./profileModalContract.js"; import { getBreakpointLg } from "../designSystem/metrics.js"; import { getProviderMetadata } from "../services/authProviders/index.js"; import { AppShell } from "./dm/index.js"; @@ -53,7 +60,6 @@ const FALLBACK_PROFILE_AVATAR = "assets/svg/default-profile.svg"; const DEFAULT_ADMIN_DM_IMAGE_URL = "https://beta.bitvid.network/assets/jpg/video-thumbnail-fallback.jpg"; const DEFAULT_BITVID_WEBSITE_URL = "https://bitvid.network/"; -const NWC_URI_SCHEME = "nostr+walletconnect://"; const SECRET_PLACEHOLDER = "*****"; const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; @@ -69,896 +75,6 @@ const ADD_PROFILE_CANCELLATION_CODES = new Set([ "modal-dismissed", ]); -const PROVIDER_BADGE_BASE_CLASS = - "text-3xs font-semibold uppercase tracking-extra-wide"; -const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({ - info: "text-status-info", - success: "text-status-success", - warning: "text-status-warning", - danger: "text-status-danger", - neutral: "text-muted", - accent: "text-accent", - primary: "text-accent", -}); - -function resolveProviderBadgeClass(variant) { - if (typeof variant === "string" && variant.trim()) { - const normalized = variant.trim().toLowerCase(); - if (Object.prototype.hasOwnProperty.call(PROVIDER_BADGE_VARIANT_CLASS_MAP, normalized)) { - return PROVIDER_BADGE_VARIANT_CLASS_MAP[normalized]; - } - } - - return PROVIDER_BADGE_VARIANT_CLASS_MAP.neutral; -} - -const DEFAULT_INTERNAL_NWC_SETTINGS = Object.freeze({ - nwcUri: "", - defaultZap: null, - lastChecked: null, -}); - -function createInternalDefaultNwcSettings() { - return { ...DEFAULT_INTERNAL_NWC_SETTINGS }; -} - -const DEFAULT_INTERNAL_MODERATION_SETTINGS = Object.freeze({ - blurThreshold: DEFAULT_BLUR_THRESHOLD, - autoplayBlockThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD, - trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, - trustedSpamHideThreshold: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, -}); - -function createInternalDefaultModerationSettings() { - return { ...DEFAULT_INTERNAL_MODERATION_SETTINGS }; -} - -const DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS = Object.freeze({ - readReceiptsEnabled: false, - typingIndicatorsEnabled: false, -}); - -function createInternalDefaultDmPrivacySettings() { - return { ...DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS }; -} - -function ensureInternalModerationSettings(internalState) { - if (!internalState || typeof internalState !== "object") { - return createInternalDefaultModerationSettings(); - } - - if ( - !internalState.moderationSettings || - typeof internalState.moderationSettings !== "object" || - internalState.moderationSettings === null - ) { - internalState.moderationSettings = createInternalDefaultModerationSettings(); - } - - return internalState.moderationSettings; -} - -function ensureInternalWalletSettings(internalState) { - if (!internalState || typeof internalState !== "object") { - return createInternalDefaultNwcSettings(); - } - - if ( - !internalState.walletSettings || - typeof internalState.walletSettings !== "object" || - internalState.walletSettings === null - ) { - internalState.walletSettings = createInternalDefaultNwcSettings(); - } - - return internalState.walletSettings; -} - -const SERVICE_CONTRACT = [ - { - key: "normalizeHexPubkey", - type: "function", - description: - "Normalizes a provided pubkey (hex or npub) so profile lookups are stable.", - fallback: () => (value) => { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (/^[0-9a-f]{64}$/i.test(trimmed)) { - return trimmed.toLowerCase(); - } - return trimmed; - }, - }, - { - key: "safeEncodeNpub", - type: "function", - description: - "Encodes a hex pubkey as an npub for display. Expected to be resilient to bad input.", - fallback: () => (pubkey) => { - if (typeof pubkey === "string" && pubkey.trim()) { - return pubkey.trim(); - } - return null; - }, - }, - { - key: "safeDecodeNpub", - type: "function", - description: - "Decodes an npub back to its hex form. Should return null/undefined for invalid payloads.", - fallback: () => () => null, - }, - { - key: "truncateMiddle", - type: "function", - description: - "Utility used for shortening long identifiers in the modal without losing context.", - fallback: () => (value) => value, - }, - { - key: "formatShortNpub", - type: "function", - description: - "Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).", - fallback: () => (value) => (typeof value === "string" ? value : ""), - }, - { - key: "requestAddProfileLogin", - type: "function", - description: - "Opens the login modal and resolves with the authentication result for the add-profile flow.", - fallback: () => async () => { - throw new Error("Login service unavailable."); - }, - }, - { - key: "describeLoginError", - type: "function", - description: - "Maps authentication errors to human-readable strings for add-profile messaging.", - fallback: () => (_, fallbackMessage) => - typeof fallbackMessage === "string" && fallbackMessage.trim() - ? fallbackMessage.trim() - : "Failed to login. Please try again.", - }, - { - key: "r2Service", - type: "object", - description: "Service for handling file uploads (R2/S3).", - optional: true, - }, - { - key: "nostrService", - type: "object", - description: - "Provides encrypted direct message helpers for loading and subscribing to inbox updates.", - optional: true, - fallback: () => ({ - getDirectMessages: () => [], - loadDirectMessages: async () => [], - ensureDirectMessageSubscription: () => null, - stopDirectMessageSubscription: () => {}, - clearDirectMessages: () => {}, - on: () => () => {}, - }), - }, - { - key: "hashtagPreferences", - type: "object", - description: - "Service providing access to interest and disinterest hashtag collections.", - fallback: () => ({ - getInterests: () => [], - getDisinterests: () => [], - addInterest: () => false, - removeInterest: () => false, - addDisinterest: () => false, - removeDisinterest: () => false, - publish: async () => false, - on: () => () => {}, - }), - }, - { - key: "getHashtagPreferences", - type: "function", - description: - "Returns the cached hashtag preferences snapshot (interests, disinterests, metadata).", - fallback: ({ resolved }) => () => { - const service = resolved.hashtagPreferences || {}; - const interests = - typeof service.getInterests === "function" - ? service.getInterests() - : []; - const disinterests = - typeof service.getDisinterests === "function" - ? service.getDisinterests() - : []; - return { - interests, - disinterests, - eventId: null, - createdAt: null, - loaded: false, - }; - }, - }, - { - key: "describeHashtagPreferencesError", - type: "function", - description: - "Maps hashtag preference errors to user-facing status messages for the modal.", - fallback: () => (_, fallbackMessage) => - typeof fallbackMessage === "string" && fallbackMessage.trim() - ? fallbackMessage.trim() - : "Failed to update hashtag preferences. Please try again.", - }, - { - key: "getProfileCacheEntry", - type: "function", - description: - "Returns the cached profile metadata for a normalized pubkey (if available).", - fallback: () => () => null, - }, - { - key: "batchFetchProfiles", - type: "function", - description: - "Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.", - fallback: () => async () => [], - }, - { - key: "fetchDmRelayHints", - type: "function", - description: - "Fetches DM relay hints (kind 10050) for a given pubkey.", - fallback: () => async () => [], - }, - { - key: "switchProfile", - type: "function", - description: - "Switches the active application profile to the provided pubkey and updates state accordingly.", - fallback: () => async () => ({ switched: false }), - }, - { - key: "removeSavedProfile", - type: "function", - description: - "Removes a saved profile entry and persists the updated collection to storage.", - fallback: () => () => ({ removed: false }), - }, - { - key: "relayManager", - type: "object", - description: - "Shared relay manager instance responsible for the user\'s relay configuration.", - }, - { - key: "userBlocks", - type: "object", - description: "User blocks helper used to read and mutate the local block list.", - }, - { - key: "nostrClient", - type: "object", - description: "Reference to the nostr client powering subscriptions and profile fetches.", - }, - { - key: "subscriptions", - type: "object", - optional: true, - description: - "Subscriptions manager used by the profile modal to inspect or refresh the viewer's channel subscriptions.", - }, - { - key: "moderation", - type: "object", - optional: true, - description: - "Moderation service exposing the viewer's Nostr contacts and trust graph metadata.", - }, - { - key: "accessControl", - type: "object", - description: - "Access control module required for admin list permission checks and updates.", - }, - { - key: "getCurrentUserNpub", - type: "function", - description: "Returns the active user\'s npub so UI actions can target the correct actor.", - fallback: () => () => null, - }, - { - key: "nwcSettings", - type: "object", - description: - "Service that manages Nostr Wallet Connect settings and interactions for the active profile.", - fallback: ({ internalState }) => { - const ensure = () => ensureInternalWalletSettings(internalState); - const service = {}; - - service.createDefaultNwcSettings = () => createInternalDefaultNwcSettings(); - - service.getActiveNwcSettings = () => { - const current = ensure(); - return { ...current }; - }; - - service.updateActiveNwcSettings = (partial = {}) => { - const current = ensure(); - const next = { - ...current, - ...(partial && typeof partial === "object" ? partial : {}), - }; - internalState.walletSettings = next; - return { ...next }; - }; - - service.hydrateNwcSettingsForPubkey = async () => { - return service.getActiveNwcSettings(); - }; - - service.handleProfileWalletPersist = async (options = {}) => { - const { nwcUri, defaultZap, lastChecked } = - options && typeof options === "object" ? options : {}; - const partial = {}; - if (nwcUri !== undefined) { - partial.nwcUri = nwcUri; - } - if (defaultZap !== undefined) { - partial.defaultZap = defaultZap; - } - if (lastChecked !== undefined) { - partial.lastChecked = lastChecked; - } - if (!Object.keys(partial).length) { - return service.getActiveNwcSettings(); - } - return service.updateActiveNwcSettings(partial); - }; - - service.hasActiveWalletConnection = () => { - const settings = ensure(); - const candidate = - typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; - return candidate.length > 0; - }; - - service.validateWalletUri = (uri, { requireValue = false } = {}) => { - const value = typeof uri === "string" ? uri.trim() : ""; - - if (!value) { - if (requireValue) { - return { - valid: false, - sanitized: "", - message: "Enter a wallet connect URI before continuing.", - }; - } - return { valid: true, sanitized: "" }; - } - - if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) { - return { - valid: false, - sanitized: value, - message: `Wallet URI must start with ${NWC_URI_SCHEME}.`, - }; - } - - return { valid: true, sanitized: value }; - }; - - service.ensureWallet = async ({ nwcUri } = {}) => { - const settings = service.getActiveNwcSettings(); - const candidate = - typeof nwcUri === "string" && nwcUri.trim() - ? nwcUri.trim() - : settings.nwcUri || ""; - const { valid, sanitized, message } = service.validateWalletUri( - candidate, - { requireValue: true }, - ); - if (!valid) { - throw new Error(message || "Invalid wallet URI provided."); - } - return { ...settings, nwcUri: sanitized }; - }; - - service.clearStoredNwcSettings = async () => true; - - service.onLogin = async () => service.getActiveNwcSettings(); - - service.onLogout = async () => service.createDefaultNwcSettings(); - - service.onIdentityChanged = () => { - internalState.walletSettings = createInternalDefaultNwcSettings(); - }; - - return service; - }, - }, - { - key: "moderationSettings", - type: "object", - description: - "Service that manages Safety & Moderation threshold overrides for the active profile.", - fallback: ({ internalState }) => { - const ensure = () => ensureInternalModerationSettings(internalState); - const sanitize = (value, fallback) => { - if (value === null || value === undefined) { - return fallback; - } - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - return fallback; - } - return Math.max(0, Math.floor(numeric)); - }; - - const service = {}; - - service.getDefaultModerationSettings = () => - createInternalDefaultModerationSettings(); - - service.getActiveModerationSettings = () => { - const current = ensure(); - const defaults = service.getDefaultModerationSettings(); - return { - blurThreshold: sanitize(current.blurThreshold, defaults.blurThreshold), - autoplayBlockThreshold: sanitize( - current.autoplayBlockThreshold, - defaults.autoplayBlockThreshold, - ), - }; - }; - - service.updateModerationSettings = (partial = {}) => { - const defaults = service.getDefaultModerationSettings(); - const current = ensure(); - const next = { ...current }; - - if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) { - const value = partial.blurThreshold; - next.blurThreshold = - value === null - ? defaults.blurThreshold - : sanitize(value, defaults.blurThreshold); - } - - if ( - Object.prototype.hasOwnProperty.call( - partial, - "autoplayBlockThreshold", - ) - ) { - const value = partial.autoplayBlockThreshold; - next.autoplayBlockThreshold = - value === null - ? defaults.autoplayBlockThreshold - : sanitize(value, defaults.autoplayBlockThreshold); - } - - internalState.moderationSettings = next; - return service.getActiveModerationSettings(); - }; - - service.resetModerationSettings = () => { - internalState.moderationSettings = createInternalDefaultModerationSettings(); - return service.getActiveModerationSettings(); - }; - - return service; - }, - }, - { - key: "getModerationOverrides", - type: "function", - description: - "Returns a list of stored moderation overrides for the active viewer.", - fallback: () => () => [], - }, - { - key: "clearModerationOverride", - type: "function", - description: - "Clears a specific moderation override when the viewer resets a decision.", - fallback: () => () => false, - }, - { - key: "loadVideos", - type: "function", - description: "Triggers a video reload so UI reflects profile or permission changes.", - fallback: () => async () => undefined, - }, - { - key: "onVideosShouldRefresh", - type: "function", - description: - "Signals that video listings should be refreshed after a profile-driven mutation.", - fallback: ({ services, resolved }) => async (context = {}) => { - const loader = - typeof services.loadVideos === "function" - ? services.loadVideos - : typeof resolved.loadVideos === "function" - ? resolved.loadVideos - : null; - if (loader) { - return loader(true, context); - } - return undefined; - }, - }, - { - key: "sendAdminListNotification", - type: "function", - description: - "Sends an administrative notification when moderators/whitelist/blacklist entries change.", - optional: true, - }, - { - key: "describeAdminError", - type: "function", - description: - "Maps low-level admin errors to readable copy for surfaced error messages.", - fallback: () => () => "", - }, - { - key: "describeNotificationError", - type: "function", - description: - "Maps notification send errors to human readable descriptions for toast/banner messaging.", - fallback: () => () => "", - }, - { - key: "onAccessControlUpdated", - type: "function", - description: - "Callback invoked after access control mutations so the app can refresh dependent UI.", - fallback: () => async () => undefined, - }, - { - key: "persistSavedProfiles", - type: "function", - description: - "Persists the saved profile collection (and optionally the active profile) to storage.", - fallback: () => () => undefined, - }, -]; - -const STATE_CONTRACT = [ - { - key: "getSavedProfiles", - type: "function", - description: - "Returns the saved profile entries that populate the account switcher UI.", - fallback: (internal) => () => internal.savedProfiles.slice(), - }, - { - key: "setSavedProfiles", - type: "function", - description: - "Replaces the saved profile entries and returns the newly stored collection.", - fallback: (internal) => (profiles = []) => { - internal.savedProfiles = Array.isArray(profiles) - ? profiles.slice() - : []; - return internal.savedProfiles; - }, - }, - { - key: "persistSavedProfiles", - type: "function", - description: - "Persists the saved profile collection (and optionally the active profile) to storage.", - fallback: () => noop, - }, - { - key: "getActivePubkey", - type: "function", - description: "Returns the currently active profile pubkey.", - fallback: (internal) => () => internal.activePubkey, - }, - { - key: "setActivePubkey", - type: "function", - description: "Updates the active profile pubkey and returns the stored value.", - fallback: (internal) => (pubkey) => { - if (typeof pubkey === "string") { - const trimmed = pubkey.trim(); - internal.activePubkey = trimmed ? trimmed : null; - } else { - internal.activePubkey = null; - } - return internal.activePubkey; - }, - }, - { - key: "getCachedSelection", - type: "function", - description: - "Reads the cached profile selection used to restore switcher focus after reloads.", - fallback: (internal) => () => internal.cachedSelection, - }, - { - key: "setCachedSelection", - type: "function", - description: - "Caches the last selected profile identifier and returns the stored value.", - fallback: (internal) => (value) => { - if (typeof value === "string") { - const trimmed = value.trim(); - internal.cachedSelection = trimmed || null; - } else { - internal.cachedSelection = null; - } - return internal.cachedSelection; - }, - }, - { - key: "getActivePane", - type: "function", - description: "Returns the identifier for the currently visible profile modal pane.", - fallback: (internal) => () => internal.activePane, - }, - { - key: "setActivePane", - type: "function", - description: "Updates the active pane identifier and returns the stored value.", - fallback: (internal) => (pane) => { - if (typeof pane === "string") { - const trimmed = pane.trim().toLowerCase(); - internal.activePane = trimmed || "account"; - } else { - internal.activePane = "account"; - } - return internal.activePane; - }, - }, - { - key: "getWalletBusy", - type: "function", - description: - "Indicates whether wallet-related actions are currently in-flight (disables UI as needed).", - fallback: (internal) => () => internal.walletBusy, - }, - { - key: "setWalletBusy", - type: "function", - description: "Updates the wallet busy flag and returns the new boolean state.", - fallback: (internal) => (isBusy) => { - internal.walletBusy = Boolean(isBusy); - return internal.walletBusy; - }, - }, - { - key: "getDmRecipient", - type: "function", - description: - "Returns the currently selected direct message recipient pubkey.", - fallback: (internal) => () => internal.dmRecipient, - }, - { - key: "setDmRecipient", - type: "function", - description: - "Updates the selected direct message recipient pubkey and returns the stored value.", - fallback: (internal) => (pubkey) => { - if (typeof pubkey === "string") { - const trimmed = pubkey.trim(); - internal.dmRecipient = trimmed || null; - } else { - internal.dmRecipient = null; - } - return internal.dmRecipient; - }, - }, - { - key: "getDmRelayHints", - type: "function", - description: - "Returns the cached DM relay hints for a given pubkey.", - fallback: (internal) => (pubkey) => { - const normalized = - typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; - if (!normalized || !(internal.dmRelayHints instanceof Map)) { - return []; - } - const hints = internal.dmRelayHints.get(normalized); - return Array.isArray(hints) ? hints.slice() : []; - }, - }, - { - key: "setDmRelayHints", - type: "function", - description: - "Stores DM relay hints for a given pubkey and returns the stored list.", - fallback: (internal) => (pubkey, hints = []) => { - if (!(internal.dmRelayHints instanceof Map)) { - internal.dmRelayHints = new Map(); - } - const normalized = - typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; - if (!normalized) { - return []; - } - const stored = Array.isArray(hints) ? hints.slice() : []; - internal.dmRelayHints.set(normalized, stored); - return stored; - }, - }, - { - key: "getDmRelayPreferences", - type: "function", - description: - "Returns the cached DM relay hints for a given pubkey (publisher view).", - fallback: (internal) => (pubkey) => { - const normalized = - typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; - if (!normalized || !(internal.dmRelayHints instanceof Map)) { - return []; - } - const hints = internal.dmRelayHints.get(normalized); - return Array.isArray(hints) ? hints.slice() : []; - }, - }, - { - key: "setDmRelayPreferences", - type: "function", - description: - "Stores DM relay hints for a given pubkey (publisher view).", - fallback: (internal) => (pubkey, hints = []) => { - if (!(internal.dmRelayHints instanceof Map)) { - internal.dmRelayHints = new Map(); - } - const normalized = - typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; - if (!normalized) { - return []; - } - const stored = Array.isArray(hints) ? hints.slice() : []; - internal.dmRelayHints.set(normalized, stored); - return stored; - }, - }, - { - key: "getDmPrivacySettings", - type: "function", - description: - "Returns the DM privacy settings (read receipts and typing indicators) for the active profile.", - fallback: (internal) => () => { - if (!(internal.dmPrivacySettings instanceof Map)) { - internal.dmPrivacySettings = new Map(); - } - const settings = internal.dmPrivacySettings.get("default"); - return settings ? { ...settings } : createInternalDefaultDmPrivacySettings(); - }, - }, - { - key: "setDmPrivacySettings", - type: "function", - description: - "Updates the DM privacy settings (read receipts and typing indicators) for the active profile.", - fallback: (internal) => (settings = {}) => { - if (!(internal.dmPrivacySettings instanceof Map)) { - internal.dmPrivacySettings = new Map(); - } - const base = createInternalDefaultDmPrivacySettings(); - const normalized = { - readReceiptsEnabled: - typeof settings.readReceiptsEnabled === "boolean" - ? settings.readReceiptsEnabled - : base.readReceiptsEnabled, - typingIndicatorsEnabled: - typeof settings.typingIndicatorsEnabled === "boolean" - ? settings.typingIndicatorsEnabled - : base.typingIndicatorsEnabled, - }; - internal.dmPrivacySettings.set("default", normalized); - return { ...normalized }; - }, - }, -]; - -function buildServicesContract(services = {}, internalState) { - const resolved = {}; - const missing = []; - - SERVICE_CONTRACT.forEach((definition) => { - const provided = services[definition.key]; - - if (provided == null) { - if (typeof definition.fallback === "function") { - const fallbackValue = definition.fallback({ - services, - resolved, - internalState, - definition, - }); - - if (definition.type === "function" && typeof fallbackValue === "function") { - resolved[definition.key] = fallbackValue; - return; - } - if ( - definition.type === "object" && - typeof fallbackValue === "object" && - fallbackValue !== null - ) { - resolved[definition.key] = fallbackValue; - return; - } - } - - if (definition.optional) { - return; - } - - missing.push(`- ${definition.key}: ${definition.description}`); - return; - } - - if (definition.type === "function" && typeof provided !== "function") { - throw new TypeError( - `Expected service \"${definition.key}\" to be a function. Received ${typeof provided}.`, - ); - } - if ( - definition.type === "object" && - (typeof provided !== "object" || provided === null) - ) { - throw new TypeError( - `Expected service \"${definition.key}\" to be an object. Received ${typeof provided}.`, - ); - } - - resolved[definition.key] = provided; - }); - - if (missing.length) { - throw new Error( - [ - "[ProfileModalController] Missing required services for profile modal controller:", - ...missing, - ].join("\n"), - ); - } - - return Object.freeze({ ...services, ...resolved }); -} - -function buildStateContract(state = {}, internalState) { - const resolved = {}; - - STATE_CONTRACT.forEach((definition) => { - const value = state[definition.key]; - if (typeof value === definition.type) { - resolved[definition.key] = value; - return; - } - - if (definition.fallback) { - const fallback = definition.fallback(internalState); - if (typeof fallback === definition.type) { - resolved[definition.key] = fallback; - return; - } - } - - throw new Error( - `[ProfileModalController] Missing state handler \"${definition.key}\" (${definition.description}).`, - ); - }); - - return { ...state, ...resolved }; -} - export class ProfileModalController { constructor(options = {}) { const { diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index 20c3f3dd..0c6c96ec 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": 14159, + "js/ui/profileModalController.js": 13275, "js/ui/components/VideoModal.js": 6284, "js/app.js": 5602, "js/channelProfile.js": 5529,