Files
bitvid/js/ui/profileModalController.js
T
thePR0M3TH3ANandClaude Opus 4.8 87af650b38 [profile] Refresh the profile modal on account switch (no manual reload)
Switching accounts with the profile modal open (e.g. NIP-07 -> a saved nsec) left
the PREVIOUS account's DMs, subscriptions, hashtags and lists on screen until the
user refreshed. Two problems:

1. handleAuthLogin ran its identity-reset steps unguarded, so if any one threw
   (renderSavedProfiles, a warm-list call, etc.) the steps after it were skipped —
   including handleActiveDmIdentityChanged, which is what clears the previous
   account's conversation. Result: stale DMs after a switch. Each reset step now
   runs in isolation via runIdentityResetStep, and the DM reset runs first.

2. The switch-with-modal-open refresh called populateOpenProfilePanels(), which
   only paints from current service state and omits lazily-loaded panes (messages,
   watch history). It also fired before the new identity's lists had loaded, with
   no reliable refresh afterward. Replaced with refreshOpenPanesForActiveIdentity(),
   which re-runs the ACTIVE pane through selectPane() — the same load+populate path
   as opening it fresh — so DMs reload, subscriptions/hashtags refetch, etc. It runs
   immediately, again once profile/wallet data settles, and once more from the auth
   coordinator when the new identity's blocks/subscriptions/hashtags finish loading
   (backfill for slow sync). Non-active panes stay lazy and repopulate on select.

Per-user list caches (blocks/subscriptions/hashtags are already keyed per pubkey)
mean the active pane shows cached data instantly on switch, then updates when the
new identity's fetch settles — no manual refresh.

Tests (tests/profile-modal-controller.test.mjs +3): a throwing reset step is
isolated; a throwing DM reset no longer skips the saved-profiles reset; and
refreshOpenPanesForActiveIdentity re-runs the active pane only when the modal is
open. Existing profile-modal / switch-coordinator / stored-nsec-switch suites green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 16:31:01 -04:00

