import { isDevMode, ADMIN_SUPER_NPUB as CONFIG_ADMIN_SUPER_NPUB, ADMIN_DM_IMAGE_URL as CONFIG_ADMIN_DM_IMAGE_URL, BITVID_WEBSITE_URL as CONFIG_BITVID_WEBSITE_URL, MAX_WALLET_DEFAULT_ZAP as CONFIG_MAX_WALLET_DEFAULT_ZAP, ENABLE_NIP17_RELAY_WARNING as CONFIG_ENABLE_NIP17_RELAY_WARNING, } from "../config.js"; import { normalizeDesignSystemContext } from "../designSystem.js"; import { RUNTIME_FLAGS } from "../constants.js"; import { PROVIDER_BADGE_BASE_CLASS, DEFAULT_INTERNAL_MODERATION_SETTINGS, resolveProviderBadgeClass, createInternalDefaultNwcSettings, createInternalDefaultModerationSettings, createInternalDefaultDmPrivacySettings, ensureInternalModerationSettings, ensureInternalWalletSettings, buildServicesContract, buildStateContract, } from "./profileModalContract.js"; 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 { describeAttachment, extractAttachmentsFromMessage, formatAttachmentSize, } from "../attachments/attachmentUtils.js"; import { clearAttachmentCache, downloadAttachment, uploadAttachment, getAttachmentCacheStats, } from "../services/attachmentService.js"; import { getLinkPreviewSettings, setLinkPreviewAutoFetch, } from "../utils/linkPreviewSettings.js"; import { SubscriptionHistoryController } from "./subscriptionHistoryController.js"; import { DMSettingsModalController } from "./dm/DMSettingsModalController.js"; import { ProfileWalletController } from "./profileModal/ProfileWalletController.js"; import { ProfileStorageController } from "./profileModal/ProfileStorageController.js"; const noop = () => {}; 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 SECRET_PLACEHOLDER = "*****"; const DEFAULT_MAX_WALLET_DEFAULT_ZAP = 100000000; const DEFAULT_SAVED_PROFILE_LABEL = "Saved profile"; const TRUSTED_MUTE_HIDE_HELPER_TEXT = "Reaching this count hides cards (with “Show anyway”); lower signals only blur thumbnails or block autoplay."; const TYPING_INDICATOR_TTL_SECONDS = 15; const TYPING_INDICATOR_COOLDOWN_MS = 4000; const DIRECT_MESSAGES_BATCH_DELAY_MS = 250; const ADD_PROFILE_CANCELLATION_CODES = new Set([ "login-cancelled", "user-cancelled", "modal-dismissed", ]); export class ProfileModalController { constructor(options = {}) { const { modalContainer = null, removeTrackingScripts = noop, createWatchHistoryRenderer = null, setGlobalModalState = noop, showError = noop, showSuccess = noop, showStatus = noop, callbacks = {}, services = {}, state = {}, constants: providedConstants = {}, designSystem = null, } = options; this.modalContainer = modalContainer; this.removeTrackingScripts = removeTrackingScripts; this.createWatchHistoryRenderer = createWatchHistoryRenderer; this.setGlobalModalState = setGlobalModalState; this.showError = showError; this.showSuccess = showSuccess; this.showStatus = showStatus; this.designSystem = normalizeDesignSystemContext(designSystem); const resolvedMaxWalletDefaultZap = typeof providedConstants.MAX_WALLET_DEFAULT_ZAP === "number" && Number.isFinite(providedConstants.MAX_WALLET_DEFAULT_ZAP) ? providedConstants.MAX_WALLET_DEFAULT_ZAP : typeof CONFIG_MAX_WALLET_DEFAULT_ZAP === "number" && Number.isFinite(CONFIG_MAX_WALLET_DEFAULT_ZAP) ? CONFIG_MAX_WALLET_DEFAULT_ZAP : DEFAULT_MAX_WALLET_DEFAULT_ZAP; const resolvedAdminSuperNpub = (() => { const fromOptions = typeof providedConstants.ADMIN_SUPER_NPUB === "string" ? providedConstants.ADMIN_SUPER_NPUB.trim() : ""; if (fromOptions) { return fromOptions; } const fromConfig = typeof CONFIG_ADMIN_SUPER_NPUB === "string" ? CONFIG_ADMIN_SUPER_NPUB.trim() : ""; return fromConfig || null; })(); const resolvedAdminDmImageUrl = (() => { const fromOptions = typeof providedConstants.ADMIN_DM_IMAGE_URL === "string" ? providedConstants.ADMIN_DM_IMAGE_URL.trim() : ""; if (fromOptions) { return fromOptions; } const fromConfig = typeof CONFIG_ADMIN_DM_IMAGE_URL === "string" ? CONFIG_ADMIN_DM_IMAGE_URL.trim() : ""; return fromConfig || DEFAULT_ADMIN_DM_IMAGE_URL; })(); const resolvedbitvidWebsiteUrl = (() => { const fromOptions = typeof providedConstants.BITVID_WEBSITE_URL === "string" ? providedConstants.BITVID_WEBSITE_URL.trim() : ""; if (fromOptions) { return fromOptions; } const fromConfig = typeof CONFIG_BITVID_WEBSITE_URL === "string" ? CONFIG_BITVID_WEBSITE_URL.trim() : ""; return fromConfig || DEFAULT_BITVID_WEBSITE_URL; })(); const resolvedEnableNip17RelayWarning = (() => { if (typeof providedConstants.ENABLE_NIP17_RELAY_WARNING === "boolean") { return providedConstants.ENABLE_NIP17_RELAY_WARNING; } return typeof CONFIG_ENABLE_NIP17_RELAY_WARNING === "boolean" ? CONFIG_ENABLE_NIP17_RELAY_WARNING : true; })(); this.maxWalletDefaultZap = resolvedMaxWalletDefaultZap; this.adminSuperNpub = resolvedAdminSuperNpub; this.adminDmImageUrl = resolvedAdminDmImageUrl; this.bitvidWebsiteUrl = resolvedbitvidWebsiteUrl; this.enableNip17RelayWarning = resolvedEnableNip17RelayWarning; this.hasShownRelayWarning = false; this.internalState = { savedProfiles: [], activePubkey: null, cachedSelection: null, activePane: "account", walletBusy: false, walletSettings: createInternalDefaultNwcSettings(), moderationSettings: createInternalDefaultModerationSettings(), dmRecipient: null, dmRelayHints: new Map(), }; this.dmMobileView = "list"; this.services = buildServicesContract(services, this.internalState); this.state = buildStateContract(state, this.internalState); this.normalizeHexPubkey = this.services.normalizeHexPubkey; this.safeEncodeNpub = this.services.safeEncodeNpub; this.safeDecodeNpub = this.services.safeDecodeNpub; this.truncateMiddle = this.services.truncateMiddle; this.formatShortNpub = this.services.formatShortNpub; this.moderationSettingsDefaults = createInternalDefaultModerationSettings(); this.currentModerationSettings = createInternalDefaultModerationSettings(); this.sendAdminListNotificationService = typeof this.services.sendAdminListNotification === "function" ? this.services.sendAdminListNotification : null; this.describeAdminErrorService = this.services.describeAdminError; this.describeNotificationErrorService = this.services.describeNotificationError; this.describeLoginErrorService = this.services.describeLoginError; this.requestAddProfileLoginService = this.services.requestAddProfileLogin; this.log = typeof this.services.log === "function" ? this.services.log : (...args) => { devLogger.log(...args); }; this.nostrService = this.services.nostrService && typeof this.services.nostrService === "object" ? this.services.nostrService : null; const subscriptionsServiceCandidate = this.services.subscriptions; this.subscriptionsService = subscriptionsServiceCandidate && typeof subscriptionsServiceCandidate === "object" ? subscriptionsServiceCandidate : null; const moderationServiceCandidate = this.services.moderation; this.moderationService = moderationServiceCandidate && typeof moderationServiceCandidate === "object" ? moderationServiceCandidate : null; this.unsubscribeModerationContacts = null; this.unsubscribeModerationStats = []; if ( this.moderationService && typeof this.moderationService.on === "function" ) { try { const updateTrustStats = () => { this.updateModerationTrustStats(); }; this.unsubscribeModerationContacts = this.moderationService.on( "contacts", () => { void this.populateFriendsList(); updateTrustStats(); }, ); this.unsubscribeModerationStats = [ this.moderationService.on("trusted-mutes", updateTrustStats), this.moderationService.on("summary", updateTrustStats), ].filter((unsubscribe) => typeof unsubscribe === "function"); } catch (error) { devLogger.warn( "[profileModal] Failed to subscribe to moderation contacts updates:", error, ); } } 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.initializeDirectMessagesService(); this.callbacks = { onClose: callbacks.onClose || noop, onLogout: callbacks.onLogout || noop, onChannelLink: callbacks.onChannelLink || noop, onAddAccount: callbacks.onAddAccount || noop, onRequestLogoutProfile: callbacks.onRequestLogoutProfile || noop, onSelectPane: callbacks.onSelectPane || noop, onPaneShown: callbacks.onPaneShown || noop, onAddRelay: callbacks.onAddRelay || noop, onRestoreRelays: callbacks.onRestoreRelays || noop, onAddBlocked: callbacks.onAddBlocked || noop, onWalletSave: callbacks.onWalletSave || noop, onWalletTest: callbacks.onWalletTest || noop, onWalletDisconnect: callbacks.onWalletDisconnect || noop, onAdminAddModerator: callbacks.onAdminAddModerator || noop, onAdminAddWhitelist: callbacks.onAdminAddWhitelist || noop, onAdminAddBlacklist: callbacks.onAdminAddBlacklist || noop, onAdminRemoveModerator: callbacks.onAdminRemoveModerator || noop, onAdminRemoveWhitelist: callbacks.onAdminRemoveWhitelist || noop, onAdminRemoveBlacklist: callbacks.onAdminRemoveBlacklist || noop, onHistoryReady: callbacks.onHistoryReady || noop, onRequestSwitchProfile: callbacks.onRequestSwitchProfile || noop, onRelayOperation: callbacks.onRelayOperation || noop, onRelayModeToggle: callbacks.onRelayModeToggle || noop, onRelayRestore: callbacks.onRelayRestore || noop, onBlocklistMutation: callbacks.onBlocklistMutation || noop, onWalletPersist: callbacks.onWalletPersist || noop, onWalletTestRequest: callbacks.onWalletTestRequest || callbacks.onWalletTest || noop, onWalletDisconnectRequest: callbacks.onWalletDisconnectRequest || callbacks.onWalletDisconnect || noop, onAdminMutation: callbacks.onAdminMutation || noop, onAdminNotifyError: callbacks.onAdminNotifyError || noop, onModerationSettingsChange: callbacks.onModerationSettingsChange || noop, onSendDm: callbacks.onSendDm || noop, onTogglePrivacy: callbacks.onTogglePrivacy || noop, onOpenRelays: callbacks.onOpenRelays || noop, onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop, onRequestPermissionPrompt: callbacks.onRequestPermissionPrompt || noop, onRetryAuthSync: callbacks.onRetryAuthSync || noop, }; this.profileModal = null; this.profileModalRoot = null; this.profileModalPanel = null; this.profileModalBackdrop = null; this.profileModalLayout = null; this.profileModalMenu = null; this.profileModalPaneWrapper = null; this.profileModalBackButton = null; this.profileAvatar = null; this.profileName = null; this.profileNpub = null; this.switcherList = null; this.profileModalAvatar = null; this.profileModalName = null; this.profileModalNpub = null; this.profileSwitcherList = null; this.globalProfileAvatar = null; this.closeButton = null; this.logoutButton = null; this.mobileLogoutButton = null; this.channelLink = null; this.addAccountButton = null; this.navButtons = { account: null, relays: null, wallet: null, storage: null, hashtags: null, subscriptions: null, friends: null, blocked: null, messages: null, history: null, admin: null, safety: null, }; this.panes = { account: null, relays: null, wallet: null, storage: null, hashtags: null, subscriptions: null, friends: null, blocked: null, messages: null, history: null, admin: null, safety: null, }; this.relayList = null; this.relayInput = null; this.addRelayButton = null; this.restoreRelaysButton = null; this.relayHealthStatus = null; this.relayHealthTelemetryToggle = null; this.relayHealthRefreshPromise = null; this.profileRelayList = null; this.profileRelayInput = null; this.profileAddRelayBtn = null; this.profileRestoreRelaysBtn = null; this.subscriptionList = null; this.subscriptionListEmpty = null; this.friendList = null; this.friendListEmpty = null; this.friendInput = null; this.addFriendButton = null; this.blockList = null; this.blockListEmpty = null; this.blockListStatus = null; this.blockListLoadingState = "idle"; this.blockInput = null; this.addBlockedButton = null; this.profileBlockedList = null; this.profileBlockedEmpty = null; this.profileBlockedInput = null; this.profileAddBlockedBtn = null; this.profileMessagesList = null; this.profileMessagesEmpty = null; this.profileMessagesLoading = null; this.profileMessagesError = null; this.profileMessagesStatus = null; this.profileMessagesReloadButton = null; this.profileMessagesPane = null; this.profileMessagesConversation = null; this.profileMessagesConversationEmpty = null; this.profileMessageInput = null; this.profileMessageSendButton = null; this.profileMessageAttachmentInput = null; this.profileMessageAttachmentButton = null; this.profileMessageAttachmentEncrypt = null; this.profileMessageAttachmentList = null; this.profileMessageAttachmentClearCache = null; this.profileMessagesComposerHelper = null; this.profileMessagesSendDmButton = null; this.profileMessagesOpenRelaysButton = null; this.profileMessagesPrivacyToggle = null; this.profileMessagesPrivacyMode = null; this.profileMessagesRelayList = null; this.profileMessagesRelayInput = null; this.profileMessagesRelayAddButton = null; this.profileMessagesRelayPublishButton = null; this.profileMessagesRelayStatus = null; this.profileMessagesUnreadDot = null; this.dmAppShellContainer = null; this.dmAppShell = null; this.profileLinkPreviewAutoToggle = null; this.walletUriInput = null; this.walletDefaultZapInput = null; this.walletSaveButton = null; this.walletTestButton = null; 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; this.permissionPromptCtaMessage = null; this.permissionPromptCtaButton = null; this.permissionPromptCtaState = { visible: false, message: "", buttonLabel: "Enable permissions", busy: false, action: "permission", }; this.authLoadingStateListener = null; this.profileSubscriptionsRefreshBtn = null; this.profileFriendsRefreshBtn = null; this.profileBlockedRefreshBtn = null; this.profileRelayRefreshBtn = null; this.moderationSettingsCard = null; this.moderationBlurInput = null; this.moderationAutoplayInput = null; this.moderationMuteHideInput = null; this.moderationSpamHideInput = null; this.moderationSaveButton = null; this.moderationResetButton = null; this.moderationStatusText = null; this.moderationOverridesList = null; this.moderationOverridesEmpty = null; this.moderationHideControlsGroup = null; this.moderationHideControlElements = []; this.boundModerationOverridesUpdate = null; this.moderatorSection = null; this.moderatorEmpty = null; this.adminModeratorList = null; this.addModeratorButton = null; this.moderatorInput = null; this.adminModeratorsRefreshBtn = null; this.adminModeratorsSection = null; this.adminModeratorsEmpty = null; this.adminAddModeratorButton = null; this.adminModeratorInput = null; this.whitelistSection = null; this.whitelistEmpty = null; this.whitelistList = null; this.addWhitelistButton = null; this.whitelistInput = null; this.adminWhitelistRefreshBtn = null; this.adminWhitelistSection = null; this.adminWhitelistEmpty = null; this.adminWhitelistList = null; this.adminAddWhitelistButton = null; this.adminWhitelistInput = null; this.blacklistSection = null; this.blacklistEmpty = null; this.blacklistList = null; this.addBlacklistButton = null; this.blacklistInput = null; this.adminBlacklistRefreshBtn = null; this.adminBlacklistSection = null; this.adminBlacklistEmpty = null; this.adminBlacklistList = null; this.adminAddBlacklistButton = null; this.adminBlacklistInput = null; this.messagesLoadingState = "idle"; this.messagesInitialLoadPending = true; this.messagesViewActive = false; this.activeMessagesRequest = null; this.directMessagesCache = []; this.directMessagesLastActor = null; this.directMessagesSubscription = null; this.directMessagesUnsubscribes = []; this.directMessagesRenderTimeout = null; this.pendingDirectMessagesUpdate = null; this.pendingMessagesRender = null; this.messagesStatusClearTimeout = null; this.profileMessagesRenderToken = 0; this.dmPrivacyToggleTouched = false; this.dmReadReceiptCache = new Set(); this.dmTypingLastSentAt = 0; this.dmAttachmentQueue = []; this.dmAttachmentUploads = new Map(); this.activeDmConversationId = ""; this.focusedDmConversationId = ""; this.dmComposerState = "idle"; this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; this.boundProfileHistoryVisibility = null; this.boundKeydown = null; this.boundFocusIn = null; this.focusableElements = []; this.focusTrapContainer = null; this.focusTrapSuspended = false; this.focusTrapSuspendCount = 0; this.focusTrapAriaHiddenBeforeSuspend = null; this.focusTrapNestedModalActiveBeforeSuspend = null; this.profileSwitcherSelectionPubkey = null; this.previouslyFocusedElement = null; this.largeLayoutQuery = null; this.largeLayoutQueryListener = null; this.isLargeLayoutActiveFlag = false; this.walletController = new ProfileWalletController(this); this.storageController = new ProfileStorageController(this); this.mobileViewState = "menu"; this.lastMobileViewState = "menu"; this.setActivePane(this.getActivePane()); this.walletController.setWalletPaneBusy(this.walletController.isWalletBusy()); this.addAccountButtonState = null; this.adminEmptyMessages = new Map(); this.hashtagPreferencesUnsubscribe = null; this.subscriptionHistoryController = new SubscriptionHistoryController(); this.dmSettingsModalController = new DMSettingsModalController(); if ( this.subscriptionsService && typeof this.subscriptionsService.on === "function" ) { this.subscriptionsService.on("change", (detail) => { this.handleSubscriptionsChange(detail); }); } if ( this.services.userBlocks && typeof this.services.userBlocks.on === "function" ) { this.services.userBlocks.on("change", () => { this.populateBlockedList(); }); } } async load() { if (!(this.modalContainer instanceof HTMLElement)) { throw new Error("profile modal container missing"); } // Invalidate any stale renderer reference so it is recreated // with the freshly loaded DOM elements. if (this.profileHistoryRenderer) { try { if (typeof this.profileHistoryRenderer.destroy === "function") { this.profileHistoryRenderer.destroy(); } } catch (err) { devLogger.warn("[profileModal] Failed to destroy stale history renderer:", err); } this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; } const response = await fetch("components/profile-modal.html"); if (!response.ok) { throw new Error(`Failed to load profile modal HTML (${response.status})`); } const html = await response.text(); const template = document.createElement("template"); template.innerHTML = html; this.removeTrackingScripts(template.content); const modalRoot = template.content.querySelector("#profileModal"); if (!(modalRoot instanceof HTMLElement)) { throw new Error("profile modal markup missing expected #profileModal root"); } this.modalContainer.appendChild(template.content); this.profileModalRoot = modalRoot; this.profileModal = modalRoot; this.profileModalPanel = modalRoot.querySelector(".bv-modal__panel") || modalRoot; this.profileModalBackdrop = modalRoot.querySelector(".bv-modal-backdrop") || null; this.cacheDomReferences(); this.setupLayoutBreakpointObserver(); this.applyModalStackingOverrides(); this.registerEventListeners(); if (!this.authLoadingStateListener && typeof window !== "undefined") { this.authLoadingStateListener = (event) => { this.handleAuthLoadingStateChange(event?.detail || {}); }; window.addEventListener( "bitvid:auth-loading-state", this.authLoadingStateListener, ); } this.populateHashtagPreferences(); this.refreshModerationSettingsUi(); const preserveMenu = this.isMobileLayoutActive(); this.selectPane(this.getActivePane(), { keepMenuView: preserveMenu }); return true; } cacheDomReferences() { this.profileModalRoot = document.getElementById("profileModal") || null; this.profileModalPanel = this.profileModalRoot?.querySelector(".bv-modal__panel") || null; this.profileModalBackdrop = this.profileModalRoot?.querySelector(".bv-modal-backdrop") || null; this.profileModalLayout = this.profileModalRoot?.querySelector("[data-profile-layout]") || null; this.profileModalMenu = this.profileModalRoot?.querySelector("[data-profile-mobile-menu]") || null; this.profileModalPaneWrapper = this.profileModalRoot?.querySelector("[data-profile-mobile-pane]") || null; this.profileModal = this.profileModalRoot; this.permissionPromptCta = document.getElementById("profilePermissionPrompt") || null; this.permissionPromptCtaMessage = document.getElementById("profilePermissionPromptMessage") || null; this.permissionPromptCtaButton = document.getElementById("profilePermissionPromptButton") || null; this.closeButton = document.getElementById("closeProfileModal") || null; this.profileModalBackButton = document.getElementById("profileModalBack") || null; this.logoutButton = document.getElementById("profileLogoutBtn") || null; this.mobileLogoutButton = document.getElementById("profileMobileLogoutBtn") || null; this.channelLink = document.getElementById("profileChannelLink") || null; this.addAccountButton = document.getElementById("profileAddAccountBtn") || null; this.profileAvatar = document.getElementById("profileModalAvatar") || null; this.profileName = document.getElementById("profileModalName") || null; this.profileNpub = document.getElementById("profileModalNpub") || null; this.switcherList = document.getElementById("profileSwitcherList") || null; this.profileModalAvatar = this.profileAvatar; this.profileModalName = this.profileName; this.profileModalNpub = this.profileNpub; this.profileSwitcherList = this.switcherList; const topLevelProfileAvatar = document.getElementById("profileAvatar") || null; if (topLevelProfileAvatar) { this.globalProfileAvatar = topLevelProfileAvatar; } this.navButtons.account = document.getElementById("profileNavAccount") || null; this.navButtons.relays = document.getElementById("profileNavRelays") || null; this.navButtons.wallet = document.getElementById("profileNavWallet") || null; this.navButtons.storage = document.getElementById("profileNavStorage") || null; this.navButtons.hashtags = document.getElementById("profileNavHashtags") || null; this.navButtons.subscriptions = document.getElementById("profileNavSubscriptions") || null; this.navButtons.friends = document.getElementById("profileNavFriends") || null; this.navButtons.blocked = document.getElementById("profileNavBlocked") || null; this.navButtons.messages = document.getElementById("profileNavMessages") || null; this.navButtons.history = document.getElementById("profileNavHistory") || null; this.navButtons.admin = document.getElementById("profileNavAdmin") || null; this.navButtons.safety = document.getElementById("profileNavSafety") || null; this.profileEditBtn = document.getElementById("profileEditBtn") || null; this.profileEditBackBtn = document.getElementById("profileEditBackBtn") || null; this.panes.account = document.getElementById("profilePaneAccount") || null; this.panes.edit = document.getElementById("profilePaneEdit") || null; this.panes.relays = document.getElementById("profilePaneRelays") || null; this.panes.wallet = document.getElementById("profilePaneWallet") || null; this.panes.storage = document.getElementById("profilePaneStorage") || null; this.panes.hashtags = document.getElementById("profilePaneHashtags") || null; this.panes.subscriptions = document.getElementById("profilePaneSubscriptions") || null; this.panes.friends = document.getElementById("profilePaneFriends") || null; this.panes.blocked = document.getElementById("profilePaneBlocked") || null; this.panes.messages = document.getElementById("profilePaneMessages") || null; this.panes.history = document.getElementById("profilePaneHistory") || null; this.panes.admin = document.getElementById("profilePaneAdmin") || null; this.panes.safety = document.getElementById("profilePaneSafety") || null; this.relayList = document.getElementById("relayList") || null; this.relayInput = document.getElementById("relayInput") || null; this.addRelayButton = document.getElementById("addRelayBtn") || null; this.restoreRelaysButton = document.getElementById("restoreRelaysBtn") || null; this.relayHealthStatus = document.getElementById("relayHealthStatus") || null; this.relayHealthTelemetryToggle = document.getElementById("relayHealthTelemetryOptIn") || null; this.profileRelayRefreshBtn = document.getElementById("relayListRefreshBtn") || null; this.subscriptionList = document.getElementById("subscriptionsList") || null; this.subscriptionListEmpty = document.getElementById("subscriptionsEmpty") || null; this.profileSubscriptionsRefreshBtn = document.getElementById("subscriptionsRefreshBtn") || null; this.profileSubscriptionsHistoryBtn = document.getElementById("subscriptionsHistoryBtn") || null; this.profileSubscriptionsBackupBtn = document.getElementById("subscriptionsBackupBtn") || null; this.friendList = document.getElementById("friendsList") || null; this.friendListEmpty = document.getElementById("friendsEmpty") || null; this.friendInput = document.getElementById("friendsInput") || null; this.addFriendButton = document.getElementById("addFriendBtn") || null; this.profileFriendsRefreshBtn = document.getElementById("friendsRefreshBtn") || null; this.blockList = document.getElementById("blockedList") || null; this.blockListEmpty = document.getElementById("blockedEmpty") || null; this.blockInput = document.getElementById("blockedInput") || null; this.addBlockedButton = document.getElementById("addBlockedBtn") || null; this.profileBlockedRefreshBtn = document.getElementById("blockedRefreshBtn") || null; this.profileMessagesPane = document.getElementById("profilePaneMessages") || null; this.profileMessagesList = document.getElementById("profileMessagesList") || null; this.profileMessagesEmpty = document.getElementById("profileMessagesEmpty") || null; this.profileMessagesLoading = document.getElementById("profileMessagesLoading") || null; this.profileMessagesError = document.getElementById("profileMessagesError") || null; this.profileMessagesStatus = document.getElementById("profileMessagesStatus") || null; this.profileMessagesReloadButton = document.getElementById("profileMessagesReload") || null; this.profileMessagesConversation = document.getElementById("profileMessagesConversation") || null; this.profileMessagesConversationEmpty = document.getElementById("profileMessagesConversationEmpty") || null; this.profileMessageInput = document.getElementById("profileMessageInput") || null; this.profileMessageSendButton = document.getElementById("profileMessageSendBtn") || null; this.profileMessageAttachmentInput = document.getElementById("profileMessageAttachmentInput") || null; this.profileMessageAttachmentButton = document.getElementById("profileMessageAttachmentButton") || null; this.profileMessageAttachmentEncrypt = document.getElementById("profileMessageAttachmentEncrypt") || null; this.profileMessageAttachmentList = document.getElementById("profileMessageAttachmentList") || null; this.profileMessageAttachmentClearCache = document.getElementById("profileMessageAttachmentClearCache") || null; this.profileMessagesComposerHelper = document.getElementById("profileMessagesComposerHelper") || null; this.profileMessagesSendDmButton = document.getElementById("profileMessagesSendDm") || null; this.profileMessagesOpenRelaysButton = document.getElementById("profileMessagesOpenRelays") || null; this.profileMessagesPrivacyToggle = document.getElementById("profileMessagesPrivacyToggle") || null; this.profileMessagesPrivacyMode = document.getElementById("profileMessagesPrivacyMode") || null; this.cacheDmRelayElements(); this.profileMessagesUnreadDot = document.getElementById("profileMessagesUnreadDot") || null; this.dmAppShellContainer = document.getElementById("dmAppShellMount") || null; this.profileLinkPreviewAutoToggle = document.getElementById("profileLinkPreviewAutoToggle") || null; this.walletController.cacheDomReferences(); this.storageController.cacheDomReferences(); if (this.pendingMessagesRender) { const { messages, actorPubkey } = this.pendingMessagesRender; this.pendingMessagesRender = null; void this.renderProfileMessages(messages, { actorPubkey }).catch((error) => { devLogger.warn( "[profileModal] Failed to render pending direct messages:", error, ); }); } else if ( (this.profileMessagesList instanceof HTMLElement || this.dmAppShellContainer instanceof HTMLElement) && Array.isArray(this.directMessagesCache) && this.directMessagesCache.length ) { void this.renderProfileMessages(this.directMessagesCache, { actorPubkey: this.directMessagesLastActor, }).catch((error) => { devLogger.warn( "[profileModal] Failed to render cached direct messages:", error, ); }); } this.setMessagesLoadingState(this.messagesLoadingState || "idle"); this.updateMessagePrivacyModeDisplay(); this.populateDmRelayPreferences(); this.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.subscriptionsStatusText = document.getElementById("subscriptionsStatus") || null; this.applyPermissionPromptCtaState(); this.profileRelayList = this.relayList; this.profileRelayInput = this.relayInput; this.profileAddRelayBtn = this.addRelayButton; this.profileRestoreRelaysBtn = this.restoreRelaysButton; this.profileSubscriptionsList = this.subscriptionList; this.profileSubscriptionsEmpty = this.subscriptionListEmpty; this.profileFriendsList = this.friendList; this.profileFriendsEmpty = this.friendListEmpty; this.profileFriendsInput = this.friendInput; this.profileAddFriendBtn = this.addFriendButton; this.profileBlockedList = this.blockList; this.profileBlockedEmpty = this.blockListEmpty; this.profileBlockedInput = this.blockInput; this.profileAddBlockedBtn = this.addBlockedButton; this.blockListStatus = this.panes.blocked?.querySelector("[data-role=\"blocked-list-status\"]") || null; this.moderationSettingsCard = document.getElementById("profileModerationSettings") || null; this.moderationBlurInput = document.getElementById("profileModerationBlurThreshold") || null; this.moderationAutoplayInput = document.getElementById("profileModerationAutoplayThreshold") || null; this.moderationMuteHideInput = document.getElementById("profileModerationMuteHideThreshold") || null; this.moderationSpamHideInput = document.getElementById("profileModerationSpamHideThreshold") || null; this.moderationSaveButton = document.getElementById("profileModerationSave") || null; this.moderationResetButton = document.getElementById("profileModerationReset") || null; this.moderationStatusText = document.getElementById("profileModerationStatus") || null; this.moderationOverridesList = document.getElementById("profileModerationOverridesList") || null; this.moderationOverridesEmpty = document.getElementById("profileModerationOverridesEmpty") || null; this.moderationTrustedContactsCount = document.getElementById("profileModerationTrustedContactsCount") || null; this.moderationTrustedMuteCount = document.getElementById("profileModerationTrustedMuteCount") || null; this.moderationTrustedReportCount = document.getElementById("profileModerationTrustedReportCount") || null; this.moderationSeedOnlyIndicator = document.getElementById("profileModerationSeedOnlyIndicator") || null; this.moderationHideControlsGroup = this.moderationSettingsCard?.querySelector( "[data-role=\"trusted-hide-controls\"]", ) || null; this.moderationHideControlElements = Array.from( this.moderationSettingsCard?.querySelectorAll( "[data-role=\"trusted-hide-control\"]", ) || [], ); this.updateTrustedMuteHideHelperCopy(); this.moderatorSection = document.getElementById("adminModeratorsSection") || null; this.moderatorEmpty = document.getElementById("adminModeratorsEmpty") || null; this.adminModeratorList = document.getElementById("adminModeratorList") || null; this.addModeratorButton = document.getElementById("adminAddModeratorBtn") || null; this.moderatorInput = document.getElementById("adminModeratorInput") || null; this.adminModeratorsRefreshBtn = document.getElementById("adminModeratorsRefreshBtn") || null; // Backwards-compatible aliases retained for application code that still // mirrors DOM references from the controller. These should be removed once // the application stops reaching through the controller. this.adminModeratorsSection = this.moderatorSection; this.adminModeratorsEmpty = this.moderatorEmpty; this.adminAddModeratorButton = this.addModeratorButton; this.adminModeratorInput = this.moderatorInput; this.whitelistSection = document.getElementById("adminWhitelistSection") || null; this.whitelistEmpty = document.getElementById("adminWhitelistEmpty") || null; this.whitelistList = document.getElementById("adminWhitelistList") || null; this.addWhitelistButton = document.getElementById("adminAddWhitelistBtn") || null; this.whitelistInput = document.getElementById("adminWhitelistInput") || null; this.adminWhitelistRefreshBtn = document.getElementById("adminWhitelistRefreshBtn") || null; this.blacklistSection = document.getElementById("adminBlacklistSection") || null; this.blacklistEmpty = document.getElementById("adminBlacklistEmpty") || null; this.blacklistList = document.getElementById("adminBlacklistList") || null; this.addBlacklistButton = document.getElementById("adminAddBlacklistBtn") || null; this.blacklistInput = document.getElementById("adminBlacklistInput") || null; this.adminBlacklistRefreshBtn = document.getElementById("adminBlacklistRefreshBtn") || null; const ensureAriaLabel = (button, label) => { if (!(button instanceof HTMLElement)) { return; } if (!button.getAttribute("aria-label")) { button.setAttribute("aria-label", label); } }; ensureAriaLabel(this.profileRelayRefreshBtn, "Refresh relay list"); ensureAriaLabel( this.profileHashtagInterestRefreshBtn, "Refresh interest hashtags", ); ensureAriaLabel( this.profileHashtagDisinterestRefreshBtn, "Refresh disinterest hashtags", ); ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions"); ensureAriaLabel(this.profileFriendsRefreshBtn, "Refresh friends"); ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh muted & blocked users"); ensureAriaLabel(this.adminModeratorsRefreshBtn, "Refresh moderators"); ensureAriaLabel(this.adminWhitelistRefreshBtn, "Refresh whitelist"); ensureAriaLabel(this.adminBlacklistRefreshBtn, "Refresh blacklist"); this.adminWhitelistSection = this.whitelistSection; this.adminWhitelistEmpty = this.whitelistEmpty; this.adminWhitelistList = this.whitelistList; this.adminAddWhitelistButton = this.addWhitelistButton; this.adminWhitelistInput = this.whitelistInput; this.adminBlacklistSection = this.blacklistSection; this.adminBlacklistEmpty = this.blacklistEmpty; this.adminBlacklistList = this.blacklistList; this.adminAddBlacklistButton = this.addBlacklistButton; this.adminBlacklistInput = this.blacklistInput; this.editNameInput = document.getElementById("editNameInput") || null; this.editDisplayNameInput = document.getElementById("editDisplayNameInput") || null; this.editAboutInput = document.getElementById("editAboutInput") || null; this.editWebsiteInput = document.getElementById("editWebsiteInput") || null; this.editNip05Input = document.getElementById("editNip05Input") || null; this.editLud16Input = document.getElementById("editLud16Input") || null; this.editPictureInput = document.getElementById("editPictureInput") || null; this.editBannerInput = document.getElementById("editBannerInput") || null; this.editPictureFile = document.getElementById("editPictureFile") || null; this.editPictureUploadBtn = document.getElementById("editPictureUploadBtn") || null; this.editPictureStorageHint = document.getElementById("editPictureStorageHint") || null; this.editPictureConfigureLink = document.getElementById("editPictureConfigureLink") || null; this.editBannerFile = document.getElementById("editBannerFile") || null; this.editBannerUploadBtn = document.getElementById("editBannerUploadBtn") || null; this.editBannerStorageHint = document.getElementById("editBannerStorageHint") || null; this.editBannerConfigureLink = document.getElementById("editBannerConfigureLink") || null; this.editSaveBtn = document.getElementById("editSaveBtn") || null; this.editCancelBtn = document.getElementById("editCancelBtn") || null; this.editStatusText = document.getElementById("editStatusText") || null; if (this.createWatchHistoryRenderer) { this.ensureProfileHistoryRenderer(); } } ensureProfileHistoryRenderer() { if (this.profileHistoryRenderer) { return this.profileHistoryRenderer; } if (!this.createWatchHistoryRenderer) { return null; } const config = this.getProfileHistoryRendererConfig(); try { this.profileHistoryRenderer = this.createWatchHistoryRenderer({ ...config, container: this.profileModalPanel || this.profileModalRoot, }); } catch (error) { userLogger.error( "[profileModal] Failed to create watch history renderer:", error, ); this.profileHistoryRenderer = null; } return this.profileHistoryRenderer; } getProfileHistoryRendererConfig() { if (this.profileHistoryRendererConfig) { return this.profileHistoryRendererConfig; } this.profileHistoryRendererConfig = { viewSelector: "#profilePaneHistory", gridSelector: "#profileHistoryGrid", loadingSelector: "#profileHistoryLoading", statusSelector: "#profileHistoryStatus", emptySelector: "#profileHistoryEmpty", sentinelSelector: "#profileHistorySentinel", scrollContainerSelector: "#profileModalPanes", errorBannerSelector: "#profileHistoryError", clearButtonSelector: "#profileHistoryClear", refreshButtonSelector: "#profileHistoryRefresh", privacyBannerSelector: "#profileHistoryPrivacyBanner", privacyMessageSelector: "#profileHistoryPrivacyMessage", privacyToggleSelector: "#profileHistoryPrivacyToggle", privacyDismissSelector: "#profileHistoryPrivacyDismiss", infoSelector: "#profileHistoryInfo", featureBannerSelector: "#profileHistoryFeatureBanner", sessionWarningSelector: "#profileHistorySessionWarning", emptyCopy: "You haven’t watched any videos yet.", variant: "modal", remove: (payload) => this.callbacks.onHistoryReady({ ...(typeof payload === "object" && payload ? payload : {}), controller: this, renderer: this.profileHistoryRenderer, }), }; return this.profileHistoryRendererConfig; } initializeDirectMessagesService() { this.teardownDirectMessagesService(); if (!this.nostrService || typeof this.nostrService.on !== "function") { return; } const unsubscribes = []; const subscribe = (eventName, handler) => { try { const unsubscribe = this.nostrService.on(eventName, handler); if (typeof unsubscribe === "function") { unsubscribes.push(unsubscribe); } } catch (error) { devLogger.warn( `[profileModal] Failed to subscribe to ${eventName} direct message events:`, error, ); } }; subscribe("directMessages:updated", (detail) => { this.handleDirectMessagesUpdated(detail); }); subscribe("directMessages:cleared", () => { this.handleDirectMessagesCleared(); }); subscribe("directMessages:error", (detail) => { this.handleDirectMessagesError(detail); }); subscribe("directMessages:failure", (detail) => { this.handleDirectMessagesError(detail); }); subscribe("directMessages:relayWarning", (detail) => { this.handleDirectMessagesRelayWarning(detail); }); this.directMessagesUnsubscribes = unsubscribes; const actor = this.resolveActiveDmActor(); if (actor) { this.directMessagesLastActor = actor; } if ( this.nostrService && typeof this.nostrService.hydrateDirectMessagesFromStore === "function" ) { void this.nostrService .hydrateDirectMessagesFromStore({ emit: true }) .then((messages) => { if (Array.isArray(messages)) { this.directMessagesCache = messages; const active = this.resolveActiveDmActor(); if (active) { this.directMessagesLastActor = active; } } }) .catch((error) => { devLogger.warn( "[profileModal] Failed to hydrate cached direct messages:", error, ); }); } } teardownDirectMessagesService() { if (!Array.isArray(this.directMessagesUnsubscribes)) { this.directMessagesUnsubscribes = []; return; } while (this.directMessagesUnsubscribes.length) { const unsubscribe = this.directMessagesUnsubscribes.pop(); if (typeof unsubscribe === "function") { try { unsubscribe(); } catch (error) { devLogger.warn( "[profileModal] Failed to remove direct message event listener:", error, ); } } } } resolveActiveDmActor() { const active = this.normalizeHexPubkey(this.getActivePubkey()); if (active) { return active; } const client = this.services.nostrClient || null; if (client) { if (typeof client.pubkey === "string" && client.pubkey.trim()) { const normalizedClient = this.normalizeHexPubkey(client.pubkey); if (normalizedClient) { return normalizedClient; } } if ( client.sessionActor && typeof client.sessionActor.pubkey === "string" && client.sessionActor.pubkey.trim() ) { const session = this.normalizeHexPubkey(client.sessionActor.pubkey); if (session) { return session; } } } return null; } resolveActiveDmRecipient() { const candidate = typeof this.state.getDmRecipient === "function" ? this.state.getDmRecipient() : null; const normalized = this.normalizeHexPubkey(candidate); if (normalized) { return normalized; } return null; } resolveActiveDmRelayOwner() { const active = this.normalizeHexPubkey(this.getActivePubkey()); if (active) { return active; } return this.resolveActiveDmActor(); } getActiveDmRelayPreferences() { const owner = this.resolveActiveDmRelayOwner(); if (!owner || typeof this.state.getDmRelayPreferences !== "function") { return []; } const relays = this.state.getDmRelayPreferences(owner); return Array.isArray(relays) ? relays.slice() : []; } setActiveDmRelayPreferences(relays = []) { const owner = this.resolveActiveDmRelayOwner(); if (!owner || typeof this.state.setDmRelayPreferences !== "function") { return []; } return this.state.setDmRelayPreferences(owner, relays); } setDmRelayPreferencesStatus(message = "") { if (!(this.profileMessagesRelayStatus instanceof HTMLElement)) { return; } const text = typeof message === "string" ? message.trim() : ""; this.profileMessagesRelayStatus.textContent = text; } updateMessagePrivacyModeDisplay() { if (!(this.profileMessagesPrivacyMode instanceof HTMLElement)) { return; } const isNip17 = this.profileMessagesPrivacyToggle instanceof HTMLInputElement ? this.profileMessagesPrivacyToggle.checked : false; const label = isNip17 ? "NIP-17" : "NIP-04"; this.profileMessagesPrivacyMode.textContent = `Privacy: ${label}`; this.profileMessagesPrivacyMode.title = isNip17 ? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints." : "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata."; } getDmPrivacySettingsSnapshot() { const fallback = createInternalDefaultDmPrivacySettings(); if (typeof this.state.getDmPrivacySettings !== "function") { return { ...fallback }; } const settings = this.state.getDmPrivacySettings(); return { readReceiptsEnabled: typeof settings?.readReceiptsEnabled === "boolean" ? settings.readReceiptsEnabled : fallback.readReceiptsEnabled, typingIndicatorsEnabled: typeof settings?.typingIndicatorsEnabled === "boolean" ? settings.typingIndicatorsEnabled : fallback.typingIndicatorsEnabled, }; } persistDmPrivacySettings(partial = {}) { if (typeof this.state.setDmPrivacySettings !== "function") { return this.getDmPrivacySettingsSnapshot(); } const resolved = partial && typeof partial === "object" ? partial : {}; const current = this.getDmPrivacySettingsSnapshot(); const merged = { readReceiptsEnabled: typeof resolved.readReceiptsEnabled === "boolean" ? resolved.readReceiptsEnabled : current.readReceiptsEnabled, typingIndicatorsEnabled: typeof resolved.typingIndicatorsEnabled === "boolean" ? resolved.typingIndicatorsEnabled : current.typingIndicatorsEnabled, }; return this.state.setDmPrivacySettings(merged); } syncDmPrivacySettingsUi() { // Legacy UI toggles removed. // This method is kept for backwards compatibility with call sites that might expect it, // though the settings are now managed via AppShell. } handleReadReceiptsToggle(enabled) { this.persistDmPrivacySettings({ readReceiptsEnabled: Boolean(enabled), }); this.syncDmPrivacySettingsUi(); this.showStatus( enabled ? "Read receipts enabled for direct messages." : "Read receipts disabled.", ); } handleTypingIndicatorsToggle(enabled) { this.persistDmPrivacySettings({ typingIndicatorsEnabled: Boolean(enabled), }); this.syncDmPrivacySettingsUi(); this.showStatus( enabled ? "Typing indicators enabled for direct messages." : "Typing indicators disabled.", ); } syncLinkPreviewSettingsUi() { if (!(this.profileLinkPreviewAutoToggle instanceof HTMLInputElement)) { return; } const settings = getLinkPreviewSettings(); this.profileLinkPreviewAutoToggle.checked = Boolean( settings?.autoFetchUnknownDomains, ); } handleLinkPreviewToggle(enabled) { setLinkPreviewAutoFetch(Boolean(enabled)); } cacheDmRelayElements() { this.profileMessagesRelayList = document.getElementById("profileMessagesRelayList") || null; this.profileMessagesRelayInput = document.getElementById("profileMessagesRelayInput") || null; this.profileMessagesRelayAddButton = document.getElementById("profileMessagesRelayAdd") || null; this.profileMessagesRelayPublishButton = document.getElementById("profileMessagesRelayPublish") || null; this.profileMessagesRelayStatus = document.getElementById("profileMessagesRelayStatus") || null; } bindDmRelayControls() { const bindOnce = (element, eventName, handler, key) => { if (!(element instanceof HTMLElement)) { return; } const datasetKey = key || "dmRelayBound"; if (element.dataset[datasetKey] === "true") { return; } element.dataset[datasetKey] = "true"; element.addEventListener(eventName, handler); }; bindOnce( this.profileMessagesRelayAddButton, "click", () => { void this.handleAddDmRelayPreference(); }, "dmRelayAddBound", ); bindOnce( this.profileMessagesRelayInput, "keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAddDmRelayPreference(); } }, "dmRelayInputBound", ); bindOnce( this.profileMessagesRelayPublishButton, "click", () => { void this.handlePublishDmRelayPreferences(); }, "dmRelayPublishBound", ); } async refreshDmRelayPreferences({ force = false } = {}) { const owner = this.resolveActiveDmRelayOwner(); if (!owner) { this.populateDmRelayPreferences(); return; } const existing = this.getActiveDmRelayPreferences(); if (!existing.length || force) { if (typeof this.services.fetchDmRelayHints === "function") { try { const hints = await this.services.fetchDmRelayHints(owner); if (typeof this.state.setDmRelayPreferences === "function") { this.state.setDmRelayPreferences(owner, hints); } } catch (error) { devLogger.warn( "[profileModal] Failed to refresh DM relay hints for profile:", error, ); } } } this.populateDmRelayPreferences(); } populateDmRelayPreferences() { if (!(this.profileMessagesRelayList instanceof HTMLElement)) { return; } const owner = this.resolveActiveDmRelayOwner(); const relays = this.getActiveDmRelayPreferences(); this.profileMessagesRelayList.textContent = ""; if (!owner) { const emptyState = document.createElement("li"); emptyState.className = "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; emptyState.textContent = "Sign in to add DM relay hints."; this.profileMessagesRelayList.appendChild(emptyState); this.setDmRelayPreferencesStatus(""); return; } if (!relays.length) { const emptyState = document.createElement("li"); emptyState.className = "card border border-dashed border-border/60 p-4 text-center text-xs text-muted"; emptyState.textContent = "No DM relay hints yet."; this.profileMessagesRelayList.appendChild(emptyState); return; } relays.forEach((url) => { const item = document.createElement("li"); item.className = "card flex items-center justify-between gap-3 p-3"; const label = document.createElement("p"); label.className = "text-xs font-medium text-text break-all"; label.textContent = url; const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "btn-ghost focus-ring text-xs"; removeBtn.dataset.variant = "danger"; removeBtn.textContent = "Remove"; removeBtn.addEventListener("click", () => { void this.handleRemoveDmRelayPreference(url); }); item.appendChild(label); item.appendChild(removeBtn); this.profileMessagesRelayList.appendChild(item); }); } async handleAddDmRelayPreference() { const input = this.profileMessagesRelayInput; if (!(input instanceof HTMLInputElement)) { return; } const owner = this.resolveActiveDmRelayOwner(); if (!owner) { this.showError("Please sign in to save DM relay hints."); return; } const rawValue = typeof input.value === "string" ? input.value.trim() : ""; const sanitized = sanitizeRelayList([rawValue]); const relayUrl = sanitized[0]; if (!relayUrl) { this.showError("Enter a valid WSS relay URL."); return; } const current = this.getActiveDmRelayPreferences(); const next = sanitizeRelayList([...current, relayUrl]); this.setActiveDmRelayPreferences(next); input.value = ""; this.populateDmRelayPreferences(); this.setDmRelayPreferencesStatus("DM relay hint added."); } async handleRemoveDmRelayPreference(url) { const owner = this.resolveActiveDmRelayOwner(); if (!owner) { this.showError("Please sign in to update DM relay hints."); return; } const target = typeof url === "string" ? url.trim() : ""; if (!target) { return; } const current = this.getActiveDmRelayPreferences(); const next = current.filter((entry) => entry !== target); this.setActiveDmRelayPreferences(next); this.populateDmRelayPreferences(); this.setDmRelayPreferencesStatus("DM relay hint removed."); } async handlePublishDmRelayPreferences() { const owner = this.resolveActiveDmRelayOwner(); if (!owner) { this.showError("Please sign in to publish DM relay hints."); return; } const relays = this.getActiveDmRelayPreferences(); if (!relays.length) { this.showError("Add at least one DM relay before publishing."); return; } const callback = this.callbacks.onPublishDmRelayPreferences; if (!callback || callback === noop) { this.showError("DM relay publishing is unavailable right now."); return; } this.setDmRelayPreferencesStatus("Publishing DM relay hints…"); try { const result = await callback({ pubkey: owner, relays, controller: this, }); if (result?.ok) { const acceptedCount = Array.isArray(result.accepted) ? result.accepted.length : 0; const summary = acceptedCount ? `Published to ${acceptedCount} relay${acceptedCount === 1 ? "" : "s"}.` : "DM relay hints published."; this.showSuccess("DM relay hints published."); this.setDmRelayPreferencesStatus(summary); return; } this.showError("Failed to publish DM relay hints."); this.setDmRelayPreferencesStatus("DM relay hints publish failed."); } catch (error) { const message = error && typeof error.message === "string" && error.message.trim() ? error.message.trim() : "Failed to publish DM relay hints."; this.showError(message); this.setDmRelayPreferencesStatus(message); } } openDmSettingsModal() { const owner = this.resolveActiveDmRelayOwner(); if (!owner) { this.showError("Please sign in to manage DM settings."); return; } const privacySettings = this.getDmPrivacySettingsSnapshot(); const relayHints = this.getActiveDmRelayPreferences(); this.dmSettingsModalController.show({ privacySettings, relayHints, onPrivacyChange: (key, value) => { this.persistDmPrivacySettings({ [key]: value }); if (key === "readReceiptsEnabled") { this.showStatus( value ? "Read receipts enabled." : "Read receipts disabled.", ); } else if (key === "typingIndicatorsEnabled") { this.showStatus( value ? "Typing indicators enabled." : "Typing indicators disabled.", ); } }, onPublishRelays: async (urls) => { return this.handleDmSettingsPublish(urls); }, }); } async handleDmSettingsPublish(relays) { const owner = this.resolveActiveDmRelayOwner(); if (!owner) { return { ok: false, error: "not-logged-in" }; } // Update local state first this.setActiveDmRelayPreferences(relays); // Publish using existing logic const callback = this.callbacks.onPublishDmRelayPreferences; if (!callback || callback === noop) { return { ok: false, error: "unavailable" }; } try { const result = await callback({ pubkey: owner, relays, controller: this, }); if (result?.ok) { this.populateDmRelayPreferences(); // Refresh old UI just in case return result; } return { ok: false, error: result?.error || "failed" }; } catch (error) { return { ok: false, error: error }; } } buildDmRecipientContext(pubkey) { const normalized = this.normalizeHexPubkey(pubkey); if (!normalized) { return null; } const npub = typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(normalized) : null; const cacheEntry = typeof this.services.getProfileCacheEntry === "function" ? this.services.getProfileCacheEntry(normalized) : null; const profile = cacheEntry?.profile || null; const displayName = profile?.display_name?.trim?.() || profile?.name?.trim?.() || (typeof this.formatShortNpub === "function" ? this.formatShortNpub(npub) : npub) || npub || "Unknown profile"; const relayHints = typeof this.state.getDmRelayHints === "function" ? this.state.getDmRelayHints(normalized) : []; return { pubkey: normalized, npub, profile, displayName, relayHints: Array.isArray(relayHints) ? relayHints.slice() : [], }; } async ensureDmRecipientData(pubkey) { const normalized = this.normalizeHexPubkey(pubkey); if (!normalized) { return null; } if ( typeof this.services.batchFetchProfiles === "function" ) { try { await this.services.batchFetchProfiles([normalized]); } catch (error) { devLogger.warn( "[profileModal] Failed to fetch DM recipient metadata:", error, ); } } if (typeof this.services.fetchDmRelayHints === "function") { try { const hints = await this.services.fetchDmRelayHints(normalized); if (typeof this.state.setDmRelayHints === "function") { this.state.setDmRelayHints(normalized, hints); } } catch (error) { devLogger.warn( "[profileModal] Failed to fetch DM relay hints:", error, ); } } const context = this.buildDmRecipientContext(normalized); this.updateDmPrivacyToggleForRecipient(context); return context; } updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) { if (!recipientContext) { return; } const relayHints = Array.isArray(recipientContext.relayHints) ? recipientContext.relayHints : []; const hasHints = relayHints.length > 0; if (!this.dmPrivacyToggleTouched || force) { this.setPrivacyToggleState(hasHints); } } setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) { const normalized = this.normalizeHexPubkey(pubkey); const nextRecipient = normalized || null; if (typeof this.state.setDmRecipient === "function") { this.state.setDmRecipient(nextRecipient); } this.dmPrivacyToggleTouched = false; this.updateMessageThreadSelection(nextRecipient); if (nextRecipient) { void this.ensureDmRecipientData(nextRecipient); this.setMessagesAnnouncement("Ready to message this recipient."); } else if (reason === "clear") { this.setMessagesAnnouncement("Message recipient cleared."); this.setFocusedDmConversation(""); } void this.renderDirectMessageConversation(); if (this.dmAppShellContainer instanceof HTMLElement) { void this.renderDmAppShell(this.directMessagesCache, { actorPubkey: this.resolveActiveDmActor(), }); } return nextRecipient; } updateMessageThreadSelection(activeRecipient) { if (!(this.profileMessagesList instanceof HTMLElement)) { return; } const normalized = this.normalizeHexPubkey(activeRecipient); const items = Array.from( this.profileMessagesList.querySelectorAll("[data-remote-pubkey]"), ); items.forEach((item) => { if (!(item instanceof HTMLElement)) { return; } const remote = this.normalizeHexPubkey(item.dataset.remotePubkey); const isActive = normalized && remote === normalized; item.dataset.state = isActive ? "active" : "inactive"; }); } focusMessageComposer() { const input = this.profileMessageInput; if (input instanceof HTMLTextAreaElement) { input.focus(); input.select(); } } setPrivacyToggleState(enabled) { if (this.profileMessagesPrivacyToggle instanceof HTMLInputElement) { this.profileMessagesPrivacyToggle.checked = Boolean(enabled); } this.updateMessagePrivacyModeDisplay(); } async ensureDirectMessageSubscription(actorPubkey = null) { if ( !this.nostrService || typeof this.nostrService.ensureDirectMessageSubscription !== "function" ) { return null; } const normalizedActor = actorPubkey ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); if (!normalizedActor) { return null; } if ( this.directMessagesSubscription && this.directMessagesSubscription.actor === normalizedActor ) { return this.directMessagesSubscription.subscription || null; } if ( this.directMessagesSubscription && this.directMessagesSubscription.actor && this.directMessagesSubscription.actor !== normalizedActor ) { this.resetDirectMessageSubscription(); } let subscription = null; try { subscription = await this.nostrService.ensureDirectMessageSubscription({ actorPubkey: normalizedActor, }); } catch (error) { userLogger.warn( "[profileModal] Failed to subscribe to direct messages:", error, ); return null; } this.directMessagesSubscription = { actor: normalizedActor, subscription, }; return subscription; } resetDirectMessageSubscription() { if ( this.directMessagesSubscription && this.nostrService && typeof this.nostrService.stopDirectMessageSubscription === "function" ) { try { this.nostrService.stopDirectMessageSubscription(); } catch (error) { devLogger.warn( "[profileModal] Failed to stop direct message subscription:", error, ); } } this.directMessagesSubscription = null; } handleActiveDmIdentityChanged(actorPubkey = null) { const normalized = actorPubkey ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); this.hasShownRelayWarning = false; this.setDirectMessageRecipient(null, { reason: "clear" }); this.resetAttachmentQueue({ clearInput: true }); this.dmReadReceiptCache.clear(); this.dmTypingLastSentAt = 0; this.syncDmPrivacySettingsUi(); if ( this.directMessagesSubscription && this.directMessagesSubscription.actor && normalized !== this.directMessagesSubscription.actor ) { this.resetDirectMessageSubscription(); } this.directMessagesLastActor = normalized || null; this.directMessagesCache = []; this.messagesInitialLoadPending = true; this.pendingMessagesRender = null; if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.textContent = ""; this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); } if (this.profileMessagesConversation instanceof HTMLElement) { this.profileMessagesConversation.textContent = ""; this.profileMessagesConversation.classList.add("hidden"); this.profileMessagesConversation.setAttribute("hidden", ""); } if (this.profileMessagesConversationEmpty instanceof HTMLElement) { this.profileMessagesConversationEmpty.classList.remove("hidden"); this.profileMessagesConversationEmpty.removeAttribute("hidden"); } if (!normalized) { this.setMessagesLoadingState("unauthenticated"); this.updateMessagesReloadState(); this.populateDmRelayPreferences(); this.setDmRelayPreferencesStatus(""); return; } this.setMessagesLoadingState("loading"); void this.ensureDirectMessageSubscription(normalized); this.updateMessagesReloadState(); if (this.getActivePane() === "messages") { void this.populateProfileMessages({ force: true, reason: "identity-change", }); } void this.refreshDmRelayPreferences({ force: true }); } setMessagesLoadingState(state, options = {}) { const normalized = typeof state === "string" ? state : "idle"; const defaults = { idle: "", loading: "Fetching direct messages from relays…", ready: "", empty: "No direct messages yet.", unauthenticated: "Sign in to view your direct messages.", error: "Failed to load direct messages. Try again later.", }; const providedMessage = typeof options.message === "string" && options.message.trim() ? options.message.trim() : ""; const message = providedMessage || defaults[normalized] || ""; this.messagesLoadingState = normalized; if (this.profileMessagesPane instanceof HTMLElement) { this.profileMessagesPane.setAttribute("data-messages-state", normalized); } const toggleVisibility = (element, shouldShow) => { if (!(element instanceof HTMLElement)) { return; } if (shouldShow) { element.classList.remove("hidden"); element.removeAttribute("hidden"); } else { element.classList.add("hidden"); element.setAttribute("hidden", ""); } }; toggleVisibility( this.profileMessagesLoading, normalized === "loading", ); if (this.profileMessagesError instanceof HTMLElement) { if (normalized === "error") { this.profileMessagesError.textContent = message; toggleVisibility(this.profileMessagesError, true); } else { this.profileMessagesError.textContent = ""; toggleVisibility(this.profileMessagesError, false); } } if (this.profileMessagesEmpty instanceof HTMLElement) { if (normalized === "empty" || normalized === "unauthenticated") { this.profileMessagesEmpty.textContent = message || defaults[normalized]; toggleVisibility(this.profileMessagesEmpty, true); } else { toggleVisibility(this.profileMessagesEmpty, false); } } const hasMessages = Array.isArray(this.directMessagesCache) && this.directMessagesCache.length > 0; if (this.profileMessagesList instanceof HTMLElement) { if (normalized === "loading" || normalized === "unauthenticated") { toggleVisibility(this.profileMessagesList, false); } else if (hasMessages) { toggleVisibility(this.profileMessagesList, true); } } if (this.profileMessagesStatus instanceof HTMLElement) { if (message && normalized !== "error") { this.profileMessagesStatus.textContent = message; } else if (normalized === "error") { this.profileMessagesStatus.textContent = ""; } else if (providedMessage) { this.profileMessagesStatus.textContent = providedMessage; } else { this.profileMessagesStatus.textContent = ""; } } this.updateMessagesReloadState(); this.updateMessageComposerState(); if (this.dmAppShellContainer instanceof HTMLElement) { void this.renderDmAppShell(this.directMessagesCache, { actorPubkey: this.resolveActiveDmActor(), }); } } updateMessagesReloadState() { const button = this.profileMessagesReloadButton; if (!(button instanceof HTMLElement)) { return; } const actor = this.resolveActiveDmActor(); const disabled = !actor || this.messagesLoadingState === "loading" || this.activeMessagesRequest !== null; if ("disabled" in button) { button.disabled = disabled; } if (disabled) { button.setAttribute("aria-disabled", "true"); } else { button.removeAttribute("aria-disabled"); } } setMessagesUnreadIndicator(visible) { if (!(this.profileMessagesUnreadDot instanceof HTMLElement)) { return; } const button = this.navButtons.messages; const isVisible = button instanceof HTMLElement && !button.classList.contains("hidden") && !button.hasAttribute("hidden"); this.profileMessagesUnreadDot.classList.toggle( "is-visible", Boolean(visible) && isVisible, ); } updateMessageComposerState() { const input = this.profileMessageInput; const button = this.profileMessageSendButton; const helper = this.profileMessagesComposerHelper; const attachmentInput = this.profileMessageAttachmentInput; const attachmentButton = this.profileMessageAttachmentButton; const attachmentEncrypt = this.profileMessageAttachmentEncrypt; const attachmentClearCache = this.profileMessageAttachmentClearCache; const shouldDisable = this.messagesLoadingState === "unauthenticated"; const applyDisabledState = (element) => { if (!(element instanceof HTMLElement) || !("disabled" in element)) { return; } element.disabled = shouldDisable; if (shouldDisable) { element.setAttribute("aria-disabled", "true"); } else { element.removeAttribute("aria-disabled"); } }; applyDisabledState(input); applyDisabledState(button); applyDisabledState(attachmentInput); applyDisabledState(attachmentButton); applyDisabledState(attachmentEncrypt); applyDisabledState(attachmentClearCache); if (helper instanceof HTMLElement) { if (shouldDisable) { helper.textContent = "Sign in to send messages."; helper.classList.remove("hidden"); helper.removeAttribute("hidden"); } else { helper.classList.add("hidden"); helper.setAttribute("hidden", ""); } } this.updateMessagePrivacyModeDisplay(); } setMessagesAnnouncement(message) { if (!(this.profileMessagesStatus instanceof HTMLElement)) { return; } const content = typeof message === "string" ? message.trim() : ""; if (!content) { this.profileMessagesStatus.textContent = ""; if (this.messagesStatusClearTimeout) { clearTimeout(this.messagesStatusClearTimeout); this.messagesStatusClearTimeout = null; } return; } this.profileMessagesStatus.textContent = content; if (typeof window !== "undefined" && window && window.setTimeout) { if (this.messagesStatusClearTimeout) { clearTimeout(this.messagesStatusClearTimeout); } this.messagesStatusClearTimeout = window.setTimeout(() => { if (this.profileMessagesStatus) { this.profileMessagesStatus.textContent = ""; } this.messagesStatusClearTimeout = null; }, 2500); } } async handleSendDmRequest() { const recipient = this.resolveActiveDmRecipient(); if (!recipient) { this.showError("Please select a message recipient."); return; } const context = await this.ensureDmRecipientData(recipient); this.focusMessageComposer(); const callback = this.callbacks.onSendDm; if (callback && callback !== noop) { callback({ actorPubkey: this.resolveActiveDmActor(), recipient: context, controller: this, }); } } async handleOpenDmRelaysRequest() { const recipient = this.resolveActiveDmRecipient(); if (!recipient) { this.showError("Please select a message recipient."); return; } const context = await this.ensureDmRecipientData(recipient); const callback = this.callbacks.onOpenRelays; if (callback && callback !== noop) { callback({ controller: this, recipient: context }); } } handlePrivacyToggle(enabled) { const recipientContext = this.buildDmRecipientContext( this.resolveActiveDmRecipient(), ); const relayHints = Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : []; if (this.enableNip17RelayWarning && enabled && !relayHints.length) { this.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", { autoHideMs: 5000 }, ); } this.dmPrivacyToggleTouched = true; this.updateMessagePrivacyModeDisplay(); const callback = this.callbacks.onTogglePrivacy; if (callback && callback !== noop) { callback({ controller: this, enabled: Boolean(enabled), recipient: recipientContext, }); } } generateAttachmentId(file) { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } const name = typeof file?.name === "string" ? file.name : "attachment"; return `${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`; } resetAttachmentQueue({ clearInput = false } = {}) { this.dmAttachmentQueue.forEach((entry) => { if (entry?.previewUrl && typeof URL !== "undefined") { try { URL.revokeObjectURL(entry.previewUrl); } catch (error) { devLogger.warn("[profileModal] Failed to revoke attachment preview URL.", error); } } }); this.dmAttachmentQueue = []; this.dmAttachmentUploads.clear(); if (clearInput && this.profileMessageAttachmentInput) { this.profileMessageAttachmentInput.value = ""; } this.renderAttachmentQueue(); } handleAttachmentSelection() { const input = this.profileMessageAttachmentInput; if (!(input instanceof HTMLInputElement) || !input.files) { return; } const files = Array.from(input.files); if (!files.length) { return; } files.forEach((file) => { const previewUrl = typeof URL !== "undefined" ? URL.createObjectURL(file) : ""; this.dmAttachmentQueue.push({ id: this.generateAttachmentId(file), file, name: file.name, type: file.type || "application/octet-stream", size: file.size, previewUrl, status: "pending", progress: 0, }); }); input.value = ""; this.renderAttachmentQueue(); } renderAttachmentQueue() { const list = this.profileMessageAttachmentList; if (!(list instanceof HTMLElement)) { return; } list.textContent = ""; if (!this.dmAttachmentQueue.length) { return; } this.dmAttachmentQueue.forEach((entry) => { const item = document.createElement("div"); item.className = "card flex flex-col gap-2 p-3"; item.dataset.attachmentId = entry.id; const header = document.createElement("div"); header.className = "flex items-center justify-between gap-2"; const title = document.createElement("div"); title.className = "text-sm font-semibold text-text"; title.textContent = entry.name || "Attachment"; header.appendChild(title); const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "btn-ghost focus-ring inline-flex items-center"; removeButton.dataset.size = "sm"; removeButton.textContent = "Remove"; removeButton.addEventListener("click", () => { this.dmAttachmentQueue = this.dmAttachmentQueue.filter( (queued) => queued.id !== entry.id, ); if (entry.previewUrl && typeof URL !== "undefined") { URL.revokeObjectURL(entry.previewUrl); } this.renderAttachmentQueue(); }); header.appendChild(removeButton); item.appendChild(header); const meta = document.createElement("div"); meta.className = "text-xs text-muted"; const sizeLabel = formatAttachmentSize(entry.size); meta.textContent = sizeLabel ? `${entry.type || "file"} · ${sizeLabel}` : entry.type || "file"; item.appendChild(meta); if (entry.previewUrl && entry.type?.startsWith("image/")) { const img = document.createElement("img"); img.src = entry.previewUrl; img.alt = entry.name || "Attachment preview"; img.className = "h-24 w-24 rounded-lg object-cover"; item.appendChild(img); } const progress = document.createElement("progress"); progress.className = "progress"; progress.value = entry.progress || 0; progress.max = 1; progress.dataset.variant = "surface"; item.appendChild(progress); const status = document.createElement("div"); status.className = "text-xs text-muted"; status.textContent = entry.status === "uploading" ? "Uploading…" : entry.status === "error" ? entry.error || "Upload failed." : "Ready to upload."; item.appendChild(status); list.appendChild(item); }); } async uploadAttachmentQueue(actorPubkey) { const r2Service = this.services.r2Service; if (!r2Service) { throw new Error("Storage service unavailable."); } const encrypt = this.profileMessageAttachmentEncrypt instanceof HTMLInputElement ? this.profileMessageAttachmentEncrypt.checked : false; const payloads = []; for (const entry of this.dmAttachmentQueue) { entry.status = "uploading"; entry.progress = 0; this.renderAttachmentQueue(); try { const payload = await uploadAttachment({ r2Service, pubkey: actorPubkey, file: entry.file, encrypt, buildKey: buildR2Key, buildUrl: buildPublicUrl, onProgress: (fraction) => { entry.progress = Number.isFinite(fraction) ? fraction : entry.progress; this.renderAttachmentQueue(); }, }); payloads.push(payload); entry.status = "uploaded"; entry.progress = 1; } catch (error) { entry.status = "error"; entry.error = error && typeof error.message === "string" ? error.message : "Attachment upload failed."; this.renderAttachmentQueue(); throw error; } } return payloads; } async renderDirectMessageConversation() { const container = this.profileMessagesConversation; const emptyState = this.profileMessagesConversationEmpty; const actor = this.resolveActiveDmActor(); const recipient = this.resolveActiveDmRecipient(); if (!(container instanceof HTMLElement)) { return; } container.textContent = ""; if (!actor || !recipient || !this.directMessagesCache.length) { container.classList.add("hidden"); container.setAttribute("hidden", ""); if (emptyState instanceof HTMLElement) { emptyState.classList.remove("hidden"); emptyState.removeAttribute("hidden"); } return; } const messages = this.directMessagesCache .filter((entry) => this.resolveDirectMessageRemote(entry, actor) === recipient) .sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)); if (!messages.length) { container.classList.add("hidden"); container.setAttribute("hidden", ""); if (emptyState instanceof HTMLElement) { emptyState.classList.remove("hidden"); emptyState.removeAttribute("hidden"); } return; } if (emptyState instanceof HTMLElement) { emptyState.classList.add("hidden"); emptyState.setAttribute("hidden", ""); } void this.maybePublishReadReceipt(messages, { recipientPubkey: recipient, }); messages.forEach((message) => { const item = document.createElement("div"); item.className = "card flex flex-col gap-2 p-3"; const body = document.createElement("div"); body.className = "text-sm text-text whitespace-pre-line"; const text = typeof message.plaintext === "string" ? message.plaintext.trim() : ""; body.textContent = text || "Attachment"; item.appendChild(body); const attachments = extractAttachmentsFromMessage(message); attachments.forEach((attachment) => { const attachmentCard = document.createElement("div"); attachmentCard.className = "flex flex-col gap-2 rounded-lg border border-border/60 p-3"; const title = document.createElement("div"); title.className = "text-xs font-semibold text-text"; title.textContent = describeAttachment(attachment); attachmentCard.appendChild(title); const meta = document.createElement("div"); meta.className = "text-3xs text-muted"; const sizeLabel = formatAttachmentSize(attachment.size); const typeLabel = attachment.type || "file"; meta.textContent = sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel; attachmentCard.appendChild(meta); const progress = document.createElement("progress"); progress.className = "progress"; progress.value = 0; progress.max = 1; progress.dataset.variant = "surface"; attachmentCard.appendChild(progress); const status = document.createElement("div"); status.className = "text-3xs text-muted"; status.textContent = attachment.encrypted ? "Decrypting attachment…" : "Downloading attachment…"; attachmentCard.appendChild(status); item.appendChild(attachmentCard); downloadAttachment({ url: attachment.url, expectedHash: attachment.x, key: attachment.key, mimeType: attachment.type, onProgress: (fraction) => { progress.value = Number.isFinite(fraction) ? fraction : progress.value; }, }) .then((result) => { progress.value = 1; progress.classList.add("hidden"); progress.setAttribute("hidden", ""); status.textContent = attachment.encrypted ? "Decrypted." : "Ready."; if (!result?.objectUrl) { return; } if (attachment.type?.startsWith("image/")) { const img = document.createElement("img"); img.src = result.objectUrl; img.alt = attachment.name || "Attachment preview"; img.className = "h-40 w-full rounded-lg object-cover"; attachmentCard.appendChild(img); } else if (attachment.type?.startsWith("video/")) { const video = document.createElement("video"); video.src = result.objectUrl; video.controls = true; video.className = "w-full rounded-lg"; attachmentCard.appendChild(video); } else if (attachment.type?.startsWith("audio/")) { const audio = document.createElement("audio"); audio.src = result.objectUrl; audio.controls = true; audio.className = "w-full"; attachmentCard.appendChild(audio); } else { const link = document.createElement("a"); link.href = result.objectUrl; link.textContent = "Download attachment"; link.className = "text-xs text-accent underline-offset-2 hover:underline"; link.download = attachment.name || "attachment"; attachmentCard.appendChild(link); } }) .catch((error) => { status.textContent = error && typeof error.message === "string" ? error.message : "Attachment download failed."; status.classList.add("text-critical"); }); }); container.appendChild(item); }); container.classList.remove("hidden"); container.removeAttribute("hidden"); } resolveDirectMessageEventId(message) { if (!message || typeof message !== "object") { return ""; } const eventId = typeof message.event?.id === "string" ? message.event.id.trim() : ""; if (eventId) { return eventId; } const innerId = typeof message.message?.id === "string" ? message.message.id.trim() : ""; return innerId; } resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey = null) { const normalizedRecipient = typeof recipientPubkey === "string" ? this.normalizeHexPubkey(recipientPubkey) : ""; if (!normalizedRecipient || !Array.isArray(this.directMessagesCache)) { return null; } const resolvedActor = actorPubkey ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); let latest = null; let latestTimestamp = 0; for (const entry of this.directMessagesCache) { if (this.resolveDirectMessageRemote(entry, resolvedActor) !== normalizedRecipient) { continue; } const timestamp = Number(entry?.timestamp) || 0; if (!latest || timestamp > latestTimestamp) { latest = entry; latestTimestamp = timestamp; } } return latest; } resolveDirectMessageKind(message) { if (!message || typeof message !== "object") { return null; } if (Number.isFinite(message?.event?.kind)) { return Number(message.event.kind); } if (Number.isFinite(message?.message?.kind)) { return Number(message.message.kind); } return null; } async maybePublishReadReceipt(messages, { recipientPubkey } = {}) { if (!Array.isArray(messages) || !messages.length) { return; } const settings = this.getDmPrivacySettingsSnapshot(); if (!settings.readReceiptsEnabled) { return; } if ( !this.services.nostrClient || typeof this.services.nostrClient.publishDmReadReceipt !== "function" ) { return; } const normalizedRecipient = typeof recipientPubkey === "string" ? this.normalizeHexPubkey(recipientPubkey) : ""; if (!normalizedRecipient) { return; } let latestMessage = null; for (let index = messages.length - 1; index >= 0; index -= 1) { const entry = messages[index]; if ( entry && typeof entry === "object" && entry.direction === "incoming" ) { const eventId = this.resolveDirectMessageEventId(entry); if (eventId) { latestMessage = entry; break; } } } if (!latestMessage) { return; } const eventId = this.resolveDirectMessageEventId(latestMessage); if (!eventId) { return; } const cacheKey = `${normalizedRecipient}:${eventId}`; if (this.dmReadReceiptCache.has(cacheKey)) { return; } const relayHints = this.buildDmRecipientContext(normalizedRecipient)?.relayHints || []; const messageKind = this.resolveDirectMessageKind(latestMessage); try { const result = await this.services.nostrClient.publishDmReadReceipt({ eventId, recipientPubkey: normalizedRecipient, messageKind, relays: relayHints, }); if (result?.ok) { this.dmReadReceiptCache.add(cacheKey); } } catch (error) { devLogger.warn("[profileModal] Failed to publish read receipt:", error); } } async maybePublishTypingIndicator() { const settings = this.getDmPrivacySettingsSnapshot(); if (!settings.typingIndicatorsEnabled) { return; } if ( !this.services.nostrClient || typeof this.services.nostrClient.publishDmTypingIndicator !== "function" ) { return; } const input = this.profileMessageInput; const messageText = input instanceof HTMLTextAreaElement ? input.value.trim() : ""; if (!messageText) { return; } const recipient = this.resolveActiveDmRecipient(); if (!recipient) { return; } const now = Date.now(); if (now - this.dmTypingLastSentAt < TYPING_INDICATOR_COOLDOWN_MS) { return; } this.dmTypingLastSentAt = now; const relayHints = this.buildDmRecipientContext(recipient)?.relayHints || []; const latestMessage = this.resolveLatestDirectMessageForRecipient( recipient, this.resolveActiveDmActor(), ); const latestEventId = this.resolveDirectMessageEventId(latestMessage); try { await this.services.nostrClient.publishDmTypingIndicator({ recipientPubkey: recipient, conversationEventId: latestEventId || null, relays: relayHints, expiresInSeconds: TYPING_INDICATOR_TTL_SECONDS, }); } catch (error) { devLogger.warn("[profileModal] Failed to publish typing indicator:", error); } } describeDirectMessageSendError(code) { switch (code) { case "sign-event-unavailable": return "Connect a Nostr signer to send messages."; case "encryption-unsupported": return "Your signer does not support NIP-04 encryption."; case "nip44-unsupported": return "Your signer does not support NIP-44 encryption required for NIP-17."; case "nip17-relays-missing": return "Recipient has not shared NIP-17 relay hints yet."; case "nip17-relays-unavailable": return "No DM relays are available to deliver this message."; case "nip17-keygen-failed": return "We couldn’t create secure wrapper keys for NIP-17 delivery."; case "extension-permission-denied": return "Please grant your Nostr extension permission to send messages."; case "extension-encryption-permission-denied": return "Please grant your Nostr extension encryption permission to send messages."; case "missing-actor-pubkey": return "We couldn’t determine your public key to send this message."; case "nostr-uninitialized": return "Direct messages are still connecting to relays. Please try again."; case "signature-failed": return "We couldn’t sign the message. Please reconnect your signer and try again."; case "encryption-failed": return "We couldn’t encrypt the message. Please try again."; case "publish-failed": return "Failed to deliver this message to any relay. Please try again."; case "invalid-target": return "Select a valid recipient before sending."; case "empty-message": return "Please enter a message or attach a file."; case "attachments-unsupported": return "Attachments require NIP-17 delivery. Enable the privacy toggle to send files."; default: return "Unable to send message. Please try again."; } } async handleSendProfileMessage() { const input = this.profileMessageInput; if (!(input instanceof HTMLTextAreaElement)) { return; } const message = typeof input.value === "string" ? input.value.trim() : ""; const hasAttachments = this.dmAttachmentQueue.length > 0; if (!message && !hasAttachments) { this.showError("Please enter a message or attach a file."); return; } const targetHex = this.resolveActiveDmRecipient(); const target = typeof targetHex === "string" && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(targetHex) : ""; if (!target) { this.showError("Please select a message recipient."); return; } const recipientContext = this.buildDmRecipientContext(targetHex); const recipientRelayHints = Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : []; const useNip17 = this.profileMessagesPrivacyToggle instanceof HTMLInputElement ? this.profileMessagesPrivacyToggle.checked : false; const senderRelayHints = this.getActiveDmRelayPreferences(); if (hasAttachments && !useNip17) { this.showError( "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.", ); return; } if (this.enableNip17RelayWarning && useNip17 && !recipientRelayHints.length) { this.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", { autoHideMs: 5000 }, ); } if ( !this.services.nostrClient || typeof this.services.nostrClient.sendDirectMessage !== "function" ) { this.showError("Direct message service unavailable."); return; } const sendButton = this.profileMessageSendButton; if (sendButton instanceof HTMLElement && "disabled" in sendButton) { sendButton.disabled = true; sendButton.setAttribute("aria-disabled", "true"); } try { let attachmentPayloads = []; if (hasAttachments) { try { attachmentPayloads = await this.uploadAttachmentQueue( this.resolveActiveDmActor(), ); } catch (error) { const messageText = error && typeof error.message === "string" ? error.message : "Attachment upload failed."; this.showError(messageText); return; } } const result = await this.services.nostrClient.sendDirectMessage( target, message, null, useNip17 ? { useNip17: true, recipientRelayHints, senderRelayHints, attachments: attachmentPayloads, } : {}, ); if (result?.ok) { input.value = ""; this.resetAttachmentQueue({ clearInput: true }); this.showSuccess("Message sent."); if (this.enableNip17RelayWarning && result?.warning === "dm-relays-fallback") { this.showStatus( "Privacy warning: this message used default relays because no NIP-17 relay list was found.", { autoHideMs: 5000 }, ); } void this.populateProfileMessages({ force: true, reason: "send-message" }); return; } const errorCode = typeof result?.error === "string" ? result.error : "unknown"; userLogger.warn("[profileModal] Failed to send direct message:", errorCode); this.showError(this.describeDirectMessageSendError(errorCode)); } catch (error) { userLogger.error("[profileModal] Unexpected DM send failure:", error); this.showError("Unable to send message. Please try again."); } finally { this.updateMessageComposerState(); } } clearProfileMessages({ message } = {}) { this.directMessagesCache = []; this.directMessagesLastActor = this.resolveActiveDmActor(); this.messagesInitialLoadPending = true; this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.textContent = ""; this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); } const actor = this.directMessagesLastActor; this.setMessagesLoadingState(actor ? "empty" : "unauthenticated", { message, }); if (this.dmAppShellContainer instanceof HTMLElement) { void this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); } } extractDirectMessagePreview(entry) { if (!entry || typeof entry !== "object") { return ""; } const candidates = []; if (typeof entry.plaintext === "string") { candidates.push(entry.plaintext); } if (typeof entry.preview === "string") { candidates.push(entry.preview); } if ( entry.snapshot && typeof entry.snapshot.preview === "string" && entry.snapshot.preview.trim() ) { candidates.push(entry.snapshot.preview); } if (entry.message && typeof entry.message.content === "string") { candidates.push(entry.message.content); } const preview = candidates.find((value) => value && value.trim()); return preview ? preview.trim() : ""; } resolveProfileSummaryForPubkey(pubkey) { const normalized = this.normalizeHexPubkey(pubkey); const fallbackNpub = normalized && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(normalized) : null; const formattedNpub = typeof this.formatShortNpub === "function" ? this.formatShortNpub(fallbackNpub) : fallbackNpub; let displayName = formattedNpub || fallbackNpub || "Unknown profile"; let avatarSrc = FALLBACK_PROFILE_AVATAR; let lightningAddress = ""; let status = ""; if (normalized && typeof this.services.getProfileCacheEntry === "function") { const cacheEntry = this.services.getProfileCacheEntry(normalized); const profile = cacheEntry?.profile || null; if (profile) { if (typeof profile.display_name === "string" && profile.display_name.trim()) { displayName = profile.display_name.trim(); } else if (typeof profile.name === "string" && profile.name.trim()) { displayName = profile.name.trim(); } if (typeof profile.picture === "string" && profile.picture.trim()) { avatarSrc = profile.picture.trim(); } if (typeof profile.lud16 === "string" && profile.lud16.trim()) { lightningAddress = profile.lud16.trim(); } else if (typeof profile.lud06 === "string" && profile.lud06.trim()) { lightningAddress = profile.lud06.trim(); } if (typeof profile.status === "string" && profile.status.trim()) { status = profile.status.trim(); } } } return { displayName, displayNpub: formattedNpub || fallbackNpub || "npub unavailable", avatarSrc, lightningAddress, status, }; } formatMessageTimestamp(timestamp) { const numeric = Number(timestamp); if (!Number.isFinite(numeric) || numeric <= 0) { return { display: "", iso: "" }; } try { const date = new Date(numeric * 1000); return { display: date.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }), iso: date.toISOString(), }; } catch (error) { return { display: "", iso: "" }; } } buildDmConversationId(actorPubkey, remotePubkey) { const normalizedActor = this.normalizeHexPubkey(actorPubkey); const normalizedRemote = this.normalizeHexPubkey(remotePubkey); if (!normalizedActor || !normalizedRemote) { return ""; } return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`; } formatConversationTimestamp(timestamp) { const numeric = Number(timestamp); if (!Number.isFinite(numeric) || numeric <= 0) { return ""; } try { return formatTimeAgo(numeric); } catch (error) { return ""; } } formatMessageClockTime(timestamp) { const numeric = Number(timestamp); if (!Number.isFinite(numeric) || numeric <= 0) { return ""; } try { const date = new Date(numeric * 1000); return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", }); } catch (error) { return ""; } } formatMessageDayLabel(timestamp) { const numeric = Number(timestamp); if (!Number.isFinite(numeric) || numeric <= 0) { return ""; } try { const date = new Date(numeric * 1000); const today = new Date(); const startOfToday = new Date( today.getFullYear(), today.getMonth(), today.getDate(), ); const startOfMessageDay = new Date( date.getFullYear(), date.getMonth(), date.getDate(), ); const diffDays = Math.round( (startOfToday - startOfMessageDay) / (1000 * 60 * 60 * 24), ); if (diffDays === 0) { return "Today"; } if (diffDays === 1) { return "Yesterday"; } return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } catch (error) { return ""; } } resolveDirectMessageScheme(message) { if (!message || typeof message !== "object") { return ""; } const scheme = typeof message.scheme === "string" ? message.scheme : typeof message.encryption_scheme === "string" ? message.encryption_scheme : typeof message?.decryptor?.scheme === "string" ? message.decryptor.scheme : ""; return typeof scheme === "string" ? scheme.trim().toLowerCase() : ""; } resolveDirectMessageStatus(message) { if (!message || typeof message !== "object") { return "sent"; } const status = typeof message.status === "string" ? message.status : typeof message.deliveryStatus === "string" ? message.deliveryStatus : typeof message.state === "string" ? message.state : ""; return status ? status.trim() : "sent"; } resolveDirectMessageBody(message) { if (!message || typeof message !== "object") { return ""; } if (typeof message.plaintext === "string" && message.plaintext.trim()) { return message.plaintext.trim(); } const preview = this.extractDirectMessagePreview(message); if (preview) { return preview; } const attachments = extractAttachmentsFromMessage(message); if (attachments.length) { return describeAttachment(attachments[0]); } return ""; } resolveDirectMessagePreviewForConversation(message) { if (!message || typeof message !== "object") { return ""; } const preview = this.extractDirectMessagePreview(message); if (preview) { return preview; } const attachments = extractAttachmentsFromMessage(message); if (attachments.length) { return describeAttachment(attachments[0]); } return ""; } resolveRemoteForConversationId(conversationId, actorPubkey) { const actor = this.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor()); const normalizedConversationId = typeof conversationId === "string" ? conversationId.trim() : ""; if (!actor || !normalizedConversationId) { return null; } for (const entry of Array.isArray(this.directMessagesCache) ? this.directMessagesCache : []) { const remote = this.resolveDirectMessageRemote(entry, actor); if (!remote) { continue; } const resolvedId = this.buildDmConversationId(actor, remote); if (resolvedId && resolvedId === normalizedConversationId) { return remote; } } return null; } resolveConversationPrivacyMode(latestMessage) { const scheme = this.resolveDirectMessageScheme(latestMessage); if (scheme.includes("nip17") || scheme.includes("nip44")) { return "nip17"; } return "nip04"; } getDirectMessagesForConversation(conversationId, actorPubkey = null) { const actor = this.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor()); if (!actor || !conversationId) { return []; } const remote = this.resolveRemoteForConversationId(conversationId, actor); if (!remote) { return []; } return (Array.isArray(this.directMessagesCache) ? this.directMessagesCache : []) .filter( (entry) => entry && entry.ok === true && this.resolveDirectMessageRemote(entry, actor) === remote, ) .sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0)); } getLatestDirectMessageTimestampForConversation(conversationId, actorPubkey = null) { const messages = this.getDirectMessagesForConversation( conversationId, actorPubkey, ); if (!messages.length) { return 0; } const last = messages[messages.length - 1]; return Number(last?.timestamp) || 0; } buildDmMessageTimeline(messages, { actorPubkey, remotePubkey } = {}) { const actor = this.normalizeHexPubkey(actorPubkey); const remote = this.normalizeHexPubkey(remotePubkey); if (!actor || !remote) { return []; } const threadMessages = Array.isArray(messages) ? messages.filter((entry) => this.resolveDirectMessageRemote(entry, actor) === remote) : []; threadMessages.sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0)); const timeline = []; let lastDayLabel = ""; threadMessages.forEach((message) => { const dayLabel = this.formatMessageDayLabel(message?.timestamp); if (dayLabel && dayLabel !== lastDayLabel) { timeline.push({ type: "day", label: dayLabel }); lastDayLabel = dayLabel; } timeline.push({ id: this.resolveDirectMessageEventId(message) || "", direction: message?.direction || "incoming", body: this.resolveDirectMessageBody(message) || "Encrypted message", timestamp: this.formatMessageClockTime(message?.timestamp), status: this.resolveDirectMessageStatus(message), }); }); return timeline; } async buildDmConversationData(messages, { actorPubkey } = {}) { const actor = this.normalizeHexPubkey( actorPubkey || this.resolveActiveDmActor(), ); if (!actor) { return { actor: "", conversations: [], activeConversationId: "", activeThread: null, activeRemotePubkey: null, timeline: [], }; } const allThreads = this.groupDirectMessages(messages, actor); const blocksService = this.services.userBlocks; const isRemoteBlocked = blocksService && typeof blocksService.isBlocked === "function" ? (pubkey) => blocksService.isBlocked(pubkey) : () => false; const threads = allThreads.filter( (thread) => !thread.remoteHex || !isRemoteBlocked(thread.remoteHex), ); const remoteKeys = new Set(); threads.forEach((thread) => { if (thread.remoteHex) { remoteKeys.add(thread.remoteHex); } }); if ( remoteKeys.size && this.services.batchFetchProfiles && typeof this.services.batchFetchProfiles === "function" ) { try { await this.services.batchFetchProfiles(remoteKeys); } catch (error) { devLogger.warn( "[profileModal] Failed to fetch DM profile metadata:", error, ); } } const conversations = threads.map((thread) => { const conversationId = this.buildDmConversationId(actor, thread.remoteHex); const summary = this.resolveProfileSummaryForPubkey(thread.remoteHex); const preview = this.resolveDirectMessagePreviewForConversation(thread.latestMessage); const unreadCount = this.nostrService && typeof this.nostrService.getDirectMessageUnseenCount === "function" && conversationId ? this.nostrService.getDirectMessageUnseenCount(conversationId) : 0; const recipientContext = this.buildDmRecipientContext(thread.remoteHex); return { id: conversationId, name: summary.displayName, preview: preview || "Encrypted message", timestamp: this.formatConversationTimestamp(thread.latestTimestamp), unreadCount, avatarSrc: summary.avatarSrc, status: summary.status, pubkey: thread.remoteHex, lightningAddress: summary.lightningAddress, relayHints: Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : [], }; }); const conversationMap = new Map(); threads.forEach((thread) => { const conversationId = this.buildDmConversationId(actor, thread.remoteHex); if (conversationId) { conversationMap.set(conversationId, thread); } }); const storedRecipient = this.resolveActiveDmRecipient(); const storedConversationId = storedRecipient && actor ? this.buildDmConversationId(actor, storedRecipient) : ""; const preferredConversationId = this.activeDmConversationId || storedConversationId; const fallbackConversationId = conversations[0]?.id || ""; const activeConversationId = preferredConversationId && conversationMap.has(preferredConversationId) ? preferredConversationId : fallbackConversationId; const activeThread = activeConversationId ? conversationMap.get(activeConversationId) : null; const activeRemotePubkey = activeThread?.remoteHex || (activeConversationId ? this.resolveRemoteForConversationId(activeConversationId, actor) : storedRecipient) || null; if (activeConversationId && this.activeDmConversationId !== activeConversationId) { this.activeDmConversationId = activeConversationId; } return { actor, conversations, activeConversationId, activeThread, activeRemotePubkey, timeline: activeRemotePubkey && actor ? this.buildDmMessageTimeline(messages, { actorPubkey: actor, remotePubkey: activeRemotePubkey, }) : [], }; } groupDirectMessages(messages, actorPubkey) { const normalizedActor = actorPubkey ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); const threadMap = new Map(); for (const entry of Array.isArray(messages) ? messages : []) { if (!entry || entry.ok !== true) { continue; } const remoteHex = this.resolveDirectMessageRemote(entry, normalizedActor); if (!remoteHex) { continue; } const thread = threadMap.get(remoteHex) || { remoteHex, messages: [], latestTimestamp: 0, latestMessage: null, }; thread.messages.push(entry); const ts = Number(entry.timestamp) || 0; if (!thread.latestMessage || ts > thread.latestTimestamp) { thread.latestTimestamp = ts; thread.latestMessage = entry; } threadMap.set(remoteHex, thread); } const threads = Array.from(threadMap.values()); threads.sort((a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0)); return threads; } resolveDirectMessageRemote(entry, actorPubkey = null) { if (!entry || entry.ok !== true) { return null; } const normalizedActor = actorPubkey ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); if (typeof entry.remotePubkey === "string") { const directRemote = this.normalizeHexPubkey(entry.remotePubkey); if (directRemote && directRemote !== normalizedActor) { return directRemote; } } if (entry.snapshot && typeof entry.snapshot.remotePubkey === "string") { const snapshotRemote = this.normalizeHexPubkey(entry.snapshot.remotePubkey); if (snapshotRemote && snapshotRemote !== normalizedActor) { return snapshotRemote; } } const direction = typeof entry.direction === "string" ? entry.direction.toLowerCase() : ""; const senderHex = entry.sender && typeof entry.sender.pubkey === "string" ? this.normalizeHexPubkey(entry.sender.pubkey) : null; if (direction === "incoming" && senderHex && senderHex !== normalizedActor) { return senderHex; } if (Array.isArray(entry.recipients)) { for (const recipient of entry.recipients) { const candidate = recipient && typeof recipient.pubkey === "string" ? this.normalizeHexPubkey(recipient.pubkey) : null; if (candidate && candidate !== normalizedActor) { return candidate; } } } if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) { return senderHex; } if (entry.message && typeof entry.message.pubkey === "string") { const messagePubkey = this.normalizeHexPubkey(entry.message.pubkey); if (messagePubkey && messagePubkey !== normalizedActor) { return messagePubkey; } } if (entry.event && typeof entry.event.pubkey === "string") { const eventPubkey = this.normalizeHexPubkey(entry.event.pubkey); if (eventPubkey && eventPubkey !== normalizedActor) { return eventPubkey; } } if (senderHex && senderHex !== normalizedActor) { return senderHex; } return null; } createDirectMessageThreadItem(thread) { if (!thread || !thread.remoteHex) { return null; } const item = document.createElement("li"); item.className = "card flex flex-col gap-3 p-4"; item.setAttribute("data-remote-pubkey", thread.remoteHex); item.dataset.state = "inactive"; const header = document.createElement("div"); header.className = "flex items-start justify-between gap-3"; const summary = this.resolveProfileSummaryForPubkey(thread.remoteHex); const summaryNode = this.createCompactProfileSummary({ displayName: summary.displayName, displayNpub: summary.displayNpub, avatarSrc: summary.avatarSrc, size: "sm", }); header.appendChild(summaryNode); const timestampMeta = this.formatMessageTimestamp(thread.latestTimestamp); if (timestampMeta.display) { const timeEl = document.createElement("time"); timeEl.className = "text-3xs font-semibold uppercase tracking-extra-wide text-muted"; if (timestampMeta.iso) { timeEl.setAttribute("datetime", timestampMeta.iso); } timeEl.textContent = timestampMeta.display; header.appendChild(timeEl); } item.appendChild(header); const previewText = this.extractDirectMessagePreview(thread.latestMessage); const previewEl = document.createElement("p"); previewEl.className = "text-sm text-text whitespace-pre-line"; previewEl.textContent = previewText || "Encrypted message"; item.appendChild(previewEl); const meta = document.createElement("div"); meta.className = "flex flex-wrap items-center gap-2"; const direction = typeof thread.latestMessage?.direction === "string" ? thread.latestMessage.direction.toLowerCase() : ""; if (direction) { const directionPill = document.createElement("span"); directionPill.className = "pill"; directionPill.dataset.variant = direction === "incoming" ? "info" : "muted"; directionPill.textContent = direction === "incoming" ? "Incoming message" : direction === "outgoing" ? "Sent message" : "Message"; meta.appendChild(directionPill); } const countPill = document.createElement("span"); countPill.className = "pill"; countPill.dataset.variant = "muted"; const messageCount = Array.isArray(thread.messages) ? thread.messages.length : 0; countPill.textContent = messageCount === 1 ? "1 message" : `${messageCount} messages`; meta.appendChild(countPill); const scheme = typeof thread.latestMessage?.scheme === "string" ? thread.latestMessage.scheme.toUpperCase() : ""; if (scheme) { const schemePill = document.createElement("span"); schemePill.className = "pill"; schemePill.dataset.variant = "muted"; schemePill.textContent = scheme; meta.appendChild(schemePill); } item.appendChild(meta); item.addEventListener("click", () => { this.setDirectMessageRecipient(thread.remoteHex, { reason: "thread-select", }); this.focusMessageComposer(); }); return item; } async renderProfileMessages(messages, { actorPubkey = null } = {}) { if (this.dmAppShellContainer instanceof HTMLElement) { await this.renderDmAppShell(messages, { actorPubkey }); } if (!(this.profileMessagesList instanceof HTMLElement)) { if (!(this.dmAppShellContainer instanceof HTMLElement)) { this.pendingMessagesRender = { messages: Array.isArray(messages) ? messages : [], actorPubkey, }; } return; } this.pendingMessagesRender = null; const normalizedActor = actorPubkey ? this.normalizeHexPubkey(actorPubkey) : this.resolveActiveDmActor(); const renderToken = (this.profileMessagesRenderToken += 1); const renderThreads = (threadsToRender) => { this.profileMessagesList.textContent = ""; if (!threadsToRender.length) { this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); void this.renderDirectMessageConversation(); return; } for (const thread of threadsToRender) { const item = this.createDirectMessageThreadItem(thread); if (item) { this.profileMessagesList.appendChild(item); } } const activeRecipient = this.resolveActiveDmRecipient(); const hasActiveRecipient = activeRecipient && threadsToRender.some((thread) => thread.remoteHex === activeRecipient); if (threadsToRender.length && !hasActiveRecipient) { this.setDirectMessageRecipient(threadsToRender[0].remoteHex, { reason: "thread-default", }); const conversationId = this.buildDmConversationId( normalizedActor, threadsToRender[0].remoteHex, ); this.setFocusedDmConversation(conversationId); } else if (hasActiveRecipient) { this.updateMessageThreadSelection(activeRecipient); } this.profileMessagesList.classList.remove("hidden"); this.profileMessagesList.removeAttribute("hidden"); void this.renderDirectMessageConversation(); }; const threads = this.groupDirectMessages(messages, normalizedActor); renderThreads(threads); const remoteKeys = new Set(); for (const thread of threads) { if (thread.remoteHex) { remoteKeys.add(thread.remoteHex); } } if ( remoteKeys.size && this.services.batchFetchProfiles && typeof this.services.batchFetchProfiles === "function" ) { Promise.resolve(this.services.batchFetchProfiles(remoteKeys)) .then(() => { if (this.profileMessagesRenderToken !== renderToken) { return; } const latestMessages = Array.isArray(this.directMessagesCache) ? this.directMessagesCache : messages; const latestActor = this.resolveActiveDmActor() || normalizedActor; const refreshedThreads = this.groupDirectMessages( latestMessages, latestActor, ); renderThreads(refreshedThreads); }) .catch((error) => { devLogger.warn( "[profileModal] Failed to fetch DM profile metadata:", error, ); }); } } async renderDmAppShell(messages, { actorPubkey = null } = {}) { const container = this.dmAppShellContainer instanceof HTMLElement ? this.dmAppShellContainer : null; if (!container) { return; } const snapshot = Array.isArray(messages) ? messages : this.directMessagesCache; const { actor, conversations, activeConversationId, activeThread, timeline, } = await this.buildDmConversationData(snapshot, { actorPubkey }); this.setFocusedDmConversation(activeConversationId); const currentRecipient = this.resolveActiveDmRecipient(); if (!currentRecipient && activeThread?.remoteHex) { this.setDirectMessageRecipient(activeThread.remoteHex, { reason: "thread-default", }); } const loadingState = this.messagesLoadingState || "idle"; const conversationState = loadingState === "loading" ? "loading" : loadingState === "error" ? "error" : loadingState === "empty" || loadingState === "unauthenticated" ? "empty" : "idle"; const hasActiveConversation = Boolean(activeConversationId); const threadState = conversationState === "loading" ? "loading" : !hasActiveConversation ? "empty" : timeline.length ? "idle" : "empty"; const privacyMode = this.resolveConversationPrivacyMode( activeThread?.latestMessage, ); const currentUserSummary = this.resolveProfileSummaryForPubkey( this.resolveActiveDmActor(), ); const currentUserAvatarUrl = currentUserSummary?.avatarSrc || ""; container.textContent = ""; try { const dmPrivacySettings = this.getDmPrivacySettingsSnapshot(); this.dmAppShell = new AppShell({ document, currentUserAvatarUrl, conversations, activeConversationId, conversationState, messages: timeline, threadState, privacyMode, dmPrivacySettings, composerState: this.dmComposerState || "idle", notifications: [], zapConfig: { signer: this.services.nostrClient, }, mobileView: this.dmMobileView || "list", onSelectConversation: (conversation) => { void this.handleDmConversationSelect(conversation); }, onRefreshConversations: () => { this.populateProfileMessages({ force: true }); }, onBack: () => { this.dmMobileView = "list"; void this.renderDmAppShell(this.directMessagesCache, { actorPubkey: this.resolveActiveDmActor(), }); }, onSendMessage: (messageText, payload) => { void this.handleDmAppShellSendMessage(messageText, payload); }, onMarkConversationRead: (conversation) => { void this.handleDmConversationMarkRead(conversation); }, onMarkAllRead: () => { void this.handleDmMarkAllConversationsRead(); }, onToggleReadReceipts: (enabled) => { this.handleReadReceiptsToggle(enabled); }, onToggleTypingIndicators: (enabled) => { this.handleTypingIndicatorsToggle(enabled); }, onOpenSettings: () => { this.openDmSettingsModal(); }, }); } catch (error) { this.dmAppShell = null; devLogger.warn("[profileModal] Failed to render DM app shell:", error); return; } const root = this.dmAppShell && typeof this.dmAppShell.getRoot === "function" ? this.dmAppShell.getRoot() : null; if (!(root instanceof HTMLElement)) { devLogger.warn("[profileModal] DM app shell root missing."); return; } root.classList.add("bg-transparent"); container.appendChild(root); if (actor && activeConversationId) { const renderedUntil = this.getLatestDirectMessageTimestampForConversation( activeConversationId, actor, ) || Date.now() / 1000; void this.nostrService?.acknowledgeRenderedDirectMessages?.( activeConversationId, renderedUntil, ); } } setFocusedDmConversation(conversationId) { if ( !this.nostrService || typeof this.nostrService.setFocusedDirectMessageConversation !== "function" ) { return; } if ( this.focusedDmConversationId && this.focusedDmConversationId !== conversationId ) { this.nostrService.setFocusedDirectMessageConversation( this.focusedDmConversationId, false, ); } if (conversationId) { this.nostrService.setFocusedDirectMessageConversation(conversationId, true); this.focusedDmConversationId = conversationId; } else { this.focusedDmConversationId = ""; } } async handleDmConversationSelect(conversation) { const conversationId = conversation && typeof conversation.id === "string" ? conversation.id.trim() : ""; if (!conversationId) { return; } this.dmMobileView = "thread"; const actor = this.resolveActiveDmActor(); const remote = this.resolveRemoteForConversationId(conversationId, actor); this.activeDmConversationId = conversationId; if (remote) { this.setDirectMessageRecipient(remote, { reason: "thread-select" }); } this.setFocusedDmConversation(conversationId); await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); } async handleDmConversationMarkRead(conversation) { const conversationId = conversation && typeof conversation.id === "string" ? conversation.id.trim() : this.activeDmConversationId; if (!conversationId) { return; } const actor = this.resolveActiveDmActor(); if ( !actor || !this.nostrService || typeof this.nostrService.acknowledgeRenderedDirectMessages !== "function" ) { return; } const renderedUntil = this.getLatestDirectMessageTimestampForConversation( conversationId, actor, ); try { await this.nostrService.acknowledgeRenderedDirectMessages( conversationId, renderedUntil, ); } catch (error) { devLogger.warn("[profileModal] Failed to mark conversation read:", error); } const recipient = this.resolveRemoteForConversationId(conversationId, actor); const messages = this.getDirectMessagesForConversation(conversationId, actor); if (recipient && messages.length) { void this.maybePublishReadReceipt(messages, { recipientPubkey: recipient, }); } await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); } async handleDmMarkAllConversationsRead() { if ( !this.nostrService || typeof this.nostrService.acknowledgeRenderedDirectMessages !== "function" ) { return; } const actor = this.resolveActiveDmActor(); if (!actor) { return; } const summaries = typeof this.nostrService.listDirectMessageConversationSummaries === "function" ? await this.nostrService.listDirectMessageConversationSummaries() : []; const list = Array.isArray(summaries) ? summaries : []; for (const summary of list) { const conversationId = typeof summary?.conversation_id === "string" ? summary.conversation_id.trim() : typeof summary?.conversationId === "string" ? summary.conversationId.trim() : ""; if (!conversationId) { continue; } const renderedUntil = Number(summary?.last_message_at) || Number(summary?.downloaded_until) || Number(summary?.opened_until) || this.getLatestDirectMessageTimestampForConversation(conversationId, actor); try { await this.nostrService.acknowledgeRenderedDirectMessages( conversationId, renderedUntil, ); } catch (error) { devLogger.warn( "[profileModal] Failed to mark conversation read:", error, ); } const recipient = this.resolveRemoteForConversationId(conversationId, actor); const messages = this.getDirectMessagesForConversation(conversationId, actor); if (recipient && messages.length) { void this.maybePublishReadReceipt(messages, { recipientPubkey: recipient, }); } } await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); } async handleDmAppShellSendMessage(messageText, payload = {}) { const normalizedPayload = payload && typeof payload === "object" ? payload : {}; const message = typeof messageText === "string" ? messageText.trim() : ""; const attachments = Array.isArray(normalizedPayload.attachments) ? normalizedPayload.attachments : []; if (!message && !attachments.length) { this.showError("Please enter a message or attach a file."); this.dmComposerState = "error"; await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: this.resolveActiveDmActor(), }); return; } const actor = this.resolveActiveDmActor(); const activeConversationId = this.activeDmConversationId || (actor && this.resolveActiveDmRecipient() ? this.buildDmConversationId(actor, this.resolveActiveDmRecipient()) : ""); const targetHex = this.resolveRemoteForConversationId(activeConversationId, actor) || this.resolveActiveDmRecipient(); const target = typeof targetHex === "string" && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(targetHex) : ""; if (!target) { this.showError("Please select a message recipient."); this.dmComposerState = "error"; await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); return; } if ( !this.services.nostrClient || typeof this.services.nostrClient.sendDirectMessage !== "function" ) { this.showError("Direct message service unavailable."); this.dmComposerState = "error"; await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); return; } const privacyMode = typeof normalizedPayload.privacyMode === "string" ? normalizedPayload.privacyMode.trim().toLowerCase() : "nip04"; const useNip17 = privacyMode === "nip17" || privacyMode === "private"; if (attachments.length && !useNip17) { this.showError( "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.", ); this.dmComposerState = "error"; await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); return; } const recipientContext = this.buildDmRecipientContext(targetHex); const recipientRelayHints = Array.isArray(recipientContext?.relayHints) ? recipientContext.relayHints : []; const senderRelayHints = this.getActiveDmRelayPreferences(); if (useNip17 && !recipientRelayHints.length) { this.showStatus( "Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.", { autoHideMs: 5000 }, ); } this.dmComposerState = "sending"; await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); try { const result = await this.services.nostrClient.sendDirectMessage( target, message, null, useNip17 ? { useNip17: true, recipientRelayHints, senderRelayHints, attachments, } : {}, ); if (result?.ok) { this.showSuccess("Message sent."); if (result?.warning === "dm-relays-fallback") { this.showStatus( "Privacy warning: this message used default relays because no NIP-17 relay list was found.", { autoHideMs: 5000 }, ); } if ( this.nostrService && typeof this.nostrService.loadDirectMessages === "function" ) { await this.nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: false, }); } this.dmComposerState = "idle"; } else { const errorCode = typeof result?.error === "string" ? result.error : "unknown"; userLogger.warn("[profileModal] Failed to send direct message:", errorCode); this.showError(this.describeDirectMessageSendError(errorCode)); this.dmComposerState = "error"; } } catch (error) { userLogger.error("[profileModal] Unexpected DM send failure:", error); this.showError("Unable to send message. Please try again."); this.dmComposerState = "error"; } finally { await this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); } } async populateProfileMessages(options = {}) { const settings = options && typeof options === "object" ? options : { force: false }; const { force = false } = settings; const actor = this.resolveActiveDmActor(); if (!actor) { this.clearProfileMessages(); return; } if ( !this.nostrService || typeof this.nostrService.loadDirectMessages !== "function" ) { this.setMessagesLoadingState("error", { message: "Direct message service unavailable.", }); return; } if ( !force && !this.messagesInitialLoadPending && Array.isArray(this.directMessagesCache) && this.directMessagesCache.length ) { await this.renderProfileMessages(this.directMessagesCache, { actorPubkey: actor, }); this.setMessagesLoadingState("ready"); return; } const requestId = Symbol("messagesLoad"); this.activeMessagesRequest = requestId; this.messagesInitialLoadPending = false; this.setMessagesLoadingState("loading"); if ( this.directMessagesLastActor && this.directMessagesLastActor !== actor ) { this.resetDirectMessageSubscription(); if ( typeof this.nostrService.clearDirectMessages === "function" ) { try { this.nostrService.clearDirectMessages({ emit: true }); } catch (error) { devLogger.warn( "[profileModal] Failed to clear direct messages cache before reload:", error, ); } } } try { let snapshot = await this.nostrService.loadDirectMessages({ actorPubkey: actor, initialLoad: true, }); if (!Array.isArray(snapshot)) { snapshot = []; } if (this.activeMessagesRequest !== requestId) { return; } this.directMessagesCache = snapshot; this.directMessagesLastActor = actor; await this.renderProfileMessages(snapshot, { actorPubkey: actor }); if (!snapshot.length) { this.setMessagesLoadingState("empty"); } else { this.setMessagesLoadingState("ready", { message: snapshot.length === 1 ? "1 direct message thread loaded." : `${snapshot.length} direct message threads loaded.`, }); } } catch (error) { if (this.activeMessagesRequest === requestId) { userLogger.error( "[profileModal] Failed to load direct messages:", error, ); this.setMessagesLoadingState("error", { message: "Failed to load direct messages. Try again later.", }); this.messagesInitialLoadPending = true; } return; } finally { if (this.activeMessagesRequest === requestId) { this.activeMessagesRequest = null; this.updateMessagesReloadState(); } } void this.ensureDirectMessageSubscription(actor); } mountDmAppShell() { const container = this.dmAppShellContainer instanceof HTMLElement ? this.dmAppShellContainer : null; if (!container) { return; } void this.renderDmAppShell(this.directMessagesCache, { actorPubkey: this.directMessagesLastActor, }); } unmountDmAppShell() { const container = this.dmAppShellContainer instanceof HTMLElement ? this.dmAppShellContainer : null; if (container) { container.textContent = ""; } this.dmAppShell = null; } resumeProfileMessages() { this.messagesViewActive = true; this.mountDmAppShell(); this.updateMessagesReloadState(); } pauseProfileMessages() { this.messagesViewActive = false; this.unmountDmAppShell(); this.updateMessagesReloadState(); } clearDirectMessagesUpdateQueue() { if (this.directMessagesRenderTimeout) { const clearTimeoutFn = typeof window !== "undefined" && typeof window.clearTimeout === "function" ? window.clearTimeout.bind(window) : clearTimeout; clearTimeoutFn(this.directMessagesRenderTimeout); this.directMessagesRenderTimeout = null; } this.pendingDirectMessagesUpdate = null; } scheduleDirectMessagesRender(payload = null) { if (!payload) { return; } this.pendingDirectMessagesUpdate = payload; if (this.directMessagesRenderTimeout) { return; } const scheduleTimeout = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout; this.directMessagesRenderTimeout = scheduleTimeout(() => { this.directMessagesRenderTimeout = null; this.flushDirectMessagesRender(); }, DIRECT_MESSAGES_BATCH_DELAY_MS); } flushDirectMessagesRender() { const pending = this.pendingDirectMessagesUpdate; this.pendingDirectMessagesUpdate = null; if (!pending) { return; } const { messages, actorPubkey, reason } = pending; void this.renderProfileMessages(messages, { actorPubkey }) .then(() => { if (!messages.length) { this.setMessagesLoadingState("empty"); } else { this.setMessagesLoadingState("ready"); } if (reason === "subscription") { this.setMessagesAnnouncement("New direct message received."); } else if (reason === "load") { this.setMessagesAnnouncement( messages.length === 1 ? "1 direct message thread synced." : `${messages.length} direct message threads synced.`, ); } }) .catch((error) => { devLogger.warn( "[profileModal] Failed to render direct messages after update:", error, ); }); } handleDirectMessagesUpdated(detail = {}) { if ( this.activeMessagesRequest && detail?.reason !== "load-incremental" ) { return; } const messages = Array.isArray(detail?.messages) ? detail.messages : []; this.directMessagesCache = messages; const actor = this.resolveActiveDmActor(); if (!actor) { this.setMessagesLoadingState("unauthenticated"); this.clearDirectMessagesUpdateQueue(); return; } this.directMessagesLastActor = actor; this.scheduleDirectMessagesRender({ messages, actorPubkey: actor, reason: typeof detail?.reason === "string" ? detail.reason : "", }); } handleDirectMessagesCleared() { if (this.activeMessagesRequest) { return; } this.clearDirectMessagesUpdateQueue(); this.directMessagesCache = []; this.setDirectMessageRecipient(null, { reason: "clear" }); if (this.profileMessagesList instanceof HTMLElement) { this.profileMessagesList.textContent = ""; this.profileMessagesList.classList.add("hidden"); this.profileMessagesList.setAttribute("hidden", ""); } const actor = this.resolveActiveDmActor(); if (!actor) { this.setMessagesLoadingState("unauthenticated"); } else { this.setMessagesLoadingState("empty"); } if (this.dmAppShellContainer instanceof HTMLElement) { void this.renderDmAppShell(this.directMessagesCache, { actorPubkey: actor, }); } } handleDirectMessagesError(detail = {}) { const error = detail?.error || detail?.failure || detail; const reason = detail?.context?.reason || ""; const errorCode = error?.code || ""; const errorMessage = typeof error === "string" ? error : typeof error?.message === "string" ? error.message : ""; const requiresNip44Decryptor = typeof errorMessage === "string" && errorMessage.includes("Gift wrap events require a NIP-44 decryptor"); const isBenign = reason === "no-decryptors" || errorCode === "decryption-failed" || (typeof error === "string" && error.includes("no-decryptors")); if (isBenign) { devLogger.info("[profileModal] Direct message sync info:", error); } else { userLogger.warn( "[profileModal] Direct message sync issue detected:", error, ); } if (this.activeMessagesRequest) { return; } if (requiresNip44Decryptor) { const nip44Message = "NIP-17 direct messages require a NIP-44-capable signer or extension. Unlock or update your extension to continue."; if (!this.directMessagesCache.length) { this.setMessagesLoadingState("error", { message: nip44Message, }); return; } this.setMessagesAnnouncement(nip44Message); this.updateMessagesReloadState(); return; } if (!this.directMessagesCache.length) { this.setMessagesLoadingState("error", { message: "Unable to sync direct messages right now.", }); return; } this.setMessagesAnnouncement("Unable to sync direct messages right now."); this.updateMessagesReloadState(); } handleDirectMessagesRelayWarning(detail = {}) { if (!this.enableNip17RelayWarning) { return; } if (detail?.warning !== "dm-relays-fallback") { return; } if (this.hasShownRelayWarning) { return; } this.hasShownRelayWarning = true; this.showStatus( "Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.", { autoHideMs: 5000 }, ); } registerEventListeners() { if (this.closeButton instanceof HTMLElement) { this.closeButton.addEventListener("click", () => { this.hide(); }); } if (this.mobileLogoutButton instanceof HTMLElement) { this.mobileLogoutButton.addEventListener("click", async () => { try { await this.callbacks.onLogout(this); } catch (error) { this.showError("Failed to logout. Please try again."); } this.hide(); }); } if (this.profileModalBackButton instanceof HTMLElement) { this.profileModalBackButton.addEventListener("click", () => { this.setMobileView("menu", { focusMenu: true }); }); } if (this.logoutButton instanceof HTMLElement) { this.logoutButton.addEventListener("click", async () => { try { await this.callbacks.onLogout(this); } catch (error) { this.showError("Failed to logout. Please try again."); } this.hide(); }); } if (this.channelLink instanceof HTMLElement) { this.channelLink.addEventListener("click", (event) => { event.preventDefault(); this.callbacks.onChannelLink(this.channelLink, this); }); } if (this.addAccountButton instanceof HTMLElement) { this.addAccountButton.addEventListener("click", () => { void this.handleAddAccountRequest(); }); } if (this.permissionPromptCtaButton instanceof HTMLElement) { this.permissionPromptCtaButton.addEventListener("click", () => { if (this.permissionPromptCtaButton?.dataset?.action === "retry-auth-sync") { this.callbacks.onRetryAuthSync({ controller: this }); return; } this.callbacks.onRequestPermissionPrompt({ controller: this }); }); } Object.entries(this.navButtons).forEach(([name, button]) => { if (button instanceof HTMLElement) { button.addEventListener("click", () => { this.selectPane(name); }); } }); if (this.addRelayButton instanceof HTMLElement) { this.addRelayButton.addEventListener("click", () => { void this.handleAddRelay(); }); } if (this.restoreRelaysButton instanceof HTMLElement) { this.restoreRelaysButton.addEventListener("click", () => { void this.handleRestoreRelays(); }); } if (this.profileRelayRefreshBtn instanceof HTMLElement) { this.profileRelayRefreshBtn.addEventListener("click", () => { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { return; } const service = this.services.relayManager; if (!service || typeof service.loadRelayList !== "function") { return; } void service .loadRelayList(activeHex) .then(() => { this.populateProfileRelays(); void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update", }); }) .catch((error) => { devLogger.warn("[profileModal] Failed to refresh relay list:", error); }); }); } if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) { this.relayHealthTelemetryToggle.addEventListener("change", () => { this.handleRelayHealthTelemetryToggle(); }); } if (this.addBlockedButton instanceof HTMLElement) { this.addBlockedButton.addEventListener("click", () => { void this.handleAddBlockedCreator(); }); } if (this.profileBlockedRefreshBtn instanceof HTMLElement) { this.profileBlockedRefreshBtn.addEventListener("click", () => { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { return; } const blocksService = this.services.userBlocks; if (!blocksService || typeof blocksService.loadBlocks !== "function") { return; } void blocksService .loadBlocks(activeHex) .then(() => { this.populateBlockedList(); }) .catch((error) => { devLogger.warn("[profileModal] Failed to refresh blocked list:", error); }); }); } if (this.blockInput instanceof HTMLElement) { this.blockInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAddBlockedCreator(); } }); } if (this.profileSubscriptionsRefreshBtn instanceof HTMLElement) { this.profileSubscriptionsRefreshBtn.addEventListener("click", () => { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { return; } const service = this.subscriptionsService; if (!service || typeof service.loadSubscriptions !== "function") { return; } void service .loadSubscriptions(activeHex, { allowPermissionPrompt: true }) .then(() => { void this.populateSubscriptionsList(); }) .catch((error) => { devLogger.warn( "[profileModal] Failed to refresh subscriptions list:", error, ); }); }); } if (this.profileFriendsRefreshBtn instanceof HTMLElement) { this.profileFriendsRefreshBtn.addEventListener("click", () => { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { return; } const moderationService = this.moderationService; const refreshPromise = moderationService && typeof moderationService.ensureViewerContactsLoaded === "function" ? moderationService.ensureViewerContactsLoaded(activeHex) : this.subscriptionsService && typeof this.subscriptionsService.ensureLoaded === "function" ? this.subscriptionsService.ensureLoaded(activeHex) : null; void Promise.resolve(refreshPromise) .then(() => { this.populateFriendsList(); }) .catch((error) => { devLogger.warn("[profileModal] Failed to refresh friends list:", error); }); }); } if (this.addHashtagInterestButton instanceof HTMLElement) { this.addHashtagInterestButton.addEventListener("click", () => { void this.handleAddHashtagPreference("interest"); }); } const handleHashtagRefresh = () => { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { return; } const service = this.hashtagPreferencesService; if (!service || typeof service.load !== "function") { return; } void service .load(activeHex, { allowPermissionPrompt: true }) .catch((error) => { devLogger.warn( "[profileModal] Failed to refresh hashtag preferences:", error, ); }); }; if (this.profileHashtagInterestRefreshBtn instanceof HTMLElement) { this.profileHashtagInterestRefreshBtn.addEventListener( "click", handleHashtagRefresh, ); } if (this.profileHashtagDisinterestRefreshBtn instanceof HTMLElement) { this.profileHashtagDisinterestRefreshBtn.addEventListener( "click", handleHashtagRefresh, ); } if (this.hashtagInterestInput instanceof HTMLElement) { this.hashtagInterestInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAddHashtagPreference("interest"); } }); } if (this.addHashtagDisinterestButton instanceof HTMLElement) { this.addHashtagDisinterestButton.addEventListener("click", () => { void this.handleAddHashtagPreference("disinterest"); }); } if (this.hashtagDisinterestInput instanceof HTMLElement) { this.hashtagDisinterestInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAddHashtagPreference("disinterest"); } }); } if (this.profileMessagesReloadButton instanceof HTMLElement) { this.profileMessagesReloadButton.addEventListener("click", () => { void this.populateProfileMessages({ force: true, reason: "manual" }); }); } if (this.profileMessagesSendDmButton instanceof HTMLElement) { this.profileMessagesSendDmButton.addEventListener("click", () => { void this.handleSendDmRequest(); }); } if (this.profileMessagesOpenRelaysButton instanceof HTMLElement) { this.profileMessagesOpenRelaysButton.addEventListener("click", () => { void this.handleOpenDmRelaysRequest(); }); } if (this.profileMessagesPrivacyToggle instanceof HTMLElement) { this.profileMessagesPrivacyToggle.addEventListener("change", (event) => { const toggle = event.currentTarget; if (toggle instanceof HTMLInputElement) { this.handlePrivacyToggle(toggle.checked); } }); } // Legacy toggles removed - handled by DMPrivacySettings in AppShell if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) { this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => { const toggle = event.currentTarget; if (toggle instanceof HTMLInputElement) { this.handleLinkPreviewToggle(toggle.checked); } }); } this.bindDmRelayControls(); this.walletController.registerEventListeners(); this.storageController.registerEventListeners(); if (this.profileMessageSendButton instanceof HTMLElement) { this.profileMessageSendButton.addEventListener("click", () => { void this.handleSendProfileMessage(); }); } if (this.profileMessageAttachmentButton instanceof HTMLElement) { this.profileMessageAttachmentButton.addEventListener("click", () => { if (this.profileMessageAttachmentInput instanceof HTMLInputElement) { this.profileMessageAttachmentInput.click(); } }); } if (this.profileMessageAttachmentInput instanceof HTMLElement) { this.profileMessageAttachmentInput.addEventListener("change", () => { this.handleAttachmentSelection(); }); } if (this.profileMessageAttachmentClearCache instanceof HTMLElement) { this.profileMessageAttachmentClearCache.addEventListener("click", () => { clearAttachmentCache(); const stats = getAttachmentCacheStats(); this.showStatus( `Attachment cache cleared (${stats.size}/${stats.maxSize}).`, ); }); } if (this.profileMessageInput instanceof HTMLElement) { this.profileMessageInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void this.handleSendProfileMessage(); } }); this.profileMessageInput.addEventListener("input", () => { void this.maybePublishTypingIndicator(); }); } if (this.profileSubscriptionsHistoryBtn) { this.profileSubscriptionsHistoryBtn.addEventListener("click", () => { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (pubkey) { this.subscriptionHistoryController.show(pubkey); } else { this.showError("Please log in to view subscription history."); } }); } if (this.profileSubscriptionsBackupBtn) { this.profileSubscriptionsBackupBtn.addEventListener("click", () => { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (pubkey) { if (confirm("Create a backup of your current subscription list?")) { this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => { this.showSuccess("Backup created."); }); } } else { this.showError("Please log in to backup subscriptions."); } }); } if (this.moderationBlurInput instanceof HTMLElement) { this.moderationBlurInput.addEventListener("input", () => { this.applyModerationSettingsControlState(); }); } if (this.moderationAutoplayInput instanceof HTMLElement) { this.moderationAutoplayInput.addEventListener("input", () => { this.applyModerationSettingsControlState(); }); } if (this.moderationMuteHideInput instanceof HTMLElement) { this.moderationMuteHideInput.addEventListener("input", () => { this.applyModerationSettingsControlState(); }); } if (this.moderationSpamHideInput instanceof HTMLElement) { this.moderationSpamHideInput.addEventListener("input", () => { this.applyModerationSettingsControlState(); }); } if (this.moderationSaveButton instanceof HTMLElement) { this.moderationSaveButton.addEventListener("click", () => { void this.handleModerationSettingsSave(); }); } if (this.moderationResetButton instanceof HTMLElement) { this.moderationResetButton.addEventListener("click", () => { void this.handleModerationSettingsReset(); }); } if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") { this.boundModerationOverridesUpdate = () => { this.refreshModerationOverridesUi(); }; document.addEventListener( "video:moderation-override", this.boundModerationOverridesUpdate, ); document.addEventListener( "video:moderation-hide", this.boundModerationOverridesUpdate, ); document.addEventListener( "video:moderation-block", this.boundModerationOverridesUpdate, ); } if (this.addModeratorButton instanceof HTMLElement) { this.addModeratorButton.addEventListener("click", () => { void this.handleAddModerator(); }); } if (this.adminModeratorsRefreshBtn instanceof HTMLElement) { this.adminModeratorsRefreshBtn.addEventListener("click", () => { const service = this.services.accessControl; if (!service || typeof service.refresh !== "function") { return; } void service .refresh() .then(() => this.refreshAdminPaneState()) .catch((error) => { devLogger.warn("[profileModal] Failed to refresh moderators:", error); }); }); } if (this.moderatorInput instanceof HTMLElement) { this.moderatorInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAddModerator(); } }); } if (this.addWhitelistButton instanceof HTMLElement) { this.addWhitelistButton.addEventListener("click", () => { void this.handleAdminListMutation("whitelist", "add"); }); } if (this.adminWhitelistRefreshBtn instanceof HTMLElement) { this.adminWhitelistRefreshBtn.addEventListener("click", () => { const service = this.services.accessControl; if (!service || typeof service.refresh !== "function") { return; } void service .refresh() .then(() => this.refreshAdminPaneState()) .catch((error) => { devLogger.warn("[profileModal] Failed to refresh whitelist:", error); }); }); } if (this.whitelistInput instanceof HTMLElement) { this.whitelistInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAdminListMutation("whitelist", "add"); } }); } if (this.addBlacklistButton instanceof HTMLElement) { this.addBlacklistButton.addEventListener("click", () => { void this.handleAdminListMutation("blacklist", "add"); }); } if (this.adminBlacklistRefreshBtn instanceof HTMLElement) { this.adminBlacklistRefreshBtn.addEventListener("click", () => { const service = this.services.accessControl; if (!service || typeof service.refresh !== "function") { return; } void service .refresh() .then(() => this.refreshAdminPaneState()) .catch((error) => { devLogger.warn("[profileModal] Failed to refresh blacklist:", error); }); }); } if (this.blacklistInput instanceof HTMLElement) { this.blacklistInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void this.handleAdminListMutation("blacklist", "add"); } }); } if (this.profileEditBtn instanceof HTMLElement) { this.profileEditBtn.addEventListener("click", () => { this.handleEditProfile(); }); } if (this.profileEditBackBtn instanceof HTMLElement) { this.profileEditBackBtn.addEventListener("click", () => { this.selectPane("account"); }); } if (this.editCancelBtn instanceof HTMLElement) { this.editCancelBtn.addEventListener("click", () => { this.selectPane("account"); }); } if (this.editSaveBtn instanceof HTMLElement) { this.editSaveBtn.addEventListener("click", () => { void this.handleSaveProfile(); }); } if (this.editPictureUploadBtn instanceof HTMLElement) { this.editPictureUploadBtn.addEventListener("click", () => { if (this.editPictureFile) this.editPictureFile.click(); }); } if (this.editPictureFile instanceof HTMLElement) { this.editPictureFile.addEventListener("change", () => { void this.handleUpload("picture"); }); } if (this.editBannerUploadBtn instanceof HTMLElement) { this.editBannerUploadBtn.addEventListener("click", () => { if (this.editBannerFile) this.editBannerFile.click(); }); } if (this.editBannerFile instanceof HTMLElement) { this.editBannerFile.addEventListener("change", () => { void this.handleUpload("banner"); }); } if (this.editPictureConfigureLink instanceof HTMLElement) { this.editPictureConfigureLink.addEventListener("click", (e) => { e.preventDefault(); this.selectPane("storage"); }); } if (this.editBannerConfigureLink instanceof HTMLElement) { this.editBannerConfigureLink.addEventListener("click", (e) => { e.preventDefault(); this.selectPane("storage"); }); } this.updateMessagesReloadState(); } resolveAddAccountLoginError(error, fallbackMessage = "") { const describe = this.describeLoginErrorService; const fallback = typeof fallbackMessage === "string" && fallbackMessage.trim() ? fallbackMessage.trim() : "Couldn't add that profile. 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] describeLoginError service threw:", describeError, ); } } 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)) { return; } const button = this.addAccountButton; const titleEl = button.querySelector("[data-profile-add-title]"); const hintEl = button.querySelector("[data-profile-add-hint]"); if (isLoading) { this.addAccountButtonState = { originalDisabled: button.disabled, originalAriaLabel: button.getAttribute("aria-label"), titleElement: titleEl instanceof HTMLElement ? titleEl : null, hintElement: hintEl instanceof HTMLElement ? hintEl : null, originalTitle: titleEl instanceof HTMLElement ? titleEl.textContent || "" : "", originalHint: hintEl instanceof HTMLElement ? hintEl.textContent || "" : "", }; button.disabled = true; button.dataset.state = "loading"; button.setAttribute("aria-busy", "true"); button.setAttribute("aria-disabled", "true"); if (this.addAccountButtonState.titleElement) { this.addAccountButtonState.titleElement.textContent = "Connecting..."; } if (this.addAccountButtonState.hintElement) { this.addAccountButtonState.hintElement.textContent = "Complete the login prompt from your provider."; } button.setAttribute( "aria-label", "Connecting to your Nostr account", ); return; } const state = this.addAccountButtonState; if (state) { button.disabled = !!state.originalDisabled; if (state.originalDisabled) { button.setAttribute("aria-disabled", "true"); } else { button.removeAttribute("aria-disabled"); } if (state.titleElement) { state.titleElement.textContent = state.originalTitle || ""; } if (state.hintElement) { state.hintElement.textContent = state.originalHint || ""; } if (state.originalAriaLabel === null) { button.removeAttribute("aria-label"); } else if (typeof state.originalAriaLabel === "string") { button.setAttribute("aria-label", state.originalAriaLabel); } } else { if (button.disabled) { button.setAttribute("aria-disabled", "true"); } else { button.removeAttribute("aria-disabled"); } } button.setAttribute("aria-busy", "false"); delete button.dataset.state; this.addAccountButtonState = null; } isAddAccountCancellationError(error) { if (!error || typeof error !== "object") { return false; } const code = typeof error.code === "string" && error.code.trim() ? error.code.trim() : ""; if (!code) { return false; } return ADD_PROFILE_CANCELLATION_CODES.has(code); } async handleAddAccountRequest() { if (!(this.addAccountButton instanceof HTMLElement)) { return; } if (this.addAccountButton.dataset.state === "loading") { return; } const requestLogin = this.requestAddProfileLoginService; if (typeof requestLogin !== "function") { devLogger.warn( "[ProfileModalController] requestAddProfileLogin service unavailable.", ); this.showError("Login is unavailable right now. Please try again later."); return; } this.setAddAccountLoading(true); try { this.bringLoginModalToFront(); } catch (error) { devLogger.warn( "[ProfileModalController] Failed to elevate login modal before add profile authentication:", error, ); } let suspendedFocusTrap = false; try { this.suspendFocusTrap(); suspendedFocusTrap = true; } catch (error) { devLogger.warn( "[ProfileModalController] Failed to suspend profile modal focus trap before login:", error, ); } try { const loginResult = await requestLogin({ controller: this, triggerElement: this.addAccountButton, }); if (loginResult === undefined) { return; } await this.invokeAddAccountCallback(loginResult); } catch (error) { if (this.isAddAccountCancellationError(error)) { try { this.log( "[ProfileModalController] Add profile flow cancelled by user.", error, ); } catch (logError) { devLogger.warn( "[ProfileModalController] Failed to log cancellation event:", logError, ); } return; } devLogger.error( "[ProfileModalController] Failed to complete add profile authentication:", error, ); const message = this.resolveAddAccountLoginError( error, "Couldn't add that profile. Please try again.", ); if (message) { this.showError(message); } } finally { if (suspendedFocusTrap) { try { this.resumeFocusTrap(); } catch (error) { devLogger.warn( "[ProfileModalController] Failed to resume profile modal focus trap after login:", error, ); } } this.setAddAccountLoading(false); } } async handleAddProfile(payload = {}) { const loginResult = payload && typeof payload === "object" ? payload.loginResult : null; if (!loginResult) { devLogger.warn( "[ProfileModalController] Ignoring add profile callback without an authentication result.", ); return; } try { const { pubkey, authType: loginAuthType, providerId } = typeof loginResult === "object" && loginResult ? loginResult : { pubkey: loginResult }; const detailAuthType = typeof loginResult?.detail?.authType === "string" ? loginResult.detail.authType : null; const detailProviderId = typeof loginResult?.detail?.providerId === "string" ? loginResult.detail.providerId : null; const resolvedAuthType = (() => { const candidates = [ detailAuthType, loginAuthType, detailProviderId, providerId, ]; for (const candidate of candidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (trimmed) { return trimmed; } } return "nip07"; })(); const resolvedProviderId = (() => { const candidates = [detailProviderId, providerId, resolvedAuthType]; for (const candidate of candidates) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (trimmed) { return trimmed; } } return resolvedAuthType; })(); const normalizedPubkey = this.normalizeHexPubkey(pubkey); if (!normalizedPubkey) { throw new Error( "Received an invalid public key from the authentication provider.", ); } const savedProfiles = this.getSavedProfiles(); const alreadySaved = savedProfiles.some( (entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedPubkey, ); if (alreadySaved) { this.showSuccess("That profile is already saved on this device."); return; } const npub = this.safeEncodeNpub(normalizedPubkey) || ""; let profileMeta = this.services.getProfileCacheEntry(normalizedPubkey)?.profile; if (!profileMeta) { await this.services.authService.loadOwnProfile(normalizedPubkey); profileMeta = this.services.getProfileCacheEntry(normalizedPubkey)?.profile; } const name = profileMeta?.name || ""; const picture = profileMeta?.picture || "assets/svg/default-profile.svg"; savedProfiles.push({ pubkey: normalizedPubkey, npub, name, picture, authType: resolvedAuthType, providerId: resolvedProviderId, }); this.setSavedProfiles(savedProfiles, { persist: false, persistActive: false }); this.persistSavedProfiles({ persistActive: false }); this.renderSavedProfiles(); this.showSuccess("Profile added. Select it when you're ready to switch."); } catch (error) { const cancellationCodes = new Set([ "login-cancelled", "user-cancelled", "modal-dismissed", ]); if ( error && typeof error === "object" && typeof error.code === "string" && cancellationCodes.has(error.code) ) { this.log("[ProfileModalController] Add profile flow cancelled.", error); return; } devLogger.error( "[ProfileModalController] Failed to add profile via authentication provider:", error, ); const message = this.describeLoginErrorService( error, "Couldn't add that profile. Please try again.", ); const rejectionError = error instanceof Error ? error : new Error(message || "Couldn't add that profile. Please try again."); if (message && rejectionError.message !== message) { rejectionError.message = message; } if ( error && typeof error === "object" && error.code && !rejectionError.code ) { rejectionError.code = error.code; } throw rejectionError; } } async invokeAddAccountCallback(loginResult) { return this.handleAddProfile({ loginResult }); } renderSavedProfiles() { const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey()); const entriesNeedingFetch = new Set(); const savedProfiles = this.getSavedProfiles(); const resolveMeta = (entry) => { if (!entry || typeof entry !== "object") { return { name: "", picture: FALLBACK_PROFILE_AVATAR, npub: null, }; } const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey); let cacheEntry = null; if (normalizedPubkey) { cacheEntry = this.services.getProfileCacheEntry(normalizedPubkey); } const cachedProfile = cacheEntry?.profile || {}; const hasStoredName = typeof entry.name === "string" && entry.name.trim().length > 0; const hasStoredPicture = typeof entry.picture === "string" && entry.picture.trim().length > 0; if ( !cacheEntry && normalizedPubkey && (!hasStoredName || !hasStoredPicture) ) { entriesNeedingFetch.add(normalizedPubkey); } let resolvedNpub = typeof entry.npub === "string" && entry.npub.trim() ? entry.npub.trim() : null; if (!resolvedNpub && entry.pubkey) { resolvedNpub = this.safeEncodeNpub(entry.pubkey); } return { name: cachedProfile.name || entry.name || "", picture: cachedProfile.picture || entry.picture || FALLBACK_PROFILE_AVATAR, npub: resolvedNpub, }; }; const savedEntries = Array.isArray(savedProfiles) ? savedProfiles.filter((entry) => entry && entry.pubkey) : []; let activeEntry = null; if (normalizedActive) { activeEntry = savedEntries.find( (entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedActive, ); } if (!activeEntry && savedEntries.length) { activeEntry = savedEntries[0]; } const activeMeta = activeEntry ? resolveMeta(activeEntry) : null; const hasActiveProfile = Boolean(activeEntry && activeMeta); const truncate = this.truncateMiddle || ((value) => value); const formatNpub = typeof this.formatShortNpub === "function" ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const activeNameFallback = activeMeta?.npub ? formatNpub(activeMeta.npub) || DEFAULT_SAVED_PROFILE_LABEL : DEFAULT_SAVED_PROFILE_LABEL; const activeDisplayName = hasActiveProfile ? activeMeta?.name?.trim() || activeNameFallback : "No active profile"; const activeAvatarSrc = hasActiveProfile ? activeMeta?.picture || FALLBACK_PROFILE_AVATAR : FALLBACK_PROFILE_AVATAR; if (this.profileName) { this.profileName.textContent = activeDisplayName; } if (this.profileAvatar instanceof HTMLImageElement) { if (this.profileAvatar.src !== activeAvatarSrc) { this.profileAvatar.src = activeAvatarSrc; } this.profileAvatar.alt = hasActiveProfile ? `${activeDisplayName} avatar` : "Default profile avatar"; } else if (this.profileAvatar instanceof HTMLElement) { this.profileAvatar.setAttribute("data-avatar-src", activeAvatarSrc); } if (this.profileNpub) { if (hasActiveProfile && activeMeta?.npub) { const displayNpub = formatNpub(activeMeta.npub); this.profileNpub.textContent = displayNpub || "npub unavailable"; } else if (hasActiveProfile) { this.profileNpub.textContent = "npub unavailable"; } else { this.profileNpub.textContent = "Link a profile to get started"; } } if (this.channelLink instanceof HTMLElement) { if (hasActiveProfile && activeMeta?.npub) { const encodedNpub = activeMeta.npub; this.channelLink.href = `#view=channel-profile&npub=${encodeURIComponent( encodedNpub, )}`; this.channelLink.dataset.targetNpub = encodedNpub; this.channelLink.classList.remove("hidden"); this.channelLink.setAttribute("aria-hidden", "false"); } else { this.channelLink.classList.add("hidden"); this.channelLink.removeAttribute("href"); if (this.channelLink.dataset) { delete this.channelLink.dataset.targetNpub; } this.channelLink.setAttribute("aria-hidden", "true"); } } if (this.globalProfileAvatar instanceof HTMLImageElement) { if (this.globalProfileAvatar.src !== activeAvatarSrc) { this.globalProfileAvatar.src = activeAvatarSrc; } this.globalProfileAvatar.alt = hasActiveProfile ? `${activeDisplayName} avatar` : this.globalProfileAvatar.alt || "Profile avatar"; } const listEl = this.switcherList; if (listEl instanceof HTMLElement) { listEl.textContent = ""; let normalizedSelection = this.normalizeHexPubkey( this.profileSwitcherSelectionPubkey, ); if (normalizedSelection && normalizedSelection === normalizedActive) { normalizedSelection = null; this.profileSwitcherSelectionPubkey = null; } const entriesToRender = savedEntries.filter((entry) => { const normalized = this.normalizeHexPubkey(entry.pubkey); return normalized && normalized !== normalizedActive; }); if (!entriesToRender.length) { listEl.setAttribute("data-profile-switcher-empty", "true"); const helper = document.createElement("p"); helper.className = "text-sm text-muted"; helper.textContent = "No other profiles saved yet."; helper.setAttribute("role", "note"); listEl.appendChild(helper); } else { listEl.removeAttribute("data-profile-switcher-empty"); entriesToRender.forEach((entry) => { const meta = resolveMeta(entry); const button = document.createElement("button"); button.type = "button"; button.className = "card focus-ring flex w-full items-center gap-4 p-4 text-left transition"; button.dataset.pubkey = entry.pubkey; if (meta.npub) { button.dataset.npub = meta.npub; } const normalizedAuthType = typeof entry.authType === "string" && entry.authType.trim() ? entry.authType.trim() : null; if (normalizedAuthType) { button.dataset.authType = normalizedAuthType; } const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey); const isSelected = normalizedSelection && normalizedPubkey === normalizedSelection; if (isSelected) { button.dataset.state = "active"; button.setAttribute("aria-pressed", "true"); } else { delete button.dataset.state; button.setAttribute("aria-pressed", "false"); } const avatarSpan = document.createElement("span"); avatarSpan.className = "flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-overlay-strong bg-overlay-panel-soft"; const avatarImg = document.createElement("img"); avatarImg.className = "h-full w-full object-cover"; avatarImg.src = meta.picture || FALLBACK_PROFILE_AVATAR; const cardDisplayName = meta.name?.trim() || (meta.npub ? formatNpub(meta.npub) || DEFAULT_SAVED_PROFILE_LABEL : DEFAULT_SAVED_PROFILE_LABEL); avatarImg.alt = `${cardDisplayName} avatar`; avatarSpan.appendChild(avatarImg); const metaSpan = document.createElement("div"); metaSpan.className = "flex min-w-0 flex-1 flex-col gap-2"; const topLine = document.createElement("div"); topLine.className = "flex flex-wrap items-center justify-between gap-3"; const providerId = this.getEntryProviderId(entry); const providerInfo = this.resolveEntryProviderMetadata(entry); const providerLabel = (providerInfo && providerInfo.label) || DEFAULT_SAVED_PROFILE_LABEL; const badgeVariant = resolveProviderBadgeClass( providerInfo && providerInfo.badgeVariant, ); const resolvedProviderId = providerId || (providerInfo && providerInfo.id) || ""; const label = document.createElement("span"); label.className = `${PROVIDER_BADGE_BASE_CLASS} ${badgeVariant}`; label.textContent = providerLabel; label.dataset.providerVariant = (providerInfo && providerInfo.badgeVariant) || "neutral"; if (providerId) { label.dataset.providerId = providerId; } else if (providerInfo && providerInfo.id) { label.dataset.providerId = providerInfo.id; } const actionGroup = document.createElement("div"); actionGroup.className = "flex flex-wrap items-center gap-2"; const action = document.createElement("span"); action.className = "text-xs font-medium text-muted"; action.setAttribute("aria-hidden", "true"); action.textContent = isSelected ? "Selected" : "Switch"; actionGroup.appendChild(action); const logoutButton = this.createSavedProfileLogoutButton({ entry, providerId: resolvedProviderId || null, cardButton: button, displayName: cardDisplayName, }); if (logoutButton) { actionGroup.appendChild(logoutButton); } topLine.append(label, actionGroup); const nameSpan = document.createElement("span"); nameSpan.className = "truncate text-sm font-semibold text-primary"; nameSpan.textContent = cardDisplayName; const npubSpan = document.createElement("span"); npubSpan.className = "break-all font-mono text-xs text-muted"; if (meta.npub) { const displayNpub = formatNpub(meta.npub); npubSpan.textContent = displayNpub || "npub unavailable"; } else { npubSpan.textContent = "npub unavailable"; } metaSpan.append(topLine, nameSpan, npubSpan); button.append(avatarSpan, metaSpan); const ariaLabel = isSelected ? `${cardDisplayName} selected` : `Switch to ${cardDisplayName}`; button.setAttribute("aria-label", ariaLabel); if (resolvedProviderId) { button.dataset.providerId = resolvedProviderId; } else { delete button.dataset.providerId; } const activateProfile = async (event) => { if (event) { event.preventDefault(); event.stopPropagation(); } if (button.dataset.loading === "true") { return; } button.dataset.loading = "true"; button.setAttribute("aria-busy", "true"); const logProviderId = providerId || (providerInfo && providerInfo.id) || normalizedAuthType || "unknown"; try { await this.switchProfile(entry.pubkey, { entry, providerId: providerId || null, }); } catch (error) { userLogger.error( `[ProfileModalController] Failed to switch profile for provider ${logProviderId}:`, error, ); } finally { button.dataset.loading = "false"; button.setAttribute("aria-busy", "false"); } }; button.addEventListener("click", activateProfile); button.addEventListener("keydown", (event) => { const key = event?.key; if (key === "Enter" || key === " " || key === "Spacebar") { activateProfile(event); } }); listEl.appendChild(button); }); } this.updateFocusTrap(); } else { this.updateFocusTrap(); } if (entriesNeedingFetch.size) { this.services.batchFetchProfiles(entriesNeedingFetch); } } async handleSavedProfileLogout({ entry, providerId, triggerButton, cardButton, displayName, } = {}) { if (!entry || typeof entry !== "object") { this.showError("Failed to logout this account. Please try again."); return { loggedOut: false, reason: "invalid-entry" }; } const targetPubkey = typeof entry.pubkey === "string" && entry.pubkey.trim() ? entry.pubkey.trim() : ""; if (!targetPubkey) { this.showError("Failed to logout this account. Please try again."); return { loggedOut: false, reason: "invalid-pubkey" }; } if ( !this.callbacks.onRequestLogoutProfile || this.callbacks.onRequestLogoutProfile === noop ) { this.showError("Account logout is not available right now."); return { loggedOut: false, reason: "logout-unavailable" }; } const revertUiState = () => { if (triggerButton instanceof HTMLElement) { triggerButton.disabled = false; triggerButton.removeAttribute("aria-busy"); delete triggerButton.dataset.state; } if (cardButton instanceof HTMLElement) { cardButton.disabled = false; cardButton.removeAttribute("aria-busy"); delete cardButton.dataset.loading; } }; if (triggerButton instanceof HTMLElement) { triggerButton.dataset.state = "loading"; triggerButton.disabled = true; triggerButton.setAttribute("aria-busy", "true"); } if (cardButton instanceof HTMLElement) { cardButton.dataset.loading = "true"; cardButton.disabled = true; cardButton.setAttribute("aria-busy", "true"); } let result; try { result = await this.callbacks.onRequestLogoutProfile({ controller: this, pubkey: targetPubkey, entry, providerId: this.normalizeProviderId(providerId) || null, }); } catch (error) { this.showError("Failed to logout this account. Please try again."); result = { loggedOut: false, error, reason: "logout-error" }; } finally { revertUiState(); } if (!result || typeof result !== "object") { this.showError("Failed to logout this account. Please try again."); return { loggedOut: false, reason: "unknown" }; } if (result.loggedOut || result.removed) { const successName = typeof displayName === "string" && displayName.trim() ? displayName.trim() : null; const message = successName ? `${successName} logged out.` : "Account logged out."; this.profileSwitcherSelectionPubkey = null; this.showSuccess(message); this.renderSavedProfiles(); return { ...result, loggedOut: true }; } if (result.reason === "not-found") { this.profileSwitcherSelectionPubkey = null; this.showStatus("This account is no longer connected."); this.renderSavedProfiles(); return result; } if (result.reason === "active-profile") { return result; } if (result.error) { this.showError("Failed to logout this account. Please try again."); return result; } this.showError("Failed to logout this account. Please try again."); return result; } createCompactProfileSummary({ displayName, displayNpub, avatarSrc, size = "sm", } = {}) { const sizeClassMap = { xs: "h-8 w-8", sm: "h-10 w-10", md: "h-12 w-12", }; const avatarSize = sizeClassMap[size] || sizeClassMap.sm; const safeName = displayName?.trim() || "Unknown profile"; const safeNpub = displayNpub?.trim() || "npub unavailable"; const avatarUrl = avatarSrc || FALLBACK_PROFILE_AVATAR; const container = document.createElement("div"); container.className = "min-w-0 flex flex-1 items-center gap-2"; const avatarWrapper = document.createElement("span"); avatarWrapper.className = `flex ${avatarSize} flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-overlay-strong bg-overlay-panel-soft`; const avatarImg = document.createElement("img"); avatarImg.className = "h-full w-full object-cover"; avatarImg.src = avatarUrl; avatarImg.alt = `${safeName} avatar`; avatarWrapper.appendChild(avatarImg); const textStack = document.createElement("div"); textStack.className = "min-w-0 flex flex-col"; const nameEl = document.createElement("p"); nameEl.className = "truncate text-xs font-semibold text-primary"; nameEl.textContent = safeName; const npubEl = document.createElement("p"); npubEl.className = "break-all font-mono text-2xs text-muted"; npubEl.textContent = safeNpub; textStack.append(nameEl, npubEl); container.append(avatarWrapper, textStack); return container; } createViewChannelButton({ targetNpub, displayNpub } = {}) { const normalizedTarget = typeof targetNpub === "string" && targetNpub.trim() ? targetNpub.trim() : ""; if (!normalizedTarget) { return null; } const button = document.createElement("button"); button.type = "button"; button.className = "btn-ghost focus-ring text-xs"; button.textContent = "View channel"; button.dataset.targetNpub = normalizedTarget; if (displayNpub && typeof displayNpub === "string") { button.setAttribute( "aria-label", `View channel ${displayNpub.trim() || normalizedTarget}`, ); button.title = `View channel ${displayNpub.trim() || normalizedTarget}`; } button.addEventListener("click", () => { this.callbacks.onChannelLink(button, this); }); return button; } createCopyNpubButton({ targetNpub, displayNpub } = {}) { const normalizedTarget = typeof targetNpub === "string" && targetNpub.trim() ? targetNpub.trim() : ""; if (!normalizedTarget) { return null; } const button = document.createElement("button"); button.type = "button"; button.className = "btn-ghost focus-ring text-xs"; button.textContent = "Copy npub"; if (displayNpub && typeof displayNpub === "string") { button.setAttribute( "aria-label", `Copy ${displayNpub.trim() || normalizedTarget}`, ); button.title = `Copy ${displayNpub.trim() || normalizedTarget}`; } const handleCopy = async () => { if (button.dataset.state === "loading") { return; } button.dataset.state = "loading"; button.disabled = true; button.setAttribute("aria-busy", "true"); try { await this.copyNpubToClipboard(normalizedTarget, { displayNpub }); } finally { button.disabled = false; button.removeAttribute("aria-busy"); delete button.dataset.state; } }; button.addEventListener("click", () => { void handleCopy(); }); return button; } createSavedProfileLogoutButton({ entry, cardButton, providerId, displayName, } = {}) { if (this.callbacks.onRequestLogoutProfile === noop) { return null; } if (!entry || typeof entry !== "object" || !entry.pubkey) { return null; } const safeName = typeof displayName === "string" && displayName.trim() ? displayName.trim() : "this account"; const button = document.createElement("button"); button.type = "button"; button.className = "btn-ghost focus-ring text-2xs"; button.dataset.variant = "critical"; button.dataset.role = "logout"; button.textContent = "Logout"; button.setAttribute("aria-label", `Log out ${safeName}`); button.title = `Log out ${safeName}`; button.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); if (button.dataset.state === "loading") { return; } void this.handleSavedProfileLogout({ entry, providerId, triggerButton: button, cardButton, displayName: safeName, }); }); return button; } createRemoveButton({ label = "Remove", confirmMessage, confirmValue, onRemove, } = {}) { if (typeof onRemove !== "function") { return null; } const button = document.createElement("button"); button.type = "button"; button.className = "btn-ghost focus-ring text-xs profile-modal__remove-button"; button.dataset.variant = "critical"; button.dataset.role = "remove"; button.textContent = label; const handleRemove = async () => { if (confirmMessage) { const replacement = typeof confirmValue === "string" && confirmValue.trim() ? confirmValue.trim() : "this entry"; const prompt = confirmMessage.replace("{npub}", replacement); if (!window.confirm(prompt)) { return; } } button.disabled = true; button.setAttribute("aria-busy", "true"); try { await onRemove(button); } catch (error) { userLogger.error("Failed to remove entry:", error); } finally { button.disabled = false; button.removeAttribute("aria-busy"); } }; button.addEventListener("click", () => { void handleRemove(); }); return button; } async copyNpubToClipboard(npub, { displayNpub } = {}) { const normalized = typeof npub === "string" && npub.trim() ? npub.trim() : ""; if (!normalized) { this.showError("Unable to copy npub. Invalid value provided."); return { copied: false, reason: "invalid" }; } const clipboard = (() => { if ( this.services?.clipboard && typeof this.services.clipboard.writeText === "function" ) { return this.services.clipboard; } if ( typeof navigator !== "undefined" && navigator?.clipboard && typeof navigator.clipboard.writeText === "function" ) { return navigator.clipboard; } return null; })(); if (!clipboard) { this.showError("Copy to clipboard is not supported in this browser."); return { copied: false, reason: "unsupported" }; } try { await clipboard.writeText(normalized); this.showSuccess("npub copied to clipboard!"); return { copied: true }; } catch (error) { userLogger.error("Failed to copy npub:", error); this.showError("Failed to copy npub. Please try again."); return { copied: false, error }; } } setupLayoutBreakpointObserver() { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { this.largeLayoutQuery = null; this.largeLayoutQueryListener = null; this.isLargeLayoutActiveFlag = false; return; } if (this.largeLayoutQuery && this.largeLayoutQueryListener) { this.teardownLayoutBreakpointObserver(); } try { const breakpointLg = getBreakpointLg(); const query = window.matchMedia(`(min-width: ${breakpointLg})`); const handler = (event) => { const matches = typeof event?.matches === "boolean" ? event.matches : query.matches; this.handleLayoutBreakpointChange(matches); }; this.largeLayoutQuery = query; this.largeLayoutQueryListener = handler; if (typeof query.addEventListener === "function") { query.addEventListener("change", handler); } else if (typeof query.addListener === "function") { query.addListener(handler); } this.handleLayoutBreakpointChange(query.matches); } catch (error) { this.largeLayoutQuery = null; this.largeLayoutQueryListener = null; devLogger.warn( "[profileModal] Failed to initialize responsive breakpoint observer:", error, ); this.handleLayoutBreakpointChange(false); } } teardownLayoutBreakpointObserver() { const query = this.largeLayoutQuery; const handler = this.largeLayoutQueryListener; if (query && handler) { if (typeof query.removeEventListener === "function") { query.removeEventListener("change", handler); } else if (typeof query.removeListener === "function") { query.removeListener(handler); } } this.largeLayoutQuery = null; this.largeLayoutQueryListener = null; } handleLayoutBreakpointChange(matches) { const isLarge = Boolean(matches); this.isLargeLayoutActiveFlag = isLarge; if (isLarge) { this.setMobileView("pane", { skipFocusTrap: false }); return; } const targetView = this.lastMobileViewState || this.mobileViewState || "menu"; this.setMobileView(targetView, { skipFocusTrap: false }); } isLargeLayoutActive() { return Boolean(this.isLargeLayoutActiveFlag); } isMobileLayoutActive() { return !this.isLargeLayoutActive(); } focusActiveNavButton() { const active = this.getActivePane(); const candidates = []; if (active && this.navButtons[active] instanceof HTMLElement) { candidates.push(this.navButtons[active]); } Object.values(this.navButtons).forEach((button) => { if ( button instanceof HTMLElement && !candidates.includes(button) && !button.classList.contains("hidden") ) { candidates.push(button); } }); const target = candidates.find((button) => button instanceof HTMLElement); if (!target) { return; } window.requestAnimationFrame(() => { try { target.focus(); } catch (error) { devLogger.warn( "[profileModal] Failed to focus active navigation button:", error, ); } }); } setMobileView(view = "menu", options = {}) { const normalizedView = view === "pane" ? "pane" : "menu"; const settings = options && typeof options === "object" ? options : { skipFocusTrap: false }; const skipFocusTrap = Boolean(settings.skipFocusTrap); const focusMenu = Boolean(settings.focusMenu); const layoutElement = this.profileModalLayout instanceof HTMLElement ? this.profileModalLayout : null; const paneWrapper = this.profileModalPaneWrapper instanceof HTMLElement ? this.profileModalPaneWrapper : null; const menuWrapper = this.profileModalMenu instanceof HTMLElement ? this.profileModalMenu : null; const panelElement = this.profileModalPanel instanceof HTMLElement ? this.profileModalPanel : null; const rootElement = this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot : null; const backButton = this.profileModalBackButton instanceof HTMLElement ? this.profileModalBackButton : null; const isLarge = this.isLargeLayoutActive(); this.mobileViewState = normalizedView; if (!isLarge) { this.lastMobileViewState = normalizedView; } if (layoutElement) { layoutElement.dataset.mobileView = normalizedView; } if (panelElement) { panelElement.dataset.mobileView = normalizedView; } if (rootElement) { rootElement.dataset.mobileView = normalizedView; } const menuHidden = !isLarge && normalizedView === "pane"; const paneHidden = !isLarge && normalizedView === "menu"; if (menuWrapper) { menuWrapper.setAttribute("aria-hidden", menuHidden ? "true" : "false"); if (menuHidden) { menuWrapper.classList.add("hidden"); menuWrapper.setAttribute("hidden", ""); } else { menuWrapper.classList.remove("hidden"); menuWrapper.removeAttribute("hidden"); } } if (paneWrapper) { paneWrapper.setAttribute("aria-hidden", paneHidden ? "true" : "false"); if (paneHidden) { paneWrapper.classList.add("hidden"); paneWrapper.setAttribute("hidden", ""); } else { paneWrapper.classList.remove("hidden"); paneWrapper.removeAttribute("hidden"); } } if (backButton) { if (isLarge || normalizedView === "menu") { backButton.classList.add("hidden"); backButton.setAttribute("aria-hidden", "true"); } else { backButton.classList.remove("hidden"); backButton.setAttribute("aria-hidden", "false"); } } if (!skipFocusTrap) { this.updateFocusTrap(); } if (focusMenu && normalizedView === "menu") { this.focusActiveNavButton(); } return normalizedView; } selectPane(name = "account", options = {}) { const { keepMenuView = false } = options && typeof options === "object" ? options : {}; const normalized = typeof name === "string" ? name.toLowerCase() : "account"; const previous = this.getActivePane(); const availableKeys = Object.keys(this.panes).filter((key) => { const pane = this.panes[key]; if (!(pane instanceof HTMLElement)) { return false; } const button = this.navButtons[key]; if (button instanceof HTMLElement && button.classList.contains("hidden")) { return false; } return true; }); const fallbackTarget = availableKeys.includes("account") ? "account" : availableKeys[0] || "account"; const target = availableKeys.includes(normalized) ? normalized : fallbackTarget; if (previous === "history" && target !== "history") { try { this.profileHistoryRenderer?.pause(); } catch (error) { userLogger.warn("[profileModal] Failed to pause history renderer:", error); } } if (previous === "messages" && target !== "messages") { this.pauseProfileMessages(); } Object.entries(this.panes).forEach(([key, pane]) => { if (!(pane instanceof HTMLElement)) { return; } const isActive = key === target; pane.classList.toggle("hidden", !isActive); pane.setAttribute("aria-hidden", (!isActive).toString()); }); Object.entries(this.navButtons).forEach(([key, button]) => { if (!(button instanceof HTMLElement)) { return; } const isActive = key === target; button.setAttribute("aria-selected", isActive ? "true" : "false"); if (isActive) { button.dataset.state = "active"; } else { delete button.dataset.state; } }); this.setActivePane(target); const isMobile = this.isMobileLayoutActive(); const shouldStayInMenu = keepMenuView && isMobile; this.setMobileView(shouldStayInMenu ? "menu" : "pane"); setTimeout(() => { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (target === "history") { void this.populateProfileWatchHistory(); } else if (target === "relays") { this.populateProfileRelays(); void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "pane-select", }); } else if (target === "messages") { this.resumeProfileMessages(); void this.populateProfileMessages({ reason: "pane-select" }); void this.refreshDmRelayPreferences(); } else if (target === "wallet") { this.walletController.refreshWalletPaneState(); } else if (target === "storage") { this.storageController.populateStoragePane(); } else if (target === "hashtags") { this.populateHashtagPreferences(); if (activeHex && this.hashtagPreferencesService) { this.hashtagPreferencesService .load(activeHex, { allowPermissionPrompt: true }) .catch(noop); } this.refreshHashtagBackgroundStatus(); } else if (target === "subscriptions") { if (activeHex && this.subscriptionsService) { this.subscriptionsService .loadSubscriptions(activeHex, { allowPermissionPrompt: true }) .catch(noop); } void this.populateSubscriptionsList(); this.refreshSubscriptionsBackgroundStatus(); } else if (target === "blocked") { if (activeHex && this.services.userBlocks) { this.services.userBlocks.loadBlocks(activeHex).catch(noop); } this.populateBlockedList(); } else if (target === "safety") { this.refreshModerationSettingsUi(); this.syncLinkPreviewSettingsUi(); } this.callbacks.onSelectPane(target, { controller: this }); this.callbacks.onPaneShown(target, { controller: this }); }, 0); } populateProfileRelays(relayEntries = null) { if (!this.relayList) { return; } const sourceEntries = Array.isArray(relayEntries) ? relayEntries : this.services.relayManager.getEntries(); const relays = sourceEntries .map((entry) => { if (typeof entry === "string") { const trimmed = entry.trim(); return trimmed ? { url: trimmed, mode: "both" } : null; } if (entry && typeof entry === "object") { const url = typeof entry.url === "string" ? entry.url.trim() : ""; if (!url) { return null; } const mode = typeof entry.mode === "string" ? entry.mode : "both"; const normalizedMode = mode === "read" || mode === "write" ? mode : "both"; return { url, mode: normalizedMode, read: entry.read !== false, write: entry.write !== false, }; } return null; }) .filter((entry) => entry && typeof entry.url === "string"); this.relayList.textContent = ""; if (!relays.length) { const emptyState = document.createElement("li"); emptyState.className = "card border border-dashed border-surface-strong p-4 text-center text-sm text-muted"; emptyState.textContent = "No relays configured."; this.relayList.appendChild(emptyState); return; } relays.forEach((entry) => { const item = document.createElement("li"); item.className = "card flex items-start justify-between gap-4 p-4"; item.dataset.relayUrl = entry.url; const info = document.createElement("div"); info.className = "flex-1 min-w-0"; const urlEl = document.createElement("p"); urlEl.className = "text-sm font-medium text-primary break-all"; urlEl.textContent = entry.url; const statusEl = document.createElement("p"); statusEl.className = "mt-1 text-xs text-muted"; let modeLabel = "Read & write"; if (entry.mode === "read") { modeLabel = "Read only"; } else if (entry.mode === "write") { modeLabel = "Write only"; } statusEl.textContent = modeLabel; const health = document.createElement("div"); health.className = "flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 text-2xs text-muted empty:hidden"; health.dataset.role = "relay-health"; info.appendChild(urlEl); info.appendChild(statusEl); info.appendChild(health); const actions = document.createElement("div"); actions.className = "flex items-center gap-2"; const editBtn = document.createElement("button"); editBtn.type = "button"; editBtn.className = "btn-ghost focus-ring text-xs"; editBtn.textContent = "Change mode"; editBtn.title = "Cycle between read-only, write-only, or read/write modes."; editBtn.addEventListener("click", () => { void this.handleRelayModeToggle(entry.url); }); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "btn-ghost focus-ring text-xs"; removeBtn.dataset.variant = "danger"; removeBtn.textContent = "Remove"; removeBtn.addEventListener("click", () => { void this.handleRemoveRelay(entry.url); }); actions.appendChild(editBtn); actions.appendChild(removeBtn); item.appendChild(info); item.appendChild(actions); this.relayList.appendChild(item); }); } updateRelayHealthStatus(message = "") { if (!this.relayHealthStatus) { return; } const text = typeof message === "string" ? message.trim() : ""; this.relayHealthStatus.textContent = text; } updateRelayHealthIndicators(snapshot = []) { if (!this.relayList) { return; } if (!Array.isArray(snapshot)) { return; } snapshot.forEach((entry) => { const url = entry.url; const item = this.relayList.querySelector( `li[data-relay-url="${CSS.escape(url)}"]`, ); if (!item) { return; } const healthContainer = item.querySelector('[data-role="relay-health"]'); if (!healthContainer) { return; } healthContainer.textContent = ""; const createBadge = (label, value, colorClass) => { const span = document.createElement("span"); span.className = "inline-flex items-center gap-1 bg-surface-strong/30 px-1.5 py-0.5 rounded"; const l = document.createElement("span"); l.textContent = label; const v = document.createElement("span"); v.className = colorClass || "text-text"; v.textContent = value; span.appendChild(l); span.appendChild(v); return span; }; if (Number.isFinite(entry.lastLatencyMs)) { let color = "text-status-success"; if (entry.lastLatencyMs > 1000) color = "text-status-danger"; else if (entry.lastLatencyMs > 300) color = "text-status-warning"; healthContainer.appendChild( createBadge("Ping:", `${entry.lastLatencyMs}ms`, color), ); } if (entry.errorCount > 0) { healthContainer.appendChild( createBadge("Errors:", `${entry.errorCount}`, "text-status-danger"), ); } }); } handleRelayHealthTelemetryToggle() { const service = this.services?.relayHealthService; if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) { return; } const enabled = service.setTelemetryOptIn( this.relayHealthTelemetryToggle.checked, ); this.relayHealthTelemetryToggle.checked = enabled; this.updateRelayHealthStatus( enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.", ); } async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) { const service = this.services?.relayHealthService; if (!service) { return []; } if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) { this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn(); } const snapshot = service.getSnapshot(); this.updateRelayHealthIndicators(snapshot); if (!forceRefresh) { return snapshot; } if (this.relayHealthRefreshPromise) { return this.relayHealthRefreshPromise; } const statusMessage = reason === "manual" ? "Refreshing relay health…" : "Checking relays…"; this.updateRelayHealthStatus(statusMessage); const refreshPromise = service .refresh() .then((latest) => { this.updateRelayHealthIndicators(latest); this.updateRelayHealthStatus("Relay health updated."); return latest; }) .catch((error) => { this.updateRelayHealthStatus("Failed to refresh relay health."); this.showError("Failed to refresh relay health."); devLogger.warn("[profileModal] Relay health refresh failed:", error); return []; }) .finally(() => { if (this.relayHealthRefreshPromise === refreshPromise) { this.relayHealthRefreshPromise = null; } }); this.relayHealthRefreshPromise = refreshPromise; return refreshPromise; } async handleRelayOperation(meta = {}, { successMessage = "Relay preferences updated.", skipPublishIfUnchanged = true, unchangedMessage = null, } = {}) { const operationContext = { ...meta, ok: false, changed: false, reason: null, error: null, publishResult: null, operationResult: null, }; const activePubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!activePubkey) { this.showError("Please login to manage your relays."); operationContext.reason = "no-active-pubkey"; return operationContext; } let result; try { result = await this.runRelayOperation({ ...meta, activePubkey, skipPublishIfUnchanged, }); } catch (error) { const message = error && typeof error.message === "string" && error.message.trim() ? error.message.trim() : "Failed to update relay preferences."; operationContext.reason = error?.code || "callback-error"; operationContext.error = error; this.showError(message); return operationContext; } if (result && typeof result === "object") { operationContext.ok = Boolean(result.ok); operationContext.changed = Boolean(result.changed); operationContext.reason = typeof result.reason === "string" ? result.reason : operationContext.reason; operationContext.error = result.error ?? operationContext.error; operationContext.publishResult = result.publishResult ?? operationContext.publishResult; operationContext.operationResult = result.operationResult ?? operationContext.operationResult; } if (!operationContext.changed && skipPublishIfUnchanged) { const reason = operationContext.reason || "unchanged"; operationContext.reason = reason; if (reason === "duplicate") { this.showSuccess("Relay is already configured."); } else if (typeof unchangedMessage === "string" && unchangedMessage) { this.showSuccess(unchangedMessage); } this.populateProfileRelays(); void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" }); return operationContext; } this.populateProfileRelays(); void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" }); if (operationContext.ok) { if (successMessage) { this.showSuccess(successMessage); } return operationContext; } const message = operationContext.error && typeof operationContext.error.message === "string" && operationContext.error.message.trim() ? operationContext.error.message.trim() : "Failed to publish relay configuration. Please try again."; if (operationContext.reason !== "no-active-pubkey") { this.showError(message); } return operationContext; } async handleAddRelay() { const rawValue = typeof this.relayInput?.value === "string" ? this.relayInput.value : ""; const trimmed = rawValue.trim(); const context = { input: this.relayInput, rawValue, url: trimmed, result: null, success: false, reason: null, }; if (!trimmed) { this.showError("Enter a relay URL to add."); context.reason = "empty"; this.callbacks.onAddRelay(context, this); return context; } const operationResult = await this.handleRelayOperation( { action: "add", url: trimmed }, { successMessage: "Relay saved.", unchangedMessage: "Relay is already configured.", }, ); if (this.relayInput) { this.relayInput.value = ""; } context.result = operationResult; context.success = !!operationResult?.ok; context.reason = operationResult?.reason || null; this.callbacks.onAddRelay(context, this); return context; } async handleRestoreRelays() { const context = { confirmed: false, result: null, success: false, reason: null, }; const confirmed = window.confirm("Restore the recommended relay defaults?"); context.confirmed = confirmed; if (!confirmed) { context.reason = "cancelled"; this.callbacks.onRestoreRelays(context, this); return context; } const operationResult = await this.handleRelayOperation( { action: "restore" }, { successMessage: "Relay defaults restored.", unchangedMessage: "Relay defaults are already in use.", }, ); context.result = operationResult; context.success = !!operationResult?.ok; context.reason = operationResult?.reason || null; this.callbacks.onRestoreRelays(context, this); this.callbacks.onRelayRestore({ controller: this, context, }); return context; } async handleRelayModeToggle(url) { if (!url) { return; } const context = await this.handleRelayOperation( { action: "mode-toggle", url }, { successMessage: "Relay mode updated." }, ); this.callbacks.onRelayModeToggle({ controller: this, url, context, }); } async handleRemoveRelay(url) { if (!url) { return; } const confirmed = window.confirm( `Remove ${url} from your relay list?`, ); if (!confirmed) { return; } await this.handleRelayOperation( { action: "remove", url }, { successMessage: "Relay removed." }, ); } ensureBlockListStatusElement() { if (this.blockListStatus instanceof HTMLElement) { return this.blockListStatus; } const anchor = this.blockList instanceof HTMLElement ? this.blockList : this.blockListEmpty instanceof HTMLElement ? this.blockListEmpty : null; if (!anchor || !(anchor.parentElement instanceof HTMLElement)) { return null; } const existing = anchor.parentElement.querySelector( '[data-role="blocked-list-status"]', ); if (existing instanceof HTMLElement) { this.blockListStatus = existing; return existing; } const status = document.createElement("div"); status.dataset.role = "blocked-list-status"; status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden"; status.setAttribute("role", "status"); status.setAttribute("aria-live", "polite"); if (this.blockList instanceof HTMLElement) { anchor.parentElement.insertBefore(status, this.blockList); } else { anchor.parentElement.appendChild(status); } this.blockListStatus = status; return status; } setBlockListLoadingState(state = "idle", options = {}) { const statusEl = this.ensureBlockListStatusElement(); if (!statusEl) { this.blockListLoadingState = state; return; } const message = typeof options.message === "string" && options.message.trim() ? options.message.trim() : ""; statusEl.textContent = ""; statusEl.classList.remove("text-status-warning"); statusEl.classList.add("text-muted"); statusEl.classList.add("hidden"); this.blockListLoadingState = state; if (state === "loading") { if (this.blockListEmpty instanceof HTMLElement) { this.blockListEmpty.classList.add("hidden"); } const spinner = document.createElement("span"); spinner.className = "status-spinner status-spinner--inline"; spinner.setAttribute("aria-hidden", "true"); const text = document.createElement("span"); text.textContent = message || "Loading blocked creators…"; statusEl.appendChild(spinner); statusEl.appendChild(text); statusEl.classList.remove("hidden"); return; } if (state === "error") { statusEl.classList.remove("text-muted"); statusEl.classList.add("text-status-warning"); if (this.blockListEmpty instanceof HTMLElement) { this.blockListEmpty.classList.add("hidden"); } const text = document.createElement("span"); text.textContent = message || "Blocked creators may be out of date. Try again later."; statusEl.appendChild(text); statusEl.classList.remove("hidden"); } } 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; } this.permissionPromptCtaState = { ...this.permissionPromptCtaState, ...nextState, }; this.applyPermissionPromptCtaState(); } applyPermissionPromptCtaState() { if (!(this.permissionPromptCta instanceof HTMLElement)) { return; } const fallbackMessage = "Enable permissions to load your subscriptions and hashtag preferences."; const { visible, message, buttonLabel, busy, action, } = this.permissionPromptCtaState || {}; const normalizedMessage = typeof message === "string" && message.trim() ? message.trim() : fallbackMessage; this.permissionPromptCta.classList.toggle("hidden", !visible); this.permissionPromptCta.setAttribute("aria-hidden", (!visible).toString()); if (this.permissionPromptCtaMessage instanceof HTMLElement) { this.permissionPromptCtaMessage.textContent = normalizedMessage; } if (this.permissionPromptCtaButton instanceof HTMLButtonElement) { const normalizedLabel = typeof buttonLabel === "string" && buttonLabel.trim() ? buttonLabel.trim() : "Enable permissions"; this.permissionPromptCtaButton.textContent = normalizedLabel; this.permissionPromptCtaButton.disabled = Boolean(busy); this.permissionPromptCtaButton.setAttribute( "aria-busy", busy ? "true" : "false", ); this.permissionPromptCtaButton.dataset.action = action === "retry-auth-sync" ? "retry-auth-sync" : "permission"; } } handleAuthLoadingStateChange(detail = {}) { const listsState = typeof detail?.lists === "string" ? detail.lists.trim().toLowerCase() : ""; const listsDetail = detail?.listsDetail && typeof detail.listsDetail === "object" ? detail.listsDetail : null; if (!listsDetail || !listsState || listsState === "loading" || listsState === "idle") { return; } const failedTasks = Array.isArray(listsDetail.tasks) ? listsDetail.tasks.filter((task) => task && task.ok === false) : []; const loadedFromCacheTasks = failedTasks.filter((task) => task.fromCache === true); const hardFailTasks = failedTasks.filter((task) => task.fromCache !== true); if (loadedFromCacheTasks.length > 0 && hardFailTasks.length === 0) { this.setSubscriptionsStatus( "Subscriptions loaded from cache; sync is retrying in background.", "warning", ); this.setHashtagStatus( "Hashtag preferences loaded from cache; sync is retrying in background.", "warning", ); this.setBlockListLoadingState("error", { message: "Blocked creators loaded from cache; syncing latest in background.", }); } else if (hardFailTasks.length > 0) { this.setSubscriptionsStatus( "Some profile lists failed to sync. Use Retry sync.", "warning", ); this.setHashtagStatus( "Some profile lists failed to sync. Use Retry sync.", "warning", ); this.setBlockListLoadingState("error", { message: "Blocked creators failed to sync. Retry to fetch latest lists.", }); } if (failedTasks.length > 0) { this.setPermissionPromptCtaState({ visible: true, message: loadedFromCacheTasks.length > 0 ? "Some lists are currently from cache. Retry now to sync with relays." : "Failed to sync required profile lists. Retry now.", buttonLabel: "Retry list sync", action: "retry-auth-sync", busy: false, }); return; } if (listsState === "ready") { this.setPermissionPromptCtaState({ visible: false, message: "", action: "permission", busy: false, }); } } 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; } const classList = this.subscriptionsStatusText.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.subscriptionsStatusText.textContent = ""; this.subscriptionsStatusText.classList.add("text-muted", "hidden"); return; } this.subscriptionsStatusText.textContent = normalized; this.subscriptionsStatusText.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; } } 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?.() || ""; if (isBackground && !this.subscriptionsBackgroundLoading) { this.subscriptionsBackgroundLoading = true; if (!statusText) { this.setSubscriptionsStatus("Loading in background…", "info"); } return; } if (!isBackground && this.subscriptionsBackgroundLoading) { if (statusText === "Loading in background…") { this.setSubscriptionsStatus("", "muted"); } this.subscriptionsBackgroundLoading = false; } } 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 : ""; if (action === "background-loading") { this.subscriptionsBackgroundLoading = true; this.setSubscriptionsStatus("Loading in background…", "info"); } else if ( (action === "sync" || action === "background-loaded" || action === "reset") && this.subscriptionsBackgroundLoading ) { const statusText = this.subscriptionsStatusText?.textContent?.trim?.() || ""; if (statusText === "Loading in background…") { this.setSubscriptionsStatus("", "muted"); } this.subscriptionsBackgroundLoading = false; } void this.populateSubscriptionsList(); this.refreshSubscriptionsBackgroundStatus(); } populateBlockedList(blocked = null) { if (!this.blockList || !this.blockListEmpty) { if (this.blockListLoadingState === "loading") { this.setBlockListLoadingState("idle"); } return; } const sourceEntries = Array.isArray(blocked) && blocked.length ? blocked : this.services.userBlocks.getBlockedPubkeys(); const normalizedEntries = []; const pushEntry = (hex, label) => { if (!hex || !label) { return; } normalizedEntries.push({ hex, label }); }; sourceEntries.forEach((entry) => { if (typeof entry === "string") { const trimmed = entry.trim(); if (!trimmed) { return; } if (trimmed.startsWith("npub1")) { const decoded = this.safeDecodeNpub(trimmed); if (!decoded) { return; } const label = this.safeEncodeNpub(decoded) || trimmed; pushEntry(decoded, label); return; } if (/^[0-9a-f]{64}$/i.test(trimmed)) { const hex = trimmed.toLowerCase(); const label = this.safeEncodeNpub(hex) || hex; pushEntry(hex, label); } return; } if (entry && typeof entry === "object") { const candidateNpub = typeof entry.npub === "string" ? entry.npub.trim() : ""; const candidateHex = typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { const normalizedHex = candidateHex.toLowerCase(); const label = candidateNpub && candidateNpub.startsWith("npub1") ? candidateNpub : this.safeEncodeNpub(normalizedHex) || normalizedHex; pushEntry(normalizedHex, label); return; } if (candidateNpub && candidateNpub.startsWith("npub1")) { const decoded = this.safeDecodeNpub(candidateNpub); if (!decoded) { return; } const label = this.safeEncodeNpub(decoded) || candidateNpub; pushEntry(decoded, label); } } }); const deduped = []; const seenHex = new Set(); normalizedEntries.forEach((entry) => { if (!seenHex.has(entry.hex)) { seenHex.add(entry.hex); deduped.push(entry); } }); this.blockList.textContent = ""; if (!deduped.length) { this.blockListEmpty.classList.remove("hidden"); this.blockList.classList.add("hidden"); if (this.blockListLoadingState === "loading") { this.setBlockListLoadingState("idle"); } return; } this.blockListEmpty.classList.add("hidden"); this.blockList.classList.remove("hidden"); const formatNpub = typeof this.formatShortNpub === "function" ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const entriesNeedingFetch = new Set(); deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); item.className = "card flex items-center justify-between gap-4 p-4"; let cachedProfile = null; if (hex) { const cacheEntry = this.services.getProfileCacheEntry(hex); cachedProfile = cacheEntry?.profile || null; if (!cacheEntry) { entriesNeedingFetch.add(hex); } } const encodedNpub = hex && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(hex) : label; const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; const displayName = cachedProfile?.name?.trim() || displayNpub || "Blocked profile"; const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; const summary = this.createCompactProfileSummary({ displayName, displayNpub, avatarSrc, }); const actions = document.createElement("div"); actions.className = "flex flex-wrap items-center justify-end gap-2"; const viewButton = this.createViewChannelButton({ targetNpub: encodedNpub, displayNpub, }); if (viewButton) { actions.appendChild(viewButton); } const copyButton = this.createCopyNpubButton({ targetNpub: encodedNpub, displayNpub, }); if (copyButton) { actions.appendChild(copyButton); } const removeButton = this.createRemoveButton({ label: "Remove", onRemove: () => this.handleRemoveBlockedCreator(hex), }); if (removeButton) { removeButton.dataset.blockedHex = hex; actions.appendChild(removeButton); } item.appendChild(summary); if (actions.childElementCount > 0) { item.appendChild(actions); } this.blockList.appendChild(item); }); if (this.blockListLoadingState === "loading") { this.setBlockListLoadingState("idle"); } if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" ) { this.services.batchFetchProfiles(entriesNeedingFetch); } } async populateSubscriptionsList(subscriptions = null) { if ( !(this.subscriptionList instanceof HTMLElement) || !(this.subscriptionListEmpty instanceof HTMLElement) ) { return; } const service = this.subscriptionsService; if (!service) { this.clearSubscriptionsList(); this.refreshSubscriptionsBackgroundStatus(); return; } const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { this.clearSubscriptionsList(); this.refreshSubscriptionsBackgroundStatus(); return; } try { const loadState = typeof service.getLoadState === "function" ? service.getLoadState() : {}; const uiReady = loadState?.uiReady === true || service.uiReady === true; const dataReady = loadState?.dataReady === true || service.dataReady === true; const lastLoadError = loadState?.lastLoadError || service.lastLoadError || null; if (!uiReady) { this.clearSubscriptionsList("Loading subscriptions…"); this.setSubscriptionsStatus("Loading subscriptions…", "info"); this.refreshSubscriptionsBackgroundStatus(); return; } if (!dataReady) { this.clearSubscriptionsList("Subscriptions unavailable. Retry to sync your list."); this.setSubscriptionsStatus( lastLoadError ? "Couldn’t sync subscriptions. Retry to load your list." : "Subscriptions unavailable right now. Retry to sync your list.", "warning", ); this.refreshSubscriptionsBackgroundStatus(); return; } let sourceEntries = []; if (Array.isArray(subscriptions) && subscriptions.length) { sourceEntries = subscriptions; } else { if (typeof service.getSubscribedAuthors === "function") { try { sourceEntries = service.getSubscribedAuthors() || []; } catch (error) { devLogger.warn( "[profileModal] Failed to resolve subscriptions for subscriptions list:", error, ); sourceEntries = []; } } else if (service.subscribedPubkeys instanceof Set) { sourceEntries = Array.from(service.subscribedPubkeys); } else if (Array.isArray(service.subscribedPubkeys)) { sourceEntries = service.subscribedPubkeys.slice(); } } const normalizedEntries = []; const pushEntry = (hex, label) => { if (!hex) { return; } normalizedEntries.push({ hex, label }); }; sourceEntries.forEach((entry) => { if (!entry) { return; } if (typeof entry === "string") { const trimmed = entry.trim(); if (!trimmed) { return; } if (trimmed.startsWith("npub1")) { const decoded = this.safeDecodeNpub(trimmed); if (!decoded) { return; } const label = this.safeEncodeNpub(decoded) || trimmed; pushEntry(decoded, label); return; } if (/^[0-9a-f]{64}$/i.test(trimmed)) { pushEntry(trimmed.toLowerCase(), trimmed); } return; } if (typeof entry !== "object") { return; } const candidateHex = typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { pushEntry(candidateHex.toLowerCase(), candidateHex); return; } const candidateNpub = typeof entry.npub === "string" ? entry.npub.trim() : ""; if (candidateNpub && candidateNpub.startsWith("npub1")) { const decoded = this.safeDecodeNpub(candidateNpub); if (!decoded) { return; } const label = this.safeEncodeNpub(decoded) || candidateNpub; pushEntry(decoded, label); } }); const deduped = []; const seenHex = new Set(); normalizedEntries.forEach((entry) => { if (!seenHex.has(entry.hex)) { seenHex.add(entry.hex); deduped.push(entry); } }); this.subscriptionList.textContent = ""; if (!deduped.length) { this.setSubscriptionsStatus("No subscriptions yet.", "muted"); this.subscriptionListEmpty.classList.remove("hidden"); this.subscriptionList.classList.add("hidden"); this.refreshSubscriptionsBackgroundStatus(); return; } this.subscriptionListEmpty.classList.add("hidden"); this.subscriptionList.classList.remove("hidden"); const formatNpub = typeof this.formatShortNpub === "function" ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const entriesNeedingFetch = new Set(); deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); item.className = "card flex items-center justify-between gap-4 p-4"; let cachedProfile = null; if (hex && typeof this.services.getProfileCacheEntry === "function") { const cacheEntry = this.services.getProfileCacheEntry(hex); cachedProfile = cacheEntry?.profile || null; if (!cacheEntry) { entriesNeedingFetch.add(hex); } } const encodedNpub = hex && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(hex) : label; const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; const displayName = (cachedProfile?.name && cachedProfile.name.trim()) || (cachedProfile?.display_name && typeof cachedProfile.display_name === "string" && cachedProfile.display_name.trim()) || displayNpub || "Subscription"; const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; const summary = this.createCompactProfileSummary({ displayName, displayNpub, avatarSrc, }); const actions = document.createElement("div"); actions.className = "flex flex-wrap items-center justify-end gap-2"; const viewButton = this.createViewChannelButton({ targetNpub: encodedNpub, displayNpub, }); if (viewButton) { actions.appendChild(viewButton); } const copyButton = this.createCopyNpubButton({ targetNpub: encodedNpub, displayNpub, }); if (copyButton) { actions.appendChild(copyButton); } if (hex) { const unsubscribeButton = this.createRemoveButton({ label: "Unsubscribe", onRemove: () => this.handleUnsubscribeFromCreator(hex), }); if (unsubscribeButton) { unsubscribeButton.dataset.subscriptionHex = hex; actions.appendChild(unsubscribeButton); } } item.appendChild(summary); if (actions.childElementCount > 0) { item.appendChild(actions); } this.subscriptionList.appendChild(item); }); if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" ) { try { this.services.batchFetchProfiles(entriesNeedingFetch); } catch (error) { devLogger.warn( "[profileModal] Failed to batch fetch profiles for subscriptions list:", error, ); } } this.refreshSubscriptionsBackgroundStatus(); } catch (error) { devLogger.warn("[profileModal] Failed to populate subscriptions list:", error); } } clearSubscriptionsList(emptyMessage = "No subscriptions yet.") { if (this.subscriptionList instanceof HTMLElement) { this.subscriptionList.textContent = ""; this.subscriptionList.classList.add("hidden"); } if (this.subscriptionListEmpty instanceof HTMLElement) { this.subscriptionListEmpty.textContent = typeof emptyMessage === "string" && emptyMessage.trim() ? emptyMessage.trim() : "No subscriptions yet."; this.subscriptionListEmpty.classList.remove("hidden"); } } async handleUnsubscribeFromCreator(candidate) { const refresh = async () => { try { await this.populateSubscriptionsList(); } catch (error) { devLogger.warn( "[profileModal] Failed to refresh subscriptions list after unsubscribe:", error, ); } try { await this.populateFriendsList(); } catch (error) { devLogger.warn( "[profileModal] Failed to refresh friends list after unsubscribe:", error, ); } }; return this.handleRemoveFriend(candidate, { successMessage: "You unsubscribed from this creator.", refresh, successReason: "unsubscribed", }); } async populateFriendsList(friends = null) { if ( !(this.friendList instanceof HTMLElement) || !(this.friendListEmpty instanceof HTMLElement) ) { return; } const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { this.clearFriendsList(); return; } try { let sourceEntries = []; let usedModerationService = false; if (Array.isArray(friends) && friends.length) { sourceEntries = friends; } else { const moderationService = this.moderationService; if (moderationService) { let contacts = []; if ( typeof moderationService.ensureViewerContactsLoaded === "function" ) { try { contacts = (await moderationService.ensureViewerContactsLoaded(activeHex)) || []; } catch (error) { devLogger.warn( "[profileModal] Failed to ensure viewer contacts before populating friends list:", error, ); contacts = []; } } if (!Array.isArray(contacts) || !contacts) { contacts = []; } if (!contacts.length) { if (moderationService.viewerContacts instanceof Set) { contacts = Array.from(moderationService.viewerContacts); } } if (Array.isArray(contacts)) { sourceEntries = contacts; usedModerationService = true; } } if (!usedModerationService) { const service = this.subscriptionsService; if (!service) { this.clearFriendsList(); return; } if (typeof service.ensureLoaded === "function") { try { await service.ensureLoaded(activeHex); } catch (error) { devLogger.warn( "[profileModal] Failed to ensure subscriptions before populating friends list:", error, ); } } if (typeof service.getSubscribedAuthors === "function") { try { sourceEntries = service.getSubscribedAuthors() || []; } catch (error) { devLogger.warn( "[profileModal] Failed to resolve subscriptions for friends list:", error, ); sourceEntries = []; } } else if (service.subscribedPubkeys instanceof Set) { sourceEntries = Array.from(service.subscribedPubkeys); } else if (Array.isArray(service.subscribedPubkeys)) { sourceEntries = service.subscribedPubkeys.slice(); } } } const normalizedEntries = []; const pushEntry = (hex, label) => { if (!hex) { return; } normalizedEntries.push({ hex, label }); }; sourceEntries.forEach((entry) => { if (!entry) { return; } if (typeof entry === "string") { const trimmed = entry.trim(); if (!trimmed) { return; } if (trimmed.startsWith("npub1")) { const decoded = this.safeDecodeNpub(trimmed); if (!decoded) { return; } const label = this.safeEncodeNpub(decoded) || trimmed; pushEntry(decoded, label); return; } if (/^[0-9a-f]{64}$/i.test(trimmed)) { const normalizedHex = trimmed.toLowerCase(); const label = this.safeEncodeNpub(normalizedHex) || normalizedHex; pushEntry(normalizedHex, label); } return; } if (entry && typeof entry === "object") { const candidateHex = typeof entry.pubkey === "string" ? entry.pubkey.trim() : ""; const candidateNpub = typeof entry.npub === "string" ? entry.npub.trim() : ""; if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { const normalizedHex = candidateHex.toLowerCase(); const label = candidateNpub && candidateNpub.startsWith("npub1") ? candidateNpub : this.safeEncodeNpub(normalizedHex) || normalizedHex; pushEntry(normalizedHex, label); return; } if (candidateNpub && candidateNpub.startsWith("npub1")) { const decoded = this.safeDecodeNpub(candidateNpub); if (!decoded) { return; } pushEntry(decoded, candidateNpub); } } }); const deduped = []; const seenHex = new Set(); normalizedEntries.forEach((entry) => { if (!seenHex.has(entry.hex)) { seenHex.add(entry.hex); deduped.push(entry); } }); this.friendList.textContent = ""; if (!deduped.length) { this.friendListEmpty.classList.remove("hidden"); this.friendList.classList.add("hidden"); return; } this.friendListEmpty.classList.add("hidden"); this.friendList.classList.remove("hidden"); const formatNpub = typeof this.formatShortNpub === "function" ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const entriesNeedingFetch = new Set(); const canRemoveFriends = this.canManageFriendsList(); deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); item.className = "card flex items-center justify-between gap-4 p-4"; let cachedProfile = null; if (hex && typeof this.services.getProfileCacheEntry === "function") { const cacheEntry = this.services.getProfileCacheEntry(hex); cachedProfile = cacheEntry?.profile || null; if (!cacheEntry) { entriesNeedingFetch.add(hex); } } const encodedNpub = hex && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(hex) : label; const displayNpub = formatNpub(encodedNpub) || encodedNpub || label; const displayName = (cachedProfile?.name && cachedProfile.name.trim()) || (cachedProfile?.display_name && typeof cachedProfile.display_name === "string" && cachedProfile.display_name.trim()) || displayNpub || "Friend"; const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; const summary = this.createCompactProfileSummary({ displayName, displayNpub, avatarSrc, }); const actions = document.createElement("div"); actions.className = "flex flex-wrap items-center justify-end gap-2"; const viewButton = this.createViewChannelButton({ targetNpub: encodedNpub, displayNpub, }); if (viewButton) { actions.appendChild(viewButton); } const copyButton = this.createCopyNpubButton({ targetNpub: encodedNpub, displayNpub, }); if (copyButton) { actions.appendChild(copyButton); } if (hex && canRemoveFriends) { const removeButton = this.createRemoveButton({ label: "Unfriend", onRemove: () => this.handleRemoveFriend(hex), }); if (removeButton) { removeButton.dataset.friendHex = hex; actions.appendChild(removeButton); } } item.appendChild(summary); if (actions.childElementCount > 0) { item.appendChild(actions); } this.friendList.appendChild(item); }); if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" ) { try { this.services.batchFetchProfiles(entriesNeedingFetch); } catch (error) { devLogger.warn( "[profileModal] Failed to batch fetch profiles for friends list:", error, ); } } } catch (error) { devLogger.warn("[profileModal] Failed to populate friends list:", error); } } canManageFriendsList() { if ( this.moderationService && (!this.subscriptionsService || typeof this.subscriptionsService.removeChannel !== "function") ) { return false; } return ( this.subscriptionsService && typeof this.subscriptionsService.removeChannel === "function" ); } clearFriendsList() { if (this.friendList instanceof HTMLElement) { this.friendList.textContent = ""; this.friendList.classList.add("hidden"); } if (this.friendListEmpty instanceof HTMLElement) { this.friendListEmpty.classList.remove("hidden"); } } async handleRemoveFriend(candidate, options = {}) { if ( this.moderationService && (!this.subscriptionsService || typeof this.subscriptionsService.removeChannel !== "function") ) { this.showError( "Friends are synced with your Nostr follows. Update your follows in your Nostr client to make changes.", ); return { success: false, reason: "nostr-friends-managed-externally", }; } const service = this.subscriptionsService; if (!service || typeof service.removeChannel !== "function") { this.showError("Friends list is unavailable right now."); return { success: false, reason: "service-unavailable" }; } const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { this.showError("Please login to manage your friends list."); return { success: false, reason: "no-active-pubkey" }; } const { successMessage = "Creator removed from your friends list.", refresh = async () => { await this.populateFriendsList(); }, successReason = "removed", } = typeof options === "object" && options ? options : {}; let targetHex = ""; if (typeof candidate === "string") { const trimmed = candidate.trim(); if (trimmed.startsWith("npub1")) { targetHex = this.safeDecodeNpub(trimmed) || ""; } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { targetHex = trimmed.toLowerCase(); } } else if (candidate && typeof candidate === "object") { const candidateHex = typeof candidate.pubkey === "string" ? candidate.pubkey.trim() : ""; const candidateNpub = typeof candidate.npub === "string" ? candidate.npub.trim() : ""; if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) { targetHex = candidateHex.toLowerCase(); } else if (candidateNpub && candidateNpub.startsWith("npub1")) { targetHex = this.safeDecodeNpub(candidateNpub) || ""; } } if (!targetHex) { devLogger.warn( "[profileModal] No valid pubkey to remove from friends list:", candidate, ); return { success: false, reason: "invalid-target" }; } try { await service.removeChannel(targetHex, activeHex); } catch (error) { userLogger.error( "[profileModal] Failed to remove creator from friends list:", error, ); const message = error?.code === "extension-permission-denied" ? "Your Nostr extension must allow encryption to manage subscriptions." : error?.message || "Failed to update your friends list. Please try again."; if (message) { this.showError(message); } return { success: false, reason: error?.code || "service-error", error }; } this.showSuccess(successMessage); if (typeof refresh === "function") { try { await refresh(); } catch (error) { devLogger.warn( "[profileModal] Failed to refresh lists after removing friend:", error, ); } } return { success: true, reason: successReason }; } async handleAddBlockedCreator() { const input = this.blockInput || null; const rawValue = typeof input?.value === "string" ? input.value : ""; const trimmed = rawValue.trim(); const context = { input, rawValue, value: trimmed, success: false, reason: null, error: null, }; if (!input) { context.reason = "missing-input"; this.callbacks.onAddBlocked(context, this); return context; } if (!trimmed) { this.showError("Enter an npub to block."); context.reason = "empty"; this.callbacks.onAddBlocked(context, this); return context; } const activePubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!activePubkey) { this.showError("Please login to manage your block list."); context.reason = "no-active-pubkey"; this.callbacks.onAddBlocked(context, this); return context; } const actorHex = activePubkey; let targetHex = ""; if (trimmed.startsWith("npub1")) { targetHex = this.safeDecodeNpub(trimmed) || ""; if (!targetHex) { this.showError("Invalid npub. Please double-check and try again."); context.reason = "invalid-npub"; this.callbacks.onAddBlocked(context, this); return context; } } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { targetHex = trimmed.toLowerCase(); } else { this.showError("Enter a valid npub or hex pubkey."); context.reason = "invalid-value"; this.callbacks.onAddBlocked(context, this); return context; } if (targetHex === actorHex) { this.showError("You cannot block yourself."); context.reason = "self"; this.callbacks.onAddBlocked(context, this); return context; } context.targetHex = targetHex; try { const mutationResult = await this.mutateBlocklist({ action: "add", actorHex, targetHex, controller: this, }); context.result = mutationResult; if (mutationResult?.ok) { this.showSuccess( "Creator blocked. You won't see their videos anymore.", ); context.success = true; context.reason = mutationResult.reason || "blocked"; } else if (mutationResult?.reason === "already-blocked") { this.showSuccess("You already blocked this creator."); context.reason = "already-blocked"; } else { const message = mutationResult?.error?.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : mutationResult?.error?.code === "extension-encryption-permission-denied" ? "Your Nostr extension must allow encryption to update your mute/block list." : mutationResult?.error?.message || "Failed to update your mute/block list. Please try again."; context.error = mutationResult?.error || null; context.reason = mutationResult?.reason || "service-error"; if (message) { this.showError(message); } } if (this.blockInput) { this.blockInput.value = ""; } this.populateBlockedList(); } catch (error) { userLogger.error("Failed to add creator to personal block list:", error); context.error = error; context.reason = error?.code || "service-error"; const message = error?.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : error?.code === "extension-encryption-permission-denied" ? "Your Nostr extension must allow encryption to update your mute/block list." : "Failed to update your mute/block list. Please try again."; this.showError(message); } this.callbacks.onAddBlocked(context, this); return context; } async handleRemoveBlockedCreator(candidate) { const activePubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!activePubkey) { this.showError("Please login to manage your block list."); return; } let targetHex = ""; if (typeof candidate === "string") { const trimmed = candidate.trim(); if (!trimmed) { return; } if (trimmed.startsWith("npub1")) { targetHex = this.safeDecodeNpub(trimmed) || ""; } else if (/^[0-9a-f]{64}$/i.test(trimmed)) { targetHex = trimmed.toLowerCase(); } } if (!targetHex) { userLogger.warn("No valid pubkey to remove from block list:", candidate); return; } try { const mutationResult = await this.mutateBlocklist({ action: "remove", actorHex: activePubkey, targetHex, controller: this, }); if (mutationResult?.ok) { this.showSuccess("Creator removed from your mute/block list."); } else if (mutationResult?.reason === "not-blocked") { this.showSuccess("Creator already removed from your mute/block list."); } else if (mutationResult?.error) { const message = mutationResult.error.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : mutationResult.error.code === "extension-encryption-permission-denied" ? "Your Nostr extension must allow encryption to update your mute/block list." : mutationResult.error.message || "Failed to update your mute/block list. Please try again."; if (message) { this.showError(message); } } this.populateBlockedList(); } catch (error) { userLogger.error( "Failed to remove creator from personal block list:", error, ); const message = error?.code === "nip04-missing" ? "Your Nostr extension must support NIP-04 to manage private lists." : error?.code === "extension-encryption-permission-denied" ? "Your Nostr extension must allow encryption to update your mute/block list." : "Failed to update your mute/block list. Please try again."; this.showError(message); } } handleEditProfile() { this.selectPane("edit"); void this.populateEditPane(); } async populateEditPane() { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!pubkey) { return; } const cacheEntry = this.services.getProfileCacheEntry(pubkey); const profile = cacheEntry?.profile || {}; if (this.editNameInput) this.editNameInput.value = profile.name || ""; if (this.editDisplayNameInput) this.editDisplayNameInput.value = profile.display_name || ""; if (this.editAboutInput) this.editAboutInput.value = profile.about || ""; if (this.editWebsiteInput) this.editWebsiteInput.value = profile.website || ""; if (this.editNip05Input) this.editNip05Input.value = profile.nip05 || ""; if (this.editLud16Input) this.editLud16Input.value = profile.lud16 || ""; if (this.editPictureInput) this.editPictureInput.value = profile.picture || ""; if (this.editBannerInput) this.editBannerInput.value = profile.banner || ""; void this.checkStorageForUploads(pubkey); } async checkStorageForUploads(pubkey) { const r2Service = this.services.r2Service; if (!r2Service) return; let hasStorage = false; try { const credentials = await r2Service.resolveConnection(pubkey); hasStorage = !!credentials; } catch (e) { hasStorage = false; } const updateUI = (uploadBtn, hint, has) => { if (uploadBtn) { uploadBtn.disabled = !has; if (!has) uploadBtn.setAttribute("aria-disabled", "true"); else uploadBtn.removeAttribute("aria-disabled"); } if (hint) { if (has) hint.classList.add("hidden"); else hint.classList.remove("hidden"); } }; updateUI( this.editPictureUploadBtn, this.editPictureStorageHint, hasStorage, ); updateUI(this.editBannerUploadBtn, this.editBannerStorageHint, hasStorage); } async handleUpload(type) { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!pubkey) return; const r2Service = this.services.r2Service; if (!r2Service) return; const fileInput = type === "picture" ? this.editPictureFile : this.editBannerFile; const urlInput = type === "picture" ? this.editPictureInput : this.editBannerInput; const uploadBtn = type === "picture" ? this.editPictureUploadBtn : this.editBannerUploadBtn; if (!fileInput || !fileInput.files.length) return; const file = fileInput.files[0]; try { if (uploadBtn) { uploadBtn.disabled = true; uploadBtn.textContent = "Uploading..."; } const credentials = await r2Service.resolveConnection(pubkey); if (!credentials) { this.showError("Storage configuration missing."); return; } const key = buildR2Key(pubkey, file); await r2Service.uploadFile({ file, ...credentials, bucket: credentials.bucket, key, }); const url = buildPublicUrl(credentials.baseDomain, key); if (urlInput) urlInput.value = url; fileInput.value = ""; } catch (error) { this.showError("Upload failed: " + (error.message || "Unknown error")); devLogger.error("Upload error:", error); } finally { if (uploadBtn) { uploadBtn.disabled = false; uploadBtn.textContent = "Upload"; } } } async handleSaveProfile() { const pubkey = this.normalizeHexPubkey(this.getActivePubkey()); if (!pubkey) return; const profile = { name: this.editNameInput?.value?.trim() || "", display_name: this.editDisplayNameInput?.value?.trim() || "", about: this.editAboutInput?.value?.trim() || "", website: this.editWebsiteInput?.value?.trim() || "", nip05: this.editNip05Input?.value?.trim() || "", lud16: this.editLud16Input?.value?.trim() || "", picture: this.editPictureInput?.value?.trim() || "", banner: this.editBannerInput?.value?.trim() || "", }; if (this.editSaveBtn) { this.editSaveBtn.disabled = true; this.editSaveBtn.textContent = "Saving..."; } try { const event = buildProfileMetadataEvent({ pubkey, created_at: Math.floor(Date.now() / 1000), metadata: profile, }); const result = await this.services.nostrClient.signAndPublishEvent(event); if (result && result.signedEvent) { if (this.services.nostrClient.handleEvent) { this.services.nostrClient.handleEvent(result.signedEvent); } } this.showSuccess("Profile updated!"); this.selectPane("account"); this.renderSavedProfiles(); } catch (error) { this.showError("Failed to save profile: " + error.message); } finally { if (this.editSaveBtn) { this.editSaveBtn.disabled = false; this.editSaveBtn.textContent = "Save Profile"; } } } async populateProfileWatchHistory() { const renderer = this.ensureProfileHistoryRenderer(); if (!renderer) { return; } let primaryActor = this.normalizeHexPubkey(this.getActivePubkey()); if (!primaryActor && this.services.nostrClient?.sessionActor?.pubkey) { const candidate = this.services.nostrClient.sessionActor.pubkey; if (typeof candidate === "string" && candidate) { primaryActor = candidate; } } try { const state = typeof renderer.getState === "function" ? renderer.getState() : {}; if (state.initialized) { await renderer.refresh({ actor: primaryActor, force: true }); } else { await renderer.ensureInitialLoad({ actor: primaryActor }); } if (!this.boundProfileHistoryVisibility) { this.boundProfileHistoryVisibility = () => { if (!this.profileHistoryRenderer) { return; } if (document.visibilityState === "visible") { this.profileHistoryRenderer.resume(); } else { this.profileHistoryRenderer.pause(); } }; document.addEventListener( "visibilitychange", this.boundProfileHistoryVisibility, ); } if (document.visibilityState === "hidden") { renderer.pause(); } else { renderer.resume(); } } catch (error) { userLogger.error( "[profileModal] Failed to populate watch history pane:", error, ); } } getModerationSettingsService() { const service = this.services.moderationSettings; if (!service || typeof service !== "object") { return null; } return service; } getModerationSettingsDefaults() { const service = this.getModerationSettingsService(); let defaults = null; if (service && typeof service.getDefaultModerationSettings === "function") { try { defaults = service.getDefaultModerationSettings(); } catch (error) { devLogger.info("[profileModal] moderation defaults fallback used", error); } } if (!defaults || typeof defaults !== "object") { defaults = createInternalDefaultModerationSettings(); } const sanitized = { blurThreshold: Math.max( 0, Math.floor( Number( defaults.blurThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.blurThreshold, ), ), ), autoplayBlockThreshold: Math.max( 0, Math.floor( Number( defaults.autoplayBlockThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.autoplayBlockThreshold, ), ), ), trustedMuteHideThreshold: Math.max( 0, Math.floor( Number( defaults.trustedMuteHideThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.trustedMuteHideThreshold, ), ), ), trustedSpamHideThreshold: Math.max( 0, Math.floor( Number( defaults.trustedSpamHideThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.trustedSpamHideThreshold, ), ), ), }; return sanitized; } normalizeModerationSettings(settings = null) { const defaults = this.getModerationSettingsDefaults(); const blur = Number.isFinite(settings?.blurThreshold) ? Math.max(0, Math.floor(settings.blurThreshold)) : defaults.blurThreshold; const autoplay = Number.isFinite(settings?.autoplayBlockThreshold) ? Math.max(0, Math.floor(settings.autoplayBlockThreshold)) : defaults.autoplayBlockThreshold; const muteHide = Number.isFinite(settings?.trustedMuteHideThreshold) ? Math.max(0, Math.floor(settings.trustedMuteHideThreshold)) : defaults.trustedMuteHideThreshold; const spamHide = Number.isFinite(settings?.trustedSpamHideThreshold) ? Math.max(0, Math.floor(settings.trustedSpamHideThreshold)) : defaults.trustedSpamHideThreshold; return { blurThreshold: blur, autoplayBlockThreshold: autoplay, trustedMuteHideThreshold: muteHide, trustedSpamHideThreshold: spamHide, }; } readModerationInputs() { const defaults = this.getModerationSettingsDefaults(); const parse = (input, fallback) => { if (!(input instanceof HTMLInputElement)) { return { value: fallback, override: null, valid: true }; } const raw = typeof input.value === "string" ? input.value.trim() : ""; if (!raw) { return { value: fallback, override: null, valid: true }; } const numeric = Number(raw); if (!Number.isFinite(numeric)) { return { value: fallback, override: null, valid: false }; } const sanitized = Math.max(0, Math.floor(numeric)); return { value: sanitized, override: sanitized, valid: true }; }; const blur = parse(this.moderationBlurInput, defaults.blurThreshold); const autoplay = parse( this.moderationAutoplayInput, defaults.autoplayBlockThreshold, ); const muteHide = parse( this.moderationMuteHideInput, defaults.trustedMuteHideThreshold, ); const spamHide = parse( this.moderationSpamHideInput, defaults.trustedSpamHideThreshold, ); const valid = blur.valid && autoplay.valid && muteHide.valid && spamHide.valid; const values = { blurThreshold: blur.value, autoplayBlockThreshold: autoplay.value, trustedMuteHideThreshold: muteHide.value, trustedSpamHideThreshold: spamHide.value, }; const overrides = { blurThreshold: blur.override, autoplayBlockThreshold: autoplay.override, trustedMuteHideThreshold: muteHide.override, trustedSpamHideThreshold: spamHide.override, }; return { defaults, values, overrides, valid }; } applyModerationSettingsControlState({ resetStatus = false } = {}) { const result = this.readModerationInputs(); const button = this.moderationSaveButton; if (button instanceof HTMLElement) { const baseline = this.currentModerationSettings || this.normalizeModerationSettings(); const isDirty = result.valid && (baseline.blurThreshold !== result.values.blurThreshold || baseline.autoplayBlockThreshold !== result.values.autoplayBlockThreshold || baseline.trustedMuteHideThreshold !== result.values.trustedMuteHideThreshold || baseline.trustedSpamHideThreshold !== result.values.trustedSpamHideThreshold); button.disabled = !(result.valid && isDirty); if (button.disabled) { button.setAttribute("aria-disabled", "true"); } else { button.removeAttribute("aria-disabled"); } } if (resetStatus) { this.updateModerationSettingsStatus("", "info"); } return result; } areTrustedHideControlsEnabled() { if ( RUNTIME_FLAGS && typeof RUNTIME_FLAGS === "object" && RUNTIME_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS === false ) { return false; } return true; } updateTrustedHideControlsVisibility() { const shouldShow = this.areTrustedHideControlsEnabled(); const targets = new Set(); if (this.moderationHideControlsGroup instanceof HTMLElement) { targets.add(this.moderationHideControlsGroup); } if (Array.isArray(this.moderationHideControlElements)) { for (const element of this.moderationHideControlElements) { if (element instanceof HTMLElement) { targets.add(element); } } } targets.forEach((element) => { if (!(element instanceof HTMLElement)) { return; } if (shouldShow) { element.classList.remove("hidden"); element.removeAttribute("hidden"); element.removeAttribute("aria-hidden"); } else { element.classList.add("hidden"); element.setAttribute("hidden", ""); element.setAttribute("aria-hidden", "true"); } }); } updateModerationSettingsStatus(message = "", variant = "info") { if (!(this.moderationStatusText instanceof HTMLElement)) { return; } const text = typeof message === "string" ? message : ""; this.moderationStatusText.textContent = text; if (text) { this.moderationStatusText.dataset.variant = variant || "info"; } else if (this.moderationStatusText.dataset.variant) { delete this.moderationStatusText.dataset.variant; } } updateTrustedMuteHideHelperCopy() { if (!(this.moderationMuteHideInput instanceof HTMLInputElement)) { return; } const label = this.moderationMuteHideInput.closest("label"); if (!(label instanceof HTMLElement)) { return; } const helper = label.querySelector("span.text-xs"); if (!(helper instanceof HTMLElement)) { return; } helper.textContent = TRUSTED_MUTE_HIDE_HELPER_TEXT; } getModerationOverrideEntries() { if (typeof this.services.getModerationOverrides !== "function") { return []; } try { const entries = this.services.getModerationOverrides(); return Array.isArray(entries) ? entries : []; } catch (error) { devLogger.info( "[profileModal] moderation overrides fallback used", error, ); return []; } } normalizeModerationOverrideEntries(entries = []) { const normalized = []; const seen = new Set(); entries.forEach((entry) => { if (!entry || typeof entry !== "object") { return; } const eventId = typeof entry.eventId === "string" ? entry.eventId.trim().toLowerCase() : ""; if (!eventId) { return; } const author = typeof entry.authorPubkey === "string" ? entry.authorPubkey.trim() : ""; const normalizedAuthor = author ? this.normalizeHexPubkey(author) || author : ""; const key = `${normalizedAuthor || ""}:${eventId}`; if (seen.has(key)) { return; } seen.add(key); normalized.push({ eventId, authorPubkey: normalizedAuthor || "", updatedAt: Number.isFinite(entry.updatedAt) ? Math.floor(entry.updatedAt) : 0, }); }); normalized.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)); return normalized; } formatModerationOverrideTimestamp(updatedAt) { const numeric = Number(updatedAt); if (!Number.isFinite(numeric) || numeric <= 0) { return { display: "", iso: "" }; } try { const date = new Date(numeric); return { display: date.toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }), iso: date.toISOString(), }; } catch (error) { return { display: "", iso: "" }; } } async handleModerationOverrideReset(entry) { if (!entry || typeof entry !== "object") { return false; } if (typeof this.services.clearModerationOverride !== "function") { return false; } try { await this.services.clearModerationOverride({ eventId: entry.eventId, authorPubkey: entry.authorPubkey, }); this.refreshModerationOverridesUi(); this.showSuccess("Moderation override reset."); return true; } catch (error) { this.showError("Unable to reset this moderation override."); return false; } } refreshModerationOverridesUi() { if ( !(this.moderationOverridesList instanceof HTMLElement) || !(this.moderationOverridesEmpty instanceof HTMLElement) ) { return; } const entries = this.normalizeModerationOverrideEntries( this.getModerationOverrideEntries(), ); this.moderationOverridesList.textContent = ""; if (!entries.length) { this.moderationOverridesEmpty.classList.remove("hidden"); this.moderationOverridesList.classList.add("hidden"); return; } this.moderationOverridesEmpty.classList.add("hidden"); this.moderationOverridesList.classList.remove("hidden"); const entriesNeedingFetch = new Set(); entries.forEach((entry) => { const item = document.createElement("li"); item.className = "card space-y-2 p-4"; const row = document.createElement("div"); row.className = "flex items-center justify-between gap-4"; const authorKey = entry.authorPubkey; let profileSummary = null; if (authorKey) { const cacheEntry = this.services.getProfileCacheEntry(authorKey); if (!cacheEntry) { entriesNeedingFetch.add(authorKey); } } const summaryData = this.resolveProfileSummaryForPubkey(authorKey); profileSummary = this.createCompactProfileSummary(summaryData); const actions = document.createElement("div"); actions.className = "flex flex-wrap items-center justify-end gap-2"; const resetButton = this.createRemoveButton({ label: "Reset", onRemove: () => this.handleModerationOverrideReset(entry), }); if (resetButton) { actions.appendChild(resetButton); } if (profileSummary) { row.appendChild(profileSummary); } if (actions.childElementCount > 0) { row.appendChild(actions); } const meta = document.createElement("div"); meta.className = "flex flex-wrap items-center gap-3 text-2xs text-muted"; const contentId = document.createElement("span"); contentId.className = "font-mono text-2xs text-muted"; const shortId = typeof this.truncateMiddle === "function" ? this.truncateMiddle(entry.eventId, 16) : entry.eventId; contentId.textContent = `Content ${shortId}`; contentId.title = entry.eventId; meta.appendChild(contentId); const timestamp = this.formatModerationOverrideTimestamp(entry.updatedAt); if (timestamp.display) { const time = document.createElement("time"); time.className = "text-2xs text-muted"; time.dateTime = timestamp.iso; time.textContent = `Updated ${timestamp.display}`; meta.appendChild(time); } item.appendChild(row); item.appendChild(meta); this.moderationOverridesList.appendChild(item); }); if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" ) { this.services.batchFetchProfiles(entriesNeedingFetch); } } refreshModerationSettingsUi() { const service = this.getModerationSettingsService(); if (!service) { this.moderationSettingsDefaults = createInternalDefaultModerationSettings(); this.currentModerationSettings = createInternalDefaultModerationSettings(); this.updateTrustedHideControlsVisibility(); this.updateModerationTrustStats(); this.refreshModerationOverridesUi(); this.applyModerationSettingsControlState({ resetStatus: true }); return; } let active = null; if (typeof service.getActiveModerationSettings === "function") { try { active = service.getActiveModerationSettings(); } catch (error) { devLogger.info("[profileModal] moderation settings fallback used", error); } } const defaults = this.getModerationSettingsDefaults(); this.moderationSettingsDefaults = defaults; const normalized = this.normalizeModerationSettings(active); this.currentModerationSettings = normalized; if (this.moderationBlurInput instanceof HTMLInputElement) { this.moderationBlurInput.value = String(normalized.blurThreshold); } if (this.moderationAutoplayInput instanceof HTMLInputElement) { this.moderationAutoplayInput.value = String( normalized.autoplayBlockThreshold, ); } if (this.moderationMuteHideInput instanceof HTMLInputElement) { this.moderationMuteHideInput.value = String( normalized.trustedMuteHideThreshold, ); } if (this.moderationSpamHideInput instanceof HTMLInputElement) { this.moderationSpamHideInput.value = String( normalized.trustedSpamHideThreshold, ); } this.updateTrustedHideControlsVisibility(); this.updateModerationTrustStats(); this.refreshModerationOverridesUi(); this.applyModerationSettingsControlState({ resetStatus: true }); } getModerationTrustStats() { const summary = { trustedContactsCount: 0, trustedMuteContributors: 0, trustedReportContributors: 0, trustedSeedOnly: false, }; const service = this.moderationService; if (!service) { return summary; } if (typeof service.isTrustedSeedOnly === "function") { summary.trustedSeedOnly = service.isTrustedSeedOnly(); } else if (typeof service.trustedSeedOnly === "boolean") { summary.trustedSeedOnly = service.trustedSeedOnly; } const trustedContacts = service.trustedContacts instanceof Set ? service.trustedContacts : Array.isArray(service.trustedContacts) ? new Set(service.trustedContacts) : new Set(); summary.trustedContactsCount = trustedContacts.size; const adminSnapshot = typeof service.getAdminListSnapshot === "function" ? service.getAdminListSnapshot() : null; const resolveStatus = (candidate) => { if (typeof service.getAccessControlStatus === "function") { return service.getAccessControlStatus(candidate, adminSnapshot); } return { hex: this.normalizeHexPubkey(candidate), whitelisted: false, blacklisted: false, }; }; const isBlocked = (pubkey) => typeof service.isPubkeyBlockedByViewer === "function" ? service.isPubkeyBlockedByViewer(pubkey) : false; const isTrustedCandidate = (status) => { if (!status || !status.hex) { return false; } if (status.blacklisted) { return false; } if (isBlocked(status.hex)) { return false; } return Boolean(status.whitelisted || trustedContacts.has(status.hex)); }; if (service.trustedMuteLists instanceof Map) { const trustedMuteOwners = new Set(); for (const owner of service.trustedMuteLists.keys()) { const status = resolveStatus(owner); if (isTrustedCandidate(status)) { trustedMuteOwners.add(status.hex); } } summary.trustedMuteContributors = trustedMuteOwners.size; } if (service.reportEvents instanceof Map) { const trustedReporters = new Set(); for (const eventReports of service.reportEvents.values()) { if (!(eventReports instanceof Map)) { continue; } for (const reporter of eventReports.keys()) { const status = resolveStatus(reporter); if (!isTrustedCandidate(status)) { continue; } trustedReporters.add(status.hex); } } summary.trustedReportContributors = trustedReporters.size; } return summary; } updateModerationTrustStats() { if ( !(this.moderationTrustedContactsCount instanceof HTMLElement) && !(this.moderationTrustedMuteCount instanceof HTMLElement) && !(this.moderationTrustedReportCount instanceof HTMLElement) && !(this.moderationSeedOnlyIndicator instanceof HTMLElement) ) { return; } const summary = this.getModerationTrustStats(); if (this.moderationTrustedContactsCount instanceof HTMLElement) { this.moderationTrustedContactsCount.textContent = String( summary.trustedContactsCount, ); } if (this.moderationTrustedMuteCount instanceof HTMLElement) { this.moderationTrustedMuteCount.textContent = String( summary.trustedMuteContributors, ); } if (this.moderationTrustedReportCount instanceof HTMLElement) { this.moderationTrustedReportCount.textContent = String( summary.trustedReportContributors, ); } if (this.moderationSeedOnlyIndicator instanceof HTMLElement) { this.moderationSeedOnlyIndicator.hidden = !summary.trustedSeedOnly; } } async handleModerationSettingsSave() { const service = this.getModerationSettingsService(); const context = { success: false, reason: null, error: null, settings: null, }; if (!service) { return context; } const inputState = this.applyModerationSettingsControlState(); if (!inputState.valid) { const message = "Enter non-negative whole numbers for moderation thresholds."; this.updateModerationSettingsStatus(message, "error"); this.showError(message); context.reason = "invalid-input"; context.error = message; return context; } const payload = {}; if (Object.prototype.hasOwnProperty.call(inputState.overrides, "blurThreshold")) { payload.blurThreshold = inputState.overrides.blurThreshold; } if ( Object.prototype.hasOwnProperty.call( inputState.overrides, "autoplayBlockThreshold", ) ) { payload.autoplayBlockThreshold = inputState.overrides.autoplayBlockThreshold; } if ( Object.prototype.hasOwnProperty.call( inputState.overrides, "trustedMuteHideThreshold", ) ) { payload.trustedMuteHideThreshold = inputState.overrides.trustedMuteHideThreshold; } if ( Object.prototype.hasOwnProperty.call( inputState.overrides, "trustedSpamHideThreshold", ) ) { payload.trustedSpamHideThreshold = inputState.overrides.trustedSpamHideThreshold; } try { const updated = typeof service.updateModerationSettings === "function" ? await service.updateModerationSettings(payload) : inputState.values; const normalized = this.normalizeModerationSettings(updated); this.currentModerationSettings = normalized; if (this.moderationBlurInput instanceof HTMLInputElement) { this.moderationBlurInput.value = String(normalized.blurThreshold); } if (this.moderationAutoplayInput instanceof HTMLInputElement) { this.moderationAutoplayInput.value = String( normalized.autoplayBlockThreshold, ); } if (this.moderationMuteHideInput instanceof HTMLInputElement) { this.moderationMuteHideInput.value = String( normalized.trustedMuteHideThreshold, ); } if (this.moderationSpamHideInput instanceof HTMLInputElement) { this.moderationSpamHideInput.value = String( normalized.trustedSpamHideThreshold, ); } this.applyModerationSettingsControlState(); this.updateModerationSettingsStatus("Moderation settings saved.", "success"); this.showSuccess("Moderation settings saved."); context.success = true; context.reason = "saved"; context.settings = normalized; this.callbacks.onModerationSettingsChange({ settings: normalized, controller: this, reason: "saved", }); } catch (error) { const fallbackMessage = "Failed to update moderation settings."; const detail = error && typeof error.message === "string" && error.message.trim() ? error.message.trim() : fallbackMessage; this.updateModerationSettingsStatus(detail, "error"); this.showError(detail); context.error = detail; context.reason = error?.code || "service-error"; } return context; } async handleModerationSettingsReset() { const service = this.getModerationSettingsService(); const context = { success: false, reason: null, error: null, settings: null, }; if (!service) { return context; } try { const updated = typeof service.resetModerationSettings === "function" ? await service.resetModerationSettings() : createInternalDefaultModerationSettings(); const normalized = this.normalizeModerationSettings(updated); this.currentModerationSettings = normalized; if (this.moderationBlurInput instanceof HTMLInputElement) { this.moderationBlurInput.value = String(normalized.blurThreshold); } if (this.moderationAutoplayInput instanceof HTMLInputElement) { this.moderationAutoplayInput.value = String( normalized.autoplayBlockThreshold, ); } if (this.moderationMuteHideInput instanceof HTMLInputElement) { this.moderationMuteHideInput.value = String( normalized.trustedMuteHideThreshold, ); } if (this.moderationSpamHideInput instanceof HTMLInputElement) { this.moderationSpamHideInput.value = String( normalized.trustedSpamHideThreshold, ); } this.updateTrustedHideControlsVisibility(); this.applyModerationSettingsControlState({ resetStatus: true }); this.updateModerationSettingsStatus( "Moderation defaults restored.", "success", ); this.showSuccess("Moderation defaults restored."); context.success = true; context.reason = "reset"; context.settings = normalized; this.callbacks.onModerationSettingsChange({ settings: normalized, controller: this, reason: "reset", }); } catch (error) { const fallbackMessage = "Failed to restore moderation defaults."; const detail = error && typeof error.message === "string" && error.message.trim() ? error.message.trim() : fallbackMessage; this.updateModerationSettingsStatus(detail, "error"); this.showError(detail); context.error = detail; context.reason = error?.code || "service-error"; } return context; } storeAdminEmptyMessages() { const capture = (element) => { if (element instanceof HTMLElement && !element.dataset.defaultMessage) { element.dataset.defaultMessage = element.textContent || ""; } }; capture(this.moderatorEmpty); capture(this.whitelistEmpty); capture(this.blacklistEmpty); } setAdminLoading(isLoading) { this.storeAdminEmptyMessages(); if (this.panes.admin instanceof HTMLElement) { this.panes.admin.setAttribute("aria-busy", isLoading ? "true" : "false"); } const toggleMessage = (element, message) => { if (!(element instanceof HTMLElement)) { return; } if (isLoading) { element.textContent = message; element.classList.remove("hidden"); } else { element.textContent = element.dataset.defaultMessage || element.textContent; } }; toggleMessage(this.moderatorEmpty, "Loading moderators…"); toggleMessage(this.whitelistEmpty, "Loading whitelist…"); toggleMessage(this.blacklistEmpty, "Loading blacklist…"); } clearAdminLists() { this.storeAdminEmptyMessages(); if (this.adminModeratorList) { this.adminModeratorList.textContent = ""; } if (this.whitelistList) { this.whitelistList.textContent = ""; } if (this.blacklistList) { this.blacklistList.textContent = ""; } if (this.moderatorEmpty instanceof HTMLElement) { this.moderatorEmpty.textContent = this.moderatorEmpty.dataset.defaultMessage || this.moderatorEmpty.textContent; this.moderatorEmpty.classList.remove("hidden"); } if (this.whitelistEmpty instanceof HTMLElement) { this.whitelistEmpty.textContent = this.whitelistEmpty.dataset.defaultMessage || this.whitelistEmpty.textContent; this.whitelistEmpty.classList.remove("hidden"); } if (this.blacklistEmpty instanceof HTMLElement) { this.blacklistEmpty.textContent = this.blacklistEmpty.dataset.defaultMessage || this.blacklistEmpty.textContent; this.blacklistEmpty.classList.remove("hidden"); } } normalizeAdminListEntries(entries) { const collected = []; const seen = new Set(); const append = (value) => { if (typeof value !== "string") { return; } const trimmed = value.trim(); if (!trimmed || seen.has(trimmed)) { return; } seen.add(trimmed); collected.push(trimmed); }; if (Array.isArray(entries)) { entries.forEach(append); } else if (entries && typeof entries?.[Symbol.iterator] === "function") { for (const entry of entries) { append(entry); } } else if (entries && typeof entries === "object") { Object.values(entries).forEach(append); } try { collected.sort((a, b) => a.localeCompare(b)); } catch (error) { devLogger.warn( "[profileModal] Failed to sort admin list entries, using fallback order.", error, ); } return collected; } renderAdminList(listEl, emptyEl, entries, options = {}) { if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) { return; } const { onRemove, removeLabel = "Remove", confirmMessage, removable = true, overlapSet, overlapLabel, } = options; const formatNpub = typeof this.formatShortNpub === "function" ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const entriesNeedingFetch = new Set(); listEl.textContent = ""; const values = this.normalizeAdminListEntries(entries); const toggleHiddenState = (element, shouldHide) => { if (!(element instanceof HTMLElement)) { return; } if (shouldHide) { element.classList.add("hidden"); element.setAttribute("hidden", ""); } else { element.classList.remove("hidden"); element.removeAttribute("hidden"); } }; if (!values.length) { toggleHiddenState(emptyEl, false); toggleHiddenState(listEl, true); return; } toggleHiddenState(emptyEl, true); toggleHiddenState(listEl, false); values.forEach((npub) => { const item = document.createElement("li"); item.className = "card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"; const normalizedNpub = typeof npub === "string" ? npub.trim() : ""; const comparableNpub = this.normalizeNpubValue(normalizedNpub) || normalizedNpub; const decodedHex = normalizedNpub && normalizedNpub.startsWith("npub1") ? this.safeDecodeNpub(normalizedNpub) : null; const normalizedHex = decodedHex && /^[0-9a-f]{64}$/i.test(decodedHex) ? decodedHex.toLowerCase() : null; let cachedProfile = null; if (normalizedHex) { const cacheEntry = this.services.getProfileCacheEntry(normalizedHex); cachedProfile = cacheEntry?.profile || null; if (!cacheEntry) { entriesNeedingFetch.add(normalizedHex); } } const encodedNpub = normalizedHex && typeof this.safeEncodeNpub === "function" ? this.safeEncodeNpub(normalizedHex) : normalizedNpub; const displayNpub = formatNpub(encodedNpub) || encodedNpub || normalizedNpub; const displayName = cachedProfile?.name?.trim() || displayNpub || "Unknown profile"; const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR; const summary = this.createCompactProfileSummary({ displayName, displayNpub, avatarSrc, }); if ( summary && overlapLabel && overlapSet instanceof Set && comparableNpub && overlapSet.has(comparableNpub) ) { const overlapBadge = document.createElement("span"); overlapBadge.className = "badge whitespace-nowrap"; overlapBadge.dataset.variant = "warning"; overlapBadge.textContent = overlapLabel; summary.appendChild(overlapBadge); } const actions = document.createElement("div"); actions.className = "flex flex-wrap items-center justify-end gap-2 sm:flex-none"; const viewButton = this.createViewChannelButton({ targetNpub: encodedNpub, displayNpub, }); if (viewButton) { actions.appendChild(viewButton); } const copyButton = this.createCopyNpubButton({ targetNpub: encodedNpub, displayNpub, }); if (copyButton) { actions.appendChild(copyButton); } if (removable && typeof onRemove === "function") { const removeBtn = this.createRemoveButton({ label: removeLabel, confirmMessage, confirmValue: displayNpub, onRemove: (button) => onRemove(npub, button), }); if (removeBtn) { actions.appendChild(removeBtn); } } item.appendChild(summary); if (actions.childElementCount > 0) { item.appendChild(actions); } listEl.appendChild(item); }); if ( entriesNeedingFetch.size && typeof this.services.batchFetchProfiles === "function" ) { this.services.batchFetchProfiles(entriesNeedingFetch); } } populateAdminLists() { const actorNpub = this.services.getCurrentUserNpub(); if (!actorNpub || !this.services.accessControl.canEditAdminLists(actorNpub)) { this.clearAdminLists(); return; } const isSuperAdmin = this.services.accessControl.isSuperAdmin(actorNpub); const editors = this.normalizeAdminListEntries( this.services.accessControl.getEditors(), ).filter((npub) => npub && npub !== this.adminSuperNpub); const whitelist = this.normalizeAdminListEntries( this.services.accessControl.getWhitelist(), ); const blacklist = this.normalizeAdminListEntries( this.services.accessControl.getBlacklist(), ); const normalizeForCompare = (value) => this.normalizeNpubValue(value) || (typeof value === "string" ? value.trim() : ""); const whitelistCompare = new Set( whitelist.map(normalizeForCompare).filter(Boolean), ); const blacklistCompare = new Set( blacklist.map(normalizeForCompare).filter(Boolean), ); this.renderAdminList( this.adminModeratorList, this.moderatorEmpty, editors, { onRemove: (npub, button) => this.handleRemoveModerator(npub, button), removeLabel: "Remove", confirmMessage: "Remove moderator {npub}? They will immediately lose access to the admin panel.", removable: isSuperAdmin, }, ); this.renderAdminList( this.whitelistList, this.whitelistEmpty, whitelist, { onRemove: (npub, button) => this.handleAdminListMutation("whitelist", "remove", npub, button), removeLabel: "Remove", confirmMessage: "Remove {npub} from the whitelist?", removable: true, overlapSet: blacklistCompare, overlapLabel: "Also blacklisted", }, ); this.renderAdminList( this.blacklistList, this.blacklistEmpty, blacklist, { onRemove: (npub, button) => this.handleAdminListMutation("blacklist", "remove", npub, button), removeLabel: "Unblock", confirmMessage: "Remove {npub} from the blacklist?", removable: true, overlapSet: whitelistCompare, overlapLabel: "Also whitelisted", }, ); } async refreshAdminPaneState() { const adminNav = this.navButtons.admin; const adminPane = this.panes.admin; let loadError = null; this.setAdminLoading(true); this.showStatus("Fetching moderation filters…"); try { const ensureResult = await this.runAdminMutation({ action: "ensure-ready", }); if (ensureResult?.error && ensureResult.ok === false) { loadError = ensureResult.error; } } catch (error) { loadError = error; } const actorNpub = this.services.getCurrentUserNpub(); const canEdit = !!actorNpub && this.services.accessControl.canEditAdminLists(actorNpub); const isSuperAdmin = !!actorNpub && this.services.accessControl.isSuperAdmin(actorNpub); if (adminNav instanceof HTMLElement) { adminNav.classList.toggle("hidden", !canEdit); if (!canEdit) { adminNav.setAttribute("aria-selected", "false"); } } if (adminPane instanceof HTMLElement) { if (!canEdit) { adminPane.classList.add("hidden"); adminPane.setAttribute("aria-hidden", "true"); } else { const isActive = this.getActivePane() === "admin"; adminPane.classList.toggle("hidden", !isActive); adminPane.setAttribute("aria-hidden", (!isActive).toString()); } } if (loadError) { if (loadError?.code === "nostr-unavailable") { devLogger.info("Moderation lists are still syncing with relays."); return; } userLogger.error("Failed to load admin lists:", loadError); this.showStatus(null); this.showError("Unable to load moderation lists. Please try again."); this.clearAdminLists(); this.setAdminLoading(false); return; } if (!canEdit) { this.clearAdminLists(); this.showStatus(null); this.setAdminLoading(false); if ( adminNav instanceof HTMLElement && adminNav.dataset.state === "active" ) { this.selectPane("account"); } return; } if (this.moderatorSection instanceof HTMLElement) { this.moderatorSection.classList.toggle("hidden", !isSuperAdmin); this.moderatorSection.setAttribute( "aria-hidden", (!isSuperAdmin).toString(), ); } this.populateAdminLists(); this.showStatus(null); this.setAdminLoading(false); } normalizeNpubValue(value) { if (typeof value !== "string") { return null; } const trimmed = value.trim(); if (!trimmed) { return null; } if (trimmed.startsWith("npub1")) { return trimmed; } const normalizedHex = this.normalizeHexPubkey(trimmed); if (!normalizedHex) { return null; } return this.safeEncodeNpub(normalizedHex); } ensureAdminActor(requireSuperAdmin = false) { const actorNpub = this.services.getCurrentUserNpub(); if (!actorNpub) { this.showError("Please login with a Nostr account to manage admin settings."); return null; } if (!this.services.accessControl.canEditAdminLists(actorNpub)) { this.showError("You do not have permission to manage bitvid moderation lists."); return null; } if (requireSuperAdmin && !this.services.accessControl.isSuperAdmin(actorNpub)) { this.showError("Only the Super Admin can manage moderators or whitelist mode."); return null; } return actorNpub; } async handleAddModerator() { const input = this.moderatorInput || null; const rawValue = typeof input?.value === "string" ? input.value : ""; const trimmed = rawValue.trim(); const normalizedValue = this.normalizeNpubValue(trimmed); const context = { input, rawValue, value: trimmed, normalizedValue, actorNpub: null, success: false, reason: null, error: null, result: null, }; let preloadError = null; try { const ensureResult = await this.runAdminMutation({ action: "ensure-ready" }); if (ensureResult?.error && ensureResult.ok === false) { preloadError = ensureResult.error; } } catch (error) { preloadError = error; userLogger.error("Failed to load admin lists before adding moderator:", error); } if (preloadError) { this.showError(this.describeAdminError(preloadError.code || "storage-error")); context.reason = preloadError.code || "storage-error"; context.error = preloadError; this.callbacks.onAdminAddModerator(context, this); return context; } const actorNpub = this.ensureAdminActor(true); context.actorNpub = actorNpub; if (!actorNpub || !input) { context.reason = actorNpub ? "missing-input" : "unauthorized"; this.callbacks.onAdminAddModerator(context, this); return context; } if (!trimmed) { this.showError("Enter an npub to add as a moderator."); context.reason = "empty"; this.callbacks.onAdminAddModerator(context, this); return context; } if (!normalizedValue) { this.showError("Enter a valid npub before adding it as a moderator."); context.reason = "invalid"; this.callbacks.onAdminAddModerator(context, this); return context; } if (this.addModeratorButton) { this.addModeratorButton.disabled = true; this.addModeratorButton.setAttribute("aria-busy", "true"); } try { const mutationResult = await this.runAdminMutation({ action: "add-moderator", actorNpub, targetNpub: normalizedValue, }); context.result = mutationResult?.result || null; if (!mutationResult?.ok) { const errorCode = mutationResult?.result?.error || mutationResult?.error?.code; this.showError(this.describeAdminError(errorCode || "service-error")); context.reason = errorCode || "service-error"; context.error = mutationResult?.error || mutationResult?.result || null; return context; } this.moderatorInput.value = ""; this.showSuccess("Moderator added successfully."); await this.services.onAccessControlUpdated(); context.success = true; context.reason = "added"; } finally { if (this.addModeratorButton) { this.addModeratorButton.disabled = false; this.addModeratorButton.removeAttribute("aria-busy"); } this.callbacks.onAdminAddModerator(context, this); } return context; } async handleRemoveModerator(npub, button) { const context = { npub, normalizedNpub: this.normalizeNpubValue(npub), button, actorNpub: null, success: false, reason: null, error: null, result: null, }; const releaseButton = () => { if (button instanceof HTMLElement) { button.disabled = false; button.removeAttribute("aria-busy"); } }; if (!context.normalizedNpub) { this.showError("Unable to remove moderator: invalid npub."); context.reason = "invalid"; releaseButton(); this.callbacks.onAdminRemoveModerator(context, this); return context; } let preloadError = null; try { const ensureResult = await this.runAdminMutation({ action: "ensure-ready" }); if (ensureResult?.error && ensureResult.ok === false) { preloadError = ensureResult.error; } } catch (error) { preloadError = error; userLogger.error("Failed to load admin lists before removing moderator:", error); } if (preloadError) { this.showError(this.describeAdminError(preloadError.code || "storage-error")); context.reason = preloadError.code || "storage-error"; context.error = preloadError; releaseButton(); this.callbacks.onAdminRemoveModerator(context, this); return context; } const actorNpub = this.ensureAdminActor(true); context.actorNpub = actorNpub; if (!actorNpub) { context.reason = "unauthorized"; releaseButton(); this.callbacks.onAdminRemoveModerator(context, this); return context; } const mutationResult = await this.runAdminMutation({ action: "remove-moderator", actorNpub, targetNpub: context.normalizedNpub, }); context.result = mutationResult?.result || null; if (!mutationResult?.ok) { const errorCode = mutationResult?.result?.error || mutationResult?.error?.code; this.showError(this.describeAdminError(errorCode || "service-error")); context.reason = errorCode || "service-error"; context.error = mutationResult?.error || mutationResult?.result || null; releaseButton(); this.callbacks.onAdminRemoveModerator(context, this); return context; } this.showSuccess("Moderator removed."); await this.services.onAccessControlUpdated(); context.success = true; context.reason = "removed"; releaseButton(); this.callbacks.onAdminRemoveModerator(context, this); return context; } async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) { const isWhitelist = listType === "whitelist"; const input = isWhitelist ? this.whitelistInput : this.blacklistInput; const addButton = isWhitelist ? this.addWhitelistButton : this.addBlacklistButton; const isAdd = action === "add"; let buttonToToggle = sourceButton || (isAdd ? addButton : null); const context = { listType, action, explicitNpub, sourceButton, actorNpub: null, targetNpub: null, success: false, reason: null, error: null, result: null, notificationResult: null, notificationError: null, }; const callbackMap = { whitelist: { add: this.callbacks.onAdminAddWhitelist, remove: this.callbacks.onAdminRemoveWhitelist, }, blacklist: { add: this.callbacks.onAdminAddBlacklist, remove: this.callbacks.onAdminRemoveBlacklist, }, }; const adminCallback = callbackMap[listType]?.[action] || noop; const setBusy = (element, busy) => { if (!(element instanceof HTMLElement)) { return; } element.disabled = !!busy; if (busy) { element.setAttribute("aria-busy", "true"); } else { element.removeAttribute("aria-busy"); } }; const finalize = () => { setBusy(buttonToToggle, false); adminCallback(context, this); }; let preloadError = null; try { const ensureResult = await this.runAdminMutation({ action: "ensure-ready" }); if (ensureResult?.error && ensureResult.ok === false) { preloadError = ensureResult.error; } } catch (error) { preloadError = error; userLogger.error("Failed to load admin lists before updating entries:", error); } if (preloadError) { this.showError(this.describeAdminError(preloadError.code || "storage-error")); context.reason = preloadError.code || "storage-error"; context.error = preloadError; finalize(); return context; } const actorNpub = this.ensureAdminActor(false); context.actorNpub = actorNpub; if (!actorNpub) { context.reason = "unauthorized"; finalize(); return context; } let target = typeof explicitNpub === "string" ? explicitNpub.trim() : ""; if (!target && input instanceof HTMLInputElement) { target = input.value.trim(); } context.targetNpub = target; if (isAdd && !target) { this.showError("Enter an npub before adding it to the list."); context.reason = "empty"; finalize(); return context; } buttonToToggle = buttonToToggle || (isAdd ? addButton : null); setBusy(buttonToToggle, true); const mutationResult = await this.runAdminMutation({ action: "list-mutation", listType, mode: action, actorNpub, targetNpub: target, }); context.result = mutationResult?.result || null; if (!mutationResult?.ok) { const errorCode = mutationResult?.result?.error || mutationResult?.error?.code; this.showError(this.describeAdminError(errorCode || "service-error")); context.reason = errorCode || "service-error"; context.error = mutationResult?.error || mutationResult?.result || null; finalize(); return context; } if (isAdd && input instanceof HTMLInputElement) { input.value = ""; } const successMessage = isWhitelist ? isAdd ? "Added to the whitelist." : "Removed from the whitelist." : isAdd ? "Added to the blacklist." : "Removed from the blacklist."; this.showSuccess(successMessage); await this.services.onAccessControlUpdated(); context.success = true; context.reason = isAdd ? "added" : "removed"; if (isAdd) { try { const notifyResult = await this.sendAdminListNotification({ listType, actorNpub, targetNpub: target, }); context.notificationResult = notifyResult; if (!notifyResult?.ok) { const errorMessage = this.describeNotificationError(notifyResult?.error); if (errorMessage) { this.showError(errorMessage); } if (isDevMode && notifyResult?.error) { userLogger.warn( "[admin] Failed to send list notification DM:", notifyResult, ); } this.notifyAdminError({ listType, action, actorNpub, targetNpub: target, error: notifyResult?.error || null, result: notifyResult, }); } } catch (error) { context.notificationError = error; userLogger.error("Failed to send list notification DM:", error); devLogger.warn( "List update succeeded, but DM notification threw an unexpected error.", error, ); this.notifyAdminError({ listType, action, actorNpub, targetNpub: target, error, }); } } finalize(); return context; } describeAdminError(code) { if (typeof this.describeAdminErrorService === "function") { const result = this.describeAdminErrorService(code); if (typeof result === "string" && result) { return result; } } switch (code) { case "invalid npub": return "Please provide a valid npub address."; case "immutable": return "That account cannot be modified."; case "self": return "You cannot blacklist yourself."; case "forbidden": return "You do not have permission to perform that action."; case "nostr-unavailable": return "Unable to reach the configured Nostr relays. Please retry once your connection is restored."; case "nostr-extension-missing": return "Connect a Nostr extension before editing moderation lists."; case "signature-failed": return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again."; case "publish-failed": return "Failed to publish the update to Nostr relays. Please try again."; case "storage-error": return "Unable to update moderation settings. Please try again."; default: return "Unable to update moderation settings. Please try again."; } } describeNotificationError(code) { if (typeof this.describeNotificationErrorService === "function") { const result = this.describeNotificationErrorService(code); if (typeof result === "string") { return result; } } switch (code) { case "nostr-extension-missing": return "List updated, but the DM notification failed because no Nostr extension is connected."; case "nostr-uninitialized": return "List updated, but the DM notification system is still connecting to Nostr relays. Please try again in a moment."; case "nip04-unavailable": return "List updated, but your Nostr extension does not support NIP-04 encryption, so the DM notification was not sent."; case "sign-event-unavailable": return "List updated, but your Nostr extension could not sign the DM notification."; case "missing-actor-pubkey": return "List updated, but we could not determine your public key to send the DM notification."; case "publish-failed": return "List updated, but the DM notification could not be delivered to any relay."; case "encryption-failed": case "signature-failed": return "List updated, but the DM notification failed while preparing the encrypted message."; case "invalid-target": case "empty-message": return ""; default: return "List updated, but the DM notification could not be sent."; } } async sendAdminListNotification({ listType, actorNpub, targetNpub }) { if (typeof this.sendAdminListNotificationService === "function") { return this.sendAdminListNotificationService({ listType, actorNpub, targetNpub }); } const normalizedTarget = this.normalizeNpubValue(targetNpub); if (!normalizedTarget) { return { ok: false, error: "invalid-target" }; } const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); if (!activeHex) { return { ok: false, error: "missing-actor-pubkey" }; } const fallbackActor = this.safeEncodeNpub(activeHex) || "a bitvid moderator"; const actorDisplay = this.normalizeNpubValue(actorNpub) || fallbackActor; const isWhitelist = listType === "whitelist"; const formatNpub = typeof this.formatShortNpub === "function" ? (value) => this.formatShortNpub(value) : (value) => (typeof value === "string" ? value : ""); const displayTarget = formatNpub(normalizedTarget) || normalizedTarget; const displayActor = formatNpub(actorDisplay) || actorDisplay; const introLine = isWhitelist ? `Great news—your npub ${displayTarget} has been added to the bitvid whitelist by ${displayActor}.` : `We wanted to let you know that your npub ${displayTarget} has been placed on the bitvid blacklist by ${displayActor}.`; const statusLine = isWhitelist ? `You now have full creator access across bitvid (${this.bitvidWebsiteUrl}).` : `This hides your channel and prevents uploads across bitvid (${this.bitvidWebsiteUrl}) for now.`; const followUpLine = isWhitelist ? "Please take a moment to review our community guidelines (https://bitvid.network/#view=community-guidelines), and reply to this DM if you have any questions." : "Please review our community guidelines (https://bitvid.network/#view=community-guidelines). If you believe this was a mistake, you can submit an appeal at https://bitvid.network/?modal=appeals to request reinstatement, or reply to this DM with any questions."; const messageBody = [ "Hi there,", "", introLine, "", statusLine, "", followUpLine, "", "— the bitvid team", ].join("\n"); const message = `![bitvid status update](${this.adminDmImageUrl})\n\n${messageBody}`; return this.services.nostrClient.sendDirectMessage( normalizedTarget, message, activeHex, ); } async requestSwitchProfile({ pubkey, entry, providerId } = {}) { const callback = this.callbacks.onRequestSwitchProfile; if (callback && callback !== noop) { return callback({ controller: this, pubkey, entry, providerId }); } if (!pubkey) { throw new Error("Missing target pubkey for switch request."); } if (providerId) { return this.services.switchProfile(pubkey, { providerId }); } return this.services.switchProfile(pubkey); } async runRelayOperation({ action, url, activePubkey, skipPublishIfUnchanged = true, } = {}) { const callback = this.callbacks.onRelayOperation; if (callback && callback !== noop) { const result = await callback({ controller: this, action, url, activePubkey, skipPublishIfUnchanged, }); if (result !== undefined) { return result; } } const context = { ok: false, changed: false, reason: null, error: null, publishResult: null, operationResult: null, }; if (!activePubkey) { context.reason = "no-active-pubkey"; return context; } const previous = this.services.relayManager.snapshot(); const runOperation = () => { switch (action) { case "add": return this.services.relayManager.addRelay(url); case "remove": return this.services.relayManager.removeRelay(url); case "restore": return this.services.relayManager.restoreDefaults(); case "mode-toggle": return this.services.relayManager.cycleRelayMode(url); default: throw Object.assign(new Error("Unknown relay operation."), { code: "invalid-operation", }); } }; let operationResult; try { operationResult = runOperation(); context.operationResult = operationResult; } catch (error) { context.reason = error?.code || "operation-error"; context.error = error; return context; } context.changed = Boolean(operationResult?.changed); if (!context.changed && skipPublishIfUnchanged) { context.reason = operationResult?.reason || "unchanged"; return context; } try { const publishResult = await this.services.relayManager.publishRelayList( activePubkey, ); if (!publishResult?.ok) { throw new Error("No relays accepted the update."); } context.ok = true; context.publishResult = publishResult; return context; } catch (error) { this.services.relayManager.setEntries(previous, { allowEmpty: false }); context.reason = error?.code || "publish-failed"; context.error = error; return context; } } async mutateBlocklist({ action, actorHex, targetHex } = {}) { const callback = this.callbacks.onBlocklistMutation; if (callback && callback !== noop) { const result = await callback({ controller: this, action, actorHex, targetHex, }); if (result !== undefined) { return result; } } const context = { ok: false, reason: null, error: null }; if (!actorHex || !targetHex) { context.reason = "invalid-target"; return context; } try { await this.services.userBlocks.ensureLoaded(actorHex); const isBlocked = this.services.userBlocks.isBlocked(targetHex); if (action === "add") { if (isBlocked) { context.reason = "already-blocked"; return context; } await this.services.userBlocks.addBlock(targetHex, actorHex); context.ok = true; context.reason = "blocked"; } else if (action === "remove") { if (!isBlocked) { context.reason = "not-blocked"; return context; } await this.services.userBlocks.removeBlock(targetHex, actorHex); context.ok = true; context.reason = "unblocked"; } else { context.reason = "invalid-action"; return context; } if (context.ok) { try { await this.services.onVideosShouldRefresh({ reason: `blocklist-${action}`, actorHex, targetHex, }); } catch (refreshError) { userLogger.warn( "[ProfileModalController] Failed to refresh videos after blocklist mutation:", refreshError, ); } } return context; } catch (error) { context.error = error; context.reason = error?.code || "service-error"; return context; } } async runAdminMutation(payload = {}) { const callback = this.callbacks.onAdminMutation; if (callback && callback !== noop) { const result = await callback({ ...payload, controller: this }); if (result !== undefined) { return result; } } const action = payload?.action; const resultContext = { ok: false, error: null, result: null }; try { switch (action) { case "ensure-ready": await this.services.accessControl.ensureReady(); resultContext.ok = true; break; case "add-moderator": resultContext.result = await this.services.accessControl.addModerator( payload.actorNpub, payload.targetNpub, ); resultContext.ok = !!resultContext.result?.ok; break; case "remove-moderator": resultContext.result = await this.services.accessControl.removeModerator( payload.actorNpub, payload.targetNpub, ); resultContext.ok = !!resultContext.result?.ok; break; case "list-mutation": if (payload.listType === "whitelist") { resultContext.result = payload.mode === "add" ? await this.services.accessControl.addToWhitelist( payload.actorNpub, payload.targetNpub, ) : await this.services.accessControl.removeFromWhitelist( payload.actorNpub, payload.targetNpub, ); } else { resultContext.result = payload.mode === "add" ? await this.services.accessControl.addToBlacklist( payload.actorNpub, payload.targetNpub, ) : await this.services.accessControl.removeFromBlacklist( payload.actorNpub, payload.targetNpub, ); } resultContext.ok = !!resultContext.result?.ok; break; default: resultContext.error = Object.assign( new Error("Unknown admin mutation."), { code: "invalid-action" }, ); } } catch (error) { resultContext.error = error; return resultContext; } return resultContext; } notifyAdminError(payload = {}) { const callback = this.callbacks.onAdminNotifyError; if (callback && callback !== noop) { callback({ ...payload, controller: this }); } } updateFocusTrap() { const container = this.profileModalPanel instanceof HTMLElement ? this.profileModalPanel : this.profileModal instanceof HTMLElement ? this.profileModal : null; if (!container) { this.focusableElements = []; return; } const selector = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'; const nodes = Array.from(container.querySelectorAll(selector)); this.focusableElements = nodes.filter((node) => { if (!(node instanceof HTMLElement)) { return false; } const style = window.getComputedStyle(node); if (style.display === "none" || style.visibility === "hidden") { return false; } if (node.hasAttribute("disabled")) { return false; } if (node.getAttribute("aria-hidden") === "true") { return false; } return true; }); this.bindFocusTrap(container); } bindFocusTrap(container) { const targetContainer = container || (this.profileModalPanel instanceof HTMLElement ? this.profileModalPanel : this.profileModal instanceof HTMLElement ? this.profileModal : null); if (!targetContainer) { return; } if (this.focusTrapSuspended) { this.focusTrapContainer = targetContainer; return; } if (!this.boundKeydown) { this.boundKeydown = (event) => { if (event.key === "Escape") { event.preventDefault(); this.hide(); return; } if (event.key !== "Tab") { return; } if (!this.focusableElements.length) { event.preventDefault(); const fallback = this.profileModalPanel || this.profileModal || targetContainer; if (typeof fallback?.focus === "function") { fallback.focus(); } return; } const first = this.focusableElements[0]; const last = this.focusableElements[this.focusableElements.length - 1]; const active = document.activeElement; if (event.shiftKey) { if (active === first || !targetContainer.contains(active)) { event.preventDefault(); if (typeof last?.focus === "function") { last.focus(); } } return; } if (active === last) { event.preventDefault(); if (typeof first?.focus === "function") { first.focus(); } } }; } if (!this.boundFocusIn) { this.boundFocusIn = (event) => { const modalRoot = this.profileModalRoot || this.profileModal; if ( !modalRoot || modalRoot.classList.contains("hidden") || modalRoot.contains(event.target) ) { return; } const fallback = this.focusableElements[0] || this.profileModalPanel || this.profileModal; if (typeof fallback?.focus === "function") { fallback.focus(); } }; } if ( this.focusTrapContainer && this.focusTrapContainer !== targetContainer && this.boundKeydown ) { this.focusTrapContainer.removeEventListener("keydown", this.boundKeydown); } targetContainer.addEventListener("keydown", this.boundKeydown); this.focusTrapContainer = targetContainer; document.addEventListener("focusin", this.boundFocusIn); } getModalRootElement() { if (this.profileModalRoot instanceof HTMLElement) { return this.profileModalRoot; } if (this.profileModal instanceof HTMLElement) { return this.profileModal; } return null; } getModalPanelElement() { if (this.profileModalPanel instanceof HTMLElement) { return this.profileModalPanel; } return this.getModalRootElement(); } suspendFocusTrap() { this.focusTrapSuspendCount += 1; this.focusTrapSuspended = true; if (this.focusTrapSuspendCount > 1) { return this.focusTrapSuspendCount; } if ( this.boundKeydown && this.focusTrapContainer instanceof HTMLElement ) { this.focusTrapContainer.removeEventListener( "keydown", this.boundKeydown, ); } if (this.boundFocusIn) { document.removeEventListener("focusin", this.boundFocusIn); } const modalRoot = this.getModalRootElement(); if (modalRoot) { this.focusTrapAriaHiddenBeforeSuspend = modalRoot.getAttribute( "aria-hidden", ); this.focusTrapNestedModalActiveBeforeSuspend = typeof modalRoot.dataset.nestedModalActive === "string" ? modalRoot.dataset.nestedModalActive : null; modalRoot.dataset.nestedModalActive = "true"; modalRoot.setAttribute("aria-hidden", "true"); } else { this.focusTrapAriaHiddenBeforeSuspend = null; this.focusTrapNestedModalActiveBeforeSuspend = null; } const panel = this.getModalPanelElement(); if (panel) { panel.setAttribute("inert", ""); } return this.focusTrapSuspendCount; } resumeFocusTrap() { if (this.focusTrapSuspendCount === 0) { return 0; } this.focusTrapSuspendCount = Math.max( 0, this.focusTrapSuspendCount - 1, ); if (this.focusTrapSuspendCount > 0) { return this.focusTrapSuspendCount; } this.focusTrapSuspended = false; const modalRoot = this.getModalRootElement(); if (modalRoot) { delete modalRoot.dataset.nestedModalActive; if (!modalRoot.classList.contains("hidden")) { if (this.focusTrapAriaHiddenBeforeSuspend === null) { modalRoot.removeAttribute("aria-hidden"); } else { modalRoot.setAttribute( "aria-hidden", this.focusTrapAriaHiddenBeforeSuspend, ); } } if (this.focusTrapNestedModalActiveBeforeSuspend === null) { delete modalRoot.dataset.nestedModalActive; } else { modalRoot.dataset.nestedModalActive = this.focusTrapNestedModalActiveBeforeSuspend; } } const panel = this.getModalPanelElement(); if (panel) { panel.removeAttribute("inert"); } this.focusTrapAriaHiddenBeforeSuspend = null; this.focusTrapNestedModalActiveBeforeSuspend = null; this.updateFocusTrap(); return 0; } bringLoginModalToFront() { if (typeof document === "undefined") { return; } const loginModal = document.getElementById("loginModal"); if (!(loginModal instanceof HTMLElement)) { return; } const container = this.modalContainer instanceof HTMLElement ? this.modalContainer : loginModal.parentElement; if (!container) { return; } if (loginModal.parentElement !== container) { container.appendChild(loginModal); return; } if (container.lastElementChild !== loginModal) { container.appendChild(loginModal); } } ensureModalOrder(modalRoot) { if (!(modalRoot instanceof HTMLElement)) { return; } const container = this.modalContainer instanceof HTMLElement ? this.modalContainer : null; const parentElement = modalRoot.parentElement; if (container) { if (parentElement !== container) { container.appendChild(modalRoot); return; } if (container.lastElementChild !== modalRoot) { container.appendChild(modalRoot); } return; } if (parentElement) { if (parentElement.lastElementChild !== modalRoot) { parentElement.appendChild(modalRoot); } return; } if (typeof document !== "undefined" && document.body) { document.body.appendChild(modalRoot); } } applyModalStackingOverrides() { const modalRoot = this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot : this.profileModal instanceof HTMLElement ? this.profileModal : null; if (modalRoot) { modalRoot.dataset.modalStack = "top"; } } async show(targetPane = "account") { const pane = typeof targetPane === "string" && targetPane.trim() ? targetPane.trim() : "account"; const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; const modalRoot = this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot : this.profileModal instanceof HTMLElement ? this.profileModal : null; if (activeElement && modalRoot && modalRoot.contains(activeElement)) { this.previouslyFocusedElement = null; } else { this.previouslyFocusedElement = activeElement; } // Render the header (saved profiles) synchronously so the modal doesn't look empty. // This is generally fast as it only touches the top section. this.renderSavedProfiles(); // Show the modal immediately to prevent UI lag. this.open(pane); const hasBlockHydrator = this.services.userBlocks && typeof this.services.userBlocks.ensureLoaded === "function"; if (hasBlockHydrator) { this.setBlockListLoadingState("loading"); } // Defer expensive operations to the next animation frame to allow the modal to paint. requestAnimationFrame(() => { this.walletController.refreshWalletPaneState(); this.refreshModerationSettingsUi(); this.syncLinkPreviewSettingsUi(); if (!hasBlockHydrator) { this.populateBlockedList(); } const backgroundTasks = []; backgroundTasks.push( Promise.resolve() .then(() => this.refreshAdminPaneState()) .catch((error) => { userLogger.error( "Failed to refresh admin pane while opening profile modal:", error, ); }), ); backgroundTasks.push( Promise.resolve().then(() => { try { this.populateProfileRelays(); } catch (error) { userLogger.warn( "Failed to populate relay list while opening profile modal:", error, ); } }), ); if (hasBlockHydrator) { const activeHex = this.normalizeHexPubkey(this.getActivePubkey()); backgroundTasks.push( Promise.resolve() .then(() => this.services.userBlocks.ensureLoaded(activeHex)) .then(() => { try { this.populateBlockedList(); } catch (error) { userLogger.warn( "Failed to render blocked creators after hydration:", error, ); this.setBlockListLoadingState("error", { message: "Blocked creators may be out of date. Try again later.", }); } }) .catch((error) => { userLogger.warn( "Failed to refresh user block list while opening profile modal:", error, ); this.setBlockListLoadingState("error", { message: "Blocked creators may be out of date. Try again later.", }); try { this.populateBlockedList(); } catch (populateError) { userLogger.warn( "Failed to render blocked creators after hydration failure:", populateError, ); } }), ); } if (backgroundTasks.length) { void Promise.allSettled(backgroundTasks); } }); return true; } showWalletPane() { return this.show("wallet"); } open(pane = "account") { const modalRoot = this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot : this.profileModal instanceof HTMLElement ? this.profileModal : null; if (!modalRoot) { return; } this.ensureModalOrder(modalRoot); this.applyModalStackingOverrides(); modalRoot.classList.remove("hidden"); modalRoot.setAttribute("aria-hidden", "false"); if (document.body) { document.body.classList.add("modal-open"); } if (document.documentElement) { document.documentElement.classList.add("modal-open"); } this.setGlobalModalState("profile", true); const preserveMenu = this.isMobileLayoutActive(); this.selectPane(pane, { keepMenuView: preserveMenu }); const focusTarget = this.focusableElements[0] || this.profileModalPanel || modalRoot; window.requestAnimationFrame(() => { if (typeof focusTarget?.focus === "function") { focusTarget.focus(); } }); } hide(options = {}) { const { silent = false } = options && typeof options === "object" ? options : {}; this.pauseProfileMessages(); const modalElement = this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot : this.profileModal instanceof HTMLElement ? this.profileModal : null; if (modalElement) { modalElement.classList.add("hidden"); modalElement.setAttribute("aria-hidden", "true"); delete modalElement.dataset.nestedModalActive; delete modalElement.dataset.modalStack; if (document.body) { document.body.classList.remove("modal-open"); } if (document.documentElement) { document.documentElement.classList.remove("modal-open"); } this.setGlobalModalState("profile", false); this.setMobileView("menu", { skipFocusTrap: true }); if (this.boundKeydown && this.focusTrapContainer) { this.focusTrapContainer.removeEventListener( "keydown", this.boundKeydown, ); } } if (this.boundFocusIn) { document.removeEventListener("focusin", this.boundFocusIn); } const panel = this.getModalPanelElement(); if (panel) { panel.removeAttribute("inert"); } this.focusTrapContainer = null; this.focusTrapSuspendCount = 0; this.focusTrapSuspended = false; this.focusTrapAriaHiddenBeforeSuspend = null; this.focusTrapNestedModalActiveBeforeSuspend = null; if ( this.boundProfileHistoryVisibility && typeof document !== "undefined" && typeof document.removeEventListener === "function" ) { document.removeEventListener( "visibilitychange", this.boundProfileHistoryVisibility, ); this.boundProfileHistoryVisibility = null; } if (this.profileHistoryRenderer) { try { if (typeof this.profileHistoryRenderer.destroy === "function") { this.profileHistoryRenderer.destroy(); } } catch (error) { userLogger.warn( "[profileModal] Failed to reset watch history renderer on close:", error, ); } this.profileHistoryRenderer = null; this.profileHistoryRendererConfig = null; } this.setActivePane(null); this.walletController.setWalletPaneBusy(false); const previous = this.previouslyFocusedElement; this.previouslyFocusedElement = null; if ( previous && typeof previous.focus === "function" && (!modalElement || !modalElement.contains(previous)) ) { const shouldRestore = "isConnected" in previous ? previous.isConnected !== false : true; if (shouldRestore) { window.requestAnimationFrame(() => { try { previous.focus(); } catch (error) { userLogger.warn( "[profileModal] Failed to restore focus after closing modal:", error, ); } }); } } if (!silent) { try { this.callbacks.onClose(this); } catch (error) { userLogger.warn( "[profileModal] onClose callback threw while hiding modal:", error, ); } } } async handleAuthLogin(detail = {}) { const postLoginPromise = detail && typeof detail.postLoginPromise?.then === "function" ? detail.postLoginPromise : Promise.resolve(detail?.postLogin ?? null); const savedProfiles = Array.isArray(detail?.savedProfiles) ? detail.savedProfiles : null; if (savedProfiles) { try { this.setSavedProfiles(savedProfiles, { persist: false, persistActive: false, }); } catch (error) { userLogger.warn( "[profileModal] Failed to sync saved profiles during login:", error, ); } } const activePubkey = detail?.activeProfilePubkey ?? detail?.pubkey ?? undefined; if (activePubkey !== undefined) { this.setActivePubkey(activePubkey); } const walletPromise = this.hydrateActiveWalletSettings(activePubkey); const adminPromise = this.refreshAdminPaneState().catch((error) => { userLogger.warn("Failed to refresh admin pane after login:", error); }); this.renderSavedProfiles(); // Trigger aggressive parallel fetches if (activePubkey) { if (this.services.userBlocks) { this.services.userBlocks.loadBlocks(activePubkey).catch(noop); } if (this.subscriptionsService) { this.subscriptionsService .loadSubscriptions(activePubkey, { allowPermissionPrompt: false }) .catch(noop); } } this.populateBlockedList(); void this.populateSubscriptionsList(); void this.populateFriendsList(); this.populateProfileRelays(); this.walletController.refreshWalletPaneState(); this.populateHashtagPreferences(); void this.storageController.populateStoragePane(); this.handleActiveDmIdentityChanged(activePubkey); void this.refreshDmRelayPreferences({ force: true }); // Ensure critical state is settled if possible, but don't block initial rendering Promise.all([walletPromise, adminPromise, postLoginPromise]) .then(() => { // Re-run population to ensure any late-arriving data is reflected this.populateBlockedList(); void this.populateSubscriptionsList(); void this.populateFriendsList(); this.populateProfileRelays(); this.walletController.refreshWalletPaneState(); this.populateHashtagPreferences(); void this.storageController.populateStoragePane(); void this.refreshDmRelayPreferences({ force: true }); }) .catch((error) => { userLogger.warn( "[profileModal] Failed to hydrate deferred login data:", error, ); }); return true; } async handleAuthLogout(detail = {}) { const savedProfiles = Array.isArray(detail?.savedProfiles) ? detail.savedProfiles : null; if (savedProfiles) { try { this.setSavedProfiles(savedProfiles, { persist: false, persistActive: false, }); } catch (error) { userLogger.warn( "[profileModal] Failed to sync saved profiles during logout:", error, ); } } if (detail?.activeProfilePubkey !== undefined) { this.setActivePubkey(detail.activeProfilePubkey); } else { this.setActivePubkey(null); } this.profileSwitcherSelectionPubkey = null; this.renderSavedProfiles(); try { await this.refreshAdminPaneState(); } catch (error) { userLogger.warn("Failed to refresh admin pane after logout:", error); } if ( this.subscriptionsService && typeof this.subscriptionsService.reset === "function" ) { try { this.subscriptionsService.reset(); } catch (error) { devLogger.warn( "[profileModal] Failed to reset subscriptions service on logout:", error, ); } } this.populateBlockedList(); this.clearSubscriptionsList(); this.clearFriendsList(); this.populateProfileRelays(); this.walletController.refreshWalletPaneState(); this.populateHashtagPreferences(); void this.storageController.populateStoragePane(); this.clearHashtagInputs(); this.setHashtagStatus("", "muted"); this.handleActiveDmIdentityChanged(null); this.setMessagesUnreadIndicator(false); this.populateDmRelayPreferences(); this.setDmRelayPreferencesStatus(""); return true; } handleProfileUpdated(detail = {}) { const previousActive = this.normalizeHexPubkey(this.getActivePubkey()); if (Array.isArray(detail?.savedProfiles)) { try { this.setSavedProfiles(detail.savedProfiles, { persist: false, persistActive: false, }); } catch (error) { userLogger.warn( "[profileModal] Failed to sync saved profiles after profile update:", error, ); } } if (detail?.activeProfilePubkey !== undefined) { this.setActivePubkey(detail.activeProfilePubkey); } else if (detail?.pubkey) { this.setActivePubkey(detail.pubkey); } this.renderSavedProfiles(); void this.populateSubscriptionsList(); const nextActive = this.normalizeHexPubkey(this.getActivePubkey()); if (previousActive !== nextActive) { this.handleActiveDmIdentityChanged(nextActive); } } removeSavedProfile(pubkey) { if (!pubkey) { return { removed: false }; } let result; try { result = this.services.removeSavedProfile(pubkey) || { removed: false }; } catch (error) { userLogger.error("Failed to remove saved profile:", error); result = { removed: false, error }; } if (result?.removed) { if ( this.normalizeHexPubkey(pubkey) === this.normalizeHexPubkey(this.getActivePubkey()) ) { this.setActivePubkey(null); } this.renderSavedProfiles(); } return result; } async hydrateActiveWalletSettings(pubkey) { const service = this.services.nwcSettings; const hydrate = service?.hydrateNwcSettingsForPubkey; if (typeof hydrate !== "function") { return null; } const normalized = this.normalizeHexPubkey( pubkey !== undefined ? pubkey : this.getActivePubkey(), ); if (!normalized) { return null; } try { return await hydrate.call(service, normalized); } catch (error) { userLogger.warn( `[ProfileModalController] Failed to hydrate wallet settings for ${normalized}:`, error, ); return null; } } async switchProfile(pubkey, { entry, providerId } = {}) { if (!pubkey) { return { switched: false, reason: "missing-pubkey" }; } let result; try { result = await this.requestSwitchProfile({ pubkey, entry, providerId }); } catch (error) { const message = error && typeof error.message === "string" && error.message.trim() ? error.message.trim() : "Failed to switch profiles. Please try again."; this.showError(message); return { switched: false, error, reason: error?.code || "switch-error", }; } if (!result?.switched) { this.hide(); return result || { switched: false }; } await this.hydrateActiveWalletSettings(pubkey); this.profileSwitcherSelectionPubkey = null; this.renderSavedProfiles(); this.hide(); return result; } getSavedProfiles() { return this.state.getSavedProfiles(); } setSavedProfiles(...args) { return this.state.setSavedProfiles(...args); } normalizeProviderId(providerId) { if (typeof providerId !== "string") { return null; } const trimmed = providerId.trim(); if (!trimmed) { return null; } return trimmed; } getEntryProviderId(entry) { if (!entry || typeof entry !== "object") { return null; } const explicit = this.normalizeProviderId(entry.providerId); if (explicit) { return explicit; } return this.normalizeProviderId(entry.authType); } resolveEntryProviderMetadata(entry) { const providerId = this.getEntryProviderId(entry); if (providerId) { return getProviderMetadata(providerId); } return getProviderMetadata(); } persistSavedProfiles(...args) { if (typeof this.services.persistSavedProfiles === "function") { try { return this.services.persistSavedProfiles(...args); } catch (error) { userLogger.warn( "[ProfileModalController] Persist saved profiles service threw:", error, ); } } return this.state.persistSavedProfiles(...args); } getActivePubkey() { return this.state.getActivePubkey(); } setActivePubkey(...args) { return this.state.setActivePubkey(...args); } getCachedProfileSelection() { return this.state.getCachedSelection(); } setCachedProfileSelection(...args) { return this.state.setCachedSelection(...args); } getActivePane() { return this.state.getActivePane(); } setActivePane(...args) { return this.state.setActivePane(...args); } } export default ProfileModalController;