5708 lines
184 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { showConfirm } from "./confirmDialog.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 {
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";
import { MyVideosController } from "./profileModal/MyVideosController.js";
import { ProfileDirectMessageController } from "./profileModal/ProfileDirectMessageController.js";
import { ProfileRelayController } from "./profileModal/ProfileRelayController.js";
import { ProfileHashtagController } from "./profileModal/ProfileHashtagController.js";
import { ProfileAdminController } from "./profileModal/ProfileAdminController.js";
import { ProfileModerationController } from "./profileModal/ProfileModerationController.js";
import { ProfileBlockListController } from "./profileModal/ProfileBlockListController.js";
import { ProfileEditController } from "./profileModal/ProfileEditController.js";
import { isInStackedModal } from "./focusTrapStacking.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",
]);
/**
* ProfileModalController
*
* Manages the "Profile" modal, which acts as the user's dashboard.
* It uses a Facade pattern, delegating specific functionality to sub-controllers:
* - ProfileDirectMessageController (DMs)
* - ProfileRelayController (Relay list)
* - ProfileWalletController (NWC/Zap settings)
* - ProfileAdminController (Moderation tools)
*
* Responsibilities:
* - Modal lifecycle (show/hide/load).
* - Pane navigation (Account, DMs, Relays, etc.).
* - Authentication state synchronization (Login/Logout/Switch).
* - DOM event binding for the modal shell.
*/
export class ProfileModalController {
/**
* Creates a new ProfileModalController instance.
*
* @param {object} options - Configuration options.
* @param {HTMLElement} [options.modalContainer] - The container to inject the modal into.
* @param {object} [options.callbacks] - Event callbacks (onClose, onLogout, etc.).
* @param {object} [options.services] - Service instances (nostrService, relayManager, etc.).
* @param {object} [options.state] - State management hooks.
*/
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.internalState = {
savedProfiles: [],
activePubkey: null,
cachedSelection: null,
activePane: "account",
walletBusy: false,
walletSettings: createInternalDefaultNwcSettings(),
moderationSettings: createInternalDefaultModerationSettings(),
dmRecipient: null,
dmRelayHints: new Map(),
};
this.services = buildServicesContract(services, this.internalState);
this.state = buildStateContract(state, this.internalState);
this.dmController = new ProfileDirectMessageController(this);
this.relayController = new ProfileRelayController(this);
this.hashtagController = new ProfileHashtagController(this);
this.adminController = new ProfileAdminController(this);
this.moderationController = new ProfileModerationController(this);
this.blockListController = new ProfileBlockListController(this);
this.editController = new ProfileEditController(this);
this.hashtagController.initialize();
this.dmController.enableNip17RelayWarning = resolvedEnableNip17RelayWarning;
this.dmController.hasShownRelayWarning = false;
this.dmController.dmMobileView = "list";
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.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",
() => {
// Only re-render the friends list if the modal is open; otherwise
// selectPane("friends") repopulates it on demand. Avoids rendering
// (and avatar-fetching) the whole contact list into a closed panel
// during the post-login contacts burst.
if (this.isProfileModalOpen()) {
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.dmController.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.relayController.relayList = null;
this.relayController.relayInput = null;
this.relayController.addRelayButton = null;
this.relayController.restoreRelaysButton = null;
this.relayController.relayHealthStatus = null;
this.relayController.relayHealthTelemetryToggle = null;
this.relayHealthRefreshPromise = null;
this.profileRelayList = null;
this.profileRelayInput = null;
this.relayController.profileAddRelayBtn = null;
this.relayController.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.dmController.profileMessagesList = null;
this.dmController.profileMessagesEmpty = null;
this.dmController.profileMessagesLoading = null;
this.dmController.profileMessagesError = null;
this.dmController.profileMessagesStatus = null;
this.dmController.profileMessagesReloadButton = null;
this.dmController.profileMessagesPane = null;
this.dmController.profileMessagesConversation = null;
this.dmController.profileMessagesConversationEmpty = null;
this.dmController.profileMessageInput = null;
this.dmController.profileMessageSendButton = null;
this.dmController.profileMessageAttachmentInput = null;
this.dmController.profileMessageAttachmentButton = null;
this.dmController.profileMessageAttachmentEncrypt = null;
this.dmController.profileMessageAttachmentList = null;
this.dmController.profileMessageAttachmentClearCache = null;
this.dmController.profileMessagesComposerHelper = null;
this.dmController.profileMessagesSendDmButton = null;
this.dmController.profileMessagesOpenRelaysButton = null;
this.dmController.profileMessagesPrivacyToggle = null;
this.dmController.profileMessagesPrivacyMode = null;
this.dmController.profileMessagesRelayList = null;
this.dmController.profileMessagesRelayInput = null;
this.dmController.profileMessagesRelayAddButton = null;
this.dmController.profileMessagesRelayPublishButton = null;
this.dmController.profileMessagesRelayStatus = null;
this.dmController.profileMessagesUnreadDot = null;
this.dmController.dmAppShellContainer = null;
this.dmController.dmAppShell = null;
this.dmController.profileLinkPreviewAutoToggle = null;
this.walletUriInput = null;
this.walletDefaultZapInput = null;
this.walletSaveButton = null;
this.walletTestButton = null;
this.walletDisconnectButton = null;
this.walletStatusText = null;
this.profileWalletStatusText = 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.relayController.profileRelayRefreshBtn = null;
// Legacy aliases managed via ProfileAdminController in cacheDomReferences
this.adminModeratorsSection = null;
this.adminModeratorsEmpty = null;
this.adminAddModeratorButton = null;
this.adminModeratorInput = null;
this.adminWhitelistSection = null;
this.adminWhitelistEmpty = null;
this.adminWhitelistList = null;
this.adminAddWhitelistButton = null;
this.adminWhitelistInput = null;
this.adminBlacklistSection = null;
this.adminBlacklistEmpty = null;
this.adminBlacklistList = null;
this.adminAddBlacklistButton = null;
this.adminBlacklistInput = null;
this.dmController.messagesLoadingState = "idle";
this.dmController.messagesInitialLoadPending = true;
this.dmController.messagesViewActive = false;
this.dmController.activeMessagesRequest = null;
this.dmController.directMessagesCache = [];
this.dmController.directMessagesLastActor = null;
this.dmController.directMessagesSubscription = null;
this.dmController.directMessagesUnsubscribes = [];
this.dmController.directMessagesRenderTimeout = null;
this.dmController.pendingDirectMessagesUpdate = null;
this.dmController.pendingMessagesRender = null;
this.dmController.messagesStatusClearTimeout = null;
this.dmController.profileMessagesRenderToken = 0;
this.dmController.dmPrivacyToggleTouched = false;
this.dmController.dmReadReceiptCache = new Set();
this.dmController.dmTypingLastSentAt = 0;
this.dmController.dmAttachmentQueue = [];
this.dmController.dmAttachmentUploads = new Map();
this.dmController.activeDmConversationId = "";
this.dmController.focusedDmConversationId = "";
this.dmController.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.myVideosController = new MyVideosController(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.dmController.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", () => {
// Re-render the blocked pane only when the modal is open; selectPane
// repopulates it on demand. Avoids rendering into a closed panel during
// the post-login block-list load.
if (this.isProfileModalOpen()) {
this.populateBlockedList();
}
});
}
}
/**
* Loads the modal HTML template and injects it into the DOM.
* Initializes sub-controllers and binds event listeners.
*
* @returns {Promise<boolean>} Resolves true when loaded successfully.
* @throws {Error} If the modal container is missing or the template fetch fails.
*/
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.hashtagController.populateHashtagPreferences();
this.moderationController.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.myvideos =
document.getElementById("profileNavMyVideos") || 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.myvideos =
document.getElementById("profilePaneMyVideos") || 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.relayController.relayList = document.getElementById("relayList") || null;
this.relayController.relayInput = document.getElementById("relayInput") || null;
this.relayController.addRelayButton = document.getElementById("addRelayBtn") || null;
this.relayController.restoreRelaysButton =
document.getElementById("restoreRelaysBtn") || null;
this.relayController.relayHealthStatus =
document.getElementById("relayHealthStatus") || null;
this.relayController.relayHealthTelemetryToggle =
document.getElementById("relayHealthTelemetryOptIn") || null;
this.relayController.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.blockListController.cacheDomReferences();
this.dmController.renderer.cacheDomReferences();
this.dmController.profileMessagesPane =
this.dmController.renderer.profileMessagesPane;
this.dmController.profileMessagesList =
this.dmController.renderer.profileMessagesList;
this.dmController.profileMessagesEmpty =
this.dmController.renderer.profileMessagesEmpty;
this.dmController.profileMessagesLoading =
this.dmController.renderer.profileMessagesLoading;
this.dmController.profileMessagesError =
this.dmController.renderer.profileMessagesError;
this.dmController.profileMessagesStatus =
this.dmController.renderer.profileMessagesStatus;
this.dmController.profileMessagesReloadButton =
this.dmController.renderer.profileMessagesReloadButton;
this.dmController.profileMessagesConversation =
this.dmController.renderer.profileMessagesConversation;
this.dmController.profileMessagesConversationEmpty =
this.dmController.renderer.profileMessagesConversationEmpty;
this.dmController.profileMessageInput =
this.dmController.renderer.profileMessageInput;
this.dmController.profileMessageSendButton =
this.dmController.renderer.profileMessageSendButton;
this.dmController.profileMessageAttachmentInput =
this.dmController.renderer.profileMessageAttachmentInput;
this.dmController.profileMessageAttachmentButton =
this.dmController.renderer.profileMessageAttachmentButton;
this.dmController.profileMessageAttachmentEncrypt =
this.dmController.renderer.profileMessageAttachmentEncrypt;
this.dmController.profileMessageAttachmentList =
this.dmController.renderer.profileMessageAttachmentList;
this.dmController.profileMessageAttachmentClearCache =
this.dmController.renderer.profileMessageAttachmentClearCache;
this.dmController.profileMessagesComposerHelper =
this.dmController.renderer.profileMessagesComposerHelper;
this.dmController.profileMessagesSendDmButton =
this.dmController.renderer.profileMessagesSendDmButton;
this.dmController.profileMessagesOpenRelaysButton =
this.dmController.renderer.profileMessagesOpenRelaysButton;
this.dmController.profileMessagesPrivacyToggle =
this.dmController.renderer.profileMessagesPrivacyToggle;
this.dmController.profileMessagesPrivacyMode =
this.dmController.renderer.profileMessagesPrivacyMode;
this.cacheDmRelayElements();
this.dmController.profileMessagesUnreadDot =
this.dmController.renderer.profileMessagesUnreadDot;
this.dmController.dmAppShellContainer =
this.dmController.renderer.dmAppShellContainer;
this.dmController.profileLinkPreviewAutoToggle =
this.dmController.renderer.profileLinkPreviewAutoToggle;
this.walletController.cacheDomReferences();
this.storageController.cacheDomReferences();
this.myVideosController.cacheDomReferences();
if (this.dmController.pendingMessagesRender) {
const { messages, actorPubkey } = this.dmController.pendingMessagesRender;
this.dmController.pendingMessagesRender = null;
void this.dmController.renderProfileMessages(messages, { actorPubkey }).catch((error) => {
devLogger.warn(
"[profileModal] Failed to render pending direct messages:",
error,
);
});
} else if (
(this.dmController.profileMessagesList instanceof HTMLElement ||
this.dmController.dmAppShellContainer instanceof HTMLElement) &&
Array.isArray(this.dmController.directMessagesCache) &&
this.dmController.directMessagesCache.length
) {
void this.dmController.renderProfileMessages(this.dmController.directMessagesCache, {
actorPubkey: this.dmController.directMessagesLastActor,
}).catch((error) => {
devLogger.warn(
"[profileModal] Failed to render cached direct messages:",
error,
);
});
}
this.dmController.setMessagesLoadingState(this.dmController.messagesLoadingState || "idle");
this.dmController.updateMessagePrivacyModeDisplay();
this.dmController.populateDmRelayPreferences();
this.dmController.syncDmPrivacySettingsUi();
this.hashtagController.cacheDomReferences();
this.subscriptionsStatusText =
document.getElementById("subscriptionsStatus") || null;
this.applyPermissionPromptCtaState();
this.profileRelayList = this.relayController.relayList;
this.profileRelayInput = this.relayController.relayInput;
this.relayController.profileAddRelayBtn = this.relayController.addRelayButton;
this.relayController.profileRestoreRelaysBtn = this.relayController.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.moderationController.cacheDomReferences();
this.adminController.cacheDomReferences();
// Backwards-compatible aliases retained for application code that still
// mirrors DOM references from the controller.
this.adminModeratorsSection = this.adminController.moderatorSection;
this.adminModeratorsEmpty = this.adminController.moderatorEmpty;
this.adminAddModeratorButton = this.adminController.addModeratorButton;
this.adminModeratorInput = this.adminController.moderatorInput;
this.adminWhitelistSection = this.adminController.whitelistSection;
this.adminWhitelistEmpty = this.adminController.whitelistEmpty;
this.adminWhitelistList = this.adminController.whitelistList;
this.adminAddWhitelistButton = this.adminController.addWhitelistButton;
this.adminWhitelistInput = this.adminController.whitelistInput;
this.adminBlacklistSection = this.adminController.blacklistSection;
this.adminBlacklistEmpty = this.adminController.blacklistEmpty;
this.adminBlacklistList = this.adminController.blacklistList;
this.adminAddBlacklistButton = this.adminController.addBlacklistButton;
this.adminBlacklistInput = this.adminController.blacklistInput;
// Direct aliases for tests that expect un-prefixed names
this.moderatorSection = this.adminController.moderatorSection;
this.adminModeratorList = this.adminController.adminModeratorList;
this.whitelistList = this.adminController.whitelistList;
this.whitelistInput = this.adminController.whitelistInput;
this.addWhitelistButton = this.adminController.addWhitelistButton;
this.addModeratorButton = this.adminController.addModeratorButton;
this.moderatorInput = this.adminController.moderatorInput;
const ensureAriaLabel = (button, label) => {
if (!(button instanceof HTMLElement)) {
return;
}
if (!button.getAttribute("aria-label")) {
button.setAttribute("aria-label", label);
}
};
ensureAriaLabel(this.relayController.profileRelayRefreshBtn, "Refresh relay list");
ensureAriaLabel(
this.hashtagController.profileHashtagInterestRefreshBtn,
"Refresh interest hashtags",
);
ensureAriaLabel(
this.hashtagController.profileHashtagDisinterestRefreshBtn,
"Refresh disinterest hashtags",
);
ensureAriaLabel(this.profileSubscriptionsRefreshBtn, "Refresh subscriptions");
ensureAriaLabel(this.profileFriendsRefreshBtn, "Refresh friends");
this.editController.cacheDomReferences();
ensureAriaLabel(this.profileBlockedRefreshBtn, "Refresh muted & blocked users");
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",
// NOTE: do NOT override `remove` here. The previous override routed removal
// to onHistoryReady -> app.handleProfileHistoryEvent(), which is a no-op
// stub — so deleting an item in the profile history pane published nothing
// (the card vanished optimistically then reappeared on refresh). Falling
// through to the renderer's default `remove` uses the working
// defaultRemoveHandler (publishes the reduced list with replace:true).
};
return this.profileHistoryRendererConfig;
}
syncLinkPreviewSettingsUi() {
if (!(this.dmController.profileLinkPreviewAutoToggle instanceof HTMLInputElement)) {
return;
}
const settings = getLinkPreviewSettings();
this.dmController.profileLinkPreviewAutoToggle.checked = Boolean(
settings?.autoFetchUnknownDomains,
);
}
cacheDmRelayElements() {
this.dmController.profileMessagesRelayList =
document.getElementById("profileMessagesRelayList") || null;
this.dmController.profileMessagesRelayInput =
document.getElementById("profileMessagesRelayInput") || null;
this.dmController.profileMessagesRelayAddButton =
document.getElementById("profileMessagesRelayAdd") || null;
this.dmController.profileMessagesRelayPublishButton =
document.getElementById("profileMessagesRelayPublish") || null;
this.dmController.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.dmController.profileMessagesRelayAddButton,
"click",
() => {
void this.dmController.handleAddDmRelayPreference();
},
"dmRelayAddBound",
);
bindOnce(
this.dmController.profileMessagesRelayInput,
"keydown",
(event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.dmController.handleAddDmRelayPreference();
}
},
"dmRelayInputBound",
);
bindOnce(
this.dmController.profileMessagesRelayPublishButton,
"click",
() => {
void this.handlePublishDmRelayPreferences();
},
"dmRelayPublishBound",
);
}
async refreshDmRelayPreferences({ force = false } = {}) {
const owner = this.dmController.resolveActiveDmRelayOwner();
if (!owner) {
this.dmController.populateDmRelayPreferences();
return;
}
const existing = this.dmController.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.dmController.populateDmRelayPreferences();
}
async handlePublishDmRelayPreferences() {
const owner = this.dmController.resolveActiveDmRelayOwner();
if (!owner) {
this.showError("Please sign in to publish DM relay hints.");
return;
}
const relays = this.dmController.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.dmController.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.dmController.setDmRelayPreferencesStatus(summary);
return;
}
this.showError("Failed to publish DM relay hints.");
this.dmController.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.dmController.setDmRelayPreferencesStatus(message);
}
}
updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) {
if (!recipientContext) {
return;
}
const relayHints = Array.isArray(recipientContext.relayHints)
? recipientContext.relayHints
: [];
const hasHints = relayHints.length > 0;
if (!this.dmController.dmPrivacyToggleTouched || force) {
this.setPrivacyToggleState(hasHints);
}
}
setPrivacyToggleState(enabled) {
if (this.dmController.profileMessagesPrivacyToggle instanceof HTMLInputElement) {
this.dmController.profileMessagesPrivacyToggle.checked = Boolean(enabled);
}
this.dmController.updateMessagePrivacyModeDisplay();
}
handleActiveDmIdentityChanged(actorPubkey = null) {
const normalized = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.dmController.resolveActiveDmActor();
this.dmController.hasShownRelayWarning = false;
this.dmController.setDirectMessageRecipient(null, { reason: "clear" });
this.resetAttachmentQueue({ clearInput: true });
this.dmController.dmReadReceiptCache.clear();
this.dmController.dmTypingLastSentAt = 0;
this.dmController.syncDmPrivacySettingsUi();
if (
this.dmController.directMessagesSubscription &&
this.dmController.directMessagesSubscription.actor &&
normalized !== this.dmController.directMessagesSubscription.actor
) {
this.dmController.resetDirectMessageSubscription();
}
this.dmController.directMessagesLastActor = normalized || null;
this.dmController.directMessagesCache = [];
this.dmController.messagesInitialLoadPending = true;
this.dmController.pendingMessagesRender = null;
if (this.dmController.profileMessagesList instanceof HTMLElement) {
this.dmController.profileMessagesList.textContent = "";
this.dmController.profileMessagesList.classList.add("hidden");
this.dmController.profileMessagesList.setAttribute("hidden", "");
}
if (this.dmController.profileMessagesConversation instanceof HTMLElement) {
this.dmController.profileMessagesConversation.textContent = "";
this.dmController.profileMessagesConversation.classList.add("hidden");
this.dmController.profileMessagesConversation.setAttribute("hidden", "");
}
if (this.dmController.profileMessagesConversationEmpty instanceof HTMLElement) {
this.dmController.profileMessagesConversationEmpty.classList.remove("hidden");
this.dmController.profileMessagesConversationEmpty.removeAttribute("hidden");
}
if (!normalized) {
this.dmController.setMessagesLoadingState("unauthenticated");
this.dmController.updateMessagesReloadState();
this.dmController.populateDmRelayPreferences();
this.dmController.setDmRelayPreferencesStatus("");
return;
}
this.dmController.setMessagesLoadingState("loading");
void this.dmController.ensureDirectMessageSubscription(normalized);
this.dmController.updateMessagesReloadState();
if (this.getActivePane() === "messages") {
void this.dmController.populateProfileMessages({
force: true,
reason: "identity-change",
});
}
void this.refreshDmRelayPreferences({ force: true });
}
// Whether the profile modal is currently visible. Used to avoid rendering
// profile-panel content into a CLOSED modal (e.g. the eager population that
// jammed the main thread right after login — KNOWN_BUGS #0 post-login freeze).
isProfileModalOpen() {
const root =
this.profileModalRoot instanceof HTMLElement
? this.profileModalRoot
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
return (
!!root &&
!root.classList.contains("hidden") &&
root.getAttribute("aria-hidden") !== "true"
);
}
// Render every profile panel at once. Only worth doing when the modal is
// actually open — otherwise rely on selectPane() to lazily populate each pane
// as it's shown.
populateOpenProfilePanels() {
this.populateBlockedList();
void this.populateSubscriptionsList();
void this.populateFriendsList();
this.relayController.populateProfileRelays();
this.walletController.refreshWalletPaneState();
this.hashtagController.populateHashtagPreferences();
void this.storageController.populateStoragePane();
void this.refreshDmRelayPreferences({ force: true });
}
// Run one identity-switch reset step in isolation. A failure in a single step
// must not skip the ones after it — notably the DM reset that clears the
// previous account's conversation on a switch.
runIdentityResetStep(label, fn) {
try {
fn();
} catch (error) {
userLogger.warn(
`[profileModal] Identity reset step "${label}" failed:`,
error,
);
}
}
// Refresh the profile modal for the ACTIVE identity while it is open (e.g. right
// after switching accounts). Re-runs the current pane's own lazy load+populate —
// the SAME path as opening the pane fresh — so DMs reload, subscriptions and
// hashtags refetch for the new account, blocked/relays/storage repopulate, etc.
// Non-active panes stay lazy and repopulate when the user selects them.
refreshOpenPanesForActiveIdentity() {
if (!this.isProfileModalOpen()) {
return;
}
try {
this.selectPane(this.getActivePane());
} catch (error) {
userLogger.warn(
"[profileModal] Failed to refresh the active pane for the current identity:",
error,
);
}
}
setMessagesUnreadIndicator(visible) {
if (!(this.dmController.profileMessagesUnreadDot instanceof HTMLElement)) {
return;
}
const button = this.navButtons.messages;
const isVisible =
button instanceof HTMLElement &&
!button.classList.contains("hidden") &&
!button.hasAttribute("hidden");
this.dmController.profileMessagesUnreadDot.classList.toggle(
"is-visible",
Boolean(visible) && isVisible,
);
}
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.dmController.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.dmController.dmAttachmentQueue = [];
this.dmController.dmAttachmentUploads.clear();
if (clearInput && this.dmController.profileMessageAttachmentInput) {
this.dmController.profileMessageAttachmentInput.value = "";
}
this.renderAttachmentQueue();
}
renderAttachmentQueue() {
const list = this.dmController.profileMessageAttachmentList;
if (!(list instanceof HTMLElement)) {
return;
}
list.textContent = "";
if (!this.dmController.dmAttachmentQueue.length) {
return;
}
this.dmController.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.dmController.dmAttachmentQueue = this.dmController.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";
img.loading = "lazy";
img.decoding = "async";
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.dmController.profileMessageAttachmentEncrypt instanceof HTMLInputElement
? this.dmController.profileMessageAttachmentEncrypt.checked
: false;
const payloads = [];
for (const entry of this.dmController.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;
}
resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey = null) {
const normalizedRecipient =
typeof recipientPubkey === "string"
? this.normalizeHexPubkey(recipientPubkey)
: "";
if (!normalizedRecipient || !Array.isArray(this.dmController.directMessagesCache)) {
return null;
}
const resolvedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.dmController.resolveActiveDmActor();
let latest = null;
let latestTimestamp = 0;
for (const entry of this.dmController.directMessagesCache) {
if (this.dmController.resolveDirectMessageRemote(entry, resolvedActor) !== normalizedRecipient) {
continue;
}
const timestamp = Number(entry?.timestamp) || 0;
if (!latest || timestamp > latestTimestamp) {
latest = entry;
latestTimestamp = timestamp;
}
}
return latest;
}
async maybePublishTypingIndicator() {
const settings = this.dmController.getDmPrivacySettingsSnapshot();
if (!settings.typingIndicatorsEnabled) {
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.publishDmTypingIndicator !== "function"
) {
return;
}
const input = this.dmController.profileMessageInput;
const messageText =
input instanceof HTMLTextAreaElement ? input.value.trim() : "";
if (!messageText) {
return;
}
const recipient = this.dmController.resolveActiveDmRecipient();
if (!recipient) {
return;
}
const now = Date.now();
if (now - this.dmController.dmTypingLastSentAt < TYPING_INDICATOR_COOLDOWN_MS) {
return;
}
this.dmController.dmTypingLastSentAt = now;
const relayHints = this.dmController.buildDmRecipientContext(recipient)?.relayHints || [];
const latestMessage = this.resolveLatestDirectMessageForRecipient(
recipient,
this.dmController.resolveActiveDmActor(),
);
const latestEventId = this.dmController.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);
}
}
clearProfileMessages({ message } = {}) {
this.dmController.directMessagesCache = [];
this.dmController.directMessagesLastActor = this.dmController.resolveActiveDmActor();
this.dmController.messagesInitialLoadPending = true;
this.dmController.setDirectMessageRecipient(null, { reason: "clear" });
if (this.dmController.profileMessagesList instanceof HTMLElement) {
this.dmController.profileMessagesList.textContent = "";
this.dmController.profileMessagesList.classList.add("hidden");
this.dmController.profileMessagesList.setAttribute("hidden", "");
}
const actor = this.dmController.directMessagesLastActor;
this.dmController.setMessagesLoadingState(actor ? "empty" : "unauthenticated", {
message,
});
if (this.dmController.dmAppShellContainer instanceof HTMLElement) {
void this.dmController.renderDmAppShell(this.dmController.directMessagesCache, {
actorPubkey: actor,
});
}
}
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() : "";
}
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.relayController.addRelayButton instanceof HTMLElement) {
this.relayController.addRelayButton.addEventListener("click", () => {
void this.relayController.handleAddRelay();
});
}
if (this.relayController.restoreRelaysButton instanceof HTMLElement) {
this.relayController.restoreRelaysButton.addEventListener("click", () => {
void this.relayController.handleRestoreRelays();
});
}
if (this.relayController.profileRelayRefreshBtn instanceof HTMLElement) {
this.relayController.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.relayController.populateProfileRelays();
void this.relayController.refreshRelayHealthPanel({
forceRefresh: true,
reason: "relay-update",
});
})
.catch((error) => {
devLogger.warn("[profileModal] Failed to refresh relay list:", error);
});
});
}
if (this.relayController.relayHealthTelemetryToggle instanceof HTMLInputElement) {
this.relayController.relayHealthTelemetryToggle.addEventListener("change", () => {
this.relayController.handleRelayHealthTelemetryToggle();
});
}
this.blockListController.registerEventListeners();
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.hashtagController.addHashtagInterestButton instanceof HTMLElement) {
this.hashtagController.addHashtagInterestButton.addEventListener("click", () => {
void this.hashtagController.handleAddHashtagPreference("interest");
});
}
const handleHashtagRefresh = () => {
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
if (!activeHex) {
return;
}
const service = this.services.hashtagPreferences;
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.hashtagController.profileHashtagInterestRefreshBtn instanceof HTMLElement) {
this.hashtagController.profileHashtagInterestRefreshBtn.addEventListener(
"click",
handleHashtagRefresh,
);
}
if (this.hashtagController.profileHashtagDisinterestRefreshBtn instanceof HTMLElement) {
this.hashtagController.profileHashtagDisinterestRefreshBtn.addEventListener(
"click",
handleHashtagRefresh,
);
}
if (this.hashtagController.hashtagInterestInput instanceof HTMLElement) {
this.hashtagController.hashtagInterestInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.hashtagController.handleAddHashtagPreference("interest");
}
});
}
if (this.hashtagController.addHashtagDisinterestButton instanceof HTMLElement) {
this.hashtagController.addHashtagDisinterestButton.addEventListener("click", () => {
void this.hashtagController.handleAddHashtagPreference("disinterest");
});
}
if (this.hashtagController.hashtagDisinterestInput instanceof HTMLElement) {
this.hashtagController.hashtagDisinterestInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.hashtagController.handleAddHashtagPreference("disinterest");
}
});
}
if (this.dmController.profileMessagesReloadButton instanceof HTMLElement) {
this.dmController.profileMessagesReloadButton.addEventListener("click", () => {
void this.dmController.populateProfileMessages({ force: true, reason: "manual" });
});
}
if (this.dmController.profileMessagesSendDmButton instanceof HTMLElement) {
this.dmController.profileMessagesSendDmButton.addEventListener("click", () => {
void this.dmController.handleSendDmRequest();
});
}
if (this.dmController.profileMessagesOpenRelaysButton instanceof HTMLElement) {
this.dmController.profileMessagesOpenRelaysButton.addEventListener("click", () => {
void this.dmController.handleOpenDmRelaysRequest();
});
}
if (this.dmController.profileMessagesPrivacyToggle instanceof HTMLElement) {
this.dmController.profileMessagesPrivacyToggle.addEventListener("change", (event) => {
const toggle = event.currentTarget;
if (toggle instanceof HTMLInputElement) {
this.dmController.handlePrivacyToggle(toggle.checked);
}
});
}
// Legacy toggles removed - handled by DMPrivacySettings in AppShell
if (this.dmController.profileLinkPreviewAutoToggle instanceof HTMLElement) {
this.dmController.profileLinkPreviewAutoToggle.addEventListener("change", (event) => {
const toggle = event.currentTarget;
if (toggle instanceof HTMLInputElement) {
this.dmController.handleLinkPreviewToggle(toggle.checked);
}
});
}
this.bindDmRelayControls();
this.walletController.registerEventListeners();
this.storageController.registerEventListeners();
this.myVideosController.registerEventListeners();
this.adminController.registerEventListeners();
this.moderationController.registerEventListeners();
if (this.dmController.profileMessageSendButton instanceof HTMLElement) {
this.dmController.profileMessageSendButton.addEventListener("click", () => {
void this.dmController.handleSendProfileMessage();
});
}
if (this.dmController.profileMessageAttachmentButton instanceof HTMLElement) {
this.dmController.profileMessageAttachmentButton.addEventListener("click", () => {
if (this.dmController.profileMessageAttachmentInput instanceof HTMLInputElement) {
this.dmController.profileMessageAttachmentInput.click();
}
});
}
if (this.dmController.profileMessageAttachmentInput instanceof HTMLElement) {
this.dmController.profileMessageAttachmentInput.addEventListener("change", () => {
this.dmController.handleAttachmentSelection();
});
}
if (this.dmController.profileMessageAttachmentClearCache instanceof HTMLElement) {
this.dmController.profileMessageAttachmentClearCache.addEventListener("click", () => {
clearAttachmentCache();
const stats = getAttachmentCacheStats();
this.showStatus(
`Attachment cache cleared (${stats.size}/${stats.maxSize}).`,
);
});
}
if (this.dmController.profileMessageInput instanceof HTMLElement) {
this.dmController.profileMessageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void this.dmController.handleSendProfileMessage();
}
});
this.dmController.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", async () => {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (
pubkey &&
(await showConfirm("Create a backup of your current subscription list?", {
confirmLabel: "Create backup",
}))
) {
this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => {
this.showSuccess("Backup created.");
});
} else {
this.showError("Please log in to backup subscriptions.");
}
});
}
this.editController.registerEventListeners();
this.dmController.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;
}
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);
}
}
/**
* Processes a request to add a new account to the "Saved Profiles" list.
*
* @param {object} payload - The result from the auth provider (pubkey, etc.).
*/
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 });
}
/**
* Renders the list of saved profiles in the modal header and switcher menu.
* Handles avatar/name resolution, selection state, and "logout" button creation.
* Triggers a batch fetch for any missing profile metadata.
*/
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,
);
}
// Only fall back to the first saved profile when an active pubkey IS set
// but doesn't match any saved entry (data-inconsistency edge case).
// When normalizedActive is null the user is logged out — do NOT auto-select
// a profile, otherwise the modal appears as if the user merely switched
// accounts instead of logging out.
if (!activeEntry && normalizedActive && 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`;
avatarImg.loading = "lazy";
avatarImg.decoding = "async";
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;
}
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 (!(await showConfirm(prompt, { danger: true }))) {
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.dmController.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.relayController.populateProfileRelays();
void this.relayController.refreshRelayHealthPanel({
forceRefresh: true,
reason: "pane-select",
});
} else if (target === "messages") {
this.dmController.resumeProfileMessages();
void this.dmController.populateProfileMessages({ reason: "pane-select" });
void this.refreshDmRelayPreferences();
} else if (target === "wallet") {
this.walletController.refreshWalletPaneState();
} else if (target === "storage") {
this.storageController.populateStoragePane();
} else if (target === "myvideos") {
void this.myVideosController.populate({ forceFetch: true });
} else if (target === "hashtags") {
this.hashtagController.populateHashtagPreferences();
if (activeHex && this.services.hashtagPreferences) {
this.services.hashtagPreferences
.load(activeHex, { allowPermissionPrompt: true })
.catch(noop);
}
this.hashtagController.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 === "friends") {
// The friends pane has no other lazy-populate trigger now that login no
// longer eagerly renders it, so populate it on selection.
void this.populateFriendsList();
} else if (target === "safety") {
this.refreshModerationSettingsUi();
this.syncLinkPreviewSettingsUi();
}
this.callbacks.onSelectPane(target, { controller: this });
this.callbacks.onPaneShown(target, { controller: this });
}, 0);
}
setBlockListLoadingState(...args) {
return this.blockListController.setBlockListLoadingState(...args);
}
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 permissionFailureTasks = failedTasks.filter((task) => {
const code =
task?.error && typeof task.error.code === "string"
? task.error.code.trim()
: "";
return code.endsWith("-permission-required");
});
const loadedFromCacheTasks = failedTasks.filter((task) => task.fromCache === true);
const hardFailTasks = failedTasks.filter((task) => task.fromCache !== true);
const onlyPermissionFailures =
failedTasks.length > 0 && permissionFailureTasks.length === failedTasks.length;
if (loadedFromCacheTasks.length > 0 && hardFailTasks.length === 0) {
this.setSubscriptionsStatus(
"Subscriptions loaded from cache; sync is retrying in background.",
"warning",
);
this.hashtagController.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 (onlyPermissionFailures) {
this.setSubscriptionsStatus(
"Waiting for extension decrypt permissions to sync your lists.",
"warning",
);
this.hashtagController.setHashtagStatus(
"Waiting for extension decrypt permissions to sync your lists.",
"warning",
);
this.setBlockListLoadingState("error", {
message: "Waiting for extension decrypt permissions to sync your blocked creators.",
});
} else if (hardFailTasks.length > 0) {
this.setSubscriptionsStatus(
"Some profile lists failed to sync. Use Retry sync.",
"warning",
);
this.hashtagController.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:
onlyPermissionFailures
? "NIP-07 decrypt permissions are needed to finish syncing your profile lists."
: loadedFromCacheTasks.length > 0
? "Some lists are currently from cache. Retry now to sync with relays."
: "Failed to sync required profile lists. Retry now.",
buttonLabel: onlyPermissionFailures
? "Enable permissions"
: "Retry list sync",
action: onlyPermissionFailures ? "permission" : "retry-auth-sync",
busy: false,
});
return;
}
if (listsState === "ready") {
this.setPermissionPromptCtaState({
visible: false,
message: "",
action: "permission",
busy: false,
});
}
}
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;
}
}
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;
}
}
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;
}
// Re-render the subscriptions pane only when the modal is open (status
// bookkeeping above stays unconditional); selectPane repopulates it on
// demand, so we don't render into a closed panel during the post-login
// subscription-list load.
if (this.isProfileModalOpen()) {
void this.populateSubscriptionsList();
}
this.refreshSubscriptionsBackgroundStatus();
}
populateBlockedList(...args) {
return this.blockListController.populateBlockedList(...args);
}
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.dmController.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
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.dmController.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
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 };
}
handleAddBlockedCreator(...args) {
return this.blockListController.handleAddBlockedCreator(...args);
}
handleRemoveBlockedCreator(...args) {
return this.blockListController.handleRemoveBlockedCreator(...args);
}
handleEditProfile(...args) {
return this.editController.handleEditProfile(...args);
}
populateEditPane(...args) {
return this.editController.populateEditPane(...args);
}
async handleUpload(...args) {
return this.editController.handleUpload(...args);
}
async handleSaveProfile(...args) {
return this.editController.handleSaveProfile(...args);
}
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,
);
}
}
// Legacy wrappers for backward compatibility and test stability
describeAdminError(...args) {
return this.adminController.describeAdminError(...args);
}
describeNotificationError(...args) {
return this.adminController.describeNotificationError(...args);
}
sendAdminListNotification(...args) {
return this.adminController.sendAdminListNotification(...args);
}
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;
}
}
runAdminMutation(...args) {
return this.adminController.runAdminMutation(...args);
}
notifyAdminError(...args) {
return this.adminController.notifyAdminError(...args);
}
refreshAdminPaneState(...args) {
return this.adminController.refreshAdminPaneState(...args);
}
refreshModerationSettingsUi(...args) {
return this.moderationController.refreshModerationSettingsUi(...args);
}
updateModerationTrustStats(...args) {
return this.moderationController.updateModerationTrustStats(...args);
}
handleAddModerator(...args) {
return this.adminController.handleAddModerator(...args);
}
handleRemoveModerator(...args) {
return this.adminController.handleRemoveModerator(...args);
}
handleAdminListMutation(...args) {
return this.adminController.handleAdminListMutation(...args);
}
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;
}
// Don't fight a modal stacked on top of the profile modal (e.g. the login
// modal's nsec passphrase/PIN field) — let it own focus so it stays typable.
if (isInStackedModal(event.target, modalRoot)) {
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";
}
}
/**
* Displays the profile modal, defaulting to the "account" pane.
* Triggers hydration of data for the active pane.
*
* @param {string} [targetPane="account"] - The pane to display initially.
* @returns {Promise<boolean>} Resolves true after the show animation starts.
*/
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.relayController.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();
}
});
}
/**
* Hides the profile modal and resets temporary state.
*
* @param {object} [options]
* @param {boolean} [options.silent=false] - If true, suppresses the `onClose` callback.
*/
hide(options = {}) {
const { silent = false } =
options && typeof options === "object" ? options : {};
this.dmController.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,
);
}
}
}
/**
* Handles a successful login event.
* Syncs the active profile, hydrates wallet/admin state, and refreshes all lists.
*
* @param {object} detail - Login details (pubkey, savedProfiles, etc.).
* @returns {Promise<boolean>}
*/
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);
}
// Reset identity-scoped UI state up front, each step isolated so one throwing
// can't skip the others. Regression this guards: switching from NIP-07 to a
// saved nsec (modal open) left the PREVIOUS account's DMs, subscriptions and
// hashtags on screen because a sibling call threw before the DM reset ran.
// DM identity setup also feeds the background unread indicator + DM
// subscription, so it stays eager.
this.runIdentityResetStep("dm-identity", () =>
this.handleActiveDmIdentityChanged(activePubkey),
);
this.runIdentityResetStep("saved-profiles", () => this.renderSavedProfiles());
const walletPromise = this.hydrateActiveWalletSettings(activePubkey);
const adminPromise = this.refreshAdminPaneState().catch((error) => {
userLogger.warn("Failed to refresh admin pane after login:", error);
});
// Warm blocks + subscriptions, but go through ensureLoaded() (not the raw
// loadBlocks/loadSubscriptions) so these COALESCE with the auth coordinator's
// login-time list loads instead of issuing a second, ungated fetch. The
// direct calls here each kicked off an independent relay round-trip,
// doubling the cold-login kind-30000/10000 REQ burst (KNOWN_BUGS #0).
if (activePubkey) {
this.runIdentityResetStep("warm-lists", () => {
if (this.services.userBlocks?.ensureLoaded) {
this.services.userBlocks.ensureLoaded(activePubkey).catch(noop);
} else if (this.services.userBlocks?.loadBlocks) {
this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
}
if (this.subscriptionsService?.ensureLoaded) {
this.subscriptionsService
.ensureLoaded(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
} else if (this.subscriptionsService?.loadSubscriptions) {
this.subscriptionsService
.loadSubscriptions(activePubkey, { allowPermissionPrompt: false })
.catch(noop);
}
});
}
// Profile-PANEL rendering is deferred: each pane lazily populates when it's
// shown (selectPane), so eagerly rendering all of them at login paints into
// CLOSED panels — friends-list avatars, subscriptions, blocks, storage,
// relays — and jams the main thread right after login (the ~10-15s "profile
// panel won't open / site frozen" report). Only refresh when the modal is
// already open (e.g. switching accounts with it visible); otherwise
// open()/selectPane handles it on demand. On a switch we re-run the ACTIVE
// pane's own load+populate (refreshOpenPanesForActiveIdentity) so the pane the
// user is viewing reloads for the new identity, then again once profile/wallet
// data settles (list panes also backfill via the auth coordinator once the new
// identity's lists finish loading).
const settleLoginData = Promise.all([
walletPromise,
adminPromise,
postLoginPromise,
]);
if (this.isProfileModalOpen()) {
this.refreshOpenPanesForActiveIdentity();
settleLoginData
.then(() => {
this.refreshOpenPanesForActiveIdentity();
})
.catch((error) => {
userLogger.warn(
"[profileModal] Failed to hydrate deferred login data:",
error,
);
});
} else {
// Keep the collaborators' promises observed even when we skip rendering.
settleLoginData.catch((error) => {
userLogger.warn(
"[profileModal] Failed to settle login data:",
error,
);
});
}
return true;
}
/**
* Handles a logout event.
* Clears personal data (DMs, friends, subscriptions) from the UI.
*
* @param {object} detail - Logout details.
* @returns {Promise<boolean>}
*/
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.relayController.populateProfileRelays();
this.walletController.refreshWalletPaneState();
this.hashtagController.populateHashtagPreferences();
void this.storageController.populateStoragePane();
this.hashtagController.clearHashtagInputs();
this.hashtagController.setHashtagStatus("", "muted");
this.handleActiveDmIdentityChanged(null);
this.setMessagesUnreadIndicator(false);
this.dmController.populateDmRelayPreferences();
this.dmController.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;
}
}
/**
* Switches the active session to another saved profile.
*
* @param {string} pubkey - The public key of the target profile.
* @param {object} [options]
* @param {object} [options.entry] - The saved profile entry object.
* @param {string} [options.providerId] - The auth provider ID to use.
* @returns {Promise<{switched: boolean, error?: Error}>}
*/
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);
}
populateProfileRelays(...args) {
return this.relayController.populateProfileRelays(...args);
}
handleDirectMessagesRelayWarning(...args) {
return this.dmController.handleDirectMessagesRelayWarning(...args);
}
// Moderation Controller Delegates & Aliases
get moderationBlurInput() { return this.moderationController.moderationBlurInput; }
get moderationAutoplayInput() { return this.moderationController.moderationAutoplayInput; }
get moderationMuteHideInput() { return this.moderationController.moderationMuteHideInput; }
get moderationSpamHideInput() { return this.moderationController.moderationSpamHideInput; }
get moderationSaveButton() { return this.moderationController.moderationSaveButton; }
get moderationResetButton() { return this.moderationController.moderationResetButton; }
get moderationStatusText() { return this.moderationController.moderationStatusText; }
get moderationOverridesList() { return this.moderationController.moderationOverridesList; }
get moderationOverridesEmpty() { return this.moderationController.moderationOverridesEmpty; }
get moderationTrustedContactsCount() { return this.moderationController.moderationTrustedContactsCount; }
get moderationTrustedMuteCount() { return this.moderationController.moderationTrustedMuteCount; }
get moderationTrustedReportCount() { return this.moderationController.moderationTrustedReportCount; }
get moderationSeedOnlyIndicator() { return this.moderationController.moderationSeedOnlyIndicator; }
get moderationHideControlsGroup() { return this.moderationController.moderationHideControlsGroup; }
get moderationHideControlElements() { return this.moderationController.moderationHideControlElements; }
handleModerationSettingsSave(...args) { return this.moderationController.handleModerationSettingsSave(...args); }
handleModerationSettingsReset(...args) { return this.moderationController.handleModerationSettingsReset(...args); }
getModerationSettingsDefaults(...args) { return this.moderationController.getModerationSettingsDefaults(...args); }
get relayList() {
return this.relayController.relayList;
}
get relayInput() {
return this.relayController.relayInput;
}
get addRelayButton() {
return this.relayController.addRelayButton;
}
get restoreRelaysButton() {
return this.relayController.restoreRelaysButton;
}
get relayHealthStatus() {
return this.relayController.relayHealthStatus;
}
get relayHealthTelemetryToggle() {
return this.relayController.relayHealthTelemetryToggle;
}
get profileRelayRefreshBtn() {
return this.relayController.profileRelayRefreshBtn;
}
// Hashtag Controller Delegates
get hashtagStatusText() { return this.hashtagController.hashtagStatusText; }
get hashtagBackgroundLoading() { return this.hashtagController.hashtagBackgroundLoading; }
set hashtagBackgroundLoading(val) { this.hashtagController.hashtagBackgroundLoading = val; }
get hashtagInterestList() { return this.hashtagController.hashtagInterestList; }
get hashtagInterestEmpty() { return this.hashtagController.hashtagInterestEmpty; }
get hashtagInterestInput() { return this.hashtagController.hashtagInterestInput; }
get addHashtagInterestButton() { return this.hashtagController.addHashtagInterestButton; }
get profileHashtagInterestRefreshBtn() { return this.hashtagController.profileHashtagInterestRefreshBtn; }
get hashtagDisinterestList() { return this.hashtagController.hashtagDisinterestList; }
get hashtagDisinterestEmpty() { return this.hashtagController.hashtagDisinterestEmpty; }
get hashtagDisinterestInput() { return this.hashtagController.hashtagDisinterestInput; }
get addHashtagDisinterestButton() { return this.hashtagController.addHashtagDisinterestButton; }
get profileHashtagDisinterestRefreshBtn() { return this.hashtagController.profileHashtagDisinterestRefreshBtn; }
populateHashtagPreferences(...args) { return this.hashtagController.populateHashtagPreferences(...args); }
handleAddHashtagPreference(...args) { return this.hashtagController.handleAddHashtagPreference(...args); }
handleRemoveHashtagPreference(...args) { return this.hashtagController.handleRemoveHashtagPreference(...args); }
handleHashtagPreferencesChange(...args) { return this.hashtagController.handleHashtagPreferencesChange(...args); }
normalizeHashtagTag(...args) { return this.hashtagController.normalizeHashtagTag(...args); }
formatHashtagTag(...args) { return this.hashtagController.formatHashtagTag(...args); }
sanitizeHashtagList(...args) { return this.hashtagController.sanitizeHashtagList(...args); }
getResolvedHashtagPreferences(...args) { return this.hashtagController.getResolvedHashtagPreferences(...args); }
setHashtagStatus(...args) { return this.hashtagController.setHashtagStatus(...args); }
refreshHashtagBackgroundStatus(...args) { return this.hashtagController.refreshHashtagBackgroundStatus(...args); }
clearHashtagInputs(...args) { return this.hashtagController.clearHashtagInputs(...args); }
describeHashtagPreferencesError(...args) { return this.hashtagController.describeHashtagPreferencesError(...args); }
}
export default ProfileModalController;