Files
bitvid/js/ui/profileModalController.js
T
google-labs-jules[bot]andPR0M3TH3AN fd4318c2f9 fix(core): optimize moderation subscriptions and reduce log noise (agent)
- Refactor `ModerationService` to use batched subscriptions for trusted mute lists, reducing relay load.
- Fix `WebTorrent` memory leak by setting `setMaxListeners(0)` on client instances.
- Suppress "Failed to connect to relay" warnings in `countDiagnostics.js`.
- Degrade benign DM sync errors (e.g. "no-decryptors") to info/debug logs in `profileModalController.js`.

Co-authored-by: PR0M3TH3AN <[email protected]>
2026-01-26 23:40:59 +00:00

13058 lines
398 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,
} from "../config.js";
import { normalizeDesignSystemContext } from "../designSystem.js";
import {
DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
DEFAULT_BLUR_THRESHOLD,
DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
RUNTIME_FLAGS,
} from "../constants.js";
import { getBreakpointLg } from "../designSystem/metrics.js";
import { getProviderMetadata } from "../services/authProviders/index.js";
import { AppShell } from "./dm/index.js";
import { devLogger, userLogger } from "../utils/logger.js";
import {
normalizeHashtag,
formatHashtag,
} from "../utils/hashtagNormalization.js";
import { formatTimeAgo } from "../utils/formatters.js";
import { getActiveSigner } from "../nostr/client.js";
import { sanitizeRelayList } from "../nostr/nip46Client.js";
import { buildPublicUrl, buildR2Key } from "../r2.js";
import { buildProfileMetadataEvent } from "../nostrEventSchemas.js";
import {
describeAttachment,
extractAttachmentsFromMessage,
formatAttachmentSize,
} from "../attachments/attachmentUtils.js";
import { getCorsOrigins, prepareS3Connection } from "../services/s3Service.js";
import {
clearAttachmentCache,
downloadAttachment,
uploadAttachment,
getAttachmentCacheStats,
} from "../services/attachmentService.js";
import { PROVIDERS } from "../services/storageService.js";
import {
getLinkPreviewSettings,
setLinkPreviewAutoFetch,
} from "../utils/linkPreviewSettings.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 NWC_URI_SCHEME = "nostr+walletconnect://";
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 ADD_PROFILE_CANCELLATION_CODES = new Set([
"login-cancelled",
"user-cancelled",
"modal-dismissed",
]);
const PROVIDER_BADGE_BASE_CLASS =
"text-3xs font-semibold uppercase tracking-extra-wide";
const PROVIDER_BADGE_VARIANT_CLASS_MAP = Object.freeze({
info: "text-status-info",
success: "text-status-success",
warning: "text-status-warning",
danger: "text-status-danger",
neutral: "text-muted",
accent: "text-accent",
primary: "text-accent",
});
function resolveProviderBadgeClass(variant) {
if (typeof variant === "string" && variant.trim()) {
const normalized = variant.trim().toLowerCase();
if (Object.prototype.hasOwnProperty.call(PROVIDER_BADGE_VARIANT_CLASS_MAP, normalized)) {
return PROVIDER_BADGE_VARIANT_CLASS_MAP[normalized];
}
}
return PROVIDER_BADGE_VARIANT_CLASS_MAP.neutral;
}
const DEFAULT_INTERNAL_NWC_SETTINGS = Object.freeze({
nwcUri: "",
defaultZap: null,
lastChecked: null,
});
function createInternalDefaultNwcSettings() {
return { ...DEFAULT_INTERNAL_NWC_SETTINGS };
}
const DEFAULT_INTERNAL_MODERATION_SETTINGS = Object.freeze({
blurThreshold: DEFAULT_BLUR_THRESHOLD,
autoplayBlockThreshold: DEFAULT_AUTOPLAY_BLOCK_THRESHOLD,
trustedMuteHideThreshold: DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD,
trustedSpamHideThreshold: DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD,
});
function createInternalDefaultModerationSettings() {
return { ...DEFAULT_INTERNAL_MODERATION_SETTINGS };
}
const DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS = Object.freeze({
readReceiptsEnabled: false,
typingIndicatorsEnabled: false,
});
function createInternalDefaultDmPrivacySettings() {
return { ...DEFAULT_INTERNAL_DM_PRIVACY_SETTINGS };
}
function ensureInternalModerationSettings(internalState) {
if (!internalState || typeof internalState !== "object") {
return createInternalDefaultModerationSettings();
}
if (
!internalState.moderationSettings ||
typeof internalState.moderationSettings !== "object" ||
internalState.moderationSettings === null
) {
internalState.moderationSettings = createInternalDefaultModerationSettings();
}
return internalState.moderationSettings;
}
function ensureInternalWalletSettings(internalState) {
if (!internalState || typeof internalState !== "object") {
return createInternalDefaultNwcSettings();
}
if (
!internalState.walletSettings ||
typeof internalState.walletSettings !== "object" ||
internalState.walletSettings === null
) {
internalState.walletSettings = createInternalDefaultNwcSettings();
}
return internalState.walletSettings;
}
const SERVICE_CONTRACT = [
{
key: "normalizeHexPubkey",
type: "function",
description:
"Normalizes a provided pubkey (hex or npub) so profile lookups are stable.",
fallback: () => (value) => {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return trimmed.toLowerCase();
}
return trimmed;
},
},
{
key: "safeEncodeNpub",
type: "function",
description:
"Encodes a hex pubkey as an npub for display. Expected to be resilient to bad input.",
fallback: () => (pubkey) => {
if (typeof pubkey === "string" && pubkey.trim()) {
return pubkey.trim();
}
return null;
},
},
{
key: "safeDecodeNpub",
type: "function",
description:
"Decodes an npub back to its hex form. Should return null/undefined for invalid payloads.",
fallback: () => () => null,
},
{
key: "truncateMiddle",
type: "function",
description:
"Utility used for shortening long identifiers in the modal without losing context.",
fallback: () => (value) => value,
},
{
key: "formatShortNpub",
type: "function",
description:
"Formats npub strings for display using the canonical short representation (npubXXXX…XXXX).",
fallback: () => (value) => (typeof value === "string" ? value : ""),
},
{
key: "requestAddProfileLogin",
type: "function",
description:
"Opens the login modal and resolves with the authentication result for the add-profile flow.",
fallback: () => async () => {
throw new Error("Login service unavailable.");
},
},
{
key: "describeLoginError",
type: "function",
description:
"Maps authentication errors to human-readable strings for add-profile messaging.",
fallback: () => (_, fallbackMessage) =>
typeof fallbackMessage === "string" && fallbackMessage.trim()
? fallbackMessage.trim()
: "Failed to login. Please try again.",
},
{
key: "r2Service",
type: "object",
description: "Service for handling file uploads (R2/S3).",
optional: true,
},
{
key: "nostrService",
type: "object",
description:
"Provides encrypted direct message helpers for loading and subscribing to inbox updates.",
optional: true,
fallback: () => ({
getDirectMessages: () => [],
loadDirectMessages: async () => [],
ensureDirectMessageSubscription: () => null,
stopDirectMessageSubscription: () => {},
clearDirectMessages: () => {},
on: () => () => {},
}),
},
{
key: "hashtagPreferences",
type: "object",
description:
"Service providing access to interest and disinterest hashtag collections.",
fallback: () => ({
getInterests: () => [],
getDisinterests: () => [],
addInterest: () => false,
removeInterest: () => false,
addDisinterest: () => false,
removeDisinterest: () => false,
publish: async () => false,
on: () => () => {},
}),
},
{
key: "getHashtagPreferences",
type: "function",
description:
"Returns the cached hashtag preferences snapshot (interests, disinterests, metadata).",
fallback: ({ resolved }) => () => {
const service = resolved.hashtagPreferences || {};
const interests =
typeof service.getInterests === "function"
? service.getInterests()
: [];
const disinterests =
typeof service.getDisinterests === "function"
? service.getDisinterests()
: [];
return {
interests,
disinterests,
eventId: null,
createdAt: null,
loaded: false,
};
},
},
{
key: "describeHashtagPreferencesError",
type: "function",
description:
"Maps hashtag preference errors to user-facing status messages for the modal.",
fallback: () => (_, fallbackMessage) =>
typeof fallbackMessage === "string" && fallbackMessage.trim()
? fallbackMessage.trim()
: "Failed to update hashtag preferences. Please try again.",
},
{
key: "getProfileCacheEntry",
type: "function",
description:
"Returns the cached profile metadata for a normalized pubkey (if available).",
fallback: () => () => null,
},
{
key: "batchFetchProfiles",
type: "function",
description:
"Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.",
fallback: () => async () => [],
},
{
key: "fetchDmRelayHints",
type: "function",
description:
"Fetches DM relay hints (kind 10050) for a given pubkey.",
fallback: () => async () => [],
},
{
key: "switchProfile",
type: "function",
description:
"Switches the active application profile to the provided pubkey and updates state accordingly.",
fallback: () => async () => ({ switched: false }),
},
{
key: "removeSavedProfile",
type: "function",
description:
"Removes a saved profile entry and persists the updated collection to storage.",
fallback: () => () => ({ removed: false }),
},
{
key: "relayManager",
type: "object",
description:
"Shared relay manager instance responsible for the user\'s relay configuration.",
},
{
key: "userBlocks",
type: "object",
description: "User blocks helper used to read and mutate the local block list.",
},
{
key: "nostrClient",
type: "object",
description: "Reference to the nostr client powering subscriptions and profile fetches.",
},
{
key: "subscriptions",
type: "object",
optional: true,
description:
"Subscriptions manager used by the profile modal to inspect or refresh the viewer's channel subscriptions.",
},
{
key: "moderation",
type: "object",
optional: true,
description:
"Moderation service exposing the viewer's Nostr contacts and trust graph metadata.",
},
{
key: "accessControl",
type: "object",
description:
"Access control module required for admin list permission checks and updates.",
},
{
key: "getCurrentUserNpub",
type: "function",
description: "Returns the active user\'s npub so UI actions can target the correct actor.",
fallback: () => () => null,
},
{
key: "nwcSettings",
type: "object",
description:
"Service that manages Nostr Wallet Connect settings and interactions for the active profile.",
fallback: ({ internalState }) => {
const ensure = () => ensureInternalWalletSettings(internalState);
const service = {};
service.createDefaultNwcSettings = () => createInternalDefaultNwcSettings();
service.getActiveNwcSettings = () => {
const current = ensure();
return { ...current };
};
service.updateActiveNwcSettings = (partial = {}) => {
const current = ensure();
const next = {
...current,
...(partial && typeof partial === "object" ? partial : {}),
};
internalState.walletSettings = next;
return { ...next };
};
service.hydrateNwcSettingsForPubkey = async () => {
return service.getActiveNwcSettings();
};
service.handleProfileWalletPersist = async (options = {}) => {
const { nwcUri, defaultZap, lastChecked } =
options && typeof options === "object" ? options : {};
const partial = {};
if (nwcUri !== undefined) {
partial.nwcUri = nwcUri;
}
if (defaultZap !== undefined) {
partial.defaultZap = defaultZap;
}
if (lastChecked !== undefined) {
partial.lastChecked = lastChecked;
}
if (!Object.keys(partial).length) {
return service.getActiveNwcSettings();
}
return service.updateActiveNwcSettings(partial);
};
service.hasActiveWalletConnection = () => {
const settings = ensure();
const candidate =
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
return candidate.length > 0;
};
service.validateWalletUri = (uri, { requireValue = false } = {}) => {
const value = typeof uri === "string" ? uri.trim() : "";
if (!value) {
if (requireValue) {
return {
valid: false,
sanitized: "",
message: "Enter a wallet connect URI before continuing.",
};
}
return { valid: true, sanitized: "" };
}
if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) {
return {
valid: false,
sanitized: value,
message: `Wallet URI must start with ${NWC_URI_SCHEME}.`,
};
}
return { valid: true, sanitized: value };
};
service.ensureWallet = async ({ nwcUri } = {}) => {
const settings = service.getActiveNwcSettings();
const candidate =
typeof nwcUri === "string" && nwcUri.trim()
? nwcUri.trim()
: settings.nwcUri || "";
const { valid, sanitized, message } = service.validateWalletUri(
candidate,
{ requireValue: true },
);
if (!valid) {
throw new Error(message || "Invalid wallet URI provided.");
}
return { ...settings, nwcUri: sanitized };
};
service.clearStoredNwcSettings = async () => true;
service.onLogin = async () => service.getActiveNwcSettings();
service.onLogout = async () => service.createDefaultNwcSettings();
service.onIdentityChanged = () => {
internalState.walletSettings = createInternalDefaultNwcSettings();
};
return service;
},
},
{
key: "moderationSettings",
type: "object",
description:
"Service that manages Safety & Moderation threshold overrides for the active profile.",
fallback: ({ internalState }) => {
const ensure = () => ensureInternalModerationSettings(internalState);
const sanitize = (value, fallback) => {
if (value === null || value === undefined) {
return fallback;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return fallback;
}
return Math.max(0, Math.floor(numeric));
};
const service = {};
service.getDefaultModerationSettings = () =>
createInternalDefaultModerationSettings();
service.getActiveModerationSettings = () => {
const current = ensure();
const defaults = service.getDefaultModerationSettings();
return {
blurThreshold: sanitize(current.blurThreshold, defaults.blurThreshold),
autoplayBlockThreshold: sanitize(
current.autoplayBlockThreshold,
defaults.autoplayBlockThreshold,
),
};
};
service.updateModerationSettings = (partial = {}) => {
const defaults = service.getDefaultModerationSettings();
const current = ensure();
const next = { ...current };
if (Object.prototype.hasOwnProperty.call(partial, "blurThreshold")) {
const value = partial.blurThreshold;
next.blurThreshold =
value === null
? defaults.blurThreshold
: sanitize(value, defaults.blurThreshold);
}
if (
Object.prototype.hasOwnProperty.call(
partial,
"autoplayBlockThreshold",
)
) {
const value = partial.autoplayBlockThreshold;
next.autoplayBlockThreshold =
value === null
? defaults.autoplayBlockThreshold
: sanitize(value, defaults.autoplayBlockThreshold);
}
internalState.moderationSettings = next;
return service.getActiveModerationSettings();
};
service.resetModerationSettings = () => {
internalState.moderationSettings = createInternalDefaultModerationSettings();
return service.getActiveModerationSettings();
};
return service;
},
},
{
key: "getModerationOverrides",
type: "function",
description:
"Returns a list of stored moderation overrides for the active viewer.",
fallback: () => () => [],
},
{
key: "clearModerationOverride",
type: "function",
description:
"Clears a specific moderation override when the viewer resets a decision.",
fallback: () => () => false,
},
{
key: "loadVideos",
type: "function",
description: "Triggers a video reload so UI reflects profile or permission changes.",
fallback: () => async () => undefined,
},
{
key: "onVideosShouldRefresh",
type: "function",
description:
"Signals that video listings should be refreshed after a profile-driven mutation.",
fallback: ({ services, resolved }) => async (context = {}) => {
const loader =
typeof services.loadVideos === "function"
? services.loadVideos
: typeof resolved.loadVideos === "function"
? resolved.loadVideos
: null;
if (loader) {
return loader(true, context);
}
return undefined;
},
},
{
key: "sendAdminListNotification",
type: "function",
description:
"Sends an administrative notification when moderators/whitelist/blacklist entries change.",
optional: true,
},
{
key: "describeAdminError",
type: "function",
description:
"Maps low-level admin errors to readable copy for surfaced error messages.",
fallback: () => () => "",
},
{
key: "describeNotificationError",
type: "function",
description:
"Maps notification send errors to human readable descriptions for toast/banner messaging.",
fallback: () => () => "",
},
{
key: "onAccessControlUpdated",
type: "function",
description:
"Callback invoked after access control mutations so the app can refresh dependent UI.",
fallback: () => async () => undefined,
},
{
key: "persistSavedProfiles",
type: "function",
description:
"Persists the saved profile collection (and optionally the active profile) to storage.",
fallback: () => () => undefined,
},
];
const STATE_CONTRACT = [
{
key: "getSavedProfiles",
type: "function",
description:
"Returns the saved profile entries that populate the account switcher UI.",
fallback: (internal) => () => internal.savedProfiles.slice(),
},
{
key: "setSavedProfiles",
type: "function",
description:
"Replaces the saved profile entries and returns the newly stored collection.",
fallback: (internal) => (profiles = []) => {
internal.savedProfiles = Array.isArray(profiles)
? profiles.slice()
: [];
return internal.savedProfiles;
},
},
{
key: "persistSavedProfiles",
type: "function",
description:
"Persists the saved profile collection (and optionally the active profile) to storage.",
fallback: () => noop,
},
{
key: "getActivePubkey",
type: "function",
description: "Returns the currently active profile pubkey.",
fallback: (internal) => () => internal.activePubkey,
},
{
key: "setActivePubkey",
type: "function",
description: "Updates the active profile pubkey and returns the stored value.",
fallback: (internal) => (pubkey) => {
if (typeof pubkey === "string") {
const trimmed = pubkey.trim();
internal.activePubkey = trimmed ? trimmed : null;
} else {
internal.activePubkey = null;
}
return internal.activePubkey;
},
},
{
key: "getCachedSelection",
type: "function",
description:
"Reads the cached profile selection used to restore switcher focus after reloads.",
fallback: (internal) => () => internal.cachedSelection,
},
{
key: "setCachedSelection",
type: "function",
description:
"Caches the last selected profile identifier and returns the stored value.",
fallback: (internal) => (value) => {
if (typeof value === "string") {
const trimmed = value.trim();
internal.cachedSelection = trimmed || null;
} else {
internal.cachedSelection = null;
}
return internal.cachedSelection;
},
},
{
key: "getActivePane",
type: "function",
description: "Returns the identifier for the currently visible profile modal pane.",
fallback: (internal) => () => internal.activePane,
},
{
key: "setActivePane",
type: "function",
description: "Updates the active pane identifier and returns the stored value.",
fallback: (internal) => (pane) => {
if (typeof pane === "string") {
const trimmed = pane.trim().toLowerCase();
internal.activePane = trimmed || "account";
} else {
internal.activePane = "account";
}
return internal.activePane;
},
},
{
key: "getWalletBusy",
type: "function",
description:
"Indicates whether wallet-related actions are currently in-flight (disables UI as needed).",
fallback: (internal) => () => internal.walletBusy,
},
{
key: "setWalletBusy",
type: "function",
description: "Updates the wallet busy flag and returns the new boolean state.",
fallback: (internal) => (isBusy) => {
internal.walletBusy = Boolean(isBusy);
return internal.walletBusy;
},
},
{
key: "getDmRecipient",
type: "function",
description:
"Returns the currently selected direct message recipient pubkey.",
fallback: (internal) => () => internal.dmRecipient,
},
{
key: "setDmRecipient",
type: "function",
description:
"Updates the selected direct message recipient pubkey and returns the stored value.",
fallback: (internal) => (pubkey) => {
if (typeof pubkey === "string") {
const trimmed = pubkey.trim();
internal.dmRecipient = trimmed || null;
} else {
internal.dmRecipient = null;
}
return internal.dmRecipient;
},
},
{
key: "getDmRelayHints",
type: "function",
description:
"Returns the cached DM relay hints for a given pubkey.",
fallback: (internal) => (pubkey) => {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!normalized || !(internal.dmRelayHints instanceof Map)) {
return [];
}
const hints = internal.dmRelayHints.get(normalized);
return Array.isArray(hints) ? hints.slice() : [];
},
},
{
key: "setDmRelayHints",
type: "function",
description:
"Stores DM relay hints for a given pubkey and returns the stored list.",
fallback: (internal) => (pubkey, hints = []) => {
if (!(internal.dmRelayHints instanceof Map)) {
internal.dmRelayHints = new Map();
}
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!normalized) {
return [];
}
const stored = Array.isArray(hints) ? hints.slice() : [];
internal.dmRelayHints.set(normalized, stored);
return stored;
},
},
{
key: "getDmRelayPreferences",
type: "function",
description:
"Returns the cached DM relay hints for a given pubkey (publisher view).",
fallback: (internal) => (pubkey) => {
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!normalized || !(internal.dmRelayHints instanceof Map)) {
return [];
}
const hints = internal.dmRelayHints.get(normalized);
return Array.isArray(hints) ? hints.slice() : [];
},
},
{
key: "setDmRelayPreferences",
type: "function",
description:
"Stores DM relay hints for a given pubkey (publisher view).",
fallback: (internal) => (pubkey, hints = []) => {
if (!(internal.dmRelayHints instanceof Map)) {
internal.dmRelayHints = new Map();
}
const normalized =
typeof pubkey === "string" ? pubkey.trim().toLowerCase() : "";
if (!normalized) {
return [];
}
const stored = Array.isArray(hints) ? hints.slice() : [];
internal.dmRelayHints.set(normalized, stored);
return stored;
},
},
{
key: "getDmPrivacySettings",
type: "function",
description:
"Returns the DM privacy settings (read receipts and typing indicators) for the active profile.",
fallback: (internal) => () => {
if (!(internal.dmPrivacySettings instanceof Map)) {
internal.dmPrivacySettings = new Map();
}
const settings = internal.dmPrivacySettings.get("default");
return settings ? { ...settings } : createInternalDefaultDmPrivacySettings();
},
},
{
key: "setDmPrivacySettings",
type: "function",
description:
"Updates the DM privacy settings (read receipts and typing indicators) for the active profile.",
fallback: (internal) => (settings = {}) => {
if (!(internal.dmPrivacySettings instanceof Map)) {
internal.dmPrivacySettings = new Map();
}
const base = createInternalDefaultDmPrivacySettings();
const normalized = {
readReceiptsEnabled:
typeof settings.readReceiptsEnabled === "boolean"
? settings.readReceiptsEnabled
: base.readReceiptsEnabled,
typingIndicatorsEnabled:
typeof settings.typingIndicatorsEnabled === "boolean"
? settings.typingIndicatorsEnabled
: base.typingIndicatorsEnabled,
};
internal.dmPrivacySettings.set("default", normalized);
return { ...normalized };
},
},
];
function buildServicesContract(services = {}, internalState) {
const resolved = {};
const missing = [];
SERVICE_CONTRACT.forEach((definition) => {
const provided = services[definition.key];
if (provided == null) {
if (typeof definition.fallback === "function") {
const fallbackValue = definition.fallback({
services,
resolved,
internalState,
definition,
});
if (definition.type === "function" && typeof fallbackValue === "function") {
resolved[definition.key] = fallbackValue;
return;
}
if (
definition.type === "object" &&
typeof fallbackValue === "object" &&
fallbackValue !== null
) {
resolved[definition.key] = fallbackValue;
return;
}
}
if (definition.optional) {
return;
}
missing.push(`- ${definition.key}: ${definition.description}`);
return;
}
if (definition.type === "function" && typeof provided !== "function") {
throw new TypeError(
`Expected service \"${definition.key}\" to be a function. Received ${typeof provided}.`,
);
}
if (
definition.type === "object" &&
(typeof provided !== "object" || provided === null)
) {
throw new TypeError(
`Expected service \"${definition.key}\" to be an object. Received ${typeof provided}.`,
);
}
resolved[definition.key] = provided;
});
if (missing.length) {
throw new Error(
[
"[ProfileModalController] Missing required services for profile modal controller:",
...missing,
].join("\n"),
);
}
return Object.freeze({ ...services, ...resolved });
}
function buildStateContract(state = {}, internalState) {
const resolved = {};
STATE_CONTRACT.forEach((definition) => {
const value = state[definition.key];
if (typeof value === definition.type) {
resolved[definition.key] = value;
return;
}
if (definition.fallback) {
const fallback = definition.fallback(internalState);
if (typeof fallback === definition.type) {
resolved[definition.key] = fallback;
return;
}
}
throw new Error(
`[ProfileModalController] Missing state handler \"${definition.key}\" (${definition.description}).`,
);
});
return { ...state, ...resolved };
}
export class ProfileModalController {
constructor(options = {}) {
const {
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;
})();
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.dmMobileView = "list";
this.services = buildServicesContract(services, this.internalState);
this.state = buildStateContract(state, this.internalState);
this.normalizeHexPubkey = this.services.normalizeHexPubkey;
this.safeEncodeNpub = this.services.safeEncodeNpub;
this.safeDecodeNpub = this.services.safeDecodeNpub;
this.truncateMiddle = this.services.truncateMiddle;
this.formatShortNpub = this.services.formatShortNpub;
this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
this.currentModerationSettings = createInternalDefaultModerationSettings();
this.sendAdminListNotificationService =
typeof this.services.sendAdminListNotification === "function"
? this.services.sendAdminListNotification
: null;
this.describeAdminErrorService = this.services.describeAdminError;
this.describeNotificationErrorService =
this.services.describeNotificationError;
this.describeLoginErrorService = this.services.describeLoginError;
this.requestAddProfileLoginService = this.services.requestAddProfileLogin;
this.log =
typeof this.services.log === "function"
? this.services.log
: (...args) => {
devLogger.log(...args);
};
this.nostrService =
this.services.nostrService &&
typeof this.services.nostrService === "object"
? this.services.nostrService
: null;
const subscriptionsServiceCandidate = this.services.subscriptions;
this.subscriptionsService =
subscriptionsServiceCandidate &&
typeof subscriptionsServiceCandidate === "object"
? subscriptionsServiceCandidate
: null;
const moderationServiceCandidate = this.services.moderation;
this.moderationService =
moderationServiceCandidate &&
typeof moderationServiceCandidate === "object"
? moderationServiceCandidate
: null;
this.unsubscribeModerationContacts = null;
this.unsubscribeModerationStats = [];
if (
this.moderationService &&
typeof this.moderationService.on === "function"
) {
try {
const updateTrustStats = () => {
this.updateModerationTrustStats();
};
this.unsubscribeModerationContacts = this.moderationService.on(
"contacts",
() => {
void this.populateFriendsList();
updateTrustStats();
},
);
this.unsubscribeModerationStats = [
this.moderationService.on("trusted-mutes", updateTrustStats),
this.moderationService.on("summary", updateTrustStats),
].filter((unsubscribe) => typeof unsubscribe === "function");
} catch (error) {
devLogger.warn(
"[profileModal] Failed to subscribe to moderation contacts updates:",
error,
);
}
}
this.hashtagPreferencesService = this.services.hashtagPreferences;
this.describeHashtagPreferencesErrorService =
this.services.describeHashtagPreferencesError;
this.getHashtagPreferencesSnapshotService =
this.services.getHashtagPreferences;
this.hashtagPreferencesPublishInFlight = false;
this.hashtagPreferencesPublishPromise = null;
if (
this.hashtagPreferencesService &&
typeof this.hashtagPreferencesService.on === "function"
) {
this.hashtagPreferencesUnsubscribe = this.hashtagPreferencesService.on(
"change",
(detail) => {
this.handleHashtagPreferencesChange({
action:
typeof detail?.action === "string" ? detail.action : "change",
preferences: detail,
});
},
);
}
this.initializeDirectMessagesService();
this.callbacks = {
onClose: callbacks.onClose || noop,
onLogout: callbacks.onLogout || noop,
onChannelLink: callbacks.onChannelLink || noop,
onAddAccount: callbacks.onAddAccount || noop,
onRequestLogoutProfile: callbacks.onRequestLogoutProfile || noop,
onSelectPane: callbacks.onSelectPane || noop,
onPaneShown: callbacks.onPaneShown || noop,
onAddRelay: callbacks.onAddRelay || noop,
onRestoreRelays: callbacks.onRestoreRelays || noop,
onAddBlocked: callbacks.onAddBlocked || noop,
onWalletSave: callbacks.onWalletSave || noop,
onWalletTest: callbacks.onWalletTest || noop,
onWalletDisconnect: callbacks.onWalletDisconnect || noop,
onAdminAddModerator: callbacks.onAdminAddModerator || noop,
onAdminAddWhitelist: callbacks.onAdminAddWhitelist || noop,
onAdminAddBlacklist: callbacks.onAdminAddBlacklist || noop,
onAdminRemoveModerator: callbacks.onAdminRemoveModerator || noop,
onAdminRemoveWhitelist: callbacks.onAdminRemoveWhitelist || noop,
onAdminRemoveBlacklist: callbacks.onAdminRemoveBlacklist || noop,
onHistoryReady: callbacks.onHistoryReady || noop,
onRequestSwitchProfile: callbacks.onRequestSwitchProfile || noop,
onRelayOperation: callbacks.onRelayOperation || noop,
onRelayModeToggle: callbacks.onRelayModeToggle || noop,
onRelayRestore: callbacks.onRelayRestore || noop,
onBlocklistMutation: callbacks.onBlocklistMutation || noop,
onWalletPersist: callbacks.onWalletPersist || noop,
onWalletTestRequest: callbacks.onWalletTestRequest || callbacks.onWalletTest || noop,
onWalletDisconnectRequest:
callbacks.onWalletDisconnectRequest || callbacks.onWalletDisconnect || noop,
onAdminMutation: callbacks.onAdminMutation || noop,
onAdminNotifyError: callbacks.onAdminNotifyError || noop,
onModerationSettingsChange:
callbacks.onModerationSettingsChange || noop,
onSendDm: callbacks.onSendDm || noop,
onTogglePrivacy: callbacks.onTogglePrivacy || noop,
onOpenRelays: callbacks.onOpenRelays || noop,
onPublishDmRelayPreferences: callbacks.onPublishDmRelayPreferences || noop,
};
this.profileModal = null;
this.profileModalRoot = null;
this.profileModalPanel = null;
this.profileModalBackdrop = null;
this.profileModalLayout = null;
this.profileModalMenu = null;
this.profileModalPaneWrapper = null;
this.profileModalBackButton = null;
this.profileAvatar = null;
this.profileName = null;
this.profileNpub = null;
this.switcherList = null;
this.profileModalAvatar = null;
this.profileModalName = null;
this.profileModalNpub = null;
this.profileSwitcherList = null;
this.globalProfileAvatar = null;
this.closeButton = null;
this.logoutButton = null;
this.mobileLogoutButton = null;
this.channelLink = null;
this.addAccountButton = null;
this.navButtons = {
account: null,
relays: null,
wallet: null,
storage: null,
hashtags: null,
subscriptions: null,
friends: null,
blocked: null,
messages: null,
history: null,
admin: null,
safety: null,
};
this.panes = {
account: null,
relays: null,
wallet: null,
storage: null,
hashtags: null,
subscriptions: null,
friends: null,
blocked: null,
messages: null,
history: null,
admin: null,
safety: null,
};
this.relayList = null;
this.relayInput = null;
this.addRelayButton = null;
this.restoreRelaysButton = null;
this.relayHealthPanel = null;
this.relayHealthList = null;
this.relayHealthStatus = null;
this.relayHealthRefreshButton = null;
this.relayHealthTelemetryToggle = null;
this.relayHealthRefreshPromise = null;
this.profileRelayList = null;
this.profileRelayInput = null;
this.profileAddRelayBtn = null;
this.profileRestoreRelaysBtn = null;
this.subscriptionList = null;
this.subscriptionListEmpty = null;
this.friendList = null;
this.friendListEmpty = null;
this.friendInput = null;
this.addFriendButton = null;
this.blockList = null;
this.blockListEmpty = null;
this.blockListStatus = null;
this.blockListLoadingState = "idle";
this.blockInput = null;
this.addBlockedButton = null;
this.profileBlockedList = null;
this.profileBlockedEmpty = null;
this.profileBlockedInput = null;
this.profileAddBlockedBtn = null;
this.profileMessagesList = null;
this.profileMessagesEmpty = null;
this.profileMessagesLoading = null;
this.profileMessagesError = null;
this.profileMessagesStatus = null;
this.profileMessagesReloadButton = null;
this.profileMessagesPane = null;
this.profileMessagesConversation = null;
this.profileMessagesConversationEmpty = null;
this.profileMessageInput = null;
this.profileMessageSendButton = null;
this.profileMessageAttachmentInput = null;
this.profileMessageAttachmentButton = null;
this.profileMessageAttachmentEncrypt = null;
this.profileMessageAttachmentList = null;
this.profileMessageAttachmentClearCache = null;
this.profileMessagesComposerHelper = null;
this.profileMessagesSendDmButton = null;
this.profileMessagesOpenRelaysButton = null;
this.profileMessagesPrivacyToggle = null;
this.profileMessagesPrivacyMode = null;
this.profileMessagesRelayList = null;
this.profileMessagesRelayInput = null;
this.profileMessagesRelayAddButton = null;
this.profileMessagesRelayPublishButton = null;
this.profileMessagesRelayStatus = null;
this.profileMessagesUnreadDot = null;
this.dmAppShellContainer = null;
this.dmAppShell = null;
this.profileLinkPreviewAutoToggle = null;
this.walletUriInput = null;
this.walletDefaultZapInput = null;
this.walletSaveButton = null;
this.walletTestButton = null;
this.walletDisconnectButton = null;
this.walletStatusText = null;
this.profileWalletStatusText = null;
this.hashtagStatusText = null;
this.hashtagInterestList = null;
this.hashtagInterestEmpty = null;
this.hashtagInterestInput = null;
this.addHashtagInterestButton = null;
this.hashtagDisinterestList = null;
this.hashtagDisinterestEmpty = null;
this.hashtagDisinterestInput = null;
this.addHashtagDisinterestButton = null;
this.moderationSettingsCard = null;
this.moderationBlurInput = null;
this.moderationAutoplayInput = null;
this.moderationMuteHideInput = null;
this.moderationSpamHideInput = null;
this.moderationSaveButton = null;
this.moderationResetButton = null;
this.moderationStatusText = null;
this.moderationOverridesList = null;
this.moderationOverridesEmpty = null;
this.moderationHideControlsGroup = null;
this.moderationHideControlElements = [];
this.boundModerationOverridesUpdate = null;
this.moderatorSection = null;
this.moderatorEmpty = null;
this.adminModeratorList = null;
this.addModeratorButton = null;
this.moderatorInput = null;
this.adminModeratorsSection = null;
this.adminModeratorsEmpty = null;
this.adminAddModeratorButton = null;
this.adminModeratorInput = null;
this.whitelistSection = null;
this.whitelistEmpty = null;
this.whitelistList = null;
this.addWhitelistButton = null;
this.whitelistInput = null;
this.adminWhitelistSection = null;
this.adminWhitelistEmpty = null;
this.adminWhitelistList = null;
this.adminAddWhitelistButton = null;
this.adminWhitelistInput = null;
this.blacklistSection = null;
this.blacklistEmpty = null;
this.blacklistList = null;
this.addBlacklistButton = null;
this.blacklistInput = null;
this.adminBlacklistSection = null;
this.adminBlacklistEmpty = null;
this.adminBlacklistList = null;
this.adminAddBlacklistButton = null;
this.adminBlacklistInput = null;
this.messagesLoadingState = "idle";
this.messagesInitialLoadPending = true;
this.messagesViewActive = false;
this.activeMessagesRequest = null;
this.directMessagesCache = [];
this.directMessagesLastActor = null;
this.directMessagesSubscription = null;
this.directMessagesUnsubscribes = [];
this.pendingMessagesRender = null;
this.messagesStatusClearTimeout = null;
this.dmPrivacyToggleTouched = false;
this.dmReadReceiptCache = new Set();
this.dmTypingLastSentAt = 0;
this.dmAttachmentQueue = [];
this.dmAttachmentUploads = new Map();
this.activeDmConversationId = "";
this.focusedDmConversationId = "";
this.dmComposerState = "idle";
this.profileHistoryRenderer = null;
this.profileHistoryRendererConfig = null;
this.boundProfileHistoryVisibility = null;
this.boundKeydown = null;
this.boundFocusIn = null;
this.focusableElements = [];
this.focusTrapContainer = null;
this.focusTrapSuspended = false;
this.focusTrapSuspendCount = 0;
this.focusTrapAriaHiddenBeforeSuspend = null;
this.focusTrapNestedModalActiveBeforeSuspend = null;
this.profileSwitcherSelectionPubkey = null;
this.previouslyFocusedElement = null;
this.largeLayoutQuery = null;
this.largeLayoutQueryListener = null;
this.isLargeLayoutActiveFlag = false;
this.mobileViewState = "menu";
this.lastMobileViewState = "menu";
this.setActivePane(this.getActivePane());
this.setWalletPaneBusy(this.isWalletBusy());
this.addAccountButtonState = null;
this.adminEmptyMessages = new Map();
this.hashtagPreferencesUnsubscribe = null;
if (
this.subscriptionsService &&
typeof this.subscriptionsService.on === "function"
) {
this.subscriptionsService.on("change", () => {
void this.populateSubscriptionsList();
});
}
if (
this.services.userBlocks &&
typeof this.services.userBlocks.on === "function"
) {
this.services.userBlocks.on("change", () => {
this.populateBlockedList();
});
}
}
async load() {
if (!(this.modalContainer instanceof HTMLElement)) {
throw new Error("profile modal container missing");
}
// Invalidate any stale renderer reference so it is recreated
// with the freshly loaded DOM elements.
if (this.profileHistoryRenderer) {
try {
if (typeof this.profileHistoryRenderer.destroy === "function") {
this.profileHistoryRenderer.destroy();
}
} catch (err) {
devLogger.warn("[profileModal] Failed to destroy stale history renderer:", err);
}
this.profileHistoryRenderer = null;
this.profileHistoryRendererConfig = null;
}
const response = await fetch("components/profile-modal.html");
if (!response.ok) {
throw new Error(`Failed to load profile modal HTML (${response.status})`);
}
const html = await response.text();
const template = document.createElement("template");
template.innerHTML = html;
this.removeTrackingScripts(template.content);
const modalRoot = template.content.querySelector("#profileModal");
if (!(modalRoot instanceof HTMLElement)) {
throw new Error("profile modal markup missing expected #profileModal root");
}
this.modalContainer.appendChild(template.content);
this.profileModalRoot = modalRoot;
this.profileModal = modalRoot;
this.profileModalPanel =
modalRoot.querySelector(".bv-modal__panel") || modalRoot;
this.profileModalBackdrop =
modalRoot.querySelector(".bv-modal-backdrop") || null;
this.cacheDomReferences();
this.setupLayoutBreakpointObserver();
this.applyModalStackingOverrides();
this.registerEventListeners();
this.populateHashtagPreferences();
this.refreshModerationSettingsUi();
const preserveMenu = this.isMobileLayoutActive();
this.selectPane(this.getActivePane(), { keepMenuView: preserveMenu });
return true;
}
cacheDomReferences() {
this.profileModalRoot = document.getElementById("profileModal") || null;
this.profileModalPanel =
this.profileModalRoot?.querySelector(".bv-modal__panel") || null;
this.profileModalBackdrop =
this.profileModalRoot?.querySelector(".bv-modal-backdrop") || null;
this.profileModalLayout =
this.profileModalRoot?.querySelector("[data-profile-layout]") || null;
this.profileModalMenu =
this.profileModalRoot?.querySelector("[data-profile-mobile-menu]") || null;
this.profileModalPaneWrapper =
this.profileModalRoot?.querySelector("[data-profile-mobile-pane]") || null;
this.profileModal = this.profileModalRoot;
this.closeButton = document.getElementById("closeProfileModal") || null;
this.profileModalBackButton =
document.getElementById("profileModalBack") || null;
this.logoutButton = document.getElementById("profileLogoutBtn") || null;
this.mobileLogoutButton =
document.getElementById("profileMobileLogoutBtn") || null;
this.channelLink = document.getElementById("profileChannelLink") || null;
this.addAccountButton =
document.getElementById("profileAddAccountBtn") || null;
this.profileAvatar = document.getElementById("profileModalAvatar") || null;
this.profileName = document.getElementById("profileModalName") || null;
this.profileNpub = document.getElementById("profileModalNpub") || null;
this.switcherList = document.getElementById("profileSwitcherList") || null;
this.profileModalAvatar = this.profileAvatar;
this.profileModalName = this.profileName;
this.profileModalNpub = this.profileNpub;
this.profileSwitcherList = this.switcherList;
const topLevelProfileAvatar =
document.getElementById("profileAvatar") || null;
if (topLevelProfileAvatar) {
this.globalProfileAvatar = topLevelProfileAvatar;
}
this.navButtons.account =
document.getElementById("profileNavAccount") || null;
this.navButtons.relays = document.getElementById("profileNavRelays") || null;
this.navButtons.wallet = document.getElementById("profileNavWallet") || null;
this.navButtons.storage = document.getElementById("profileNavStorage") || null;
this.navButtons.hashtags =
document.getElementById("profileNavHashtags") || null;
this.navButtons.subscriptions =
document.getElementById("profileNavSubscriptions") || null;
this.navButtons.friends =
document.getElementById("profileNavFriends") || null;
this.navButtons.blocked =
document.getElementById("profileNavBlocked") || null;
this.navButtons.messages =
document.getElementById("profileNavMessages") || null;
this.navButtons.history =
document.getElementById("profileNavHistory") || null;
this.navButtons.admin = document.getElementById("profileNavAdmin") || null;
this.navButtons.safety = document.getElementById("profileNavSafety") || null;
this.profileEditBtn = document.getElementById("profileEditBtn") || null;
this.profileEditBackBtn = document.getElementById("profileEditBackBtn") || null;
this.panes.account = document.getElementById("profilePaneAccount") || null;
this.panes.edit = document.getElementById("profilePaneEdit") || null;
this.panes.relays = document.getElementById("profilePaneRelays") || null;
this.panes.wallet = document.getElementById("profilePaneWallet") || null;
this.panes.storage = document.getElementById("profilePaneStorage") || null;
this.panes.hashtags = document.getElementById("profilePaneHashtags") || null;
this.panes.subscriptions =
document.getElementById("profilePaneSubscriptions") || null;
this.panes.friends = document.getElementById("profilePaneFriends") || null;
this.panes.blocked = document.getElementById("profilePaneBlocked") || null;
this.panes.messages = document.getElementById("profilePaneMessages") || null;
this.panes.history = document.getElementById("profilePaneHistory") || null;
this.panes.admin = document.getElementById("profilePaneAdmin") || null;
this.panes.safety = document.getElementById("profilePaneSafety") || null;
this.relayList = document.getElementById("relayList") || null;
this.relayInput = document.getElementById("relayInput") || null;
this.addRelayButton = document.getElementById("addRelayBtn") || null;
this.restoreRelaysButton =
document.getElementById("restoreRelaysBtn") || null;
this.relayHealthPanel =
document.getElementById("relayHealthPanel") || null;
this.relayHealthList =
document.getElementById("relayHealthList") || null;
this.relayHealthStatus =
document.getElementById("relayHealthStatus") || null;
this.relayHealthRefreshButton =
document.getElementById("relayHealthRefreshBtn") || null;
this.relayHealthTelemetryToggle =
document.getElementById("relayHealthTelemetryOptIn") || null;
this.subscriptionList =
document.getElementById("subscriptionsList") || null;
this.subscriptionListEmpty =
document.getElementById("subscriptionsEmpty") || 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.blockList = document.getElementById("blockedList") || null;
this.blockListEmpty = document.getElementById("blockedEmpty") || null;
this.blockInput = document.getElementById("blockedInput") || null;
this.addBlockedButton = document.getElementById("addBlockedBtn") || null;
this.profileMessagesPane =
document.getElementById("profilePaneMessages") || null;
this.profileMessagesList =
document.getElementById("profileMessagesList") || null;
this.profileMessagesEmpty =
document.getElementById("profileMessagesEmpty") || null;
this.profileMessagesLoading =
document.getElementById("profileMessagesLoading") || null;
this.profileMessagesError =
document.getElementById("profileMessagesError") || null;
this.profileMessagesStatus =
document.getElementById("profileMessagesStatus") || null;
this.profileMessagesReloadButton =
document.getElementById("profileMessagesReload") || null;
this.profileMessagesConversation =
document.getElementById("profileMessagesConversation") || null;
this.profileMessagesConversationEmpty =
document.getElementById("profileMessagesConversationEmpty") || null;
this.profileMessageInput =
document.getElementById("profileMessageInput") || null;
this.profileMessageSendButton =
document.getElementById("profileMessageSendBtn") || null;
this.profileMessageAttachmentInput =
document.getElementById("profileMessageAttachmentInput") || null;
this.profileMessageAttachmentButton =
document.getElementById("profileMessageAttachmentButton") || null;
this.profileMessageAttachmentEncrypt =
document.getElementById("profileMessageAttachmentEncrypt") || null;
this.profileMessageAttachmentList =
document.getElementById("profileMessageAttachmentList") || null;
this.profileMessageAttachmentClearCache =
document.getElementById("profileMessageAttachmentClearCache") || null;
this.profileMessagesComposerHelper =
document.getElementById("profileMessagesComposerHelper") || null;
this.profileMessagesSendDmButton =
document.getElementById("profileMessagesSendDm") || null;
this.profileMessagesOpenRelaysButton =
document.getElementById("profileMessagesOpenRelays") || null;
this.profileMessagesPrivacyToggle =
document.getElementById("profileMessagesPrivacyToggle") || null;
this.profileMessagesPrivacyMode =
document.getElementById("profileMessagesPrivacyMode") || null;
this.profileMessagesRelayList =
document.getElementById("profileMessagesRelayList") || null;
this.profileMessagesRelayInput =
document.getElementById("profileMessagesRelayInput") || null;
this.profileMessagesRelayAddButton =
document.getElementById("profileMessagesRelayAdd") || null;
this.profileMessagesRelayPublishButton =
document.getElementById("profileMessagesRelayPublish") || null;
this.profileMessagesRelayStatus =
document.getElementById("profileMessagesRelayStatus") || null;
this.profileMessagesUnreadDot =
document.getElementById("profileMessagesUnreadDot") || null;
this.dmAppShellContainer =
document.getElementById("dmAppShellMount") || null;
this.profileLinkPreviewAutoToggle =
document.getElementById("profileLinkPreviewAutoToggle") || null;
if (this.pendingMessagesRender) {
const { messages, actorPubkey } = this.pendingMessagesRender;
this.pendingMessagesRender = null;
void this.renderProfileMessages(messages, { actorPubkey }).catch((error) => {
devLogger.warn(
"[profileModal] Failed to render pending direct messages:",
error,
);
});
} else if (
(this.profileMessagesList instanceof HTMLElement ||
this.dmAppShellContainer instanceof HTMLElement) &&
Array.isArray(this.directMessagesCache) &&
this.directMessagesCache.length
) {
void this.renderProfileMessages(this.directMessagesCache, {
actorPubkey: this.directMessagesLastActor,
}).catch((error) => {
devLogger.warn(
"[profileModal] Failed to render cached direct messages:",
error,
);
});
}
this.setMessagesLoadingState(this.messagesLoadingState || "idle");
this.updateMessagePrivacyModeDisplay();
this.populateDmRelayPreferences();
this.syncDmPrivacySettingsUi();
this.walletUriInput = document.getElementById("profileWalletUri") || null;
this.walletDefaultZapInput =
document.getElementById("profileWalletDefaultZap") || null;
this.walletSaveButton =
document.getElementById("profileWalletSave") || null;
this.walletTestButton =
document.getElementById("profileWalletTest") || null;
this.walletDisconnectButton =
document.getElementById("profileWalletDisconnect") || null;
this.walletStatusText =
document.getElementById("profileWalletStatus") || null;
this.hashtagStatusText =
document.getElementById("profileHashtagStatus") || null;
this.hashtagInterestList =
document.getElementById("profileHashtagInterestList") || null;
this.hashtagInterestEmpty =
document.getElementById("profileHashtagInterestEmpty") || null;
this.hashtagInterestInput =
document.getElementById("profileHashtagInterestInput") || null;
this.addHashtagInterestButton =
document.getElementById("profileAddHashtagInterestBtn") || null;
this.hashtagDisinterestList =
document.getElementById("profileHashtagDisinterestList") || null;
this.hashtagDisinterestEmpty =
document.getElementById("profileHashtagDisinterestEmpty") || null;
this.hashtagDisinterestInput =
document.getElementById("profileHashtagDisinterestInput") || null;
this.addHashtagDisinterestButton =
document.getElementById("profileAddHashtagDisinterestBtn") || null;
this.profileRelayList = this.relayList;
this.profileRelayInput = this.relayInput;
this.profileAddRelayBtn = this.addRelayButton;
this.profileRestoreRelaysBtn = this.restoreRelaysButton;
this.profileSubscriptionsList = this.subscriptionList;
this.profileSubscriptionsEmpty = this.subscriptionListEmpty;
this.profileFriendsList = this.friendList;
this.profileFriendsEmpty = this.friendListEmpty;
this.profileFriendsInput = this.friendInput;
this.profileAddFriendBtn = this.addFriendButton;
this.profileBlockedList = this.blockList;
this.profileBlockedEmpty = this.blockListEmpty;
this.profileBlockedInput = this.blockInput;
this.profileAddBlockedBtn = this.addBlockedButton;
this.blockListStatus =
this.panes.blocked?.querySelector("[data-role=\"blocked-list-status\"]") ||
null;
this.profileWalletStatusText = this.walletStatusText;
this.moderationSettingsCard =
document.getElementById("profileModerationSettings") || null;
this.moderationBlurInput =
document.getElementById("profileModerationBlurThreshold") || null;
this.moderationAutoplayInput =
document.getElementById("profileModerationAutoplayThreshold") || null;
this.moderationMuteHideInput =
document.getElementById("profileModerationMuteHideThreshold") || null;
this.moderationSpamHideInput =
document.getElementById("profileModerationSpamHideThreshold") || null;
this.moderationSaveButton =
document.getElementById("profileModerationSave") || null;
this.moderationResetButton =
document.getElementById("profileModerationReset") || null;
this.moderationStatusText =
document.getElementById("profileModerationStatus") || null;
this.moderationOverridesList =
document.getElementById("profileModerationOverridesList") || null;
this.moderationOverridesEmpty =
document.getElementById("profileModerationOverridesEmpty") || null;
this.moderationTrustedContactsCount =
document.getElementById("profileModerationTrustedContactsCount") || null;
this.moderationTrustedMuteCount =
document.getElementById("profileModerationTrustedMuteCount") || null;
this.moderationTrustedReportCount =
document.getElementById("profileModerationTrustedReportCount") || null;
this.moderationSeedOnlyIndicator =
document.getElementById("profileModerationSeedOnlyIndicator") || null;
this.moderationHideControlsGroup =
this.moderationSettingsCard?.querySelector(
"[data-role=\"trusted-hide-controls\"]",
) || null;
this.moderationHideControlElements = Array.from(
this.moderationSettingsCard?.querySelectorAll(
"[data-role=\"trusted-hide-control\"]",
) || [],
);
this.updateTrustedMuteHideHelperCopy();
this.moderatorSection =
document.getElementById("adminModeratorsSection") || null;
this.moderatorEmpty =
document.getElementById("adminModeratorsEmpty") || null;
this.adminModeratorList =
document.getElementById("adminModeratorList") || null;
this.addModeratorButton =
document.getElementById("adminAddModeratorBtn") || null;
this.moderatorInput =
document.getElementById("adminModeratorInput") || null;
this.storageUnlockBtn = document.getElementById("profileStorageUnlockBtn") || null;
this.storageSaveBtn = document.getElementById("storageSaveBtn") || null;
this.storageTestBtn = document.getElementById("storageTestBtn") || null;
this.storageClearBtn = document.getElementById("storageClearBtn") || null;
this.storageUnlockSection = document.getElementById("profileStorageUnlock") || null;
this.storageFormSection = document.getElementById("profileStorageForm") || null;
this.storageStatusText = document.getElementById("profileStorageStatus") || null;
this.storageFormStatus = document.getElementById("storageFormStatus") || null;
this.storageProviderInput = document.getElementById("storageProvider") || null;
this.storageEndpointInput = document.getElementById("storageEndpoint") || null;
this.storageRegionInput = document.getElementById("storageRegion") || null;
this.storageAccessKeyInput = document.getElementById("storageAccessKey") || null;
this.storageSecretKeyInput = document.getElementById("storageSecretKey") || null;
this.storageBucketInput = document.getElementById("storageBucket") || null;
this.storagePrefixInput = document.getElementById("storagePrefix") || null;
this.storagePrefixWarning = document.getElementById("storagePrefixWarning") || null;
this.storageDefaultInput = document.getElementById("storageDefault") || null;
this.storageR2Helper = document.getElementById("storageR2Helper") || null;
this.storageS3Helper = document.getElementById("storageS3Helper") || null;
this.storageForcePathStyleInput = document.getElementById("storageForcePathStyle") || null;
this.storageForcePathStyleLabel = document.getElementById("storageForcePathStyleLabel") || null;
// Backwards-compatible aliases retained for application code that still
// mirrors DOM references from the controller. These should be removed once
// the application stops reaching through the controller.
this.adminModeratorsSection = this.moderatorSection;
this.adminModeratorsEmpty = this.moderatorEmpty;
this.adminAddModeratorButton = this.addModeratorButton;
this.adminModeratorInput = this.moderatorInput;
this.whitelistSection =
document.getElementById("adminWhitelistSection") || null;
this.whitelistEmpty =
document.getElementById("adminWhitelistEmpty") || null;
this.whitelistList =
document.getElementById("adminWhitelistList") || null;
this.addWhitelistButton =
document.getElementById("adminAddWhitelistBtn") || null;
this.whitelistInput =
document.getElementById("adminWhitelistInput") || null;
this.blacklistSection =
document.getElementById("adminBlacklistSection") || null;
this.blacklistEmpty =
document.getElementById("adminBlacklistEmpty") || null;
this.blacklistList =
document.getElementById("adminBlacklistList") || null;
this.addBlacklistButton =
document.getElementById("adminAddBlacklistBtn") || null;
this.blacklistInput =
document.getElementById("adminBlacklistInput") || null;
this.adminWhitelistSection = this.whitelistSection;
this.adminWhitelistEmpty = this.whitelistEmpty;
this.adminWhitelistList = this.whitelistList;
this.adminAddWhitelistButton = this.addWhitelistButton;
this.adminWhitelistInput = this.whitelistInput;
this.adminBlacklistSection = this.blacklistSection;
this.adminBlacklistEmpty = this.blacklistEmpty;
this.adminBlacklistList = this.blacklistList;
this.adminAddBlacklistButton = this.addBlacklistButton;
this.adminBlacklistInput = this.blacklistInput;
this.editNameInput = document.getElementById("editNameInput") || null;
this.editDisplayNameInput = document.getElementById("editDisplayNameInput") || null;
this.editAboutInput = document.getElementById("editAboutInput") || null;
this.editWebsiteInput = document.getElementById("editWebsiteInput") || null;
this.editNip05Input = document.getElementById("editNip05Input") || null;
this.editLud16Input = document.getElementById("editLud16Input") || null;
this.editPictureInput = document.getElementById("editPictureInput") || null;
this.editBannerInput = document.getElementById("editBannerInput") || null;
this.editPictureFile = document.getElementById("editPictureFile") || null;
this.editPictureUploadBtn = document.getElementById("editPictureUploadBtn") || null;
this.editPictureStorageHint = document.getElementById("editPictureStorageHint") || null;
this.editPictureConfigureLink = document.getElementById("editPictureConfigureLink") || null;
this.editBannerFile = document.getElementById("editBannerFile") || null;
this.editBannerUploadBtn = document.getElementById("editBannerUploadBtn") || null;
this.editBannerStorageHint = document.getElementById("editBannerStorageHint") || null;
this.editBannerConfigureLink = document.getElementById("editBannerConfigureLink") || null;
this.editSaveBtn = document.getElementById("editSaveBtn") || null;
this.editCancelBtn = document.getElementById("editCancelBtn") || null;
this.editStatusText = document.getElementById("editStatusText") || null;
if (this.createWatchHistoryRenderer) {
this.ensureProfileHistoryRenderer();
}
}
ensureProfileHistoryRenderer() {
if (this.profileHistoryRenderer) {
return this.profileHistoryRenderer;
}
if (!this.createWatchHistoryRenderer) {
return null;
}
const config = this.getProfileHistoryRendererConfig();
try {
this.profileHistoryRenderer = this.createWatchHistoryRenderer({
...config,
container: this.profileModalPanel || this.profileModalRoot,
});
} catch (error) {
userLogger.error(
"[profileModal] Failed to create watch history renderer:",
error,
);
this.profileHistoryRenderer = null;
}
return this.profileHistoryRenderer;
}
getProfileHistoryRendererConfig() {
if (this.profileHistoryRendererConfig) {
return this.profileHistoryRendererConfig;
}
this.profileHistoryRendererConfig = {
viewSelector: "#profilePaneHistory",
gridSelector: "#profileHistoryGrid",
loadingSelector: "#profileHistoryLoading",
statusSelector: "#profileHistoryStatus",
emptySelector: "#profileHistoryEmpty",
sentinelSelector: "#profileHistorySentinel",
scrollContainerSelector: "#profileModalPanes",
errorBannerSelector: "#profileHistoryError",
clearButtonSelector: "#profileHistoryClear",
refreshButtonSelector: "#profileHistoryRefresh",
privacyBannerSelector: "#profileHistoryPrivacyBanner",
privacyMessageSelector: "#profileHistoryPrivacyMessage",
privacyToggleSelector: "#profileHistoryPrivacyToggle",
privacyDismissSelector: "#profileHistoryPrivacyDismiss",
infoSelector: "#profileHistoryInfo",
featureBannerSelector: "#profileHistoryFeatureBanner",
sessionWarningSelector: "#profileHistorySessionWarning",
emptyCopy: "You haven’t watched any videos yet.",
variant: "modal",
remove: (payload) =>
this.callbacks.onHistoryReady({
...(typeof payload === "object" && payload ? payload : {}),
controller: this,
renderer: this.profileHistoryRenderer,
}),
};
return this.profileHistoryRendererConfig;
}
initializeDirectMessagesService() {
this.teardownDirectMessagesService();
if (!this.nostrService || typeof this.nostrService.on !== "function") {
return;
}
const unsubscribes = [];
const subscribe = (eventName, handler) => {
try {
const unsubscribe = this.nostrService.on(eventName, handler);
if (typeof unsubscribe === "function") {
unsubscribes.push(unsubscribe);
}
} catch (error) {
devLogger.warn(
`[profileModal] Failed to subscribe to ${eventName} direct message events:`,
error,
);
}
};
subscribe("directMessages:updated", (detail) => {
this.handleDirectMessagesUpdated(detail);
});
subscribe("directMessages:cleared", () => {
this.handleDirectMessagesCleared();
});
subscribe("directMessages:error", (detail) => {
this.handleDirectMessagesError(detail);
});
subscribe("directMessages:failure", (detail) => {
this.handleDirectMessagesError(detail);
});
subscribe("directMessages:relayWarning", (detail) => {
this.handleDirectMessagesRelayWarning(detail);
});
this.directMessagesUnsubscribes = unsubscribes;
const actor = this.resolveActiveDmActor();
if (actor) {
this.directMessagesLastActor = actor;
}
if (
this.nostrService &&
typeof this.nostrService.hydrateDirectMessagesFromStore === "function"
) {
void this.nostrService
.hydrateDirectMessagesFromStore({ emit: true })
.then((messages) => {
if (Array.isArray(messages)) {
this.directMessagesCache = messages;
const active = this.resolveActiveDmActor();
if (active) {
this.directMessagesLastActor = active;
}
}
})
.catch((error) => {
devLogger.warn(
"[profileModal] Failed to hydrate cached direct messages:",
error,
);
});
}
}
teardownDirectMessagesService() {
if (!Array.isArray(this.directMessagesUnsubscribes)) {
this.directMessagesUnsubscribes = [];
return;
}
while (this.directMessagesUnsubscribes.length) {
const unsubscribe = this.directMessagesUnsubscribes.pop();
if (typeof unsubscribe === "function") {
try {
unsubscribe();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to remove direct message event listener:",
error,
);
}
}
}
}
resolveActiveDmActor() {
const active = this.normalizeHexPubkey(this.getActivePubkey());
if (active) {
return active;
}
const client = this.services.nostrClient || null;
if (client) {
if (typeof client.pubkey === "string" && client.pubkey.trim()) {
const normalizedClient = this.normalizeHexPubkey(client.pubkey);
if (normalizedClient) {
return normalizedClient;
}
}
if (
client.sessionActor &&
typeof client.sessionActor.pubkey === "string" &&
client.sessionActor.pubkey.trim()
) {
const session = this.normalizeHexPubkey(client.sessionActor.pubkey);
if (session) {
return session;
}
}
}
return null;
}
resolveActiveDmRecipient() {
const candidate =
typeof this.state.getDmRecipient === "function"
? this.state.getDmRecipient()
: null;
const normalized = this.normalizeHexPubkey(candidate);
if (normalized) {
return normalized;
}
return null;
}
resolveActiveDmRelayOwner() {
const active = this.normalizeHexPubkey(this.getActivePubkey());
if (active) {
return active;
}
return this.resolveActiveDmActor();
}
getActiveDmRelayPreferences() {
const owner = this.resolveActiveDmRelayOwner();
if (!owner || typeof this.state.getDmRelayPreferences !== "function") {
return [];
}
const relays = this.state.getDmRelayPreferences(owner);
return Array.isArray(relays) ? relays.slice() : [];
}
setActiveDmRelayPreferences(relays = []) {
const owner = this.resolveActiveDmRelayOwner();
if (!owner || typeof this.state.setDmRelayPreferences !== "function") {
return [];
}
return this.state.setDmRelayPreferences(owner, relays);
}
setDmRelayPreferencesStatus(message = "") {
if (!(this.profileMessagesRelayStatus instanceof HTMLElement)) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
this.profileMessagesRelayStatus.textContent = text;
}
updateMessagePrivacyModeDisplay() {
if (!(this.profileMessagesPrivacyMode instanceof HTMLElement)) {
return;
}
const isNip17 =
this.profileMessagesPrivacyToggle instanceof HTMLInputElement
? this.profileMessagesPrivacyToggle.checked
: false;
const label = isNip17 ? "NIP-17" : "NIP-04";
this.profileMessagesPrivacyMode.textContent = `Privacy: ${label}`;
this.profileMessagesPrivacyMode.title = isNip17
? "NIP-17 gift-wraps your DM so relays only see the wrapper and relay hints."
: "NIP-04 sends a direct encrypted DM; relays can still see sender and recipient metadata.";
}
getDmPrivacySettingsSnapshot() {
const fallback = createInternalDefaultDmPrivacySettings();
if (typeof this.state.getDmPrivacySettings !== "function") {
return { ...fallback };
}
const settings = this.state.getDmPrivacySettings();
return {
readReceiptsEnabled:
typeof settings?.readReceiptsEnabled === "boolean"
? settings.readReceiptsEnabled
: fallback.readReceiptsEnabled,
typingIndicatorsEnabled:
typeof settings?.typingIndicatorsEnabled === "boolean"
? settings.typingIndicatorsEnabled
: fallback.typingIndicatorsEnabled,
};
}
persistDmPrivacySettings(partial = {}) {
if (typeof this.state.setDmPrivacySettings !== "function") {
return this.getDmPrivacySettingsSnapshot();
}
const resolved =
partial && typeof partial === "object" ? partial : {};
const current = this.getDmPrivacySettingsSnapshot();
const merged = {
readReceiptsEnabled:
typeof resolved.readReceiptsEnabled === "boolean"
? resolved.readReceiptsEnabled
: current.readReceiptsEnabled,
typingIndicatorsEnabled:
typeof resolved.typingIndicatorsEnabled === "boolean"
? resolved.typingIndicatorsEnabled
: current.typingIndicatorsEnabled,
};
return this.state.setDmPrivacySettings(merged);
}
syncDmPrivacySettingsUi() {
// Legacy UI toggles removed.
// This method is kept for backwards compatibility with call sites that might expect it,
// though the settings are now managed via AppShell.
}
handleReadReceiptsToggle(enabled) {
this.persistDmPrivacySettings({
readReceiptsEnabled: Boolean(enabled),
});
this.syncDmPrivacySettingsUi();
this.showStatus(
enabled
? "Read receipts enabled for direct messages."
: "Read receipts disabled.",
);
}
handleTypingIndicatorsToggle(enabled) {
this.persistDmPrivacySettings({
typingIndicatorsEnabled: Boolean(enabled),
});
this.syncDmPrivacySettingsUi();
this.showStatus(
enabled
? "Typing indicators enabled for direct messages."
: "Typing indicators disabled.",
);
}
syncLinkPreviewSettingsUi() {
if (!(this.profileLinkPreviewAutoToggle instanceof HTMLInputElement)) {
return;
}
const settings = getLinkPreviewSettings();
this.profileLinkPreviewAutoToggle.checked = Boolean(
settings?.autoFetchUnknownDomains,
);
}
handleLinkPreviewToggle(enabled) {
setLinkPreviewAutoFetch(Boolean(enabled));
}
async refreshDmRelayPreferences({ force = false } = {}) {
const owner = this.resolveActiveDmRelayOwner();
if (!owner) {
this.populateDmRelayPreferences();
return;
}
const existing = this.getActiveDmRelayPreferences();
if (!existing.length || force) {
if (typeof this.services.fetchDmRelayHints === "function") {
try {
const hints = await this.services.fetchDmRelayHints(owner);
if (typeof this.state.setDmRelayPreferences === "function") {
this.state.setDmRelayPreferences(owner, hints);
}
} catch (error) {
devLogger.warn(
"[profileModal] Failed to refresh DM relay hints for profile:",
error,
);
}
}
}
this.populateDmRelayPreferences();
}
populateDmRelayPreferences() {
if (!(this.profileMessagesRelayList instanceof HTMLElement)) {
return;
}
const owner = this.resolveActiveDmRelayOwner();
const relays = this.getActiveDmRelayPreferences();
this.profileMessagesRelayList.innerHTML = "";
if (!owner) {
const emptyState = document.createElement("li");
emptyState.className =
"card border border-dashed border-border/60 p-4 text-center text-xs text-muted";
emptyState.textContent = "Sign in to add DM relay hints.";
this.profileMessagesRelayList.appendChild(emptyState);
this.setDmRelayPreferencesStatus("");
return;
}
if (!relays.length) {
const emptyState = document.createElement("li");
emptyState.className =
"card border border-dashed border-border/60 p-4 text-center text-xs text-muted";
emptyState.textContent = "No DM relay hints yet.";
this.profileMessagesRelayList.appendChild(emptyState);
return;
}
relays.forEach((url) => {
const item = document.createElement("li");
item.className = "card flex items-center justify-between gap-3 p-3";
const label = document.createElement("p");
label.className = "text-xs font-medium text-text break-all";
label.textContent = url;
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn-ghost focus-ring text-xs";
removeBtn.dataset.variant = "danger";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => {
void this.handleRemoveDmRelayPreference(url);
});
item.appendChild(label);
item.appendChild(removeBtn);
this.profileMessagesRelayList.appendChild(item);
});
}
async handleAddDmRelayPreference() {
const input = this.profileMessagesRelayInput;
if (!(input instanceof HTMLInputElement)) {
return;
}
const owner = this.resolveActiveDmRelayOwner();
if (!owner) {
this.showError("Please sign in to save DM relay hints.");
return;
}
const rawValue = typeof input.value === "string" ? input.value.trim() : "";
const sanitized = sanitizeRelayList([rawValue]);
const relayUrl = sanitized[0];
if (!relayUrl) {
this.showError("Enter a valid WSS relay URL.");
return;
}
const current = this.getActiveDmRelayPreferences();
const next = sanitizeRelayList([...current, relayUrl]);
this.setActiveDmRelayPreferences(next);
input.value = "";
this.populateDmRelayPreferences();
this.setDmRelayPreferencesStatus("DM relay hint added.");
}
async handleRemoveDmRelayPreference(url) {
const owner = this.resolveActiveDmRelayOwner();
if (!owner) {
this.showError("Please sign in to update DM relay hints.");
return;
}
const target = typeof url === "string" ? url.trim() : "";
if (!target) {
return;
}
const current = this.getActiveDmRelayPreferences();
const next = current.filter((entry) => entry !== target);
this.setActiveDmRelayPreferences(next);
this.populateDmRelayPreferences();
this.setDmRelayPreferencesStatus("DM relay hint removed.");
}
async handlePublishDmRelayPreferences() {
const owner = this.resolveActiveDmRelayOwner();
if (!owner) {
this.showError("Please sign in to publish DM relay hints.");
return;
}
const relays = this.getActiveDmRelayPreferences();
if (!relays.length) {
this.showError("Add at least one DM relay before publishing.");
return;
}
const callback = this.callbacks.onPublishDmRelayPreferences;
if (!callback || callback === noop) {
this.showError("DM relay publishing is unavailable right now.");
return;
}
this.setDmRelayPreferencesStatus("Publishing DM relay hints…");
try {
const result = await callback({
pubkey: owner,
relays,
controller: this,
});
if (result?.ok) {
const acceptedCount = Array.isArray(result.accepted)
? result.accepted.length
: 0;
const summary = acceptedCount
? `Published to ${acceptedCount} relay${acceptedCount === 1 ? "" : "s"}.`
: "DM relay hints published.";
this.showSuccess("DM relay hints published.");
this.setDmRelayPreferencesStatus(summary);
return;
}
this.showError("Failed to publish DM relay hints.");
this.setDmRelayPreferencesStatus("DM relay hints publish failed.");
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to publish DM relay hints.";
this.showError(message);
this.setDmRelayPreferencesStatus(message);
}
}
buildDmRecipientContext(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
}
const npub =
typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(normalized)
: null;
const cacheEntry =
typeof this.services.getProfileCacheEntry === "function"
? this.services.getProfileCacheEntry(normalized)
: null;
const profile = cacheEntry?.profile || null;
const displayName =
profile?.display_name?.trim?.() ||
profile?.name?.trim?.() ||
(typeof this.formatShortNpub === "function"
? this.formatShortNpub(npub)
: npub) ||
npub ||
"Unknown profile";
const relayHints =
typeof this.state.getDmRelayHints === "function"
? this.state.getDmRelayHints(normalized)
: [];
return {
pubkey: normalized,
npub,
profile,
displayName,
relayHints: Array.isArray(relayHints) ? relayHints.slice() : [],
};
}
async ensureDmRecipientData(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
if (!normalized) {
return null;
}
if (
typeof this.services.batchFetchProfiles === "function"
) {
try {
await this.services.batchFetchProfiles([normalized]);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to fetch DM recipient metadata:",
error,
);
}
}
if (typeof this.services.fetchDmRelayHints === "function") {
try {
const hints = await this.services.fetchDmRelayHints(normalized);
if (typeof this.state.setDmRelayHints === "function") {
this.state.setDmRelayHints(normalized, hints);
}
} catch (error) {
devLogger.warn(
"[profileModal] Failed to fetch DM relay hints:",
error,
);
}
}
const context = this.buildDmRecipientContext(normalized);
this.updateDmPrivacyToggleForRecipient(context);
return context;
}
updateDmPrivacyToggleForRecipient(recipientContext, { force = false } = {}) {
if (!recipientContext) {
return;
}
const relayHints = Array.isArray(recipientContext.relayHints)
? recipientContext.relayHints
: [];
const hasHints = relayHints.length > 0;
if (!this.dmPrivacyToggleTouched || force) {
this.setPrivacyToggleState(hasHints);
}
}
setDirectMessageRecipient(pubkey, { reason = "manual" } = {}) {
const normalized = this.normalizeHexPubkey(pubkey);
const nextRecipient = normalized || null;
if (typeof this.state.setDmRecipient === "function") {
this.state.setDmRecipient(nextRecipient);
}
this.dmPrivacyToggleTouched = false;
this.updateMessageThreadSelection(nextRecipient);
if (nextRecipient) {
void this.ensureDmRecipientData(nextRecipient);
this.setMessagesAnnouncement("Ready to message this recipient.");
} else if (reason === "clear") {
this.setMessagesAnnouncement("Message recipient cleared.");
this.setFocusedDmConversation("");
}
void this.renderDirectMessageConversation();
if (this.dmAppShellContainer instanceof HTMLElement) {
void this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: this.resolveActiveDmActor(),
});
}
return nextRecipient;
}
updateMessageThreadSelection(activeRecipient) {
if (!(this.profileMessagesList instanceof HTMLElement)) {
return;
}
const normalized = this.normalizeHexPubkey(activeRecipient);
const items = Array.from(
this.profileMessagesList.querySelectorAll("[data-remote-pubkey]"),
);
items.forEach((item) => {
if (!(item instanceof HTMLElement)) {
return;
}
const remote = this.normalizeHexPubkey(item.dataset.remotePubkey);
const isActive = normalized && remote === normalized;
item.dataset.state = isActive ? "active" : "inactive";
});
}
focusMessageComposer() {
const input = this.profileMessageInput;
if (input instanceof HTMLTextAreaElement) {
input.focus();
input.select();
}
}
setPrivacyToggleState(enabled) {
if (this.profileMessagesPrivacyToggle instanceof HTMLInputElement) {
this.profileMessagesPrivacyToggle.checked = Boolean(enabled);
}
this.updateMessagePrivacyModeDisplay();
}
async ensureDirectMessageSubscription(actorPubkey = null) {
if (
!this.nostrService ||
typeof this.nostrService.ensureDirectMessageSubscription !== "function"
) {
return null;
}
const normalizedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
if (!normalizedActor) {
return null;
}
if (
this.directMessagesSubscription &&
this.directMessagesSubscription.actor === normalizedActor
) {
return this.directMessagesSubscription.subscription || null;
}
if (
this.directMessagesSubscription &&
this.directMessagesSubscription.actor &&
this.directMessagesSubscription.actor !== normalizedActor
) {
this.resetDirectMessageSubscription();
}
let subscription = null;
try {
subscription = await this.nostrService.ensureDirectMessageSubscription({
actorPubkey: normalizedActor,
});
} catch (error) {
userLogger.warn(
"[profileModal] Failed to subscribe to direct messages:",
error,
);
return null;
}
this.directMessagesSubscription = {
actor: normalizedActor,
subscription,
};
return subscription;
}
resetDirectMessageSubscription() {
if (
this.directMessagesSubscription &&
this.nostrService &&
typeof this.nostrService.stopDirectMessageSubscription === "function"
) {
try {
this.nostrService.stopDirectMessageSubscription();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to stop direct message subscription:",
error,
);
}
}
this.directMessagesSubscription = null;
}
handleActiveDmIdentityChanged(actorPubkey = null) {
const normalized = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
this.setDirectMessageRecipient(null, { reason: "clear" });
this.resetAttachmentQueue({ clearInput: true });
this.dmReadReceiptCache.clear();
this.dmTypingLastSentAt = 0;
this.syncDmPrivacySettingsUi();
if (
this.directMessagesSubscription &&
this.directMessagesSubscription.actor &&
normalized !== this.directMessagesSubscription.actor
) {
this.resetDirectMessageSubscription();
}
this.directMessagesLastActor = normalized || null;
this.directMessagesCache = [];
this.messagesInitialLoadPending = true;
this.pendingMessagesRender = null;
if (this.profileMessagesList instanceof HTMLElement) {
this.profileMessagesList.innerHTML = "";
this.profileMessagesList.classList.add("hidden");
this.profileMessagesList.setAttribute("hidden", "");
}
if (this.profileMessagesConversation instanceof HTMLElement) {
this.profileMessagesConversation.innerHTML = "";
this.profileMessagesConversation.classList.add("hidden");
this.profileMessagesConversation.setAttribute("hidden", "");
}
if (this.profileMessagesConversationEmpty instanceof HTMLElement) {
this.profileMessagesConversationEmpty.classList.remove("hidden");
this.profileMessagesConversationEmpty.removeAttribute("hidden");
}
if (!normalized) {
this.setMessagesLoadingState("unauthenticated");
this.updateMessagesReloadState();
this.populateDmRelayPreferences();
this.setDmRelayPreferencesStatus("");
return;
}
this.setMessagesLoadingState("loading");
void this.ensureDirectMessageSubscription(normalized);
this.updateMessagesReloadState();
if (this.getActivePane() === "messages") {
void this.populateProfileMessages({
force: true,
reason: "identity-change",
});
}
void this.refreshDmRelayPreferences({ force: true });
}
setMessagesLoadingState(state, options = {}) {
const normalized = typeof state === "string" ? state : "idle";
const defaults = {
idle: "",
loading: "Fetching direct messages from relays…",
ready: "",
empty: "No direct messages yet.",
unauthenticated: "Sign in to view your direct messages.",
error: "Failed to load direct messages. Try again later.",
};
const providedMessage =
typeof options.message === "string" && options.message.trim()
? options.message.trim()
: "";
const message = providedMessage || defaults[normalized] || "";
this.messagesLoadingState = normalized;
if (this.profileMessagesPane instanceof HTMLElement) {
this.profileMessagesPane.setAttribute("data-messages-state", normalized);
}
const toggleVisibility = (element, shouldShow) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (shouldShow) {
element.classList.remove("hidden");
element.removeAttribute("hidden");
} else {
element.classList.add("hidden");
element.setAttribute("hidden", "");
}
};
toggleVisibility(
this.profileMessagesLoading,
normalized === "loading",
);
if (this.profileMessagesError instanceof HTMLElement) {
if (normalized === "error") {
this.profileMessagesError.textContent = message;
toggleVisibility(this.profileMessagesError, true);
} else {
this.profileMessagesError.textContent = "";
toggleVisibility(this.profileMessagesError, false);
}
}
if (this.profileMessagesEmpty instanceof HTMLElement) {
if (normalized === "empty" || normalized === "unauthenticated") {
this.profileMessagesEmpty.textContent = message || defaults[normalized];
toggleVisibility(this.profileMessagesEmpty, true);
} else {
toggleVisibility(this.profileMessagesEmpty, false);
}
}
const hasMessages =
Array.isArray(this.directMessagesCache) &&
this.directMessagesCache.length > 0;
if (this.profileMessagesList instanceof HTMLElement) {
if (normalized === "loading" || normalized === "unauthenticated") {
toggleVisibility(this.profileMessagesList, false);
} else if (hasMessages) {
toggleVisibility(this.profileMessagesList, true);
}
}
if (this.profileMessagesStatus instanceof HTMLElement) {
if (message && normalized !== "error") {
this.profileMessagesStatus.textContent = message;
} else if (normalized === "error") {
this.profileMessagesStatus.textContent = "";
} else if (providedMessage) {
this.profileMessagesStatus.textContent = providedMessage;
} else {
this.profileMessagesStatus.textContent = "";
}
}
this.updateMessagesReloadState();
this.updateMessageComposerState();
if (this.dmAppShellContainer instanceof HTMLElement) {
void this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: this.resolveActiveDmActor(),
});
}
}
updateMessagesReloadState() {
const button = this.profileMessagesReloadButton;
if (!(button instanceof HTMLElement)) {
return;
}
const actor = this.resolveActiveDmActor();
const disabled =
!actor ||
this.messagesLoadingState === "loading" ||
this.activeMessagesRequest !== null;
if ("disabled" in button) {
button.disabled = disabled;
}
if (disabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
}
setMessagesUnreadIndicator(visible) {
if (!(this.profileMessagesUnreadDot instanceof HTMLElement)) {
return;
}
const button = this.navButtons.messages;
const isVisible =
button instanceof HTMLElement &&
!button.classList.contains("hidden") &&
!button.hasAttribute("hidden");
this.profileMessagesUnreadDot.classList.toggle(
"is-visible",
Boolean(visible) && isVisible,
);
}
updateMessageComposerState() {
const input = this.profileMessageInput;
const button = this.profileMessageSendButton;
const helper = this.profileMessagesComposerHelper;
const attachmentInput = this.profileMessageAttachmentInput;
const attachmentButton = this.profileMessageAttachmentButton;
const attachmentEncrypt = this.profileMessageAttachmentEncrypt;
const attachmentClearCache = this.profileMessageAttachmentClearCache;
const shouldDisable = this.messagesLoadingState === "unauthenticated";
const applyDisabledState = (element) => {
if (!(element instanceof HTMLElement) || !("disabled" in element)) {
return;
}
element.disabled = shouldDisable;
if (shouldDisable) {
element.setAttribute("aria-disabled", "true");
} else {
element.removeAttribute("aria-disabled");
}
};
applyDisabledState(input);
applyDisabledState(button);
applyDisabledState(attachmentInput);
applyDisabledState(attachmentButton);
applyDisabledState(attachmentEncrypt);
applyDisabledState(attachmentClearCache);
if (helper instanceof HTMLElement) {
if (shouldDisable) {
helper.textContent = "Sign in to send messages.";
helper.classList.remove("hidden");
helper.removeAttribute("hidden");
} else {
helper.classList.add("hidden");
helper.setAttribute("hidden", "");
}
}
this.updateMessagePrivacyModeDisplay();
}
setMessagesAnnouncement(message) {
if (!(this.profileMessagesStatus instanceof HTMLElement)) {
return;
}
const content = typeof message === "string" ? message.trim() : "";
if (!content) {
this.profileMessagesStatus.textContent = "";
if (this.messagesStatusClearTimeout) {
clearTimeout(this.messagesStatusClearTimeout);
this.messagesStatusClearTimeout = null;
}
return;
}
this.profileMessagesStatus.textContent = content;
if (typeof window !== "undefined" && window && window.setTimeout) {
if (this.messagesStatusClearTimeout) {
clearTimeout(this.messagesStatusClearTimeout);
}
this.messagesStatusClearTimeout = window.setTimeout(() => {
if (this.profileMessagesStatus) {
this.profileMessagesStatus.textContent = "";
}
this.messagesStatusClearTimeout = null;
}, 2500);
}
}
async handleSendDmRequest() {
const recipient = this.resolveActiveDmRecipient();
if (!recipient) {
this.showError("Please select a message recipient.");
return;
}
const context = await this.ensureDmRecipientData(recipient);
this.focusMessageComposer();
const callback = this.callbacks.onSendDm;
if (callback && callback !== noop) {
callback({
actorPubkey: this.resolveActiveDmActor(),
recipient: context,
controller: this,
});
}
}
async handleOpenDmRelaysRequest() {
const recipient = this.resolveActiveDmRecipient();
if (!recipient) {
this.showError("Please select a message recipient.");
return;
}
const context = await this.ensureDmRecipientData(recipient);
const callback = this.callbacks.onOpenRelays;
if (callback && callback !== noop) {
callback({ controller: this, recipient: context });
}
}
handlePrivacyToggle(enabled) {
const recipientContext = this.buildDmRecipientContext(
this.resolveActiveDmRecipient(),
);
const relayHints = Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [];
if (enabled && !relayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
);
}
this.dmPrivacyToggleTouched = true;
this.updateMessagePrivacyModeDisplay();
const callback = this.callbacks.onTogglePrivacy;
if (callback && callback !== noop) {
callback({
controller: this,
enabled: Boolean(enabled),
recipient: recipientContext,
});
}
}
generateAttachmentId(file) {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const name = typeof file?.name === "string" ? file.name : "attachment";
return `${name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
resetAttachmentQueue({ clearInput = false } = {}) {
this.dmAttachmentQueue.forEach((entry) => {
if (entry?.previewUrl && typeof URL !== "undefined") {
try {
URL.revokeObjectURL(entry.previewUrl);
} catch (error) {
devLogger.warn("[profileModal] Failed to revoke attachment preview URL.", error);
}
}
});
this.dmAttachmentQueue = [];
this.dmAttachmentUploads.clear();
if (clearInput && this.profileMessageAttachmentInput) {
this.profileMessageAttachmentInput.value = "";
}
this.renderAttachmentQueue();
}
handleAttachmentSelection() {
const input = this.profileMessageAttachmentInput;
if (!(input instanceof HTMLInputElement) || !input.files) {
return;
}
const files = Array.from(input.files);
if (!files.length) {
return;
}
files.forEach((file) => {
const previewUrl =
typeof URL !== "undefined" ? URL.createObjectURL(file) : "";
this.dmAttachmentQueue.push({
id: this.generateAttachmentId(file),
file,
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
previewUrl,
status: "pending",
progress: 0,
});
});
input.value = "";
this.renderAttachmentQueue();
}
renderAttachmentQueue() {
const list = this.profileMessageAttachmentList;
if (!(list instanceof HTMLElement)) {
return;
}
list.innerHTML = "";
if (!this.dmAttachmentQueue.length) {
return;
}
this.dmAttachmentQueue.forEach((entry) => {
const item = document.createElement("div");
item.className = "card flex flex-col gap-2 p-3";
item.dataset.attachmentId = entry.id;
const header = document.createElement("div");
header.className = "flex items-center justify-between gap-2";
const title = document.createElement("div");
title.className = "text-sm font-semibold text-text";
title.textContent = entry.name || "Attachment";
header.appendChild(title);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "btn-ghost focus-ring inline-flex items-center";
removeButton.dataset.size = "sm";
removeButton.textContent = "Remove";
removeButton.addEventListener("click", () => {
this.dmAttachmentQueue = this.dmAttachmentQueue.filter(
(queued) => queued.id !== entry.id,
);
if (entry.previewUrl && typeof URL !== "undefined") {
URL.revokeObjectURL(entry.previewUrl);
}
this.renderAttachmentQueue();
});
header.appendChild(removeButton);
item.appendChild(header);
const meta = document.createElement("div");
meta.className = "text-xs text-muted";
const sizeLabel = formatAttachmentSize(entry.size);
meta.textContent = sizeLabel
? `${entry.type || "file"} · ${sizeLabel}`
: entry.type || "file";
item.appendChild(meta);
if (entry.previewUrl && entry.type?.startsWith("image/")) {
const img = document.createElement("img");
img.src = entry.previewUrl;
img.alt = entry.name || "Attachment preview";
img.className = "h-24 w-24 rounded-lg object-cover";
item.appendChild(img);
}
const progress = document.createElement("progress");
progress.className = "progress";
progress.value = entry.progress || 0;
progress.max = 1;
progress.dataset.variant = "surface";
item.appendChild(progress);
const status = document.createElement("div");
status.className = "text-xs text-muted";
status.textContent =
entry.status === "uploading"
? "Uploading…"
: entry.status === "error"
? entry.error || "Upload failed."
: "Ready to upload.";
item.appendChild(status);
list.appendChild(item);
});
}
async uploadAttachmentQueue(actorPubkey) {
const r2Service = this.services.r2Service;
if (!r2Service) {
throw new Error("Storage service unavailable.");
}
const encrypt =
this.profileMessageAttachmentEncrypt instanceof HTMLInputElement
? this.profileMessageAttachmentEncrypt.checked
: false;
const payloads = [];
for (const entry of this.dmAttachmentQueue) {
entry.status = "uploading";
entry.progress = 0;
this.renderAttachmentQueue();
try {
const payload = await uploadAttachment({
r2Service,
pubkey: actorPubkey,
file: entry.file,
encrypt,
buildKey: buildR2Key,
buildUrl: buildPublicUrl,
onProgress: (fraction) => {
entry.progress = Number.isFinite(fraction) ? fraction : entry.progress;
this.renderAttachmentQueue();
},
});
payloads.push(payload);
entry.status = "uploaded";
entry.progress = 1;
} catch (error) {
entry.status = "error";
entry.error =
error && typeof error.message === "string"
? error.message
: "Attachment upload failed.";
this.renderAttachmentQueue();
throw error;
}
}
return payloads;
}
async renderDirectMessageConversation() {
const container = this.profileMessagesConversation;
const emptyState = this.profileMessagesConversationEmpty;
const actor = this.resolveActiveDmActor();
const recipient = this.resolveActiveDmRecipient();
if (!(container instanceof HTMLElement)) {
return;
}
container.innerHTML = "";
if (!actor || !recipient || !this.directMessagesCache.length) {
container.classList.add("hidden");
container.setAttribute("hidden", "");
if (emptyState instanceof HTMLElement) {
emptyState.classList.remove("hidden");
emptyState.removeAttribute("hidden");
}
return;
}
const messages = this.directMessagesCache
.filter((entry) => this.resolveDirectMessageRemote(entry, actor) === recipient)
.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
if (!messages.length) {
container.classList.add("hidden");
container.setAttribute("hidden", "");
if (emptyState instanceof HTMLElement) {
emptyState.classList.remove("hidden");
emptyState.removeAttribute("hidden");
}
return;
}
if (emptyState instanceof HTMLElement) {
emptyState.classList.add("hidden");
emptyState.setAttribute("hidden", "");
}
void this.maybePublishReadReceipt(messages, {
recipientPubkey: recipient,
});
messages.forEach((message) => {
const item = document.createElement("div");
item.className = "card flex flex-col gap-2 p-3";
const body = document.createElement("div");
body.className = "text-sm text-text whitespace-pre-line";
const text = typeof message.plaintext === "string" ? message.plaintext.trim() : "";
body.textContent = text || "Attachment";
item.appendChild(body);
const attachments = extractAttachmentsFromMessage(message);
attachments.forEach((attachment) => {
const attachmentCard = document.createElement("div");
attachmentCard.className = "flex flex-col gap-2 rounded-lg border border-border/60 p-3";
const title = document.createElement("div");
title.className = "text-xs font-semibold text-text";
title.textContent = describeAttachment(attachment);
attachmentCard.appendChild(title);
const meta = document.createElement("div");
meta.className = "text-3xs text-muted";
const sizeLabel = formatAttachmentSize(attachment.size);
const typeLabel = attachment.type || "file";
meta.textContent = sizeLabel ? `${typeLabel} · ${sizeLabel}` : typeLabel;
attachmentCard.appendChild(meta);
const progress = document.createElement("progress");
progress.className = "progress";
progress.value = 0;
progress.max = 1;
progress.dataset.variant = "surface";
attachmentCard.appendChild(progress);
const status = document.createElement("div");
status.className = "text-3xs text-muted";
status.textContent = attachment.encrypted
? "Decrypting attachment…"
: "Downloading attachment…";
attachmentCard.appendChild(status);
item.appendChild(attachmentCard);
downloadAttachment({
url: attachment.url,
expectedHash: attachment.x,
key: attachment.key,
mimeType: attachment.type,
onProgress: (fraction) => {
progress.value = Number.isFinite(fraction) ? fraction : progress.value;
},
})
.then((result) => {
progress.value = 1;
progress.classList.add("hidden");
progress.setAttribute("hidden", "");
status.textContent = attachment.encrypted ? "Decrypted." : "Ready.";
if (!result?.objectUrl) {
return;
}
if (attachment.type?.startsWith("image/")) {
const img = document.createElement("img");
img.src = result.objectUrl;
img.alt = attachment.name || "Attachment preview";
img.className = "h-40 w-full rounded-lg object-cover";
attachmentCard.appendChild(img);
} else if (attachment.type?.startsWith("video/")) {
const video = document.createElement("video");
video.src = result.objectUrl;
video.controls = true;
video.className = "w-full rounded-lg";
attachmentCard.appendChild(video);
} else if (attachment.type?.startsWith("audio/")) {
const audio = document.createElement("audio");
audio.src = result.objectUrl;
audio.controls = true;
audio.className = "w-full";
attachmentCard.appendChild(audio);
} else {
const link = document.createElement("a");
link.href = result.objectUrl;
link.textContent = "Download attachment";
link.className = "text-xs text-accent underline-offset-2 hover:underline";
link.download = attachment.name || "attachment";
attachmentCard.appendChild(link);
}
})
.catch((error) => {
status.textContent =
error && typeof error.message === "string"
? error.message
: "Attachment download failed.";
status.classList.add("text-critical");
});
});
container.appendChild(item);
});
container.classList.remove("hidden");
container.removeAttribute("hidden");
}
resolveDirectMessageEventId(message) {
if (!message || typeof message !== "object") {
return "";
}
const eventId =
typeof message.event?.id === "string" ? message.event.id.trim() : "";
if (eventId) {
return eventId;
}
const innerId =
typeof message.message?.id === "string" ? message.message.id.trim() : "";
return innerId;
}
resolveLatestDirectMessageForRecipient(recipientPubkey, actorPubkey = null) {
const normalizedRecipient =
typeof recipientPubkey === "string"
? this.normalizeHexPubkey(recipientPubkey)
: "";
if (!normalizedRecipient || !Array.isArray(this.directMessagesCache)) {
return null;
}
const resolvedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
let latest = null;
let latestTimestamp = 0;
for (const entry of this.directMessagesCache) {
if (this.resolveDirectMessageRemote(entry, resolvedActor) !== normalizedRecipient) {
continue;
}
const timestamp = Number(entry?.timestamp) || 0;
if (!latest || timestamp > latestTimestamp) {
latest = entry;
latestTimestamp = timestamp;
}
}
return latest;
}
resolveDirectMessageKind(message) {
if (!message || typeof message !== "object") {
return null;
}
if (Number.isFinite(message?.event?.kind)) {
return Number(message.event.kind);
}
if (Number.isFinite(message?.message?.kind)) {
return Number(message.message.kind);
}
return null;
}
async maybePublishReadReceipt(messages, { recipientPubkey } = {}) {
if (!Array.isArray(messages) || !messages.length) {
return;
}
const settings = this.getDmPrivacySettingsSnapshot();
if (!settings.readReceiptsEnabled) {
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.publishDmReadReceipt !== "function"
) {
return;
}
const normalizedRecipient =
typeof recipientPubkey === "string"
? this.normalizeHexPubkey(recipientPubkey)
: "";
if (!normalizedRecipient) {
return;
}
let latestMessage = null;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const entry = messages[index];
if (
entry &&
typeof entry === "object" &&
entry.direction === "incoming"
) {
const eventId = this.resolveDirectMessageEventId(entry);
if (eventId) {
latestMessage = entry;
break;
}
}
}
if (!latestMessage) {
return;
}
const eventId = this.resolveDirectMessageEventId(latestMessage);
if (!eventId) {
return;
}
const cacheKey = `${normalizedRecipient}:${eventId}`;
if (this.dmReadReceiptCache.has(cacheKey)) {
return;
}
const relayHints = this.buildDmRecipientContext(normalizedRecipient)?.relayHints || [];
const messageKind = this.resolveDirectMessageKind(latestMessage);
try {
const result = await this.services.nostrClient.publishDmReadReceipt({
eventId,
recipientPubkey: normalizedRecipient,
messageKind,
relays: relayHints,
});
if (result?.ok) {
this.dmReadReceiptCache.add(cacheKey);
}
} catch (error) {
devLogger.warn("[profileModal] Failed to publish read receipt:", error);
}
}
async maybePublishTypingIndicator() {
const settings = this.getDmPrivacySettingsSnapshot();
if (!settings.typingIndicatorsEnabled) {
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.publishDmTypingIndicator !== "function"
) {
return;
}
const input = this.profileMessageInput;
const messageText =
input instanceof HTMLTextAreaElement ? input.value.trim() : "";
if (!messageText) {
return;
}
const recipient = this.resolveActiveDmRecipient();
if (!recipient) {
return;
}
const now = Date.now();
if (now - this.dmTypingLastSentAt < TYPING_INDICATOR_COOLDOWN_MS) {
return;
}
this.dmTypingLastSentAt = now;
const relayHints = this.buildDmRecipientContext(recipient)?.relayHints || [];
const latestMessage = this.resolveLatestDirectMessageForRecipient(
recipient,
this.resolveActiveDmActor(),
);
const latestEventId = this.resolveDirectMessageEventId(latestMessage);
try {
await this.services.nostrClient.publishDmTypingIndicator({
recipientPubkey: recipient,
conversationEventId: latestEventId || null,
relays: relayHints,
expiresInSeconds: TYPING_INDICATOR_TTL_SECONDS,
});
} catch (error) {
devLogger.warn("[profileModal] Failed to publish typing indicator:", error);
}
}
describeDirectMessageSendError(code) {
switch (code) {
case "sign-event-unavailable":
return "Connect a Nostr signer to send messages.";
case "encryption-unsupported":
return "Your signer does not support NIP-04 encryption.";
case "nip44-unsupported":
return "Your signer does not support NIP-44 encryption required for NIP-17.";
case "nip17-relays-missing":
return "Recipient has not shared NIP-17 relay hints yet.";
case "nip17-relays-unavailable":
return "No DM relays are available to deliver this message.";
case "nip17-keygen-failed":
return "We couldn’t create secure wrapper keys for NIP-17 delivery.";
case "extension-permission-denied":
return "Please grant your Nostr extension permission to send messages.";
case "missing-actor-pubkey":
return "We couldn’t determine your public key to send this message.";
case "nostr-uninitialized":
return "Direct messages are still connecting to relays. Please try again.";
case "signature-failed":
return "We couldn’t sign the message. Please reconnect your signer and try again.";
case "encryption-failed":
return "We couldn’t encrypt the message. Please try again.";
case "publish-failed":
return "Failed to deliver this message to any relay. Please try again.";
case "invalid-target":
return "Select a valid recipient before sending.";
case "empty-message":
return "Please enter a message or attach a file.";
case "attachments-unsupported":
return "Attachments require NIP-17 delivery. Enable the privacy toggle to send files.";
default:
return "Unable to send message. Please try again.";
}
}
async handleSendProfileMessage() {
const input = this.profileMessageInput;
if (!(input instanceof HTMLTextAreaElement)) {
return;
}
const message = typeof input.value === "string" ? input.value.trim() : "";
const hasAttachments = this.dmAttachmentQueue.length > 0;
if (!message && !hasAttachments) {
this.showError("Please enter a message or attach a file.");
return;
}
const targetHex = this.resolveActiveDmRecipient();
const target =
typeof targetHex === "string" && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(targetHex)
: "";
if (!target) {
this.showError("Please select a message recipient.");
return;
}
const recipientContext = this.buildDmRecipientContext(targetHex);
const recipientRelayHints = Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [];
const useNip17 =
this.profileMessagesPrivacyToggle instanceof HTMLInputElement
? this.profileMessagesPrivacyToggle.checked
: false;
const senderRelayHints = this.getActiveDmRelayPreferences();
if (hasAttachments && !useNip17) {
this.showError(
"Attachments require NIP-17 delivery. Enable the privacy toggle to send files.",
);
return;
}
if (useNip17 && !recipientRelayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
);
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.sendDirectMessage !== "function"
) {
this.showError("Direct message service unavailable.");
return;
}
const sendButton = this.profileMessageSendButton;
if (sendButton instanceof HTMLElement && "disabled" in sendButton) {
sendButton.disabled = true;
sendButton.setAttribute("aria-disabled", "true");
}
try {
let attachmentPayloads = [];
if (hasAttachments) {
try {
attachmentPayloads = await this.uploadAttachmentQueue(
this.resolveActiveDmActor(),
);
} catch (error) {
const messageText =
error && typeof error.message === "string"
? error.message
: "Attachment upload failed.";
this.showError(messageText);
return;
}
}
const result = await this.services.nostrClient.sendDirectMessage(
target,
message,
null,
useNip17
? {
useNip17: true,
recipientRelayHints,
senderRelayHints,
attachments: attachmentPayloads,
}
: {},
);
if (result?.ok) {
input.value = "";
this.resetAttachmentQueue({ clearInput: true });
this.showSuccess("Message sent.");
if (result?.warning === "dm-relays-fallback") {
this.showStatus(
"Privacy warning: this message used default relays because no NIP-17 relay list was found.",
{ autoHideMs: 5000 },
);
}
void this.populateProfileMessages({ force: true, reason: "send-message" });
return;
}
const errorCode =
typeof result?.error === "string" ? result.error : "unknown";
userLogger.warn("[profileModal] Failed to send direct message:", errorCode);
this.showError(this.describeDirectMessageSendError(errorCode));
} catch (error) {
userLogger.error("[profileModal] Unexpected DM send failure:", error);
this.showError("Unable to send message. Please try again.");
} finally {
this.updateMessageComposerState();
}
}
clearProfileMessages({ message } = {}) {
this.directMessagesCache = [];
this.directMessagesLastActor = this.resolveActiveDmActor();
this.messagesInitialLoadPending = true;
this.setDirectMessageRecipient(null, { reason: "clear" });
if (this.profileMessagesList instanceof HTMLElement) {
this.profileMessagesList.innerHTML = "";
this.profileMessagesList.classList.add("hidden");
this.profileMessagesList.setAttribute("hidden", "");
}
const actor = this.directMessagesLastActor;
this.setMessagesLoadingState(actor ? "empty" : "unauthenticated", {
message,
});
if (this.dmAppShellContainer instanceof HTMLElement) {
void this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
}
}
extractDirectMessagePreview(entry) {
if (!entry || typeof entry !== "object") {
return "";
}
const candidates = [];
if (typeof entry.plaintext === "string") {
candidates.push(entry.plaintext);
}
if (typeof entry.preview === "string") {
candidates.push(entry.preview);
}
if (
entry.snapshot &&
typeof entry.snapshot.preview === "string" &&
entry.snapshot.preview.trim()
) {
candidates.push(entry.snapshot.preview);
}
if (entry.message && typeof entry.message.content === "string") {
candidates.push(entry.message.content);
}
const preview = candidates.find((value) => value && value.trim());
return preview ? preview.trim() : "";
}
resolveProfileSummaryForPubkey(pubkey) {
const normalized = this.normalizeHexPubkey(pubkey);
const fallbackNpub =
normalized && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(normalized)
: null;
const formattedNpub =
typeof this.formatShortNpub === "function"
? this.formatShortNpub(fallbackNpub)
: fallbackNpub;
let displayName = formattedNpub || fallbackNpub || "Unknown profile";
let avatarSrc = FALLBACK_PROFILE_AVATAR;
let lightningAddress = "";
let status = "";
if (normalized && typeof this.services.getProfileCacheEntry === "function") {
const cacheEntry = this.services.getProfileCacheEntry(normalized);
const profile = cacheEntry?.profile || null;
if (profile) {
if (typeof profile.display_name === "string" && profile.display_name.trim()) {
displayName = profile.display_name.trim();
} else if (typeof profile.name === "string" && profile.name.trim()) {
displayName = profile.name.trim();
}
if (typeof profile.picture === "string" && profile.picture.trim()) {
avatarSrc = profile.picture.trim();
}
if (typeof profile.lud16 === "string" && profile.lud16.trim()) {
lightningAddress = profile.lud16.trim();
} else if (typeof profile.lud06 === "string" && profile.lud06.trim()) {
lightningAddress = profile.lud06.trim();
}
if (typeof profile.status === "string" && profile.status.trim()) {
status = profile.status.trim();
}
}
}
return {
displayName,
displayNpub: formattedNpub || fallbackNpub || "npub unavailable",
avatarSrc,
lightningAddress,
status,
};
}
formatMessageTimestamp(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return { display: "", iso: "" };
}
try {
const date = new Date(numeric * 1000);
return {
display: date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
iso: date.toISOString(),
};
} catch (error) {
return { display: "", iso: "" };
}
}
buildDmConversationId(actorPubkey, remotePubkey) {
const normalizedActor = this.normalizeHexPubkey(actorPubkey);
const normalizedRemote = this.normalizeHexPubkey(remotePubkey);
if (!normalizedActor || !normalizedRemote) {
return "";
}
return `dm:${[normalizedActor, normalizedRemote].sort().join(":")}`;
}
formatConversationTimestamp(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "";
}
try {
return formatTimeAgo(numeric);
} catch (error) {
return "";
}
}
formatMessageClockTime(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "";
}
try {
const date = new Date(numeric * 1000);
return date.toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
});
} catch (error) {
return "";
}
}
formatMessageDayLabel(timestamp) {
const numeric = Number(timestamp);
if (!Number.isFinite(numeric) || numeric <= 0) {
return "";
}
try {
const date = new Date(numeric * 1000);
const today = new Date();
const startOfToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const startOfMessageDay = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
);
const diffDays = Math.round(
(startOfToday - startOfMessageDay) / (1000 * 60 * 60 * 24),
);
if (diffDays === 0) {
return "Today";
}
if (diffDays === 1) {
return "Yesterday";
}
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch (error) {
return "";
}
}
resolveDirectMessageScheme(message) {
if (!message || typeof message !== "object") {
return "";
}
const scheme =
typeof message.scheme === "string"
? message.scheme
: typeof message.encryption_scheme === "string"
? message.encryption_scheme
: typeof message?.decryptor?.scheme === "string"
? message.decryptor.scheme
: "";
return typeof scheme === "string" ? scheme.trim().toLowerCase() : "";
}
resolveDirectMessageStatus(message) {
if (!message || typeof message !== "object") {
return "sent";
}
const status =
typeof message.status === "string"
? message.status
: typeof message.deliveryStatus === "string"
? message.deliveryStatus
: typeof message.state === "string"
? message.state
: "";
return status ? status.trim() : "sent";
}
resolveDirectMessageBody(message) {
if (!message || typeof message !== "object") {
return "";
}
if (typeof message.plaintext === "string" && message.plaintext.trim()) {
return message.plaintext.trim();
}
const preview = this.extractDirectMessagePreview(message);
if (preview) {
return preview;
}
const attachments = extractAttachmentsFromMessage(message);
if (attachments.length) {
return describeAttachment(attachments[0]);
}
return "";
}
resolveDirectMessagePreviewForConversation(message) {
if (!message || typeof message !== "object") {
return "";
}
const preview = this.extractDirectMessagePreview(message);
if (preview) {
return preview;
}
const attachments = extractAttachmentsFromMessage(message);
if (attachments.length) {
return describeAttachment(attachments[0]);
}
return "";
}
resolveRemoteForConversationId(conversationId, actorPubkey) {
const actor = this.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor());
const normalizedConversationId =
typeof conversationId === "string" ? conversationId.trim() : "";
if (!actor || !normalizedConversationId) {
return null;
}
for (const entry of Array.isArray(this.directMessagesCache) ? this.directMessagesCache : []) {
const remote = this.resolveDirectMessageRemote(entry, actor);
if (!remote) {
continue;
}
const resolvedId = this.buildDmConversationId(actor, remote);
if (resolvedId && resolvedId === normalizedConversationId) {
return remote;
}
}
return null;
}
resolveConversationPrivacyMode(latestMessage) {
const scheme = this.resolveDirectMessageScheme(latestMessage);
if (scheme.includes("nip17") || scheme.includes("nip44")) {
return "nip17";
}
return "nip04";
}
getDirectMessagesForConversation(conversationId, actorPubkey = null) {
const actor = this.normalizeHexPubkey(actorPubkey || this.resolveActiveDmActor());
if (!actor || !conversationId) {
return [];
}
const remote = this.resolveRemoteForConversationId(conversationId, actor);
if (!remote) {
return [];
}
return (Array.isArray(this.directMessagesCache) ? this.directMessagesCache : [])
.filter(
(entry) =>
entry &&
entry.ok === true &&
this.resolveDirectMessageRemote(entry, actor) === remote,
)
.sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0));
}
getLatestDirectMessageTimestampForConversation(conversationId, actorPubkey = null) {
const messages = this.getDirectMessagesForConversation(
conversationId,
actorPubkey,
);
if (!messages.length) {
return 0;
}
const last = messages[messages.length - 1];
return Number(last?.timestamp) || 0;
}
buildDmMessageTimeline(messages, { actorPubkey, remotePubkey } = {}) {
const actor = this.normalizeHexPubkey(actorPubkey);
const remote = this.normalizeHexPubkey(remotePubkey);
if (!actor || !remote) {
return [];
}
const threadMessages = Array.isArray(messages)
? messages.filter((entry) => this.resolveDirectMessageRemote(entry, actor) === remote)
: [];
threadMessages.sort((a, b) => (a?.timestamp || 0) - (b?.timestamp || 0));
const timeline = [];
let lastDayLabel = "";
threadMessages.forEach((message) => {
const dayLabel = this.formatMessageDayLabel(message?.timestamp);
if (dayLabel && dayLabel !== lastDayLabel) {
timeline.push({ type: "day", label: dayLabel });
lastDayLabel = dayLabel;
}
timeline.push({
id: this.resolveDirectMessageEventId(message) || "",
direction: message?.direction || "incoming",
body: this.resolveDirectMessageBody(message) || "Encrypted message",
timestamp: this.formatMessageClockTime(message?.timestamp),
status: this.resolveDirectMessageStatus(message),
});
});
return timeline;
}
async buildDmConversationData(messages, { actorPubkey } = {}) {
const actor = this.normalizeHexPubkey(
actorPubkey || this.resolveActiveDmActor(),
);
if (!actor) {
return {
actor: "",
conversations: [],
activeConversationId: "",
activeThread: null,
activeRemotePubkey: null,
timeline: [],
};
}
const threads = this.groupDirectMessages(messages, actor);
const remoteKeys = new Set();
threads.forEach((thread) => {
if (thread.remoteHex) {
remoteKeys.add(thread.remoteHex);
}
});
if (
remoteKeys.size &&
this.services.batchFetchProfiles &&
typeof this.services.batchFetchProfiles === "function"
) {
try {
await this.services.batchFetchProfiles(remoteKeys);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to fetch DM profile metadata:",
error,
);
}
}
const conversations = threads.map((thread) => {
const conversationId = this.buildDmConversationId(actor, thread.remoteHex);
const summary = this.resolveProfileSummaryForPubkey(thread.remoteHex);
const preview = this.resolveDirectMessagePreviewForConversation(thread.latestMessage);
const unreadCount =
this.nostrService &&
typeof this.nostrService.getDirectMessageUnseenCount === "function" &&
conversationId
? this.nostrService.getDirectMessageUnseenCount(conversationId)
: 0;
const recipientContext = this.buildDmRecipientContext(thread.remoteHex);
return {
id: conversationId,
name: summary.displayName,
preview: preview || "Encrypted message",
timestamp: this.formatConversationTimestamp(thread.latestTimestamp),
unreadCount,
avatarSrc: summary.avatarSrc,
status: summary.status,
pubkey: thread.remoteHex,
lightningAddress: summary.lightningAddress,
relayHints: Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [],
};
});
const conversationMap = new Map();
threads.forEach((thread) => {
const conversationId = this.buildDmConversationId(actor, thread.remoteHex);
if (conversationId) {
conversationMap.set(conversationId, thread);
}
});
const storedRecipient = this.resolveActiveDmRecipient();
const storedConversationId =
storedRecipient && actor
? this.buildDmConversationId(actor, storedRecipient)
: "";
const preferredConversationId =
this.activeDmConversationId || storedConversationId;
const fallbackConversationId = conversations[0]?.id || "";
const activeConversationId =
preferredConversationId && conversationMap.has(preferredConversationId)
? preferredConversationId
: fallbackConversationId;
const activeThread = activeConversationId
? conversationMap.get(activeConversationId)
: null;
const activeRemotePubkey =
activeThread?.remoteHex ||
(activeConversationId
? this.resolveRemoteForConversationId(activeConversationId, actor)
: storedRecipient) ||
null;
if (activeConversationId && this.activeDmConversationId !== activeConversationId) {
this.activeDmConversationId = activeConversationId;
}
return {
actor,
conversations,
activeConversationId,
activeThread,
activeRemotePubkey,
timeline:
activeRemotePubkey && actor
? this.buildDmMessageTimeline(messages, {
actorPubkey: actor,
remotePubkey: activeRemotePubkey,
})
: [],
};
}
groupDirectMessages(messages, actorPubkey) {
const normalizedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
const threadMap = new Map();
for (const entry of Array.isArray(messages) ? messages : []) {
if (!entry || entry.ok !== true) {
continue;
}
const remoteHex = this.resolveDirectMessageRemote(entry, normalizedActor);
if (!remoteHex) {
continue;
}
const thread = threadMap.get(remoteHex) || {
remoteHex,
messages: [],
latestTimestamp: 0,
latestMessage: null,
};
thread.messages.push(entry);
const ts = Number(entry.timestamp) || 0;
if (!thread.latestMessage || ts > thread.latestTimestamp) {
thread.latestTimestamp = ts;
thread.latestMessage = entry;
}
threadMap.set(remoteHex, thread);
}
const threads = Array.from(threadMap.values());
threads.sort((a, b) => (b.latestTimestamp || 0) - (a.latestTimestamp || 0));
return threads;
}
resolveDirectMessageRemote(entry, actorPubkey = null) {
if (!entry || entry.ok !== true) {
return null;
}
const normalizedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
if (typeof entry.remotePubkey === "string") {
const directRemote = this.normalizeHexPubkey(entry.remotePubkey);
if (directRemote && directRemote !== normalizedActor) {
return directRemote;
}
}
if (entry.snapshot && typeof entry.snapshot.remotePubkey === "string") {
const snapshotRemote = this.normalizeHexPubkey(entry.snapshot.remotePubkey);
if (snapshotRemote && snapshotRemote !== normalizedActor) {
return snapshotRemote;
}
}
const direction =
typeof entry.direction === "string" ? entry.direction.toLowerCase() : "";
const senderHex =
entry.sender && typeof entry.sender.pubkey === "string"
? this.normalizeHexPubkey(entry.sender.pubkey)
: null;
if (direction === "incoming" && senderHex && senderHex !== normalizedActor) {
return senderHex;
}
if (Array.isArray(entry.recipients)) {
for (const recipient of entry.recipients) {
const candidate =
recipient && typeof recipient.pubkey === "string"
? this.normalizeHexPubkey(recipient.pubkey)
: null;
if (candidate && candidate !== normalizedActor) {
return candidate;
}
}
}
if (direction === "outgoing" && senderHex && senderHex !== normalizedActor) {
return senderHex;
}
if (entry.message && typeof entry.message.pubkey === "string") {
const messagePubkey = this.normalizeHexPubkey(entry.message.pubkey);
if (messagePubkey && messagePubkey !== normalizedActor) {
return messagePubkey;
}
}
if (entry.event && typeof entry.event.pubkey === "string") {
const eventPubkey = this.normalizeHexPubkey(entry.event.pubkey);
if (eventPubkey && eventPubkey !== normalizedActor) {
return eventPubkey;
}
}
if (senderHex && senderHex !== normalizedActor) {
return senderHex;
}
return null;
}
createDirectMessageThreadItem(thread) {
if (!thread || !thread.remoteHex) {
return null;
}
const item = document.createElement("li");
item.className = "card flex flex-col gap-3 p-4";
item.setAttribute("data-remote-pubkey", thread.remoteHex);
item.dataset.state = "inactive";
const header = document.createElement("div");
header.className = "flex items-start justify-between gap-3";
const summary = this.resolveProfileSummaryForPubkey(thread.remoteHex);
const summaryNode = this.createCompactProfileSummary({
displayName: summary.displayName,
displayNpub: summary.displayNpub,
avatarSrc: summary.avatarSrc,
size: "sm",
});
header.appendChild(summaryNode);
const timestampMeta = this.formatMessageTimestamp(thread.latestTimestamp);
if (timestampMeta.display) {
const timeEl = document.createElement("time");
timeEl.className =
"text-3xs font-semibold uppercase tracking-extra-wide text-muted";
if (timestampMeta.iso) {
timeEl.setAttribute("datetime", timestampMeta.iso);
}
timeEl.textContent = timestampMeta.display;
header.appendChild(timeEl);
}
item.appendChild(header);
const previewText = this.extractDirectMessagePreview(thread.latestMessage);
const previewEl = document.createElement("p");
previewEl.className = "text-sm text-text whitespace-pre-line";
previewEl.textContent = previewText || "Encrypted message";
item.appendChild(previewEl);
const meta = document.createElement("div");
meta.className = "flex flex-wrap items-center gap-2";
const direction =
typeof thread.latestMessage?.direction === "string"
? thread.latestMessage.direction.toLowerCase()
: "";
if (direction) {
const directionPill = document.createElement("span");
directionPill.className = "pill";
directionPill.dataset.variant = direction === "incoming" ? "info" : "muted";
directionPill.textContent =
direction === "incoming"
? "Incoming message"
: direction === "outgoing"
? "Sent message"
: "Message";
meta.appendChild(directionPill);
}
const countPill = document.createElement("span");
countPill.className = "pill";
countPill.dataset.variant = "muted";
const messageCount = Array.isArray(thread.messages)
? thread.messages.length
: 0;
countPill.textContent =
messageCount === 1 ? "1 message" : `${messageCount} messages`;
meta.appendChild(countPill);
const scheme =
typeof thread.latestMessage?.scheme === "string"
? thread.latestMessage.scheme.toUpperCase()
: "";
if (scheme) {
const schemePill = document.createElement("span");
schemePill.className = "pill";
schemePill.dataset.variant = "muted";
schemePill.textContent = scheme;
meta.appendChild(schemePill);
}
item.appendChild(meta);
item.addEventListener("click", () => {
this.setDirectMessageRecipient(thread.remoteHex, {
reason: "thread-select",
});
this.focusMessageComposer();
});
return item;
}
async renderProfileMessages(messages, { actorPubkey = null } = {}) {
if (this.dmAppShellContainer instanceof HTMLElement) {
await this.renderDmAppShell(messages, { actorPubkey });
}
if (!(this.profileMessagesList instanceof HTMLElement)) {
if (!(this.dmAppShellContainer instanceof HTMLElement)) {
this.pendingMessagesRender = {
messages: Array.isArray(messages) ? messages : [],
actorPubkey,
};
}
return;
}
this.pendingMessagesRender = null;
const normalizedActor = actorPubkey
? this.normalizeHexPubkey(actorPubkey)
: this.resolveActiveDmActor();
const threads = this.groupDirectMessages(messages, normalizedActor);
const remoteKeys = new Set();
for (const thread of threads) {
if (thread.remoteHex) {
remoteKeys.add(thread.remoteHex);
}
}
if (
remoteKeys.size &&
this.services.batchFetchProfiles &&
typeof this.services.batchFetchProfiles === "function"
) {
try {
await this.services.batchFetchProfiles(remoteKeys);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to fetch DM profile metadata:",
error,
);
}
}
this.profileMessagesList.innerHTML = "";
if (!threads.length) {
this.profileMessagesList.classList.add("hidden");
this.profileMessagesList.setAttribute("hidden", "");
void this.renderDirectMessageConversation();
return;
}
for (const thread of threads) {
const item = this.createDirectMessageThreadItem(thread);
if (item) {
this.profileMessagesList.appendChild(item);
}
}
const activeRecipient = this.resolveActiveDmRecipient();
const hasActiveRecipient =
activeRecipient &&
threads.some((thread) => thread.remoteHex === activeRecipient);
if (threads.length && !hasActiveRecipient) {
this.setDirectMessageRecipient(threads[0].remoteHex, {
reason: "thread-default",
});
} else if (hasActiveRecipient) {
this.updateMessageThreadSelection(activeRecipient);
}
this.profileMessagesList.classList.remove("hidden");
this.profileMessagesList.removeAttribute("hidden");
void this.renderDirectMessageConversation();
}
async renderDmAppShell(messages, { actorPubkey = null } = {}) {
const container =
this.dmAppShellContainer instanceof HTMLElement
? this.dmAppShellContainer
: null;
if (!container) {
return;
}
const snapshot = Array.isArray(messages) ? messages : this.directMessagesCache;
const {
actor,
conversations,
activeConversationId,
activeThread,
timeline,
} = await this.buildDmConversationData(snapshot, { actorPubkey });
const currentRecipient = this.resolveActiveDmRecipient();
if (!currentRecipient && activeThread?.remoteHex) {
this.setDirectMessageRecipient(activeThread.remoteHex, {
reason: "thread-default",
});
}
const loadingState = this.messagesLoadingState || "idle";
const conversationState =
loadingState === "loading"
? "loading"
: loadingState === "error"
? "error"
: loadingState === "empty" || loadingState === "unauthenticated"
? "empty"
: "idle";
const hasActiveConversation = Boolean(activeConversationId);
const threadState =
conversationState === "loading"
? "loading"
: !hasActiveConversation
? "empty"
: timeline.length
? "idle"
: "empty";
const privacyMode = this.resolveConversationPrivacyMode(
activeThread?.latestMessage,
);
const currentUserSummary = this.resolveProfileSummaryForPubkey(
this.resolveActiveDmActor(),
);
const currentUserAvatarUrl = currentUserSummary?.avatarSrc || "";
container.innerHTML = "";
try {
const dmPrivacySettings = this.getDmPrivacySettingsSnapshot();
this.dmAppShell = new AppShell({
document,
currentUserAvatarUrl,
conversations,
activeConversationId,
conversationState,
messages: timeline,
threadState,
privacyMode,
dmPrivacySettings,
composerState: this.dmComposerState || "idle",
notifications: [],
zapConfig: {
signer: this.services.nostrClient,
},
mobileView: this.dmMobileView || "list",
onSelectConversation: (conversation) => {
void this.handleDmConversationSelect(conversation);
},
onRefreshConversations: () => {
this.populateProfileMessages({ force: true });
},
onBack: () => {
this.dmMobileView = "list";
void this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: this.resolveActiveDmActor(),
});
},
onSendMessage: (messageText, payload) => {
void this.handleDmAppShellSendMessage(messageText, payload);
},
onMarkConversationRead: (conversation) => {
void this.handleDmConversationMarkRead(conversation);
},
onMarkAllRead: () => {
void this.handleDmMarkAllConversationsRead();
},
onToggleReadReceipts: (enabled) => {
this.handleReadReceiptsToggle(enabled);
},
onToggleTypingIndicators: (enabled) => {
this.handleTypingIndicatorsToggle(enabled);
},
});
} catch (error) {
this.dmAppShell = null;
devLogger.warn("[profileModal] Failed to render DM app shell:", error);
return;
}
const root =
this.dmAppShell &&
typeof this.dmAppShell.getRoot === "function"
? this.dmAppShell.getRoot()
: null;
if (!(root instanceof HTMLElement)) {
devLogger.warn("[profileModal] DM app shell root missing.");
return;
}
root.classList.add("bg-transparent");
container.appendChild(root);
if (actor && activeConversationId) {
const renderedUntil =
this.getLatestDirectMessageTimestampForConversation(
activeConversationId,
actor,
) || Date.now() / 1000;
void this.nostrService?.acknowledgeRenderedDirectMessages?.(
activeConversationId,
renderedUntil,
);
}
}
setFocusedDmConversation(conversationId) {
if (
!this.nostrService ||
typeof this.nostrService.setFocusedDirectMessageConversation !== "function"
) {
return;
}
if (
this.focusedDmConversationId &&
this.focusedDmConversationId !== conversationId
) {
this.nostrService.setFocusedDirectMessageConversation(
this.focusedDmConversationId,
false,
);
}
if (conversationId) {
this.nostrService.setFocusedDirectMessageConversation(conversationId, true);
this.focusedDmConversationId = conversationId;
} else {
this.focusedDmConversationId = "";
}
}
async handleDmConversationSelect(conversation) {
const conversationId =
conversation && typeof conversation.id === "string"
? conversation.id.trim()
: "";
if (!conversationId) {
return;
}
this.dmMobileView = "thread";
const actor = this.resolveActiveDmActor();
const remote = this.resolveRemoteForConversationId(conversationId, actor);
this.activeDmConversationId = conversationId;
if (remote) {
this.setDirectMessageRecipient(remote, { reason: "thread-select" });
}
this.setFocusedDmConversation(conversationId);
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
}
async handleDmConversationMarkRead(conversation) {
const conversationId =
conversation && typeof conversation.id === "string"
? conversation.id.trim()
: this.activeDmConversationId;
if (!conversationId) {
return;
}
const actor = this.resolveActiveDmActor();
if (
!actor ||
!this.nostrService ||
typeof this.nostrService.acknowledgeRenderedDirectMessages !== "function"
) {
return;
}
const renderedUntil = this.getLatestDirectMessageTimestampForConversation(
conversationId,
actor,
);
try {
await this.nostrService.acknowledgeRenderedDirectMessages(
conversationId,
renderedUntil,
);
} catch (error) {
devLogger.warn("[profileModal] Failed to mark conversation read:", error);
}
const recipient = this.resolveRemoteForConversationId(conversationId, actor);
const messages = this.getDirectMessagesForConversation(conversationId, actor);
if (recipient && messages.length) {
void this.maybePublishReadReceipt(messages, {
recipientPubkey: recipient,
});
}
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
}
async handleDmMarkAllConversationsRead() {
if (
!this.nostrService ||
typeof this.nostrService.acknowledgeRenderedDirectMessages !== "function"
) {
return;
}
const actor = this.resolveActiveDmActor();
if (!actor) {
return;
}
const summaries =
typeof this.nostrService.listDirectMessageConversationSummaries === "function"
? await this.nostrService.listDirectMessageConversationSummaries()
: [];
const list = Array.isArray(summaries) ? summaries : [];
for (const summary of list) {
const conversationId =
typeof summary?.conversation_id === "string"
? summary.conversation_id.trim()
: typeof summary?.conversationId === "string"
? summary.conversationId.trim()
: "";
if (!conversationId) {
continue;
}
const renderedUntil =
Number(summary?.last_message_at) ||
Number(summary?.downloaded_until) ||
Number(summary?.opened_until) ||
this.getLatestDirectMessageTimestampForConversation(conversationId, actor);
try {
await this.nostrService.acknowledgeRenderedDirectMessages(
conversationId,
renderedUntil,
);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to mark conversation read:",
error,
);
}
const recipient = this.resolveRemoteForConversationId(conversationId, actor);
const messages = this.getDirectMessagesForConversation(conversationId, actor);
if (recipient && messages.length) {
void this.maybePublishReadReceipt(messages, {
recipientPubkey: recipient,
});
}
}
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
}
async handleDmAppShellSendMessage(messageText, payload = {}) {
const normalizedPayload =
payload && typeof payload === "object" ? payload : {};
const message =
typeof messageText === "string" ? messageText.trim() : "";
const attachments = Array.isArray(normalizedPayload.attachments)
? normalizedPayload.attachments
: [];
if (!message && !attachments.length) {
this.showError("Please enter a message or attach a file.");
this.dmComposerState = "error";
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: this.resolveActiveDmActor(),
});
return;
}
const actor = this.resolveActiveDmActor();
const activeConversationId =
this.activeDmConversationId ||
(actor && this.resolveActiveDmRecipient()
? this.buildDmConversationId(actor, this.resolveActiveDmRecipient())
: "");
const targetHex =
this.resolveRemoteForConversationId(activeConversationId, actor) ||
this.resolveActiveDmRecipient();
const target =
typeof targetHex === "string" && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(targetHex)
: "";
if (!target) {
this.showError("Please select a message recipient.");
this.dmComposerState = "error";
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
return;
}
if (
!this.services.nostrClient ||
typeof this.services.nostrClient.sendDirectMessage !== "function"
) {
this.showError("Direct message service unavailable.");
this.dmComposerState = "error";
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
return;
}
const privacyMode =
typeof normalizedPayload.privacyMode === "string"
? normalizedPayload.privacyMode.trim().toLowerCase()
: "nip04";
const useNip17 = privacyMode === "nip17" || privacyMode === "private";
if (attachments.length && !useNip17) {
this.showError(
"Attachments require NIP-17 delivery. Enable the privacy toggle to send files.",
);
this.dmComposerState = "error";
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
return;
}
const recipientContext = this.buildDmRecipientContext(targetHex);
const recipientRelayHints = Array.isArray(recipientContext?.relayHints)
? recipientContext.relayHints
: [];
const senderRelayHints = this.getActiveDmRelayPreferences();
if (useNip17 && !recipientRelayHints.length) {
this.showStatus(
"Privacy warning: this recipient has not shared NIP-17 relays, so we'll use your default relays.",
{ autoHideMs: 5000 },
);
}
this.dmComposerState = "sending";
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
try {
const result = await this.services.nostrClient.sendDirectMessage(
target,
message,
null,
useNip17
? {
useNip17: true,
recipientRelayHints,
senderRelayHints,
attachments,
}
: {},
);
if (result?.ok) {
this.showSuccess("Message sent.");
if (result?.warning === "dm-relays-fallback") {
this.showStatus(
"Privacy warning: this message used default relays because no NIP-17 relay list was found.",
{ autoHideMs: 5000 },
);
}
if (
this.nostrService &&
typeof this.nostrService.loadDirectMessages === "function"
) {
await this.nostrService.loadDirectMessages({
actorPubkey: actor,
initialLoad: false,
});
}
this.dmComposerState = "idle";
} else {
const errorCode =
typeof result?.error === "string" ? result.error : "unknown";
userLogger.warn("[profileModal] Failed to send direct message:", errorCode);
this.showError(this.describeDirectMessageSendError(errorCode));
this.dmComposerState = "error";
}
} catch (error) {
userLogger.error("[profileModal] Unexpected DM send failure:", error);
this.showError("Unable to send message. Please try again.");
this.dmComposerState = "error";
} finally {
await this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
}
}
async populateProfileMessages(options = {}) {
const settings =
options && typeof options === "object" ? options : { force: false };
const { force = false } = settings;
const actor = this.resolveActiveDmActor();
if (!actor) {
this.clearProfileMessages();
return;
}
if (
!this.nostrService ||
typeof this.nostrService.loadDirectMessages !== "function"
) {
this.setMessagesLoadingState("error", {
message: "Direct message service unavailable.",
});
return;
}
if (
!force &&
!this.messagesInitialLoadPending &&
Array.isArray(this.directMessagesCache) &&
this.directMessagesCache.length
) {
await this.renderProfileMessages(this.directMessagesCache, {
actorPubkey: actor,
});
this.setMessagesLoadingState("ready");
return;
}
const requestId = Symbol("messagesLoad");
this.activeMessagesRequest = requestId;
this.messagesInitialLoadPending = false;
this.setMessagesLoadingState("loading");
if (
this.directMessagesLastActor &&
this.directMessagesLastActor !== actor
) {
this.resetDirectMessageSubscription();
if (
typeof this.nostrService.clearDirectMessages === "function"
) {
try {
this.nostrService.clearDirectMessages({ emit: true });
} catch (error) {
devLogger.warn(
"[profileModal] Failed to clear direct messages cache before reload:",
error,
);
}
}
}
try {
let snapshot = await this.nostrService.loadDirectMessages({
actorPubkey: actor,
initialLoad: true,
});
if (!Array.isArray(snapshot)) {
snapshot = [];
}
if (this.activeMessagesRequest !== requestId) {
return;
}
this.directMessagesCache = snapshot;
this.directMessagesLastActor = actor;
await this.renderProfileMessages(snapshot, { actorPubkey: actor });
if (!snapshot.length) {
this.setMessagesLoadingState("empty");
} else {
this.setMessagesLoadingState("ready", {
message:
snapshot.length === 1
? "1 direct message thread loaded."
: `${snapshot.length} direct message threads loaded.`,
});
}
} catch (error) {
if (this.activeMessagesRequest === requestId) {
userLogger.error(
"[profileModal] Failed to load direct messages:",
error,
);
this.setMessagesLoadingState("error", {
message: "Failed to load direct messages. Try again later.",
});
this.messagesInitialLoadPending = true;
}
return;
} finally {
if (this.activeMessagesRequest === requestId) {
this.activeMessagesRequest = null;
this.updateMessagesReloadState();
}
}
void this.ensureDirectMessageSubscription(actor);
}
mountDmAppShell() {
const container =
this.dmAppShellContainer instanceof HTMLElement
? this.dmAppShellContainer
: null;
if (!container) {
return;
}
void this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: this.directMessagesLastActor,
});
}
unmountDmAppShell() {
const container =
this.dmAppShellContainer instanceof HTMLElement
? this.dmAppShellContainer
: null;
if (container) {
container.innerHTML = "";
}
this.dmAppShell = null;
}
resumeProfileMessages() {
this.messagesViewActive = true;
this.mountDmAppShell();
this.updateMessagesReloadState();
}
pauseProfileMessages() {
this.messagesViewActive = false;
this.unmountDmAppShell();
this.updateMessagesReloadState();
}
handleDirectMessagesUpdated(detail = {}) {
if (this.activeMessagesRequest) {
return;
}
const messages = Array.isArray(detail?.messages)
? detail.messages
: [];
this.directMessagesCache = messages;
const actor = this.resolveActiveDmActor();
if (!actor) {
this.setMessagesLoadingState("unauthenticated");
return;
}
this.directMessagesLastActor = actor;
void this.renderProfileMessages(messages, { actorPubkey: actor })
.then(() => {
if (!messages.length) {
this.setMessagesLoadingState("empty");
} else {
this.setMessagesLoadingState("ready");
}
const reason =
typeof detail?.reason === "string" ? detail.reason : "";
if (reason === "subscription") {
this.setMessagesAnnouncement("New direct message received.");
} else if (reason === "load") {
this.setMessagesAnnouncement(
messages.length === 1
? "1 direct message thread synced."
: `${messages.length} direct message threads synced.`,
);
}
})
.catch((error) => {
devLogger.warn(
"[profileModal] Failed to render direct messages after update:",
error,
);
});
}
handleDirectMessagesCleared() {
if (this.activeMessagesRequest) {
return;
}
this.directMessagesCache = [];
this.setDirectMessageRecipient(null, { reason: "clear" });
if (this.profileMessagesList instanceof HTMLElement) {
this.profileMessagesList.innerHTML = "";
this.profileMessagesList.classList.add("hidden");
this.profileMessagesList.setAttribute("hidden", "");
}
const actor = this.resolveActiveDmActor();
if (!actor) {
this.setMessagesLoadingState("unauthenticated");
} else {
this.setMessagesLoadingState("empty");
}
if (this.dmAppShellContainer instanceof HTMLElement) {
void this.renderDmAppShell(this.directMessagesCache, {
actorPubkey: actor,
});
}
}
handleDirectMessagesError(detail = {}) {
const error = detail?.error || detail?.failure || detail;
const reason = detail?.context?.reason || "";
const errorCode = error?.code || "";
const isBenign =
reason === "no-decryptors" ||
errorCode === "decryption-failed" ||
(typeof error === "string" && error.includes("no-decryptors"));
if (isBenign) {
devLogger.info("[profileModal] Direct message sync info:", error);
} else {
userLogger.warn(
"[profileModal] Direct message sync issue detected:",
error,
);
}
if (this.activeMessagesRequest) {
return;
}
if (!this.directMessagesCache.length) {
this.setMessagesLoadingState("error", {
message: "Unable to sync direct messages right now.",
});
return;
}
this.setMessagesAnnouncement("Unable to sync direct messages right now.");
this.updateMessagesReloadState();
}
handleDirectMessagesRelayWarning(detail = {}) {
if (detail?.warning !== "dm-relays-fallback") {
return;
}
this.showStatus(
"Privacy warning: direct messages are using your default relays because no NIP-17 relay list is available.",
{ autoHideMs: 5000 },
);
}
registerEventListeners() {
if (this.closeButton instanceof HTMLElement) {
this.closeButton.addEventListener("click", () => {
this.hide();
});
}
if (this.mobileLogoutButton instanceof HTMLElement) {
this.mobileLogoutButton.addEventListener("click", async () => {
try {
await this.callbacks.onLogout(this);
} catch (error) {
this.showError("Failed to logout. Please try again.");
}
this.hide();
});
}
if (this.profileModalBackButton instanceof HTMLElement) {
this.profileModalBackButton.addEventListener("click", () => {
this.setMobileView("menu", { focusMenu: true });
});
}
if (this.logoutButton instanceof HTMLElement) {
this.logoutButton.addEventListener("click", async () => {
try {
await this.callbacks.onLogout(this);
} catch (error) {
this.showError("Failed to logout. Please try again.");
}
this.hide();
});
}
if (this.channelLink instanceof HTMLElement) {
this.channelLink.addEventListener("click", (event) => {
event.preventDefault();
this.callbacks.onChannelLink(this.channelLink, this);
});
}
if (this.addAccountButton instanceof HTMLElement) {
this.addAccountButton.addEventListener("click", () => {
void this.handleAddAccountRequest();
});
}
Object.entries(this.navButtons).forEach(([name, button]) => {
if (button instanceof HTMLElement) {
button.addEventListener("click", () => {
this.selectPane(name);
});
}
});
if (this.addRelayButton instanceof HTMLElement) {
this.addRelayButton.addEventListener("click", () => {
void this.handleAddRelay();
});
}
if (this.restoreRelaysButton instanceof HTMLElement) {
this.restoreRelaysButton.addEventListener("click", () => {
void this.handleRestoreRelays();
});
}
if (this.relayHealthRefreshButton instanceof HTMLElement) {
this.relayHealthRefreshButton.addEventListener("click", () => {
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "manual" });
});
}
if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
this.relayHealthTelemetryToggle.addEventListener("change", () => {
this.handleRelayHealthTelemetryToggle();
});
}
if (this.addBlockedButton instanceof HTMLElement) {
this.addBlockedButton.addEventListener("click", () => {
void this.handleAddBlockedCreator();
});
}
if (this.blockInput instanceof HTMLElement) {
this.blockInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddBlockedCreator();
}
});
}
if (this.addHashtagInterestButton instanceof HTMLElement) {
this.addHashtagInterestButton.addEventListener("click", () => {
void this.handleAddHashtagPreference("interest");
});
}
if (this.hashtagInterestInput instanceof HTMLElement) {
this.hashtagInterestInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddHashtagPreference("interest");
}
});
}
if (this.addHashtagDisinterestButton instanceof HTMLElement) {
this.addHashtagDisinterestButton.addEventListener("click", () => {
void this.handleAddHashtagPreference("disinterest");
});
}
if (this.hashtagDisinterestInput instanceof HTMLElement) {
this.hashtagDisinterestInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddHashtagPreference("disinterest");
}
});
}
if (this.profileMessagesReloadButton instanceof HTMLElement) {
this.profileMessagesReloadButton.addEventListener("click", () => {
void this.populateProfileMessages({ force: true, reason: "manual" });
});
}
if (this.profileMessagesSendDmButton instanceof HTMLElement) {
this.profileMessagesSendDmButton.addEventListener("click", () => {
void this.handleSendDmRequest();
});
}
if (this.profileMessagesOpenRelaysButton instanceof HTMLElement) {
this.profileMessagesOpenRelaysButton.addEventListener("click", () => {
void this.handleOpenDmRelaysRequest();
});
}
if (this.profileMessagesPrivacyToggle instanceof HTMLElement) {
this.profileMessagesPrivacyToggle.addEventListener("change", (event) => {
const toggle = event.currentTarget;
if (toggle instanceof HTMLInputElement) {
this.handlePrivacyToggle(toggle.checked);
}
});
}
// Legacy toggles removed - handled by DMPrivacySettings in AppShell
if (this.profileLinkPreviewAutoToggle instanceof HTMLElement) {
this.profileLinkPreviewAutoToggle.addEventListener("change", (event) => {
const toggle = event.currentTarget;
if (toggle instanceof HTMLInputElement) {
this.handleLinkPreviewToggle(toggle.checked);
}
});
}
if (this.profileMessagesRelayAddButton instanceof HTMLElement) {
this.profileMessagesRelayAddButton.addEventListener("click", () => {
void this.handleAddDmRelayPreference();
});
}
if (this.profileMessagesRelayInput instanceof HTMLElement) {
this.profileMessagesRelayInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddDmRelayPreference();
}
});
}
if (this.profileMessagesRelayPublishButton instanceof HTMLElement) {
this.profileMessagesRelayPublishButton.addEventListener("click", () => {
void this.handlePublishDmRelayPreferences();
});
}
if (this.profileMessageSendButton instanceof HTMLElement) {
this.profileMessageSendButton.addEventListener("click", () => {
void this.handleSendProfileMessage();
});
}
if (this.profileMessageAttachmentButton instanceof HTMLElement) {
this.profileMessageAttachmentButton.addEventListener("click", () => {
if (this.profileMessageAttachmentInput instanceof HTMLInputElement) {
this.profileMessageAttachmentInput.click();
}
});
}
if (this.profileMessageAttachmentInput instanceof HTMLElement) {
this.profileMessageAttachmentInput.addEventListener("change", () => {
this.handleAttachmentSelection();
});
}
if (this.profileMessageAttachmentClearCache instanceof HTMLElement) {
this.profileMessageAttachmentClearCache.addEventListener("click", () => {
clearAttachmentCache();
const stats = getAttachmentCacheStats();
this.showStatus(
`Attachment cache cleared (${stats.size}/${stats.maxSize}).`,
);
});
}
if (this.profileMessageInput instanceof HTMLElement) {
this.profileMessageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void this.handleSendProfileMessage();
}
});
this.profileMessageInput.addEventListener("input", () => {
void this.maybePublishTypingIndicator();
});
}
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.addEventListener("focus", () => {
this.revealSecretInputValue(this.walletUriInput);
});
this.walletUriInput.addEventListener("blur", () => {
this.handleSecretInputBlur(this.walletUriInput);
this.applyWalletControlState();
});
this.walletUriInput.addEventListener("input", () => {
this.handleSecretInputChange(this.walletUriInput);
this.applyWalletControlState();
});
}
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.addEventListener("input", () => {
this.applyWalletControlState();
});
}
if (this.walletSaveButton instanceof HTMLElement) {
this.walletSaveButton.addEventListener("click", () => {
void this.handleWalletSave();
});
}
if (this.walletTestButton instanceof HTMLElement) {
this.walletTestButton.addEventListener("click", () => {
void this.handleWalletTest();
});
}
if (this.walletDisconnectButton instanceof HTMLElement) {
this.walletDisconnectButton.addEventListener("click", () => {
void this.handleWalletDisconnect();
});
}
if (this.moderationBlurInput instanceof HTMLElement) {
this.moderationBlurInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationAutoplayInput instanceof HTMLElement) {
this.moderationAutoplayInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationMuteHideInput instanceof HTMLElement) {
this.moderationMuteHideInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationSpamHideInput instanceof HTMLElement) {
this.moderationSpamHideInput.addEventListener("input", () => {
this.applyModerationSettingsControlState();
});
}
if (this.moderationSaveButton instanceof HTMLElement) {
this.moderationSaveButton.addEventListener("click", () => {
void this.handleModerationSettingsSave();
});
}
if (this.moderationResetButton instanceof HTMLElement) {
this.moderationResetButton.addEventListener("click", () => {
void this.handleModerationSettingsReset();
});
}
if (!this.boundModerationOverridesUpdate && typeof document !== "undefined") {
this.boundModerationOverridesUpdate = () => {
this.refreshModerationOverridesUi();
};
document.addEventListener(
"video:moderation-override",
this.boundModerationOverridesUpdate,
);
document.addEventListener(
"video:moderation-hide",
this.boundModerationOverridesUpdate,
);
document.addEventListener(
"video:moderation-block",
this.boundModerationOverridesUpdate,
);
}
if (this.addModeratorButton instanceof HTMLElement) {
this.addModeratorButton.addEventListener("click", () => {
void this.handleAddModerator();
});
}
if (this.moderatorInput instanceof HTMLElement) {
this.moderatorInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAddModerator();
}
});
}
if (this.addWhitelistButton instanceof HTMLElement) {
this.addWhitelistButton.addEventListener("click", () => {
void this.handleAdminListMutation("whitelist", "add");
});
}
if (this.whitelistInput instanceof HTMLElement) {
this.whitelistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAdminListMutation("whitelist", "add");
}
});
}
if (this.addBlacklistButton instanceof HTMLElement) {
this.addBlacklistButton.addEventListener("click", () => {
void this.handleAdminListMutation("blacklist", "add");
});
}
if (this.blacklistInput instanceof HTMLElement) {
this.blacklistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
void this.handleAdminListMutation("blacklist", "add");
}
});
}
if (this.storageUnlockBtn instanceof HTMLElement) {
this.storageUnlockBtn.addEventListener("click", () => {
void this.handleUnlockStorage();
});
}
if (this.storageSaveBtn instanceof HTMLElement) {
this.storageSaveBtn.addEventListener("click", () => {
void this.handleSaveStorage();
});
}
if (this.storageTestBtn instanceof HTMLElement) {
this.storageTestBtn.addEventListener("click", () => {
void this.handleTestStorage();
});
}
if (this.storageClearBtn instanceof HTMLElement) {
this.storageClearBtn.addEventListener("click", () => {
void this.handleClearStorage();
});
}
if (this.storageProviderInput instanceof HTMLElement) {
this.storageProviderInput.addEventListener("change", () => {
this.updateStorageFormVisibility();
});
}
if (this.storagePrefixInput instanceof HTMLElement) {
this.storagePrefixInput.addEventListener("input", () => {
this.handlePublicUrlInput();
});
}
if (this.profileEditBtn instanceof HTMLElement) {
this.profileEditBtn.addEventListener("click", () => {
this.handleEditProfile();
});
}
if (this.profileEditBackBtn instanceof HTMLElement) {
this.profileEditBackBtn.addEventListener("click", () => {
this.selectPane("account");
});
}
if (this.editCancelBtn instanceof HTMLElement) {
this.editCancelBtn.addEventListener("click", () => {
this.selectPane("account");
});
}
if (this.editSaveBtn instanceof HTMLElement) {
this.editSaveBtn.addEventListener("click", () => {
void this.handleSaveProfile();
});
}
if (this.editPictureUploadBtn instanceof HTMLElement) {
this.editPictureUploadBtn.addEventListener("click", () => {
if (this.editPictureFile) this.editPictureFile.click();
});
}
if (this.editPictureFile instanceof HTMLElement) {
this.editPictureFile.addEventListener("change", () => {
void this.handleUpload("picture");
});
}
if (this.editBannerUploadBtn instanceof HTMLElement) {
this.editBannerUploadBtn.addEventListener("click", () => {
if (this.editBannerFile) this.editBannerFile.click();
});
}
if (this.editBannerFile instanceof HTMLElement) {
this.editBannerFile.addEventListener("change", () => {
void this.handleUpload("banner");
});
}
if (this.editPictureConfigureLink instanceof HTMLElement) {
this.editPictureConfigureLink.addEventListener("click", (e) => {
e.preventDefault();
this.selectPane("storage");
});
}
if (this.editBannerConfigureLink instanceof HTMLElement) {
this.editBannerConfigureLink.addEventListener("click", (e) => {
e.preventDefault();
this.selectPane("storage");
});
}
this.updateMessagesReloadState();
}
resolveAddAccountLoginError(error, fallbackMessage = "") {
const describe = this.describeLoginErrorService;
const fallback =
typeof fallbackMessage === "string" && fallbackMessage.trim()
? fallbackMessage.trim()
: "Couldn't add that profile. Please try again.";
if (typeof describe === "function") {
try {
const message = describe(error, fallback);
if (typeof message === "string" && message.trim()) {
return message.trim();
}
} catch (describeError) {
devLogger.warn(
"[ProfileModalController] describeLoginError service threw:",
describeError,
);
}
}
return fallback;
}
describeHashtagPreferencesError(error, options = {}) {
const describe = this.describeHashtagPreferencesErrorService;
const { fallbackMessage, operation } =
options && typeof options === "object" ? options : {};
const normalizedOperation =
typeof operation === "string" && operation.trim()
? operation.trim().toLowerCase()
: "update";
const fallback =
typeof fallbackMessage === "string" && fallbackMessage.trim()
? fallbackMessage.trim()
: normalizedOperation === "load"
? "Failed to load hashtag preferences. Please try again."
: normalizedOperation === "reset"
? "Failed to reset hashtag preferences."
: "Failed to update hashtag preferences. Please try again.";
if (typeof describe === "function") {
try {
const message = describe(error, fallback);
if (typeof message === "string" && message.trim()) {
return message.trim();
}
} catch (describeError) {
devLogger.warn(
"[ProfileModalController] describeHashtagPreferencesError service threw:",
describeError,
);
}
}
return fallback;
}
setAddAccountLoading(isLoading) {
if (!(this.addAccountButton instanceof HTMLElement)) {
return;
}
const button = this.addAccountButton;
const titleEl = button.querySelector("[data-profile-add-title]");
const hintEl = button.querySelector("[data-profile-add-hint]");
if (isLoading) {
this.addAccountButtonState = {
originalDisabled: button.disabled,
originalAriaLabel: button.getAttribute("aria-label"),
titleElement: titleEl instanceof HTMLElement ? titleEl : null,
hintElement: hintEl instanceof HTMLElement ? hintEl : null,
originalTitle:
titleEl instanceof HTMLElement ? titleEl.textContent || "" : "",
originalHint:
hintEl instanceof HTMLElement ? hintEl.textContent || "" : "",
};
button.disabled = true;
button.dataset.state = "loading";
button.setAttribute("aria-busy", "true");
button.setAttribute("aria-disabled", "true");
if (this.addAccountButtonState.titleElement) {
this.addAccountButtonState.titleElement.textContent = "Connecting...";
}
if (this.addAccountButtonState.hintElement) {
this.addAccountButtonState.hintElement.textContent =
"Complete the login prompt from your provider.";
}
button.setAttribute(
"aria-label",
"Connecting to your Nostr account",
);
return;
}
const state = this.addAccountButtonState;
if (state) {
button.disabled = !!state.originalDisabled;
if (state.originalDisabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
if (state.titleElement) {
state.titleElement.textContent = state.originalTitle || "";
}
if (state.hintElement) {
state.hintElement.textContent = state.originalHint || "";
}
if (state.originalAriaLabel === null) {
button.removeAttribute("aria-label");
} else if (typeof state.originalAriaLabel === "string") {
button.setAttribute("aria-label", state.originalAriaLabel);
}
} else {
if (button.disabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
}
button.setAttribute("aria-busy", "false");
delete button.dataset.state;
this.addAccountButtonState = null;
}
isAddAccountCancellationError(error) {
if (!error || typeof error !== "object") {
return false;
}
const code =
typeof error.code === "string" && error.code.trim()
? error.code.trim()
: "";
if (!code) {
return false;
}
return ADD_PROFILE_CANCELLATION_CODES.has(code);
}
async handleAddAccountRequest() {
if (!(this.addAccountButton instanceof HTMLElement)) {
return;
}
if (this.addAccountButton.dataset.state === "loading") {
return;
}
const requestLogin = this.requestAddProfileLoginService;
if (typeof requestLogin !== "function") {
devLogger.warn(
"[ProfileModalController] requestAddProfileLogin service unavailable.",
);
this.showError("Login is unavailable right now. Please try again later.");
return;
}
this.setAddAccountLoading(true);
try {
this.bringLoginModalToFront();
} catch (error) {
devLogger.warn(
"[ProfileModalController] Failed to elevate login modal before add profile authentication:",
error,
);
}
let suspendedFocusTrap = false;
try {
this.suspendFocusTrap();
suspendedFocusTrap = true;
} catch (error) {
devLogger.warn(
"[ProfileModalController] Failed to suspend profile modal focus trap before login:",
error,
);
}
try {
const loginResult = await requestLogin({
controller: this,
triggerElement: this.addAccountButton,
});
if (loginResult === undefined) {
return;
}
await this.invokeAddAccountCallback(loginResult);
} catch (error) {
if (this.isAddAccountCancellationError(error)) {
try {
this.log(
"[ProfileModalController] Add profile flow cancelled by user.",
error,
);
} catch (logError) {
devLogger.warn(
"[ProfileModalController] Failed to log cancellation event:",
logError,
);
}
return;
}
devLogger.error(
"[ProfileModalController] Failed to complete add profile authentication:",
error,
);
const message = this.resolveAddAccountLoginError(
error,
"Couldn't add that profile. Please try again.",
);
if (message) {
this.showError(message);
}
} finally {
if (suspendedFocusTrap) {
try {
this.resumeFocusTrap();
} catch (error) {
devLogger.warn(
"[ProfileModalController] Failed to resume profile modal focus trap after login:",
error,
);
}
}
this.setAddAccountLoading(false);
}
}
async invokeAddAccountCallback(loginResult) {
if (typeof this.callbacks.onAddAccount !== "function") {
return;
}
try {
await this.callbacks.onAddAccount({
controller: this,
loginResult,
});
} catch (error) {
devLogger.warn(
"[ProfileModalController] onAddAccount callback threw:",
error,
);
throw error;
}
}
renderSavedProfiles() {
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
const entriesNeedingFetch = new Set();
const savedProfiles = this.getSavedProfiles();
const resolveMeta = (entry) => {
if (!entry || typeof entry !== "object") {
return {
name: "",
picture: FALLBACK_PROFILE_AVATAR,
npub: null,
};
}
const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey);
let cacheEntry = null;
if (normalizedPubkey) {
cacheEntry = this.services.getProfileCacheEntry(normalizedPubkey);
}
const cachedProfile = cacheEntry?.profile || {};
const hasStoredName =
typeof entry.name === "string" && entry.name.trim().length > 0;
const hasStoredPicture =
typeof entry.picture === "string" && entry.picture.trim().length > 0;
if (
!cacheEntry &&
normalizedPubkey &&
(!hasStoredName || !hasStoredPicture)
) {
entriesNeedingFetch.add(normalizedPubkey);
}
let resolvedNpub =
typeof entry.npub === "string" && entry.npub.trim()
? entry.npub.trim()
: null;
if (!resolvedNpub && entry.pubkey) {
resolvedNpub = this.safeEncodeNpub(entry.pubkey);
}
return {
name: cachedProfile.name || entry.name || "",
picture: cachedProfile.picture || entry.picture || FALLBACK_PROFILE_AVATAR,
npub: resolvedNpub,
};
};
const savedEntries = Array.isArray(savedProfiles)
? savedProfiles.filter((entry) => entry && entry.pubkey)
: [];
let activeEntry = null;
if (normalizedActive) {
activeEntry = savedEntries.find(
(entry) => this.normalizeHexPubkey(entry.pubkey) === normalizedActive,
);
}
if (!activeEntry && savedEntries.length) {
activeEntry = savedEntries[0];
}
const activeMeta = activeEntry ? resolveMeta(activeEntry) : null;
const hasActiveProfile = Boolean(activeEntry && activeMeta);
const truncate = this.truncateMiddle || ((value) => value);
const formatNpub =
typeof this.formatShortNpub === "function"
? (value) => this.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const activeNameFallback = activeMeta?.npub
? formatNpub(activeMeta.npub) || DEFAULT_SAVED_PROFILE_LABEL
: DEFAULT_SAVED_PROFILE_LABEL;
const activeDisplayName = hasActiveProfile
? activeMeta?.name?.trim() || activeNameFallback
: "No active profile";
const activeAvatarSrc = hasActiveProfile
? activeMeta?.picture || FALLBACK_PROFILE_AVATAR
: FALLBACK_PROFILE_AVATAR;
if (this.profileName) {
this.profileName.textContent = activeDisplayName;
}
if (this.profileAvatar instanceof HTMLImageElement) {
if (this.profileAvatar.src !== activeAvatarSrc) {
this.profileAvatar.src = activeAvatarSrc;
}
this.profileAvatar.alt = hasActiveProfile
? `${activeDisplayName} avatar`
: "Default profile avatar";
} else if (this.profileAvatar instanceof HTMLElement) {
this.profileAvatar.setAttribute("data-avatar-src", activeAvatarSrc);
}
if (this.profileNpub) {
if (hasActiveProfile && activeMeta?.npub) {
const displayNpub = formatNpub(activeMeta.npub);
this.profileNpub.textContent = displayNpub || "npub unavailable";
} else if (hasActiveProfile) {
this.profileNpub.textContent = "npub unavailable";
} else {
this.profileNpub.textContent = "Link a profile to get started";
}
}
if (this.channelLink instanceof HTMLElement) {
if (hasActiveProfile && activeMeta?.npub) {
const encodedNpub = activeMeta.npub;
this.channelLink.href = `#view=channel-profile&npub=${encodeURIComponent(
encodedNpub,
)}`;
this.channelLink.dataset.targetNpub = encodedNpub;
this.channelLink.classList.remove("hidden");
this.channelLink.setAttribute("aria-hidden", "false");
} else {
this.channelLink.classList.add("hidden");
this.channelLink.removeAttribute("href");
if (this.channelLink.dataset) {
delete this.channelLink.dataset.targetNpub;
}
this.channelLink.setAttribute("aria-hidden", "true");
}
}
if (this.globalProfileAvatar instanceof HTMLImageElement) {
if (this.globalProfileAvatar.src !== activeAvatarSrc) {
this.globalProfileAvatar.src = activeAvatarSrc;
}
this.globalProfileAvatar.alt = hasActiveProfile
? `${activeDisplayName} avatar`
: this.globalProfileAvatar.alt || "Profile avatar";
}
const listEl = this.switcherList;
if (listEl instanceof HTMLElement) {
listEl.innerHTML = "";
let normalizedSelection = this.normalizeHexPubkey(
this.profileSwitcherSelectionPubkey,
);
if (normalizedSelection && normalizedSelection === normalizedActive) {
normalizedSelection = null;
this.profileSwitcherSelectionPubkey = null;
}
const entriesToRender = savedEntries.filter((entry) => {
const normalized = this.normalizeHexPubkey(entry.pubkey);
return normalized && normalized !== normalizedActive;
});
if (!entriesToRender.length) {
listEl.setAttribute("data-profile-switcher-empty", "true");
const helper = document.createElement("p");
helper.className = "text-sm text-muted";
helper.textContent = "No other profiles saved yet.";
helper.setAttribute("role", "note");
listEl.appendChild(helper);
} else {
listEl.removeAttribute("data-profile-switcher-empty");
entriesToRender.forEach((entry) => {
const meta = resolveMeta(entry);
const button = document.createElement("button");
button.type = "button";
button.className =
"card focus-ring flex w-full items-center gap-4 p-4 text-left transition";
button.dataset.pubkey = entry.pubkey;
if (meta.npub) {
button.dataset.npub = meta.npub;
}
const normalizedAuthType =
typeof entry.authType === "string" && entry.authType.trim()
? entry.authType.trim()
: null;
if (normalizedAuthType) {
button.dataset.authType = normalizedAuthType;
}
const normalizedPubkey = this.normalizeHexPubkey(entry.pubkey);
const isSelected =
normalizedSelection && normalizedPubkey === normalizedSelection;
if (isSelected) {
button.dataset.state = "active";
button.setAttribute("aria-pressed", "true");
} else {
delete button.dataset.state;
button.setAttribute("aria-pressed", "false");
}
const avatarSpan = document.createElement("span");
avatarSpan.className =
"flex h-12 w-12 flex-shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-overlay-strong bg-overlay-panel-soft";
const avatarImg = document.createElement("img");
avatarImg.className = "h-full w-full object-cover";
avatarImg.src = meta.picture || FALLBACK_PROFILE_AVATAR;
const cardDisplayName =
meta.name?.trim() ||
(meta.npub
? formatNpub(meta.npub) || DEFAULT_SAVED_PROFILE_LABEL
: DEFAULT_SAVED_PROFILE_LABEL);
avatarImg.alt = `${cardDisplayName} avatar`;
avatarSpan.appendChild(avatarImg);
const metaSpan = document.createElement("div");
metaSpan.className = "flex min-w-0 flex-1 flex-col gap-2";
const topLine = document.createElement("div");
topLine.className =
"flex flex-wrap items-center justify-between gap-3";
const providerId = this.getEntryProviderId(entry);
const providerInfo = this.resolveEntryProviderMetadata(entry);
const providerLabel =
(providerInfo && providerInfo.label) || DEFAULT_SAVED_PROFILE_LABEL;
const badgeVariant = resolveProviderBadgeClass(
providerInfo && providerInfo.badgeVariant,
);
const resolvedProviderId =
providerId || (providerInfo && providerInfo.id) || "";
const label = document.createElement("span");
label.className = `${PROVIDER_BADGE_BASE_CLASS} ${badgeVariant}`;
label.textContent = providerLabel;
label.dataset.providerVariant =
(providerInfo && providerInfo.badgeVariant) || "neutral";
if (providerId) {
label.dataset.providerId = providerId;
} else if (providerInfo && providerInfo.id) {
label.dataset.providerId = providerInfo.id;
}
const actionGroup = document.createElement("div");
actionGroup.className = "flex flex-wrap items-center gap-2";
const action = document.createElement("span");
action.className = "text-xs font-medium text-muted";
action.setAttribute("aria-hidden", "true");
action.textContent = isSelected ? "Selected" : "Switch";
actionGroup.appendChild(action);
const logoutButton = this.createSavedProfileLogoutButton({
entry,
providerId: resolvedProviderId || null,
cardButton: button,
displayName: cardDisplayName,
});
if (logoutButton) {
actionGroup.appendChild(logoutButton);
}
topLine.append(label, actionGroup);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate text-sm font-semibold text-primary";
nameSpan.textContent = cardDisplayName;
const npubSpan = document.createElement("span");
npubSpan.className = "break-all font-mono text-xs text-muted";
if (meta.npub) {
const displayNpub = formatNpub(meta.npub);
npubSpan.textContent = displayNpub || "npub unavailable";
} else {
npubSpan.textContent = "npub unavailable";
}
metaSpan.append(topLine, nameSpan, npubSpan);
button.append(avatarSpan, metaSpan);
const ariaLabel = isSelected
? `${cardDisplayName} selected`
: `Switch to ${cardDisplayName}`;
button.setAttribute("aria-label", ariaLabel);
if (resolvedProviderId) {
button.dataset.providerId = resolvedProviderId;
} else {
delete button.dataset.providerId;
}
const activateProfile = async (event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (button.dataset.loading === "true") {
return;
}
button.dataset.loading = "true";
button.setAttribute("aria-busy", "true");
const logProviderId =
providerId || (providerInfo && providerInfo.id) || normalizedAuthType || "unknown";
try {
await this.switchProfile(entry.pubkey, {
entry,
providerId: providerId || null,
});
} catch (error) {
userLogger.error(
`[ProfileModalController] Failed to switch profile for provider ${logProviderId}:`,
error,
);
} finally {
button.dataset.loading = "false";
button.setAttribute("aria-busy", "false");
}
};
button.addEventListener("click", activateProfile);
button.addEventListener("keydown", (event) => {
const key = event?.key;
if (key === "Enter" || key === " " || key === "Spacebar") {
activateProfile(event);
}
});
listEl.appendChild(button);
});
}
this.updateFocusTrap();
} else {
this.updateFocusTrap();
}
if (entriesNeedingFetch.size) {
this.services.batchFetchProfiles(entriesNeedingFetch);
}
}
async handleSavedProfileLogout({
entry,
providerId,
triggerButton,
cardButton,
displayName,
} = {}) {
if (!entry || typeof entry !== "object") {
this.showError("Failed to logout this account. Please try again.");
return { loggedOut: false, reason: "invalid-entry" };
}
const targetPubkey =
typeof entry.pubkey === "string" && entry.pubkey.trim()
? entry.pubkey.trim()
: "";
if (!targetPubkey) {
this.showError("Failed to logout this account. Please try again.");
return { loggedOut: false, reason: "invalid-pubkey" };
}
if (
!this.callbacks.onRequestLogoutProfile ||
this.callbacks.onRequestLogoutProfile === noop
) {
this.showError("Account logout is not available right now.");
return { loggedOut: false, reason: "logout-unavailable" };
}
const revertUiState = () => {
if (triggerButton instanceof HTMLElement) {
triggerButton.disabled = false;
triggerButton.removeAttribute("aria-busy");
delete triggerButton.dataset.state;
}
if (cardButton instanceof HTMLElement) {
cardButton.disabled = false;
cardButton.removeAttribute("aria-busy");
delete cardButton.dataset.loading;
}
};
if (triggerButton instanceof HTMLElement) {
triggerButton.dataset.state = "loading";
triggerButton.disabled = true;
triggerButton.setAttribute("aria-busy", "true");
}
if (cardButton instanceof HTMLElement) {
cardButton.dataset.loading = "true";
cardButton.disabled = true;
cardButton.setAttribute("aria-busy", "true");
}
let result;
try {
result = await this.callbacks.onRequestLogoutProfile({
controller: this,
pubkey: targetPubkey,
entry,
providerId: this.normalizeProviderId(providerId) || null,
});
} catch (error) {
this.showError("Failed to logout this account. Please try again.");
result = { loggedOut: false, error, reason: "logout-error" };
} finally {
revertUiState();
}
if (!result || typeof result !== "object") {
this.showError("Failed to logout this account. Please try again.");
return { loggedOut: false, reason: "unknown" };
}
if (result.loggedOut || result.removed) {
const successName =
typeof displayName === "string" && displayName.trim()
? displayName.trim()
: null;
const message = successName
? `${successName} logged out.`
: "Account logged out.";
this.profileSwitcherSelectionPubkey = null;
this.showSuccess(message);
this.renderSavedProfiles();
return { ...result, loggedOut: true };
}
if (result.reason === "not-found") {
this.profileSwitcherSelectionPubkey = null;
this.showStatus("This account is no longer connected.");
this.renderSavedProfiles();
return result;
}
if (result.reason === "active-profile") {
return result;
}
if (result.error) {
this.showError("Failed to logout this account. Please try again.");
return result;
}
this.showError("Failed to logout this account. Please try again.");
return result;
}
createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
size = "sm",
} = {}) {
const sizeClassMap = {
xs: "h-8 w-8",
sm: "h-10 w-10",
md: "h-12 w-12",
};
const avatarSize = sizeClassMap[size] || sizeClassMap.sm;
const safeName = displayName?.trim() || "Unknown profile";
const safeNpub = displayNpub?.trim() || "npub unavailable";
const avatarUrl = avatarSrc || FALLBACK_PROFILE_AVATAR;
const container = document.createElement("div");
container.className = "min-w-0 flex flex-1 items-center gap-2";
const avatarWrapper = document.createElement("span");
avatarWrapper.className = `flex ${avatarSize} flex-shrink-0 items-center justify-center overflow-hidden rounded-full border border-overlay-strong bg-overlay-panel-soft`;
const avatarImg = document.createElement("img");
avatarImg.className = "h-full w-full object-cover";
avatarImg.src = avatarUrl;
avatarImg.alt = `${safeName} avatar`;
avatarWrapper.appendChild(avatarImg);
const textStack = document.createElement("div");
textStack.className = "min-w-0 flex flex-col";
const nameEl = document.createElement("p");
nameEl.className = "truncate text-xs font-semibold text-primary";
nameEl.textContent = safeName;
const npubEl = document.createElement("p");
npubEl.className = "break-all font-mono text-2xs text-muted";
npubEl.textContent = safeNpub;
textStack.append(nameEl, npubEl);
container.append(avatarWrapper, textStack);
return container;
}
createViewChannelButton({ targetNpub, displayNpub } = {}) {
const normalizedTarget =
typeof targetNpub === "string" && targetNpub.trim()
? targetNpub.trim()
: "";
if (!normalizedTarget) {
return null;
}
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs";
button.textContent = "View channel";
button.dataset.targetNpub = normalizedTarget;
if (displayNpub && typeof displayNpub === "string") {
button.setAttribute(
"aria-label",
`View channel ${displayNpub.trim() || normalizedTarget}`,
);
button.title = `View channel ${displayNpub.trim() || normalizedTarget}`;
}
button.addEventListener("click", () => {
this.callbacks.onChannelLink(button, this);
});
return button;
}
createCopyNpubButton({ targetNpub, displayNpub } = {}) {
const normalizedTarget =
typeof targetNpub === "string" && targetNpub.trim()
? targetNpub.trim()
: "";
if (!normalizedTarget) {
return null;
}
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs";
button.textContent = "Copy npub";
if (displayNpub && typeof displayNpub === "string") {
button.setAttribute(
"aria-label",
`Copy ${displayNpub.trim() || normalizedTarget}`,
);
button.title = `Copy ${displayNpub.trim() || normalizedTarget}`;
}
const handleCopy = async () => {
if (button.dataset.state === "loading") {
return;
}
button.dataset.state = "loading";
button.disabled = true;
button.setAttribute("aria-busy", "true");
try {
await this.copyNpubToClipboard(normalizedTarget, { displayNpub });
} finally {
button.disabled = false;
button.removeAttribute("aria-busy");
delete button.dataset.state;
}
};
button.addEventListener("click", () => {
void handleCopy();
});
return button;
}
createSavedProfileLogoutButton({
entry,
cardButton,
providerId,
displayName,
} = {}) {
if (this.callbacks.onRequestLogoutProfile === noop) {
return null;
}
if (!entry || typeof entry !== "object" || !entry.pubkey) {
return null;
}
const safeName =
typeof displayName === "string" && displayName.trim()
? displayName.trim()
: "this account";
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-2xs";
button.dataset.variant = "critical";
button.dataset.role = "logout";
button.textContent = "Logout";
button.setAttribute("aria-label", `Log out ${safeName}`);
button.title = `Log out ${safeName}`;
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (button.dataset.state === "loading") {
return;
}
void this.handleSavedProfileLogout({
entry,
providerId,
triggerButton: button,
cardButton,
displayName: safeName,
});
});
return button;
}
createRemoveButton({
label = "Remove",
confirmMessage,
confirmValue,
onRemove,
} = {}) {
if (typeof onRemove !== "function") {
return null;
}
const button = document.createElement("button");
button.type = "button";
button.className = "btn-ghost focus-ring text-xs profile-modal__remove-button";
button.dataset.variant = "critical";
button.dataset.role = "remove";
button.textContent = label;
const handleRemove = async () => {
if (confirmMessage) {
const replacement =
typeof confirmValue === "string" && confirmValue.trim()
? confirmValue.trim()
: "this entry";
const prompt = confirmMessage.replace("{npub}", replacement);
if (!window.confirm(prompt)) {
return;
}
}
button.disabled = true;
button.setAttribute("aria-busy", "true");
try {
await onRemove(button);
} catch (error) {
userLogger.error("Failed to remove entry:", error);
} finally {
button.disabled = false;
button.removeAttribute("aria-busy");
}
};
button.addEventListener("click", () => {
void handleRemove();
});
return button;
}
async copyNpubToClipboard(npub, { displayNpub } = {}) {
const normalized =
typeof npub === "string" && npub.trim() ? npub.trim() : "";
if (!normalized) {
this.showError("Unable to copy npub. Invalid value provided.");
return { copied: false, reason: "invalid" };
}
const clipboard = (() => {
if (
this.services?.clipboard &&
typeof this.services.clipboard.writeText === "function"
) {
return this.services.clipboard;
}
if (
typeof navigator !== "undefined" &&
navigator?.clipboard &&
typeof navigator.clipboard.writeText === "function"
) {
return navigator.clipboard;
}
return null;
})();
if (!clipboard) {
this.showError("Copy to clipboard is not supported in this browser.");
return { copied: false, reason: "unsupported" };
}
try {
await clipboard.writeText(normalized);
this.showSuccess("npub copied to clipboard!");
return { copied: true };
} catch (error) {
userLogger.error("Failed to copy npub:", error);
this.showError("Failed to copy npub. Please try again.");
return { copied: false, error };
}
}
setupLayoutBreakpointObserver() {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
this.largeLayoutQuery = null;
this.largeLayoutQueryListener = null;
this.isLargeLayoutActiveFlag = false;
return;
}
if (this.largeLayoutQuery && this.largeLayoutQueryListener) {
this.teardownLayoutBreakpointObserver();
}
try {
const breakpointLg = getBreakpointLg();
const query = window.matchMedia(`(min-width: ${breakpointLg})`);
const handler = (event) => {
const matches =
typeof event?.matches === "boolean" ? event.matches : query.matches;
this.handleLayoutBreakpointChange(matches);
};
this.largeLayoutQuery = query;
this.largeLayoutQueryListener = handler;
if (typeof query.addEventListener === "function") {
query.addEventListener("change", handler);
} else if (typeof query.addListener === "function") {
query.addListener(handler);
}
this.handleLayoutBreakpointChange(query.matches);
} catch (error) {
this.largeLayoutQuery = null;
this.largeLayoutQueryListener = null;
devLogger.warn(
"[profileModal] Failed to initialize responsive breakpoint observer:",
error,
);
this.handleLayoutBreakpointChange(false);
}
}
teardownLayoutBreakpointObserver() {
const query = this.largeLayoutQuery;
const handler = this.largeLayoutQueryListener;
if (query && handler) {
if (typeof query.removeEventListener === "function") {
query.removeEventListener("change", handler);
} else if (typeof query.removeListener === "function") {
query.removeListener(handler);
}
}
this.largeLayoutQuery = null;
this.largeLayoutQueryListener = null;
}
handleLayoutBreakpointChange(matches) {
const isLarge = Boolean(matches);
this.isLargeLayoutActiveFlag = isLarge;
if (isLarge) {
this.setMobileView("pane", { skipFocusTrap: false });
return;
}
const targetView = this.lastMobileViewState || this.mobileViewState || "menu";
this.setMobileView(targetView, { skipFocusTrap: false });
}
isLargeLayoutActive() {
return Boolean(this.isLargeLayoutActiveFlag);
}
isMobileLayoutActive() {
return !this.isLargeLayoutActive();
}
focusActiveNavButton() {
const active = this.getActivePane();
const candidates = [];
if (active && this.navButtons[active] instanceof HTMLElement) {
candidates.push(this.navButtons[active]);
}
Object.values(this.navButtons).forEach((button) => {
if (
button instanceof HTMLElement &&
!candidates.includes(button) &&
!button.classList.contains("hidden")
) {
candidates.push(button);
}
});
const target = candidates.find((button) => button instanceof HTMLElement);
if (!target) {
return;
}
window.requestAnimationFrame(() => {
try {
target.focus();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to focus active navigation button:",
error,
);
}
});
}
setMobileView(view = "menu", options = {}) {
const normalizedView = view === "pane" ? "pane" : "menu";
const settings =
options && typeof options === "object" ? options : { skipFocusTrap: false };
const skipFocusTrap = Boolean(settings.skipFocusTrap);
const focusMenu = Boolean(settings.focusMenu);
const layoutElement =
this.profileModalLayout instanceof HTMLElement
? this.profileModalLayout
: null;
const paneWrapper =
this.profileModalPaneWrapper instanceof HTMLElement
? this.profileModalPaneWrapper
: null;
const menuWrapper =
this.profileModalMenu instanceof HTMLElement
? this.profileModalMenu
: null;
const panelElement =
this.profileModalPanel instanceof HTMLElement
? this.profileModalPanel
: null;
const rootElement =
this.profileModalRoot instanceof HTMLElement ? this.profileModalRoot : null;
const backButton =
this.profileModalBackButton instanceof HTMLElement
? this.profileModalBackButton
: null;
const isLarge = this.isLargeLayoutActive();
this.mobileViewState = normalizedView;
if (!isLarge) {
this.lastMobileViewState = normalizedView;
}
if (layoutElement) {
layoutElement.dataset.mobileView = normalizedView;
}
if (panelElement) {
panelElement.dataset.mobileView = normalizedView;
}
if (rootElement) {
rootElement.dataset.mobileView = normalizedView;
}
const menuHidden = !isLarge && normalizedView === "pane";
const paneHidden = !isLarge && normalizedView === "menu";
if (menuWrapper) {
menuWrapper.setAttribute("aria-hidden", menuHidden ? "true" : "false");
if (menuHidden) {
menuWrapper.classList.add("hidden");
menuWrapper.setAttribute("hidden", "");
} else {
menuWrapper.classList.remove("hidden");
menuWrapper.removeAttribute("hidden");
}
}
if (paneWrapper) {
paneWrapper.setAttribute("aria-hidden", paneHidden ? "true" : "false");
if (paneHidden) {
paneWrapper.classList.add("hidden");
paneWrapper.setAttribute("hidden", "");
} else {
paneWrapper.classList.remove("hidden");
paneWrapper.removeAttribute("hidden");
}
}
if (backButton) {
if (isLarge || normalizedView === "menu") {
backButton.classList.add("hidden");
backButton.setAttribute("aria-hidden", "true");
} else {
backButton.classList.remove("hidden");
backButton.setAttribute("aria-hidden", "false");
}
}
if (!skipFocusTrap) {
this.updateFocusTrap();
}
if (focusMenu && normalizedView === "menu") {
this.focusActiveNavButton();
}
return normalizedView;
}
selectPane(name = "account", options = {}) {
const { keepMenuView = false } =
options && typeof options === "object" ? options : {};
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
const previous = this.getActivePane();
const availableKeys = Object.keys(this.panes).filter((key) => {
const pane = this.panes[key];
if (!(pane instanceof HTMLElement)) {
return false;
}
const button = this.navButtons[key];
if (button instanceof HTMLElement && button.classList.contains("hidden")) {
return false;
}
return true;
});
const fallbackTarget = availableKeys.includes("account")
? "account"
: availableKeys[0] || "account";
const target = availableKeys.includes(normalized)
? normalized
: fallbackTarget;
if (previous === "history" && target !== "history") {
try {
this.profileHistoryRenderer?.pause();
} catch (error) {
userLogger.warn("[profileModal] Failed to pause history renderer:", error);
}
}
if (previous === "messages" && target !== "messages") {
this.pauseProfileMessages();
}
Object.entries(this.panes).forEach(([key, pane]) => {
if (!(pane instanceof HTMLElement)) {
return;
}
const isActive = key === target;
pane.classList.toggle("hidden", !isActive);
pane.setAttribute("aria-hidden", (!isActive).toString());
});
Object.entries(this.navButtons).forEach(([key, button]) => {
if (!(button instanceof HTMLElement)) {
return;
}
const isActive = key === target;
button.setAttribute("aria-selected", isActive ? "true" : "false");
if (isActive) {
button.dataset.state = "active";
} else {
delete button.dataset.state;
}
});
this.setActivePane(target);
const isMobile = this.isMobileLayoutActive();
const shouldStayInMenu = keepMenuView && isMobile;
this.setMobileView(shouldStayInMenu ? "menu" : "pane");
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
if (target === "history") {
void this.populateProfileWatchHistory();
} else if (target === "relays") {
this.populateProfileRelays();
void this.refreshRelayHealthPanel({
forceRefresh: true,
reason: "pane-select",
});
} else if (target === "messages") {
this.resumeProfileMessages();
void this.populateProfileMessages({ reason: "pane-select" });
void this.refreshDmRelayPreferences();
} else if (target === "wallet") {
this.refreshWalletPaneState();
} else if (target === "storage") {
this.populateStoragePane();
} else if (target === "hashtags") {
this.populateHashtagPreferences();
} else if (target === "subscriptions") {
if (activeHex && this.subscriptionsService) {
this.subscriptionsService.loadSubscriptions(activeHex).catch(noop);
}
void this.populateSubscriptionsList();
} else if (target === "blocked") {
if (activeHex && this.services.userBlocks) {
this.services.userBlocks.loadBlocks(activeHex).catch(noop);
}
this.populateBlockedList();
} else if (target === "safety") {
this.refreshModerationSettingsUi();
this.syncLinkPreviewSettingsUi();
}
this.callbacks.onSelectPane(target, { controller: this });
this.callbacks.onPaneShown(target, { controller: this });
}
populateProfileRelays(relayEntries = null) {
if (!this.relayList) {
return;
}
const sourceEntries = Array.isArray(relayEntries)
? relayEntries
: this.services.relayManager.getEntries();
const relays = sourceEntries
.map((entry) => {
if (typeof entry === "string") {
const trimmed = entry.trim();
return trimmed ? { url: trimmed, mode: "both" } : null;
}
if (entry && typeof entry === "object") {
const url = typeof entry.url === "string" ? entry.url.trim() : "";
if (!url) {
return null;
}
const mode = typeof entry.mode === "string" ? entry.mode : "both";
const normalizedMode =
mode === "read" || mode === "write" ? mode : "both";
return {
url,
mode: normalizedMode,
read: entry.read !== false,
write: entry.write !== false,
};
}
return null;
})
.filter((entry) => entry && typeof entry.url === "string");
this.relayList.innerHTML = "";
if (!relays.length) {
const emptyState = document.createElement("li");
emptyState.className =
"card border border-dashed border-surface-strong p-4 text-center text-sm text-muted";
emptyState.textContent = "No relays configured.";
this.relayList.appendChild(emptyState);
return;
}
relays.forEach((entry) => {
const item = document.createElement("li");
item.className =
"card flex items-start justify-between gap-4 p-4";
const info = document.createElement("div");
info.className = "flex-1 min-w-0";
const urlEl = document.createElement("p");
urlEl.className = "text-sm font-medium text-primary break-all";
urlEl.textContent = entry.url;
const statusEl = document.createElement("p");
statusEl.className = "mt-1 text-xs text-muted";
let modeLabel = "Read & write";
if (entry.mode === "read") {
modeLabel = "Read only";
} else if (entry.mode === "write") {
modeLabel = "Write only";
}
statusEl.textContent = modeLabel;
info.appendChild(urlEl);
info.appendChild(statusEl);
const actions = document.createElement("div");
actions.className = "flex items-center gap-2";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "btn-ghost focus-ring text-xs";
editBtn.textContent = "Change mode";
editBtn.title = "Cycle between read-only, write-only, or read/write modes.";
editBtn.addEventListener("click", () => {
void this.handleRelayModeToggle(entry.url);
});
const removeBtn = document.createElement("button");
removeBtn.type = "button";
removeBtn.className = "btn-ghost focus-ring text-xs";
removeBtn.dataset.variant = "danger";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => {
void this.handleRemoveRelay(entry.url);
});
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
item.appendChild(info);
item.appendChild(actions);
this.relayList.appendChild(item);
});
}
updateRelayHealthStatus(message = "") {
if (!this.relayHealthStatus) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
this.relayHealthStatus.textContent = text;
}
renderRelayHealthSnapshot(snapshot = []) {
if (!this.relayHealthList) {
return;
}
this.relayHealthList.innerHTML = "";
if (!snapshot.length) {
const emptyState = document.createElement("li");
emptyState.className =
"card border border-dashed border-surface-strong p-4 text-center text-sm text-muted";
emptyState.textContent = "No relays configured.";
this.relayHealthList.appendChild(emptyState);
return;
}
snapshot.forEach((entry) => {
const item = document.createElement("li");
item.className = "card flex flex-col gap-2 p-4";
const header = document.createElement("div");
header.className = "flex items-center justify-between gap-3";
const urlEl = document.createElement("p");
urlEl.className = "text-sm font-medium text-primary break-all";
urlEl.textContent = entry.url;
const connection = document.createElement("span");
const connected = Boolean(entry.connected);
connection.className = `text-xs font-semibold ${
connected ? "text-status-success" : "text-status-danger"
}`;
connection.textContent = connected ? "Connected" : "Unavailable";
header.appendChild(urlEl);
header.appendChild(connection);
const details = document.createElement("div");
details.className = "grid grid-cols-2 gap-3 text-xs text-muted sm:grid-cols-3";
const latency = document.createElement("div");
latency.className = "flex items-center justify-between gap-2";
const latencyLabel = document.createElement("span");
latencyLabel.textContent = "Latency";
const latencyValue = document.createElement("span");
latencyValue.className = "text-text";
latencyValue.textContent = Number.isFinite(entry.lastLatencyMs)
? `${entry.lastLatencyMs} ms`
: "—";
latency.appendChild(latencyLabel);
latency.appendChild(latencyValue);
const errors = document.createElement("div");
errors.className = "flex items-center justify-between gap-2";
const errorsLabel = document.createElement("span");
errorsLabel.textContent = "Errors";
const errorsValue = document.createElement("span");
errorsValue.className = "text-text";
errorsValue.textContent = Number.isFinite(entry.errorCount)
? `${entry.errorCount}`
: "0";
errors.appendChild(errorsLabel);
errors.appendChild(errorsValue);
const checks = document.createElement("div");
checks.className = "flex items-center justify-between gap-2";
const checksLabel = document.createElement("span");
checksLabel.textContent = "Checked";
const checksValue = document.createElement("span");
checksValue.className = "text-text";
if (Number.isFinite(entry.lastCheckedAt) && entry.lastCheckedAt > 0) {
checksValue.textContent = new Date(entry.lastCheckedAt).toLocaleTimeString();
} else {
checksValue.textContent = "—";
}
checks.appendChild(checksLabel);
checks.appendChild(checksValue);
details.appendChild(latency);
details.appendChild(errors);
details.appendChild(checks);
item.appendChild(header);
item.appendChild(details);
this.relayHealthList.appendChild(item);
});
}
handleRelayHealthTelemetryToggle() {
const service = this.services?.relayHealthService;
if (!service || !(this.relayHealthTelemetryToggle instanceof HTMLInputElement)) {
return;
}
const enabled = service.setTelemetryOptIn(
this.relayHealthTelemetryToggle.checked,
);
this.relayHealthTelemetryToggle.checked = enabled;
this.updateRelayHealthStatus(
enabled ? "Relay health telemetry enabled." : "Relay health telemetry disabled.",
);
}
async refreshRelayHealthPanel({ forceRefresh = false, reason = "" } = {}) {
const service = this.services?.relayHealthService;
if (!service || !this.relayHealthList) {
return [];
}
if (this.relayHealthTelemetryToggle instanceof HTMLInputElement) {
this.relayHealthTelemetryToggle.checked = service.getTelemetryOptIn();
}
const snapshot = service.getSnapshot();
this.renderRelayHealthSnapshot(snapshot);
if (!forceRefresh) {
return snapshot;
}
if (this.relayHealthRefreshPromise) {
return this.relayHealthRefreshPromise;
}
const statusMessage = reason === "manual" ? "Refreshing relay health…" : "Checking relays…";
this.updateRelayHealthStatus(statusMessage);
const refreshPromise = service
.refresh()
.then((latest) => {
this.renderRelayHealthSnapshot(latest);
this.updateRelayHealthStatus("Relay health updated.");
return latest;
})
.catch((error) => {
this.updateRelayHealthStatus("Failed to refresh relay health.");
this.showError("Failed to refresh relay health.");
devLogger.warn("[profileModal] Relay health refresh failed:", error);
return [];
})
.finally(() => {
if (this.relayHealthRefreshPromise === refreshPromise) {
this.relayHealthRefreshPromise = null;
}
});
this.relayHealthRefreshPromise = refreshPromise;
return refreshPromise;
}
async handleRelayOperation(meta = {}, {
successMessage = "Relay preferences updated.",
skipPublishIfUnchanged = true,
unchangedMessage = null,
} = {}) {
const operationContext = {
...meta,
ok: false,
changed: false,
reason: null,
error: null,
publishResult: null,
operationResult: null,
};
const activePubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!activePubkey) {
this.showError("Please login to manage your relays.");
operationContext.reason = "no-active-pubkey";
return operationContext;
}
let result;
try {
result = await this.runRelayOperation({
...meta,
activePubkey,
skipPublishIfUnchanged,
});
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to update relay preferences.";
operationContext.reason = error?.code || "callback-error";
operationContext.error = error;
this.showError(message);
return operationContext;
}
if (result && typeof result === "object") {
operationContext.ok = Boolean(result.ok);
operationContext.changed = Boolean(result.changed);
operationContext.reason =
typeof result.reason === "string" ? result.reason : operationContext.reason;
operationContext.error = result.error ?? operationContext.error;
operationContext.publishResult =
result.publishResult ?? operationContext.publishResult;
operationContext.operationResult =
result.operationResult ?? operationContext.operationResult;
}
if (!operationContext.changed && skipPublishIfUnchanged) {
const reason = operationContext.reason || "unchanged";
operationContext.reason = reason;
if (reason === "duplicate") {
this.showSuccess("Relay is already configured.");
} else if (typeof unchangedMessage === "string" && unchangedMessage) {
this.showSuccess(unchangedMessage);
}
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
return operationContext;
}
this.populateProfileRelays();
void this.refreshRelayHealthPanel({ forceRefresh: true, reason: "relay-update" });
if (operationContext.ok) {
if (successMessage) {
this.showSuccess(successMessage);
}
return operationContext;
}
const message =
operationContext.error &&
typeof operationContext.error.message === "string" &&
operationContext.error.message.trim()
? operationContext.error.message.trim()
: "Failed to publish relay configuration. Please try again.";
if (operationContext.reason !== "no-active-pubkey") {
this.showError(message);
}
return operationContext;
}
async handleAddRelay() {
const rawValue =
typeof this.relayInput?.value === "string"
? this.relayInput.value
: "";
const trimmed = rawValue.trim();
const context = {
input: this.relayInput,
rawValue,
url: trimmed,
result: null,
success: false,
reason: null,
};
if (!trimmed) {
this.showError("Enter a relay URL to add.");
context.reason = "empty";
this.callbacks.onAddRelay(context, this);
return context;
}
const operationResult = await this.handleRelayOperation(
{ action: "add", url: trimmed },
{
successMessage: "Relay saved.",
unchangedMessage: "Relay is already configured.",
},
);
if (this.relayInput) {
this.relayInput.value = "";
}
context.result = operationResult;
context.success = !!operationResult?.ok;
context.reason = operationResult?.reason || null;
this.callbacks.onAddRelay(context, this);
return context;
}
async handleRestoreRelays() {
const context = {
confirmed: false,
result: null,
success: false,
reason: null,
};
const confirmed = window.confirm("Restore the recommended relay defaults?");
context.confirmed = confirmed;
if (!confirmed) {
context.reason = "cancelled";
this.callbacks.onRestoreRelays(context, this);
return context;
}
const operationResult = await this.handleRelayOperation(
{ action: "restore" },
{
successMessage: "Relay defaults restored.",
unchangedMessage: "Relay defaults are already in use.",
},
);
context.result = operationResult;
context.success = !!operationResult?.ok;
context.reason = operationResult?.reason || null;
this.callbacks.onRestoreRelays(context, this);
this.callbacks.onRelayRestore({
controller: this,
context,
});
return context;
}
async handleRelayModeToggle(url) {
if (!url) {
return;
}
const context = await this.handleRelayOperation(
{ action: "mode-toggle", url },
{ successMessage: "Relay mode updated." },
);
this.callbacks.onRelayModeToggle({
controller: this,
url,
context,
});
}
async handleRemoveRelay(url) {
if (!url) {
return;
}
const confirmed = window.confirm(
`Remove ${url} from your relay list?`,
);
if (!confirmed) {
return;
}
await this.handleRelayOperation(
{ action: "remove", url },
{ successMessage: "Relay removed." },
);
}
ensureBlockListStatusElement() {
if (this.blockListStatus instanceof HTMLElement) {
return this.blockListStatus;
}
const anchor =
this.blockList instanceof HTMLElement
? this.blockList
: this.blockListEmpty instanceof HTMLElement
? this.blockListEmpty
: null;
if (!anchor || !(anchor.parentElement instanceof HTMLElement)) {
return null;
}
const existing = anchor.parentElement.querySelector(
'[data-role="blocked-list-status"]',
);
if (existing instanceof HTMLElement) {
this.blockListStatus = existing;
return existing;
}
const status = document.createElement("div");
status.dataset.role = "blocked-list-status";
status.className = "mt-4 flex items-center gap-3 text-sm text-muted hidden";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
if (this.blockList instanceof HTMLElement) {
anchor.parentElement.insertBefore(status, this.blockList);
} else {
anchor.parentElement.appendChild(status);
}
this.blockListStatus = status;
return status;
}
setBlockListLoadingState(state = "idle", options = {}) {
const statusEl = this.ensureBlockListStatusElement();
if (!statusEl) {
this.blockListLoadingState = state;
return;
}
const message =
typeof options.message === "string" && options.message.trim()
? options.message.trim()
: "";
statusEl.innerHTML = "";
statusEl.classList.remove("text-status-warning");
statusEl.classList.add("text-muted");
statusEl.classList.add("hidden");
this.blockListLoadingState = state;
if (state === "loading") {
if (this.blockListEmpty instanceof HTMLElement) {
this.blockListEmpty.classList.add("hidden");
}
const spinner = document.createElement("span");
spinner.className = "status-spinner status-spinner--inline";
spinner.setAttribute("aria-hidden", "true");
const text = document.createElement("span");
text.textContent = message || "Loading blocked creators…";
statusEl.appendChild(spinner);
statusEl.appendChild(text);
statusEl.classList.remove("hidden");
return;
}
if (state === "error") {
statusEl.classList.remove("text-muted");
statusEl.classList.add("text-status-warning");
if (this.blockListEmpty instanceof HTMLElement) {
this.blockListEmpty.classList.add("hidden");
}
const text = document.createElement("span");
text.textContent =
message || "Blocked creators may be out of date. Try again later.";
statusEl.appendChild(text);
statusEl.classList.remove("hidden");
}
}
normalizeHashtagTag(value) {
return normalizeHashtag(value);
}
formatHashtagTag(value) {
return formatHashtag(value);
}
sanitizeHashtagList(list) {
if (!Array.isArray(list)) {
return [];
}
const seen = new Set();
const normalized = [];
list.forEach((entry) => {
if (typeof entry !== "string") {
return;
}
const tag = this.normalizeHashtagTag(entry);
if (!tag || seen.has(tag)) {
return;
}
seen.add(tag);
normalized.push(tag);
});
normalized.sort((a, b) => a.localeCompare(b));
return normalized;
}
getResolvedHashtagPreferences(preferences = null) {
const candidate =
preferences && typeof preferences === "object" ? preferences : null;
let snapshot = null;
if (candidate) {
snapshot = candidate;
} else if (typeof this.getHashtagPreferencesSnapshotService === "function") {
try {
const resolved = this.getHashtagPreferencesSnapshotService();
if (resolved && typeof resolved === "object") {
snapshot = resolved;
}
} catch (error) {
devLogger.warn(
"[ProfileModalController] Failed to read hashtag preferences snapshot:",
error,
);
}
}
const service = this.hashtagPreferencesService || {};
const interestsSource = Array.isArray(snapshot?.interests)
? snapshot.interests
: typeof service.getInterests === "function"
? service.getInterests()
: [];
const disinterestsSource = Array.isArray(snapshot?.disinterests)
? snapshot.disinterests
: typeof service.getDisinterests === "function"
? service.getDisinterests()
: [];
return {
interests: this.sanitizeHashtagList(interestsSource),
disinterests: this.sanitizeHashtagList(disinterestsSource),
};
}
setHashtagStatus(message = "", tone = "muted") {
if (!(this.hashtagStatusText instanceof HTMLElement)) {
return;
}
const classList = this.hashtagStatusText.classList;
classList.remove(
"text-status-success",
"text-status-warning",
"text-status-danger",
"text-status-info",
"text-muted",
);
const normalized =
typeof message === "string" && message.trim() ? message.trim() : "";
if (!normalized) {
this.hashtagStatusText.textContent = "";
this.hashtagStatusText.classList.add("text-muted", "hidden");
return;
}
this.hashtagStatusText.textContent = normalized;
this.hashtagStatusText.classList.remove("hidden");
switch (tone) {
case "success":
classList.add("text-status-success");
break;
case "warning":
case "error":
classList.add("text-status-warning");
break;
case "info":
classList.add("text-status-info");
break;
default:
classList.add("text-muted");
break;
}
}
clearHashtagInputs() {
if (this.hashtagInterestInput instanceof HTMLInputElement) {
this.hashtagInterestInput.value = "";
}
if (this.hashtagDisinterestInput instanceof HTMLInputElement) {
this.hashtagDisinterestInput.value = "";
}
}
populateHashtagPreferences(preferences = null) {
const snapshot = this.getResolvedHashtagPreferences(preferences);
this.renderHashtagList("interest", snapshot.interests);
this.renderHashtagList("disinterest", snapshot.disinterests);
if (!snapshot.interests.length && !snapshot.disinterests.length) {
this.setHashtagStatus("", "muted");
}
}
renderHashtagList(type, tags) {
const list =
type === "interest" ? this.hashtagInterestList : this.hashtagDisinterestList;
const empty =
type === "interest" ? this.hashtagInterestEmpty : this.hashtagDisinterestEmpty;
if (!(list instanceof HTMLElement) || !(empty instanceof HTMLElement)) {
return;
}
list.innerHTML = "";
const normalized = this.sanitizeHashtagList(tags);
if (!normalized.length) {
empty.classList.remove("hidden");
list.classList.add("hidden");
return;
}
empty.classList.add("hidden");
list.classList.remove("hidden");
normalized.forEach((tag) => {
const item = this.createHashtagListItem(type, tag);
if (item) {
list.appendChild(item);
}
});
}
createHashtagListItem(type, tag) {
const normalized = this.normalizeHashtagTag(tag);
if (!normalized) {
return null;
}
const item = document.createElement("li");
item.className = "profile-hashtag-item";
item.dataset.hashtagType = type;
item.dataset.tag = normalized;
const label = document.createElement("span");
label.textContent = this.formatHashtagTag(normalized);
item.appendChild(label);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "profile-hashtag-remove focus-ring";
removeButton.dataset.hashtagType = type;
removeButton.dataset.tag = normalized;
removeButton.setAttribute(
"aria-label",
type === "interest"
? `Remove ${this.formatHashtagTag(normalized)} from interests`
: `Remove ${this.formatHashtagTag(normalized)} from disinterests`,
);
removeButton.innerHTML = "<span aria-hidden=\"true\">&times;</span>";
removeButton.addEventListener("click", () => {
void this.handleRemoveHashtagPreference(type, normalized);
});
item.appendChild(removeButton);
return item;
}
async persistHashtagPreferences(options = {}) {
const service = this.hashtagPreferencesService;
const publish =
service && typeof service.publish === "function" ? service.publish : null;
if (!publish) {
const message = this.describeHashtagPreferencesError(null, {
fallbackMessage: "Hashtag preferences are unavailable right now.",
});
if (message) {
this.showError(message);
this.setHashtagStatus(message, "warning");
}
const error = new Error(
message || "Hashtag preferences are unavailable right now.",
);
error.code = "service-unavailable";
throw error;
}
if (this.hashtagPreferencesPublishInFlight) {
return this.hashtagPreferencesPublishPromise;
}
const { successMessage, pubkey, progressMessage } =
options && typeof options === "object" ? options : {};
const resolvedPubkeyCandidate =
typeof pubkey === "string" && pubkey.trim()
? pubkey
: this.getActivePubkey();
const normalizedPubkey = this.normalizeHexPubkey(resolvedPubkeyCandidate);
const payload = normalizedPubkey ? { pubkey: normalizedPubkey } : {};
const pendingMessage =
typeof progressMessage === "string" && progressMessage.trim()
? progressMessage.trim()
: "Saving hashtag preferences…";
const finalMessage =
typeof successMessage === "string" && successMessage.trim()
? successMessage.trim()
: "Hashtag preferences saved.";
this.hashtagPreferencesPublishInFlight = true;
this.setHashtagStatus(pendingMessage, "info");
const publishPromise = (async () => {
try {
const result = await publish.call(service, payload);
this.setHashtagStatus(finalMessage, "success");
return result;
} catch (error) {
const failure =
error instanceof Error ? error : new Error(String(error || ""));
if (!failure.code) {
failure.code = "hashtag-preferences-publish-failed";
}
const message = this.describeHashtagPreferencesError(failure, {
fallbackMessage:
"Failed to update hashtag preferences. Please try again.",
});
if (message) {
this.showError(message);
this.setHashtagStatus(message, "warning");
}
throw failure;
} finally {
this.hashtagPreferencesPublishInFlight = false;
this.hashtagPreferencesPublishPromise = null;
}
})();
this.hashtagPreferencesPublishPromise = publishPromise;
return publishPromise;
}
async handleAddHashtagPreference(type) {
const isInterest = type === "interest";
const input = isInterest
? this.hashtagInterestInput
: this.hashtagDisinterestInput;
const rawValue =
input instanceof HTMLInputElement ? input.value || "" : "";
const normalized = this.normalizeHashtagTag(rawValue);
if (!(input instanceof HTMLInputElement)) {
return { success: false, reason: "missing-input" };
}
if (!normalized) {
const message = "Enter a hashtag to add.";
this.showError(message);
this.setHashtagStatus(message, "warning");
return { success: false, reason: "empty" };
}
const service = this.hashtagPreferencesService;
const addMethod = isInterest
? service?.addInterest
: service?.addDisinterest;
if (typeof addMethod !== "function") {
const message = "Hashtag preferences are unavailable right now.";
this.showError(message);
this.setHashtagStatus(message, "warning");
return { success: false, reason: "service-unavailable" };
}
const snapshot = this.getResolvedHashtagPreferences();
const alreadyInTarget = isInterest
? snapshot.interests.includes(normalized)
: snapshot.disinterests.includes(normalized);
const inOpposite = isInterest
? snapshot.disinterests.includes(normalized)
: snapshot.interests.includes(normalized);
let result = false;
try {
result = addMethod.call(service, normalized);
} catch (error) {
const message = this.describeHashtagPreferencesError(error, {
fallbackMessage: "Failed to update hashtag preferences. Please try again.",
});
if (message) {
this.showError(message);
this.setHashtagStatus(message, "warning");
}
return { success: false, reason: error?.code || "service-error", error };
} finally {
if (input) {
input.value = "";
}
}
if (result) {
const actionMessage = inOpposite
? `${this.formatHashtagTag(normalized)} moved to ${
isInterest ? "interests" : "disinterests"
}.`
: `${this.formatHashtagTag(normalized)} added to ${
isInterest ? "interests" : "disinterests"
}.`;
this.populateHashtagPreferences();
try {
await this.persistHashtagPreferences({
successMessage: actionMessage,
});
this.showSuccess(actionMessage);
return { success: true, reason: inOpposite ? "moved" : "added" };
} catch (error) {
return {
success: false,
reason: error?.code || "publish-failed",
error,
};
} finally {
this.populateHashtagPreferences();
}
}
if (alreadyInTarget) {
const message = `${this.formatHashtagTag(normalized)} is already in your ${
isInterest ? "interests" : "disinterests"
}.`;
this.showStatus(message);
this.setHashtagStatus(message, "info");
this.populateHashtagPreferences();
return { success: false, reason: "duplicate" };
}
const fallbackMessage = this.describeHashtagPreferencesError(null, {
fallbackMessage: `Failed to add ${this.formatHashtagTag(normalized)}.`,
});
if (fallbackMessage) {
this.showError(fallbackMessage);
this.setHashtagStatus(fallbackMessage, "warning");
}
this.populateHashtagPreferences();
return { success: false, reason: "no-change" };
}
async handleRemoveHashtagPreference(type, candidate) {
const normalized = this.normalizeHashtagTag(candidate);
if (!normalized) {
return { success: false, reason: "invalid" };
}
const service = this.hashtagPreferencesService;
const removeMethod =
type === "interest"
? service?.removeInterest
: service?.removeDisinterest;
if (typeof removeMethod !== "function") {
const message = "Hashtag preferences are unavailable right now.";
this.showError(message);
this.setHashtagStatus(message, "warning");
return { success: false, reason: "service-unavailable" };
}
let removed = false;
try {
removed = removeMethod.call(service, normalized);
} catch (error) {
const message = this.describeHashtagPreferencesError(error, {
fallbackMessage: `Failed to remove ${this.formatHashtagTag(normalized)}.`,
});
if (message) {
this.showError(message);
this.setHashtagStatus(message, "warning");
}
this.populateHashtagPreferences();
return { success: false, reason: error?.code || "service-error", error };
}
if (removed) {
const message = `${this.formatHashtagTag(normalized)} removed from ${
type === "interest" ? "interests" : "disinterests"
}.`;
this.populateHashtagPreferences();
try {
await this.persistHashtagPreferences({ successMessage: message });
this.showSuccess(message);
} catch (error) {
return {
success: false,
reason: error?.code || "publish-failed",
error,
};
} finally {
this.populateHashtagPreferences();
}
} else {
const message = `${this.formatHashtagTag(normalized)} is already removed.`;
this.showStatus(message);
this.setHashtagStatus(message, "info");
}
this.populateHashtagPreferences();
return { success: removed, reason: removed ? "removed" : "already-removed" };
}
handleHashtagPreferencesChange(detail = {}) {
const preferences =
detail && typeof detail.preferences === "object"
? detail.preferences
: detail;
this.populateHashtagPreferences(preferences);
}
populateBlockedList(blocked = null) {
if (!this.blockList || !this.blockListEmpty) {
if (this.blockListLoadingState === "loading") {
this.setBlockListLoadingState("idle");
}
return;
}
const sourceEntries =
Array.isArray(blocked) && blocked.length
? blocked
: this.services.userBlocks.getBlockedPubkeys();
const normalizedEntries = [];
const pushEntry = (hex, label) => {
if (!hex || !label) {
return;
}
normalizedEntries.push({ hex, label });
};
sourceEntries.forEach((entry) => {
if (typeof entry === "string") {
const trimmed = entry.trim();
if (!trimmed) {
return;
}
if (trimmed.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(trimmed);
if (!decoded) {
return;
}
const label = this.safeEncodeNpub(decoded) || trimmed;
pushEntry(decoded, label);
return;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
const hex = trimmed.toLowerCase();
const label = this.safeEncodeNpub(hex) || hex;
pushEntry(hex, label);
}
return;
}
if (entry && typeof entry === "object") {
const candidateNpub =
typeof entry.npub === "string" ? entry.npub.trim() : "";
const candidateHex =
typeof entry.pubkey === "string" ? entry.pubkey.trim() : "";
if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) {
const normalizedHex = candidateHex.toLowerCase();
const label =
candidateNpub && candidateNpub.startsWith("npub1")
? candidateNpub
: this.safeEncodeNpub(normalizedHex) || normalizedHex;
pushEntry(normalizedHex, label);
return;
}
if (candidateNpub && candidateNpub.startsWith("npub1")) {
const decoded = this.safeDecodeNpub(candidateNpub);
if (!decoded) {
return;
}
const label = this.safeEncodeNpub(decoded) || candidateNpub;
pushEntry(decoded, label);
}
}
});
const deduped = [];
const seenHex = new Set();
normalizedEntries.forEach((entry) => {
if (!seenHex.has(entry.hex)) {
seenHex.add(entry.hex);
deduped.push(entry);
}
});
this.blockList.innerHTML = "";
if (!deduped.length) {
this.blockListEmpty.classList.remove("hidden");
this.blockList.classList.add("hidden");
if (this.blockListLoadingState === "loading") {
this.setBlockListLoadingState("idle");
}
return;
}
this.blockListEmpty.classList.add("hidden");
this.blockList.classList.remove("hidden");
const formatNpub =
typeof this.formatShortNpub === "function"
? (value) => this.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const entriesNeedingFetch = new Set();
deduped.forEach(({ hex, label }) => {
const item = document.createElement("li");
item.className =
"card flex items-center justify-between gap-4 p-4";
let cachedProfile = null;
if (hex) {
const cacheEntry = this.services.getProfileCacheEntry(hex);
cachedProfile = cacheEntry?.profile || null;
if (!cacheEntry) {
entriesNeedingFetch.add(hex);
}
}
const encodedNpub =
hex && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(hex)
: label;
const displayNpub = formatNpub(encodedNpub) || encodedNpub || label;
const displayName =
cachedProfile?.name?.trim() || displayNpub || "Blocked profile";
const avatarSrc =
cachedProfile?.picture || FALLBACK_PROFILE_AVATAR;
const summary = this.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
const removeButton = this.createRemoveButton({
label: "Remove",
onRemove: () => this.handleRemoveBlockedCreator(hex),
});
if (removeButton) {
removeButton.dataset.blockedHex = hex;
actions.appendChild(removeButton);
}
item.appendChild(summary);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
this.blockList.appendChild(item);
});
if (this.blockListLoadingState === "loading") {
this.setBlockListLoadingState("idle");
}
if (
entriesNeedingFetch.size &&
typeof this.services.batchFetchProfiles === "function"
) {
this.services.batchFetchProfiles(entriesNeedingFetch);
}
}
async populateSubscriptionsList(subscriptions = null) {
if (
!(this.subscriptionList instanceof HTMLElement) ||
!(this.subscriptionListEmpty instanceof HTMLElement)
) {
return;
}
const service = this.subscriptionsService;
if (!service) {
this.clearSubscriptionsList();
return;
}
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
if (!activeHex) {
this.clearSubscriptionsList();
return;
}
try {
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.innerHTML = "";
if (!deduped.length) {
this.subscriptionListEmpty.classList.remove("hidden");
this.subscriptionList.classList.add("hidden");
return;
}
this.subscriptionListEmpty.classList.add("hidden");
this.subscriptionList.classList.remove("hidden");
const formatNpub =
typeof this.formatShortNpub === "function"
? (value) => this.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const entriesNeedingFetch = new Set();
deduped.forEach(({ hex, label }) => {
const item = document.createElement("li");
item.className = "card flex items-center justify-between gap-4 p-4";
let cachedProfile = null;
if (hex && typeof this.services.getProfileCacheEntry === "function") {
const cacheEntry = this.services.getProfileCacheEntry(hex);
cachedProfile = cacheEntry?.profile || null;
if (!cacheEntry) {
entriesNeedingFetch.add(hex);
}
}
const encodedNpub =
hex && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(hex)
: label;
const displayNpub = formatNpub(encodedNpub) || encodedNpub || label;
const displayName =
(cachedProfile?.name && cachedProfile.name.trim()) ||
(cachedProfile?.display_name &&
typeof cachedProfile.display_name === "string" &&
cachedProfile.display_name.trim()) ||
displayNpub ||
"Subscription";
const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR;
const summary = this.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
if (hex) {
const unsubscribeButton = this.createRemoveButton({
label: "Unsubscribe",
onRemove: () => this.handleUnsubscribeFromCreator(hex),
});
if (unsubscribeButton) {
unsubscribeButton.dataset.subscriptionHex = hex;
actions.appendChild(unsubscribeButton);
}
}
item.appendChild(summary);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
this.subscriptionList.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.services.batchFetchProfiles === "function"
) {
try {
this.services.batchFetchProfiles(entriesNeedingFetch);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to batch fetch profiles for subscriptions list:",
error,
);
}
}
} catch (error) {
devLogger.warn("[profileModal] Failed to populate subscriptions list:", error);
}
}
clearSubscriptionsList() {
if (this.subscriptionList instanceof HTMLElement) {
this.subscriptionList.innerHTML = "";
this.subscriptionList.classList.add("hidden");
}
if (this.subscriptionListEmpty instanceof HTMLElement) {
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.innerHTML = "";
if (!deduped.length) {
this.friendListEmpty.classList.remove("hidden");
this.friendList.classList.add("hidden");
return;
}
this.friendListEmpty.classList.add("hidden");
this.friendList.classList.remove("hidden");
const formatNpub =
typeof this.formatShortNpub === "function"
? (value) => this.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const entriesNeedingFetch = new Set();
const canRemoveFriends = this.canManageFriendsList();
deduped.forEach(({ hex, label }) => {
const item = document.createElement("li");
item.className = "card flex items-center justify-between gap-4 p-4";
let cachedProfile = null;
if (hex && typeof this.services.getProfileCacheEntry === "function") {
const cacheEntry = this.services.getProfileCacheEntry(hex);
cachedProfile = cacheEntry?.profile || null;
if (!cacheEntry) {
entriesNeedingFetch.add(hex);
}
}
const encodedNpub =
hex && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(hex)
: label;
const displayNpub = formatNpub(encodedNpub) || encodedNpub || label;
const displayName =
(cachedProfile?.name && cachedProfile.name.trim()) ||
(cachedProfile?.display_name &&
typeof cachedProfile.display_name === "string" &&
cachedProfile.display_name.trim()) ||
displayNpub ||
"Friend";
const avatarSrc = cachedProfile?.picture || FALLBACK_PROFILE_AVATAR;
const summary = this.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
if (hex && canRemoveFriends) {
const removeButton = this.createRemoveButton({
label: "Unfriend",
onRemove: () => this.handleRemoveFriend(hex),
});
if (removeButton) {
removeButton.dataset.friendHex = hex;
actions.appendChild(removeButton);
}
}
item.appendChild(summary);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
this.friendList.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.services.batchFetchProfiles === "function"
) {
try {
this.services.batchFetchProfiles(entriesNeedingFetch);
} catch (error) {
devLogger.warn(
"[profileModal] Failed to batch fetch profiles for friends list:",
error,
);
}
}
} catch (error) {
devLogger.warn("[profileModal] Failed to populate friends list:", error);
}
}
canManageFriendsList() {
if (
this.moderationService &&
(!this.subscriptionsService ||
typeof this.subscriptionsService.removeChannel !== "function")
) {
return false;
}
return (
this.subscriptionsService &&
typeof this.subscriptionsService.removeChannel === "function"
);
}
clearFriendsList() {
if (this.friendList instanceof HTMLElement) {
this.friendList.innerHTML = "";
this.friendList.classList.add("hidden");
}
if (this.friendListEmpty instanceof HTMLElement) {
this.friendListEmpty.classList.remove("hidden");
}
}
async handleRemoveFriend(candidate, options = {}) {
if (
this.moderationService &&
(!this.subscriptionsService ||
typeof this.subscriptionsService.removeChannel !== "function")
) {
this.showError(
"Friends are synced with your Nostr follows. Update your follows in your Nostr client to make changes.",
);
return {
success: false,
reason: "nostr-friends-managed-externally",
};
}
const service = this.subscriptionsService;
if (!service || typeof service.removeChannel !== "function") {
this.showError("Friends list is unavailable right now.");
return { success: false, reason: "service-unavailable" };
}
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
if (!activeHex) {
this.showError("Please login to manage your friends list.");
return { success: false, reason: "no-active-pubkey" };
}
const {
successMessage = "Creator removed from your friends list.",
refresh = async () => {
await this.populateFriendsList();
},
successReason = "removed",
} = typeof options === "object" && options ? options : {};
let targetHex = "";
if (typeof candidate === "string") {
const trimmed = candidate.trim();
if (trimmed.startsWith("npub1")) {
targetHex = this.safeDecodeNpub(trimmed) || "";
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
targetHex = trimmed.toLowerCase();
}
} else if (candidate && typeof candidate === "object") {
const candidateHex =
typeof candidate.pubkey === "string" ? candidate.pubkey.trim() : "";
const candidateNpub =
typeof candidate.npub === "string" ? candidate.npub.trim() : "";
if (candidateHex && /^[0-9a-f]{64}$/i.test(candidateHex)) {
targetHex = candidateHex.toLowerCase();
} else if (candidateNpub && candidateNpub.startsWith("npub1")) {
targetHex = this.safeDecodeNpub(candidateNpub) || "";
}
}
if (!targetHex) {
devLogger.warn(
"[profileModal] No valid pubkey to remove from friends list:",
candidate,
);
return { success: false, reason: "invalid-target" };
}
try {
await service.removeChannel(targetHex, activeHex);
} catch (error) {
userLogger.error(
"[profileModal] Failed to remove creator from friends list:",
error,
);
const message =
error?.code === "extension-permission-denied"
? "Your Nostr extension must allow encryption to manage subscriptions."
: error?.message || "Failed to update your friends list. Please try again.";
if (message) {
this.showError(message);
}
return { success: false, reason: error?.code || "service-error", error };
}
this.showSuccess(successMessage);
if (typeof refresh === "function") {
try {
await refresh();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to refresh lists after removing friend:",
error,
);
}
}
return { success: true, reason: successReason };
}
async handleAddBlockedCreator() {
const input = this.blockInput || null;
const rawValue = typeof input?.value === "string" ? input.value : "";
const trimmed = rawValue.trim();
const context = {
input,
rawValue,
value: trimmed,
success: false,
reason: null,
error: null,
};
if (!input) {
context.reason = "missing-input";
this.callbacks.onAddBlocked(context, this);
return context;
}
if (!trimmed) {
this.showError("Enter an npub to block.");
context.reason = "empty";
this.callbacks.onAddBlocked(context, this);
return context;
}
const activePubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!activePubkey) {
this.showError("Please login to manage your block list.");
context.reason = "no-active-pubkey";
this.callbacks.onAddBlocked(context, this);
return context;
}
const actorHex = activePubkey;
let targetHex = "";
if (trimmed.startsWith("npub1")) {
targetHex = this.safeDecodeNpub(trimmed) || "";
if (!targetHex) {
this.showError("Invalid npub. Please double-check and try again.");
context.reason = "invalid-npub";
this.callbacks.onAddBlocked(context, this);
return context;
}
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
targetHex = trimmed.toLowerCase();
} else {
this.showError("Enter a valid npub or hex pubkey.");
context.reason = "invalid-value";
this.callbacks.onAddBlocked(context, this);
return context;
}
if (targetHex === actorHex) {
this.showError("You cannot block yourself.");
context.reason = "self";
this.callbacks.onAddBlocked(context, this);
return context;
}
context.targetHex = targetHex;
try {
const mutationResult = await this.mutateBlocklist({
action: "add",
actorHex,
targetHex,
controller: this,
});
context.result = mutationResult;
if (mutationResult?.ok) {
this.showSuccess(
"Creator blocked. You won't see their videos anymore.",
);
context.success = true;
context.reason = mutationResult.reason || "blocked";
} else if (mutationResult?.reason === "already-blocked") {
this.showSuccess("You already blocked this creator.");
context.reason = "already-blocked";
} else {
const message =
mutationResult?.error?.code === "nip04-missing"
? "Your Nostr extension must support NIP-04 to manage private lists."
: mutationResult?.error?.message ||
"Failed to update your block list. Please try again.";
context.error = mutationResult?.error || null;
context.reason = mutationResult?.reason || "service-error";
if (message) {
this.showError(message);
}
}
if (this.blockInput) {
this.blockInput.value = "";
}
this.populateBlockedList();
} catch (error) {
userLogger.error("Failed to add creator to personal block list:", error);
context.error = error;
context.reason = error?.code || "service-error";
const message =
error?.code === "nip04-missing"
? "Your Nostr extension must support NIP-04 to manage private lists."
: "Failed to update your block list. Please try again.";
this.showError(message);
}
this.callbacks.onAddBlocked(context, this);
return context;
}
async handleRemoveBlockedCreator(candidate) {
const activePubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!activePubkey) {
this.showError("Please login to manage your block list.");
return;
}
let targetHex = "";
if (typeof candidate === "string") {
const trimmed = candidate.trim();
if (!trimmed) {
return;
}
if (trimmed.startsWith("npub1")) {
targetHex = this.safeDecodeNpub(trimmed) || "";
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
targetHex = trimmed.toLowerCase();
}
}
if (!targetHex) {
userLogger.warn("No valid pubkey to remove from block list:", candidate);
return;
}
try {
const mutationResult = await this.mutateBlocklist({
action: "remove",
actorHex: activePubkey,
targetHex,
controller: this,
});
if (mutationResult?.ok) {
this.showSuccess("Creator removed from your block list.");
} else if (mutationResult?.reason === "not-blocked") {
this.showSuccess("Creator already removed from your block list.");
} else if (mutationResult?.error) {
const message =
mutationResult.error.code === "nip04-missing"
? "Your Nostr extension must support NIP-04 to manage private lists."
: mutationResult.error.message ||
"Failed to update your block list. Please try again.";
if (message) {
this.showError(message);
}
}
this.populateBlockedList();
} catch (error) {
userLogger.error(
"Failed to remove creator from personal block list:",
error,
);
const message =
error?.code === "nip04-missing"
? "Your Nostr extension must support NIP-04 to manage private lists."
: "Failed to update your block list. Please try again.";
this.showError(message);
}
}
handleEditProfile() {
this.selectPane("edit");
void this.populateEditPane();
}
async populateEditPane() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) {
return;
}
const cacheEntry = this.services.getProfileCacheEntry(pubkey);
const profile = cacheEntry?.profile || {};
if (this.editNameInput) this.editNameInput.value = profile.name || "";
if (this.editDisplayNameInput)
this.editDisplayNameInput.value = profile.display_name || "";
if (this.editAboutInput) this.editAboutInput.value = profile.about || "";
if (this.editWebsiteInput)
this.editWebsiteInput.value = profile.website || "";
if (this.editNip05Input) this.editNip05Input.value = profile.nip05 || "";
if (this.editLud16Input) this.editLud16Input.value = profile.lud16 || "";
if (this.editPictureInput)
this.editPictureInput.value = profile.picture || "";
if (this.editBannerInput) this.editBannerInput.value = profile.banner || "";
void this.checkStorageForUploads(pubkey);
}
async checkStorageForUploads(pubkey) {
const r2Service = this.services.r2Service;
if (!r2Service) return;
let hasStorage = false;
try {
const credentials = await r2Service.resolveConnection(pubkey);
hasStorage = !!credentials;
} catch (e) {
hasStorage = false;
}
const updateUI = (uploadBtn, hint, has) => {
if (uploadBtn) {
uploadBtn.disabled = !has;
if (!has) uploadBtn.setAttribute("aria-disabled", "true");
else uploadBtn.removeAttribute("aria-disabled");
}
if (hint) {
if (has) hint.classList.add("hidden");
else hint.classList.remove("hidden");
}
};
updateUI(
this.editPictureUploadBtn,
this.editPictureStorageHint,
hasStorage,
);
updateUI(this.editBannerUploadBtn, this.editBannerStorageHint, hasStorage);
}
async handleUpload(type) {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
const r2Service = this.services.r2Service;
if (!r2Service) return;
const fileInput =
type === "picture" ? this.editPictureFile : this.editBannerFile;
const urlInput =
type === "picture" ? this.editPictureInput : this.editBannerInput;
const uploadBtn =
type === "picture" ? this.editPictureUploadBtn : this.editBannerUploadBtn;
if (!fileInput || !fileInput.files.length) return;
const file = fileInput.files[0];
try {
if (uploadBtn) {
uploadBtn.disabled = true;
uploadBtn.textContent = "Uploading...";
}
const credentials = await r2Service.resolveConnection(pubkey);
if (!credentials) {
this.showError("Storage configuration missing.");
return;
}
const key = buildR2Key(pubkey, file);
await r2Service.uploadFile({
file,
...credentials,
bucket: credentials.bucket,
key,
});
const url = buildPublicUrl(credentials.baseDomain, key);
if (urlInput) urlInput.value = url;
fileInput.value = "";
} catch (error) {
this.showError("Upload failed: " + (error.message || "Unknown error"));
devLogger.error("Upload error:", error);
} finally {
if (uploadBtn) {
uploadBtn.disabled = false;
uploadBtn.textContent = "Upload";
}
}
}
async handleSaveProfile() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
const profile = {
name: this.editNameInput?.value?.trim() || "",
display_name: this.editDisplayNameInput?.value?.trim() || "",
about: this.editAboutInput?.value?.trim() || "",
website: this.editWebsiteInput?.value?.trim() || "",
nip05: this.editNip05Input?.value?.trim() || "",
lud16: this.editLud16Input?.value?.trim() || "",
picture: this.editPictureInput?.value?.trim() || "",
banner: this.editBannerInput?.value?.trim() || "",
};
if (this.editSaveBtn) {
this.editSaveBtn.disabled = true;
this.editSaveBtn.textContent = "Saving...";
}
try {
const event = buildProfileMetadataEvent({
pubkey,
created_at: Math.floor(Date.now() / 1000),
metadata: profile,
});
const result =
await this.services.nostrClient.signAndPublishEvent(event);
if (result && result.signedEvent) {
if (this.services.nostrClient.handleEvent) {
this.services.nostrClient.handleEvent(result.signedEvent);
}
}
this.showSuccess("Profile updated!");
this.selectPane("account");
this.renderSavedProfiles();
} catch (error) {
this.showError("Failed to save profile: " + error.message);
} finally {
if (this.editSaveBtn) {
this.editSaveBtn.disabled = false;
this.editSaveBtn.textContent = "Save Profile";
}
}
}
async populateStoragePane() {
const storageService = this.services.storageService;
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
this.handleClearStorage();
if (!pubkey) {
// Not logged in
if (this.storageUnlockSection) this.storageUnlockSection.classList.add("hidden");
if (this.storageFormSection) this.storageFormSection.classList.add("hidden");
if (this.storageStatusText) {
this.storageStatusText.textContent = "Please login to manage storage.";
this.storageStatusText.className = "text-xs text-status-danger";
}
return;
}
const isUnlocked = storageService && storageService.masterKeys.has(pubkey);
if (this.storageStatusText) {
this.storageStatusText.textContent = isUnlocked ? "Unlocked" : "Locked";
this.storageStatusText.className = isUnlocked ? "text-xs text-status-success" : "text-xs text-status-warning";
}
if (isUnlocked) {
if (this.storageUnlockSection) this.storageUnlockSection.classList.add("hidden");
if (this.storageFormSection) this.storageFormSection.classList.remove("hidden");
// Load existing connection if form is empty or needs refresh
// For now, we assume managing a 'default' connection or the first one found.
try {
const connections = await storageService.listConnections(pubkey);
// Prioritize default for uploads
const defaultConn = connections.find(c => c.meta?.defaultForUploads);
const targetConn = defaultConn || connections[0];
if (targetConn) {
const conn = await storageService.getConnection(pubkey, targetConn.id);
if (conn) {
this.fillStorageForm(conn);
if (this.storageStatusText) {
const label = conn.meta?.label || conn.provider || "S3";
this.storageStatusText.textContent = `Unlocked (${label})`;
}
}
}
} catch (error) {
devLogger.error("Failed to load storage connections:", error);
}
} else {
if (this.storageUnlockSection) this.storageUnlockSection.classList.remove("hidden");
if (this.storageFormSection) this.storageFormSection.classList.add("hidden");
}
this.updateStorageFormVisibility();
}
fillStorageForm(conn) {
if (!conn) return;
const { provider, accessKeyId, secretAccessKey, accountId: payloadAccountId, endpoint: payloadEndpoint, forcePathStyle: payloadForcePathStyle } = conn;
const { endpoint, region, bucket, prefix, defaultForUploads, accountId, forcePathStyle: metaForcePathStyle } = conn.meta || {};
if (this.storageProviderInput) this.storageProviderInput.value = provider || "cloudflare_r2";
// For R2, accountId is critical.
const resolvedEndpoint = endpoint || accountId || payloadAccountId || payloadEndpoint || "";
if (this.storageEndpointInput) this.storageEndpointInput.value = resolvedEndpoint;
if (this.storageRegionInput) this.storageRegionInput.value = region || "auto";
if (this.storageAccessKeyInput) this.storageAccessKeyInput.value = accessKeyId || "";
if (this.storageSecretKeyInput) this.storageSecretKeyInput.value = secretAccessKey || "";
if (this.storageBucketInput) this.storageBucketInput.value = bucket || "";
if (this.storagePrefixInput) this.storagePrefixInput.value = prefix || "";
if (this.storageDefaultInput) this.storageDefaultInput.checked = !!defaultForUploads;
if (this.storageForcePathStyleInput) {
if (typeof payloadForcePathStyle === "boolean") {
this.storageForcePathStyleInput.checked = payloadForcePathStyle;
} else if (typeof metaForcePathStyle === "boolean") {
this.storageForcePathStyleInput.checked = metaForcePathStyle;
} else {
// Default to true for S3 if unspecified
this.storageForcePathStyleInput.checked = true;
}
}
this.updateStorageFormVisibility();
this.handlePublicUrlInput();
}
handlePublicUrlInput() {
if (!this.storagePrefixInput || !this.storagePrefixWarning) return;
this.storagePrefixWarning.textContent = "";
this.storagePrefixWarning.classList.add("hidden");
}
updateStorageFormVisibility() {
const provider = this.storageProviderInput?.value || "cloudflare_r2";
const isR2 = provider === "cloudflare_r2";
if (this.storageEndpointInput) {
const label = this.storageEndpointInput.parentElement.querySelector("span");
if (isR2) {
if (label) label.textContent = "Cloudflare Account ID";
this.storageEndpointInput.placeholder =
"Account ID from Cloudflare dashboard";
this.storageEndpointInput.parentElement.classList.remove("hidden");
this.storageEndpointInput.type = "text";
if (this.storagePrefixInput) {
this.storagePrefixInput.placeholder = "https://pub-xxx.r2.dev";
}
} else {
if (label) label.textContent = "Endpoint URL";
this.storageEndpointInput.placeholder = "https://s3.example.com";
this.storageEndpointInput.parentElement.classList.remove("hidden");
this.storageEndpointInput.type = "url";
if (this.storagePrefixInput) {
this.storagePrefixInput.placeholder = "https://cdn.example.com";
}
}
}
if (this.storageR2Helper) {
if (isR2) this.storageR2Helper.classList.remove("hidden");
else this.storageR2Helper.classList.add("hidden");
}
if (this.storageS3Helper) {
if (!isR2) this.storageS3Helper.classList.remove("hidden");
else this.storageS3Helper.classList.add("hidden");
}
if (this.storageForcePathStyleLabel) {
if (!isR2) this.storageForcePathStyleLabel.classList.remove("hidden", "flex");
else this.storageForcePathStyleLabel.classList.add("hidden");
if (!isR2) {
this.storageForcePathStyleLabel.classList.add("flex");
}
}
}
async handleUnlockStorage() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
// We need the active signer.
const signer = getActiveSigner();
// Or try resolving via client if global one is missing?
// ProfileModalController imports getActiveSigner.
if (!signer) {
this.showError("No active signer found. Please login.");
return;
}
if (
signer.pubkey &&
this.normalizeHexPubkey(signer.pubkey) !== pubkey
) {
this.showError(
`Signer account (${signer.pubkey.slice(
0,
8,
)}...) does not match profile (${pubkey.slice(
0,
8,
)}...). Please switch accounts in your extension.`,
);
return;
}
const storageService = this.services.storageService;
if (!storageService) {
this.showError("Storage service unavailable.");
return;
}
if (this.storageUnlockBtn) {
this.storageUnlockBtn.disabled = true;
this.storageUnlockBtn.textContent = "Unlocking...";
}
try {
await storageService.unlock(pubkey, { signer });
this.showSuccess("Storage unlocked.");
this.populateStoragePane();
} catch (error) {
devLogger.error("Failed to unlock storage:", error);
const message =
error && typeof error.message === "string"
? error.message
: "Failed to unlock storage. Ensure your signer supports NIP-04/44.";
this.showError(message);
} finally {
if (this.storageUnlockBtn) {
this.storageUnlockBtn.disabled = false;
this.storageUnlockBtn.textContent = "Unlock Storage";
}
}
}
async handleSaveStorage() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
const storageService = this.services.storageService;
if (!storageService) return;
const provider = this.storageProviderInput?.value || "cloudflare_r2";
let endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
const bucket = this.storageBucketInput?.value?.trim() || "";
const prefix = this.storagePrefixInput?.value?.trim() || "";
const isDefault = this.storageDefaultInput?.checked || false;
if (!accessKeyId || !secretAccessKey || !bucket || !endpointOrAccount) {
this.setStorageFormStatus("Please fill in all required fields.", "error");
return;
}
if (
provider === "cloudflare_r2" &&
(prefix.includes(".r2.cloudflarestorage.com") ||
prefix.includes(".s3.") ||
prefix.includes(".amazonaws.com"))
) {
this.setStorageFormStatus(
"Invalid Public URL. Please use your R2.dev or custom domain.",
"error",
);
return;
}
let publicBaseUrl = "";
// For Generic S3, respect the user's checkbox selection.
let forcePathStyle = false;
if (provider === PROVIDERS.GENERIC) {
forcePathStyle = this.storageForcePathStyleInput?.checked ?? true;
}
const payload = {
provider,
accessKeyId,
secretAccessKey,
};
// For R2, endpoint input is Account ID.
// For Generic S3, it's the full endpoint URL.
if (provider === "cloudflare_r2") {
payload.accountId = endpointOrAccount;
} else {
try {
const normalized = await prepareS3Connection({
endpoint: endpointOrAccount,
region,
accessKeyId,
secretAccessKey,
bucket,
forcePathStyle,
origins: getCorsOrigins(),
});
endpointOrAccount = normalized.endpoint;
publicBaseUrl = normalized.publicBaseUrl;
forcePathStyle = normalized.forcePathStyle;
} catch (error) {
devLogger.error("Failed to validate S3 connection:", error);
this.setStorageFormStatus(
error?.message || "Invalid S3 configuration.",
"error"
);
return;
}
payload.endpoint = endpointOrAccount;
payload.forcePathStyle = forcePathStyle;
}
const meta = {
provider,
region,
bucket,
prefix,
defaultForUploads: isDefault,
label: `${provider} - ${bucket}`,
// Duplicate non-sensitive info for easier listing
endpoint: provider === "cloudflare_r2" ? undefined : endpointOrAccount,
};
// If R2, we also need accountId in meta? No, keep it private if possible, but R2Service needs it.
// Actually R2 account ID is not strictly secret, but payload is encrypted.
// We can store it in meta if we want to show it in UI without decrypting.
if (provider === "cloudflare_r2") {
meta.accountId = endpointOrAccount;
// For R2, the "Prefix" input serves as the Public Base URL (e.g. https://pub-xxx.r2.dev)
meta.publicBaseUrl = prefix;
meta.baseDomain = prefix;
} else {
meta.publicBaseUrl = publicBaseUrl;
meta.baseDomain = publicBaseUrl;
meta.forcePathStyle = forcePathStyle;
}
this.setStorageFormStatus("Saving...", "info");
try {
await storageService.saveConnection(pubkey, "default", payload, meta);
this.setStorageFormStatus("Connection saved.", "success");
this.showSuccess("Storage connection saved.");
} catch (error) {
devLogger.error("Failed to save connection:", error);
this.setStorageFormStatus("Failed to save connection.", "error");
}
}
async handleTestStorage() {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (!pubkey) return;
const storageService = this.services.storageService;
if (!storageService) {
this.setStorageFormStatus("Storage service unavailable.", "error");
return;
}
const provider = this.storageProviderInput?.value || "cloudflare_r2";
const endpointOrAccount = this.storageEndpointInput?.value?.trim() || "";
const region = this.storageRegionInput?.value?.trim() || "auto";
const accessKeyId = this.storageAccessKeyInput?.value?.trim() || "";
const secretAccessKey = this.storageSecretKeyInput?.value?.trim() || "";
const bucket = this.storageBucketInput?.value?.trim() || "";
// Checkbox is only relevant for Generic S3
const forcePathStyle = provider === PROVIDERS.GENERIC
? (this.storageForcePathStyleInput?.checked ?? true)
: false;
const publicBaseUrl = this.storagePrefixInput?.value?.trim() || "";
if (!accessKeyId || !secretAccessKey || !endpointOrAccount) {
this.setStorageFormStatus("Missing credentials for test.", "error");
return;
}
if (
provider === "cloudflare_r2" &&
(publicBaseUrl.includes(".r2.cloudflarestorage.com") ||
publicBaseUrl.includes(".s3.") ||
publicBaseUrl.includes(".amazonaws.com"))
) {
this.setStorageFormStatus(
"Invalid Public URL. Please use your R2.dev or custom domain.",
"error",
);
return;
}
this.setStorageFormStatus("Testing connection...", "info");
const config = {
provider,
accessKeyId,
secretAccessKey,
region,
bucket,
};
if (provider === "cloudflare_r2") {
config.accountId = endpointOrAccount;
config.publicBaseUrl = publicBaseUrl;
config.baseDomain = publicBaseUrl;
} else {
config.endpoint = endpointOrAccount;
config.forcePathStyle = forcePathStyle;
}
try {
const result = await storageService.testAccess(provider, config);
if (result.success) {
this.setStorageFormStatus(result.message || "Connection Verified!", "success");
} else {
this.setStorageFormStatus(`Test Failed: ${result.error}`, "error");
}
} catch (error) {
this.setStorageFormStatus(`Test Error: ${error.message}`, "error");
}
}
handleClearStorage() {
this.storageEndpointInput.value = "";
this.storageRegionInput.value = "auto";
this.storageAccessKeyInput.value = "";
this.storageSecretKeyInput.value = "";
this.storageBucketInput.value = "";
this.storagePrefixInput.value = "";
this.storageDefaultInput.checked = false;
this.storageProviderInput.value = "cloudflare_r2";
if (this.storageForcePathStyleInput) {
this.storageForcePathStyleInput.checked = true;
}
this.updateStorageFormVisibility();
this.setStorageFormStatus("", "info");
}
setStorageFormStatus(message, variant = "info") {
if (!this.storageFormStatus) return;
this.storageFormStatus.textContent = message;
this.storageFormStatus.className = `text-sm text-status-${variant}`;
}
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,
);
}
}
applyWalletControlState() {
const hasActive = Boolean(this.normalizeHexPubkey(this.getActivePubkey()));
const busy = this.isWalletBusy();
const uriValue = this.getSecretInputValue(this.walletUriInput);
const hasUri = uriValue.length > 0;
const applyDisabledState = (element, disabled) => {
if (!(element instanceof HTMLElement)) {
return;
}
if ("disabled" in element) {
element.disabled = disabled;
}
if (disabled) {
element.setAttribute("aria-disabled", "true");
} else {
element.removeAttribute("aria-disabled");
}
};
applyDisabledState(this.walletUriInput, busy || !hasActive);
applyDisabledState(this.walletDefaultZapInput, busy || !hasActive);
applyDisabledState(this.walletSaveButton, busy || !hasActive);
const testDisabled = busy || !hasActive || !hasUri;
applyDisabledState(this.walletTestButton, testDisabled);
const disconnectDisabled = busy || !hasActive || !hasUri;
applyDisabledState(this.walletDisconnectButton, disconnectDisabled);
if (this.walletDisconnectButton instanceof HTMLElement) {
this.walletDisconnectButton.classList.toggle("hidden", !hasUri);
if (!hasUri) {
this.walletDisconnectButton.setAttribute("aria-hidden", "true");
} else {
this.walletDisconnectButton.removeAttribute("aria-hidden");
}
}
}
updateWalletStatus(message, variant = "info") {
if (!(this.walletStatusText instanceof HTMLElement)) {
return;
}
const element = this.walletStatusText;
const variants = {
success: "text-status-success",
error: "text-status-danger",
info: "text-status-info",
neutral: "text-status-neutral",
};
element.classList.remove(
"text-status-info",
"text-status-success",
"text-status-danger",
"text-status-neutral",
);
const variantClass = variants[variant] || variants.neutral;
element.classList.add(variantClass);
element.textContent = message || "";
}
refreshWalletPaneState() {
const hasActive = Boolean(this.normalizeHexPubkey(this.getActivePubkey()));
if (!hasActive) {
this.setSecretInputValue(this.walletUriInput, "");
if (this.walletDefaultZapInput && "value" in this.walletDefaultZapInput) {
try {
this.walletDefaultZapInput.value = "";
} catch (error) {
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.setAttribute("data-value", "");
}
}
}
this.updateWalletStatus("Sign in to connect a wallet.", "info");
this.applyWalletControlState();
return;
}
let settings = this.services.nwcSettings.getActiveNwcSettings();
if (!settings || typeof settings !== "object") {
settings = this.services.nwcSettings.createDefaultNwcSettings();
}
this.setSecretInputValue(this.walletUriInput, settings.nwcUri || "");
if (this.walletDefaultZapInput && "value" in this.walletDefaultZapInput) {
const defaultZapValue =
settings.defaultZap === null || settings.defaultZap === undefined
? ""
: String(settings.defaultZap);
try {
this.walletDefaultZapInput.value = defaultZapValue;
} catch (error) {
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.setAttribute("data-value", defaultZapValue);
}
}
}
if (settings.nwcUri) {
this.updateWalletStatus(
"Wallet connected via Nostr Wallet Connect.",
"success",
);
} else {
this.updateWalletStatus("No wallet connected yet.", "info");
}
this.applyWalletControlState();
}
isSecretInputElement(element) {
if (!element || typeof element !== "object") {
return false;
}
if (typeof HTMLInputElement !== "undefined" && element instanceof HTMLInputElement) {
return true;
}
return typeof element.value === "string";
}
sanitizeSecretValue(value) {
return typeof value === "string" ? value.trim() : "";
}
getSecretInputValue(element) {
if (!this.isSecretInputElement(element)) {
return "";
}
const placeholder =
typeof element.dataset?.secretPlaceholder === "string"
? element.dataset.secretPlaceholder
: SECRET_PLACEHOLDER;
const stored = this.sanitizeSecretValue(
typeof element.dataset?.secretValue === "string"
? element.dataset.secretValue
: "",
);
const raw = this.sanitizeSecretValue(element.value);
const isMasked = element.dataset?.secretMasked === "true";
if (isMasked && placeholder && raw === placeholder) {
return stored;
}
if (!raw && isMasked) {
return stored;
}
return raw;
}
setSecretInputValue(element, value) {
if (!this.isSecretInputElement(element)) {
return;
}
const sanitized = this.sanitizeSecretValue(value);
if (!sanitized) {
if (element.dataset) {
delete element.dataset.secretValue;
delete element.dataset.secretMasked;
delete element.dataset.secretPlaceholder;
}
element.value = "";
return;
}
const placeholder = SECRET_PLACEHOLDER;
if (element.dataset) {
element.dataset.secretValue = sanitized;
element.dataset.secretPlaceholder = placeholder;
element.dataset.secretMasked = "true";
}
element.value = placeholder;
}
revealSecretInputValue(element) {
if (!this.isSecretInputElement(element)) {
return;
}
const stored = this.sanitizeSecretValue(
typeof element.dataset?.secretValue === "string"
? element.dataset.secretValue
: "",
);
if (!stored) {
if (element.dataset) {
delete element.dataset.secretMasked;
}
return;
}
if (element.dataset) {
element.dataset.secretMasked = "false";
}
element.value = stored;
try {
if (typeof element.setSelectionRange === "function") {
const length = stored.length;
element.setSelectionRange(length, length);
}
} catch (error) {
// Ignore selection errors on unsupported input types.
}
}
handleSecretInputChange(element) {
if (!this.isSecretInputElement(element)) {
return;
}
const value = this.sanitizeSecretValue(element.value);
if (!value) {
if (element.dataset) {
element.dataset.secretMasked = "false";
delete element.dataset.secretValue;
}
return;
}
if (element.dataset) {
element.dataset.secretValue = value;
element.dataset.secretMasked = "false";
if (!element.dataset.secretPlaceholder) {
element.dataset.secretPlaceholder = SECRET_PLACEHOLDER;
}
}
}
handleSecretInputBlur(element) {
if (!this.isSecretInputElement(element)) {
return;
}
const value = this.sanitizeSecretValue(this.getSecretInputValue(element));
if (!value) {
if (element.dataset) {
delete element.dataset.secretValue;
delete element.dataset.secretMasked;
delete element.dataset.secretPlaceholder;
}
element.value = "";
return;
}
if (element.dataset) {
element.dataset.secretValue = value;
element.dataset.secretPlaceholder =
element.dataset.secretPlaceholder || SECRET_PLACEHOLDER;
element.dataset.secretMasked = "true";
}
element.value = element.dataset?.secretPlaceholder || SECRET_PLACEHOLDER;
}
getWalletFormValues() {
const uri = this.getSecretInputValue(this.walletUriInput);
const defaultZapRaw =
typeof this.walletDefaultZapInput?.value === "string"
? this.walletDefaultZapInput.value.trim()
: "";
if (defaultZapRaw) {
const numeric = Number(defaultZapRaw);
if (!Number.isFinite(numeric)) {
return { uri, error: "Default zap amount must be a number." };
}
const rounded = Math.round(numeric);
if (!Number.isFinite(rounded) || rounded < 0) {
return {
uri,
error: "Default zap amount must be a positive whole number.",
};
}
const clamped = Math.min(this.maxWalletDefaultZap, rounded);
return { uri, defaultZap: clamped };
}
return { uri, defaultZap: null };
}
validateWalletUri(uri, { requireValue = false } = {}) {
const value = typeof uri === "string" ? uri.trim() : "";
if (!value) {
if (requireValue) {
return {
valid: false,
sanitized: "",
message: "Enter a wallet connect URI before continuing.",
};
}
return { valid: true, sanitized: "" };
}
if (!value.toLowerCase().startsWith(NWC_URI_SCHEME)) {
return {
valid: false,
sanitized: value,
message: `Wallet URI must start with ${NWC_URI_SCHEME}.`,
};
}
return { valid: true, sanitized: value };
}
async handleWalletSave() {
const { uri, defaultZap, error } = this.getWalletFormValues();
const context = {
uri,
defaultZap: defaultZap ?? null,
sanitizedUri: null,
success: false,
reason: null,
error: error || null,
status: null,
variant: null,
};
if (this.isWalletBusy()) {
context.reason = "busy";
this.callbacks.onWalletSave(context, this);
return context;
}
if (error) {
this.updateWalletStatus(error, "error");
this.showError(error);
context.reason = "invalid-default-zap";
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.focus();
}
this.callbacks.onWalletSave(context, this);
return context;
}
const { valid, sanitized, message } = this.validateWalletUri(uri);
context.sanitizedUri = sanitized;
if (!valid) {
this.updateWalletStatus(message, "error");
this.showError(message);
context.reason = "invalid-uri";
context.error = message;
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.focus();
}
this.callbacks.onWalletSave(context, this);
return context;
}
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
if (!normalizedActive) {
const loginMessage = "Sign in to save wallet settings.";
this.updateWalletStatus(loginMessage, "error");
this.showError(loginMessage);
context.reason = "no-active-pubkey";
context.error = loginMessage;
this.callbacks.onWalletSave(context, this);
return context;
}
this.setWalletPaneBusy(true);
let finalStatus = null;
let finalVariant = "info";
try {
const persistResult = await this.persistWalletSettings({
nwcUri: sanitized,
defaultZap,
activePubkey: normalizedActive,
});
context.persistResult = persistResult || null;
if (sanitized) {
finalStatus = "Wallet settings saved.";
finalVariant = "success";
this.showSuccess("Wallet settings saved.");
context.reason = "saved";
} else {
finalStatus = "Wallet connection removed.";
finalVariant = "info";
this.showStatus("Wallet connection removed.");
context.reason = "cleared";
}
context.success = true;
} catch (error) {
const fallbackMessage = "Failed to save wallet settings.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
finalStatus = detail;
finalVariant = "error";
context.error = detail;
context.reason = error?.code || "service-error";
this.showError(detail);
} finally {
this.setWalletPaneBusy(false);
this.refreshWalletPaneState();
if (finalStatus) {
this.updateWalletStatus(finalStatus, finalVariant);
}
context.status = finalStatus;
context.variant = finalVariant;
this.callbacks.onWalletSave(context, this);
}
return context;
}
async handleWalletTest() {
const { uri, defaultZap, error } = this.getWalletFormValues();
const context = {
uri,
defaultZap: defaultZap ?? null,
sanitizedUri: null,
success: false,
reason: null,
error: error || null,
status: null,
variant: null,
result: null,
};
if (this.isWalletBusy()) {
context.reason = "busy";
this.callbacks.onWalletTest(context, this);
return context.result;
}
if (error) {
this.updateWalletStatus(error, "error");
this.showError(error);
context.reason = "invalid-default-zap";
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.focus();
}
this.callbacks.onWalletTest(context, this);
return context.result;
}
const { valid, sanitized, message } = this.validateWalletUri(uri, {
requireValue: true,
});
context.sanitizedUri = sanitized;
if (!valid) {
this.updateWalletStatus(message, "error");
this.showError(message);
context.reason = "invalid-uri";
context.error = message;
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.focus();
}
this.callbacks.onWalletTest(context, this);
return context.result;
}
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
if (!normalizedActive) {
const loginMessage = "Sign in to test your wallet connection.";
this.updateWalletStatus(loginMessage, "error");
this.showError(loginMessage);
context.reason = "no-active-pubkey";
context.error = loginMessage;
this.callbacks.onWalletTest(context, this);
return context.result;
}
this.setWalletPaneBusy(true);
let finalStatus = null;
let finalVariant = "info";
try {
const result = await this.testWalletConnection({
nwcUri: sanitized,
defaultZap,
activePubkey: normalizedActive,
});
finalStatus = "Wallet connection confirmed.";
finalVariant = "success";
this.showSuccess("Wallet connection confirmed.");
context.result = result;
context.success = true;
context.reason = "tested";
let currentSettings = this.services.nwcSettings.getActiveNwcSettings();
if (!currentSettings || typeof currentSettings !== "object") {
currentSettings = this.services.nwcSettings.createDefaultNwcSettings();
}
if (currentSettings.nwcUri === sanitized) {
await this.persistWalletSettings({
lastChecked: Date.now(),
activePubkey: normalizedActive,
});
}
} catch (error) {
const fallbackMessage = "Failed to reach wallet.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
finalStatus = detail;
finalVariant = "error";
context.error = detail;
context.reason = error?.code || "service-error";
this.showError(detail);
} finally {
this.setWalletPaneBusy(false);
this.refreshWalletPaneState();
if (finalStatus) {
this.updateWalletStatus(finalStatus, finalVariant);
}
context.status = finalStatus;
context.variant = finalVariant;
this.callbacks.onWalletTest(context, this);
}
return context.result;
}
async handleWalletDisconnect() {
const context = {
success: false,
reason: null,
error: null,
status: null,
variant: null,
};
if (this.isWalletBusy()) {
context.reason = "busy";
this.callbacks.onWalletDisconnect(context, this);
return context;
}
const normalizedActive = this.normalizeHexPubkey(this.getActivePubkey());
if (!normalizedActive) {
const loginMessage = "Sign in to disconnect your wallet.";
this.updateWalletStatus(loginMessage, "error");
this.showError(loginMessage);
context.reason = "no-active-pubkey";
context.error = loginMessage;
this.callbacks.onWalletDisconnect(context, this);
return context;
}
this.setWalletPaneBusy(true);
let finalStatus = null;
let finalVariant = "info";
try {
const disconnectResult = await this.disconnectWallet({
activePubkey: normalizedActive,
});
context.result = disconnectResult || null;
finalStatus = "Wallet disconnected.";
this.showStatus("Wallet disconnected.");
context.success = true;
context.reason = "disconnected";
} catch (error) {
const fallbackMessage = "Failed to disconnect wallet.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
finalStatus = detail;
finalVariant = "error";
context.error = detail;
context.reason = error?.code || "service-error";
this.showError(detail);
} finally {
this.setWalletPaneBusy(false);
this.refreshWalletPaneState();
if (finalStatus) {
this.updateWalletStatus(finalStatus, finalVariant);
}
context.status = finalStatus;
context.variant = finalVariant;
this.callbacks.onWalletDisconnect(context, this);
}
return context;
}
getModerationSettingsService() {
const service = this.services.moderationSettings;
if (!service || typeof service !== "object") {
return null;
}
return service;
}
getModerationSettingsDefaults() {
const service = this.getModerationSettingsService();
let defaults = null;
if (service && typeof service.getDefaultModerationSettings === "function") {
try {
defaults = service.getDefaultModerationSettings();
} catch (error) {
devLogger.info("[profileModal] moderation defaults fallback used", error);
}
}
if (!defaults || typeof defaults !== "object") {
defaults = createInternalDefaultModerationSettings();
}
const sanitized = {
blurThreshold: Math.max(
0,
Math.floor(
Number(
defaults.blurThreshold ?? DEFAULT_INTERNAL_MODERATION_SETTINGS.blurThreshold,
),
),
),
autoplayBlockThreshold: Math.max(
0,
Math.floor(
Number(
defaults.autoplayBlockThreshold ??
DEFAULT_INTERNAL_MODERATION_SETTINGS.autoplayBlockThreshold,
),
),
),
trustedMuteHideThreshold: Math.max(
0,
Math.floor(
Number(
defaults.trustedMuteHideThreshold ??
DEFAULT_INTERNAL_MODERATION_SETTINGS.trustedMuteHideThreshold,
),
),
),
trustedSpamHideThreshold: Math.max(
0,
Math.floor(
Number(
defaults.trustedSpamHideThreshold ??
DEFAULT_INTERNAL_MODERATION_SETTINGS.trustedSpamHideThreshold,
),
),
),
};
return sanitized;
}
normalizeModerationSettings(settings = null) {
const defaults = this.getModerationSettingsDefaults();
const blur = Number.isFinite(settings?.blurThreshold)
? Math.max(0, Math.floor(settings.blurThreshold))
: defaults.blurThreshold;
const autoplay = Number.isFinite(settings?.autoplayBlockThreshold)
? Math.max(0, Math.floor(settings.autoplayBlockThreshold))
: defaults.autoplayBlockThreshold;
const muteHide = Number.isFinite(settings?.trustedMuteHideThreshold)
? Math.max(0, Math.floor(settings.trustedMuteHideThreshold))
: defaults.trustedMuteHideThreshold;
const spamHide = Number.isFinite(settings?.trustedSpamHideThreshold)
? Math.max(0, Math.floor(settings.trustedSpamHideThreshold))
: defaults.trustedSpamHideThreshold;
return {
blurThreshold: blur,
autoplayBlockThreshold: autoplay,
trustedMuteHideThreshold: muteHide,
trustedSpamHideThreshold: spamHide,
};
}
readModerationInputs() {
const defaults = this.getModerationSettingsDefaults();
const parse = (input, fallback) => {
if (!(input instanceof HTMLInputElement)) {
return { value: fallback, override: null, valid: true };
}
const raw = typeof input.value === "string" ? input.value.trim() : "";
if (!raw) {
return { value: fallback, override: null, valid: true };
}
const numeric = Number(raw);
if (!Number.isFinite(numeric)) {
return { value: fallback, override: null, valid: false };
}
const sanitized = Math.max(0, Math.floor(numeric));
return { value: sanitized, override: sanitized, valid: true };
};
const blur = parse(this.moderationBlurInput, defaults.blurThreshold);
const autoplay = parse(
this.moderationAutoplayInput,
defaults.autoplayBlockThreshold,
);
const muteHide = parse(
this.moderationMuteHideInput,
defaults.trustedMuteHideThreshold,
);
const spamHide = parse(
this.moderationSpamHideInput,
defaults.trustedSpamHideThreshold,
);
const valid = blur.valid && autoplay.valid && muteHide.valid && spamHide.valid;
const values = {
blurThreshold: blur.value,
autoplayBlockThreshold: autoplay.value,
trustedMuteHideThreshold: muteHide.value,
trustedSpamHideThreshold: spamHide.value,
};
const overrides = {
blurThreshold: blur.override,
autoplayBlockThreshold: autoplay.override,
trustedMuteHideThreshold: muteHide.override,
trustedSpamHideThreshold: spamHide.override,
};
return { defaults, values, overrides, valid };
}
applyModerationSettingsControlState({ resetStatus = false } = {}) {
const result = this.readModerationInputs();
const button = this.moderationSaveButton;
if (button instanceof HTMLElement) {
const baseline = this.currentModerationSettings || this.normalizeModerationSettings();
const isDirty =
result.valid &&
(baseline.blurThreshold !== result.values.blurThreshold ||
baseline.autoplayBlockThreshold !== result.values.autoplayBlockThreshold ||
baseline.trustedMuteHideThreshold !==
result.values.trustedMuteHideThreshold ||
baseline.trustedSpamHideThreshold !==
result.values.trustedSpamHideThreshold);
button.disabled = !(result.valid && isDirty);
if (button.disabled) {
button.setAttribute("aria-disabled", "true");
} else {
button.removeAttribute("aria-disabled");
}
}
if (resetStatus) {
this.updateModerationSettingsStatus("", "info");
}
return result;
}
areTrustedHideControlsEnabled() {
if (
RUNTIME_FLAGS &&
typeof RUNTIME_FLAGS === "object" &&
RUNTIME_FLAGS.FEATURE_TRUSTED_HIDE_CONTROLS === false
) {
return false;
}
return true;
}
updateTrustedHideControlsVisibility() {
const shouldShow = this.areTrustedHideControlsEnabled();
const targets = new Set();
if (this.moderationHideControlsGroup instanceof HTMLElement) {
targets.add(this.moderationHideControlsGroup);
}
if (Array.isArray(this.moderationHideControlElements)) {
for (const element of this.moderationHideControlElements) {
if (element instanceof HTMLElement) {
targets.add(element);
}
}
}
targets.forEach((element) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (shouldShow) {
element.classList.remove("hidden");
element.removeAttribute("hidden");
element.removeAttribute("aria-hidden");
} else {
element.classList.add("hidden");
element.setAttribute("hidden", "");
element.setAttribute("aria-hidden", "true");
}
});
}
updateModerationSettingsStatus(message = "", variant = "info") {
if (!(this.moderationStatusText instanceof HTMLElement)) {
return;
}
const text = typeof message === "string" ? message : "";
this.moderationStatusText.textContent = text;
if (text) {
this.moderationStatusText.dataset.variant = variant || "info";
} else if (this.moderationStatusText.dataset.variant) {
delete this.moderationStatusText.dataset.variant;
}
}
updateTrustedMuteHideHelperCopy() {
if (!(this.moderationMuteHideInput instanceof HTMLInputElement)) {
return;
}
const label = this.moderationMuteHideInput.closest("label");
if (!(label instanceof HTMLElement)) {
return;
}
const helper = label.querySelector("span.text-xs");
if (!(helper instanceof HTMLElement)) {
return;
}
helper.textContent = TRUSTED_MUTE_HIDE_HELPER_TEXT;
}
getModerationOverrideEntries() {
if (typeof this.services.getModerationOverrides !== "function") {
return [];
}
try {
const entries = this.services.getModerationOverrides();
return Array.isArray(entries) ? entries : [];
} catch (error) {
devLogger.info(
"[profileModal] moderation overrides fallback used",
error,
);
return [];
}
}
normalizeModerationOverrideEntries(entries = []) {
const normalized = [];
const seen = new Set();
entries.forEach((entry) => {
if (!entry || typeof entry !== "object") {
return;
}
const eventId =
typeof entry.eventId === "string"
? entry.eventId.trim().toLowerCase()
: "";
if (!eventId) {
return;
}
const author =
typeof entry.authorPubkey === "string"
? entry.authorPubkey.trim()
: "";
const normalizedAuthor = author ? this.normalizeHexPubkey(author) || author : "";
const key = `${normalizedAuthor || ""}:${eventId}`;
if (seen.has(key)) {
return;
}
seen.add(key);
normalized.push({
eventId,
authorPubkey: normalizedAuthor || "",
updatedAt: Number.isFinite(entry.updatedAt)
? Math.floor(entry.updatedAt)
: 0,
});
});
normalized.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return normalized;
}
formatModerationOverrideTimestamp(updatedAt) {
const numeric = Number(updatedAt);
if (!Number.isFinite(numeric) || numeric <= 0) {
return { display: "", iso: "" };
}
try {
const date = new Date(numeric);
return {
display: date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}),
iso: date.toISOString(),
};
} catch (error) {
return { display: "", iso: "" };
}
}
async handleModerationOverrideReset(entry) {
if (!entry || typeof entry !== "object") {
return false;
}
if (typeof this.services.clearModerationOverride !== "function") {
return false;
}
try {
await this.services.clearModerationOverride({
eventId: entry.eventId,
authorPubkey: entry.authorPubkey,
});
this.refreshModerationOverridesUi();
this.showSuccess("Moderation override reset.");
return true;
} catch (error) {
this.showError("Unable to reset this moderation override.");
return false;
}
}
refreshModerationOverridesUi() {
if (
!(this.moderationOverridesList instanceof HTMLElement) ||
!(this.moderationOverridesEmpty instanceof HTMLElement)
) {
return;
}
const entries = this.normalizeModerationOverrideEntries(
this.getModerationOverrideEntries(),
);
this.moderationOverridesList.innerHTML = "";
if (!entries.length) {
this.moderationOverridesEmpty.classList.remove("hidden");
this.moderationOverridesList.classList.add("hidden");
return;
}
this.moderationOverridesEmpty.classList.add("hidden");
this.moderationOverridesList.classList.remove("hidden");
const entriesNeedingFetch = new Set();
entries.forEach((entry) => {
const item = document.createElement("li");
item.className = "card space-y-2 p-4";
const row = document.createElement("div");
row.className = "flex items-center justify-between gap-4";
const authorKey = entry.authorPubkey;
let profileSummary = null;
if (authorKey) {
const cacheEntry = this.services.getProfileCacheEntry(authorKey);
if (!cacheEntry) {
entriesNeedingFetch.add(authorKey);
}
}
const summaryData = this.resolveProfileSummaryForPubkey(authorKey);
profileSummary = this.createCompactProfileSummary(summaryData);
const actions = document.createElement("div");
actions.className = "flex flex-wrap items-center justify-end gap-2";
const resetButton = this.createRemoveButton({
label: "Reset",
onRemove: () => this.handleModerationOverrideReset(entry),
});
if (resetButton) {
actions.appendChild(resetButton);
}
if (profileSummary) {
row.appendChild(profileSummary);
}
if (actions.childElementCount > 0) {
row.appendChild(actions);
}
const meta = document.createElement("div");
meta.className = "flex flex-wrap items-center gap-3 text-2xs text-muted";
const contentId = document.createElement("span");
contentId.className = "font-mono text-2xs text-muted";
const shortId =
typeof this.truncateMiddle === "function"
? this.truncateMiddle(entry.eventId, 16)
: entry.eventId;
contentId.textContent = `Content ${shortId}`;
contentId.title = entry.eventId;
meta.appendChild(contentId);
const timestamp = this.formatModerationOverrideTimestamp(entry.updatedAt);
if (timestamp.display) {
const time = document.createElement("time");
time.className = "text-2xs text-muted";
time.dateTime = timestamp.iso;
time.textContent = `Updated ${timestamp.display}`;
meta.appendChild(time);
}
item.appendChild(row);
item.appendChild(meta);
this.moderationOverridesList.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.services.batchFetchProfiles === "function"
) {
this.services.batchFetchProfiles(entriesNeedingFetch);
}
}
refreshModerationSettingsUi() {
const service = this.getModerationSettingsService();
if (!service) {
this.moderationSettingsDefaults = createInternalDefaultModerationSettings();
this.currentModerationSettings = createInternalDefaultModerationSettings();
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
return;
}
let active = null;
if (typeof service.getActiveModerationSettings === "function") {
try {
active = service.getActiveModerationSettings();
} catch (error) {
devLogger.info("[profileModal] moderation settings fallback used", error);
}
}
const defaults = this.getModerationSettingsDefaults();
this.moderationSettingsDefaults = defaults;
const normalized = this.normalizeModerationSettings(active);
this.currentModerationSettings = normalized;
if (this.moderationBlurInput instanceof HTMLInputElement) {
this.moderationBlurInput.value = String(normalized.blurThreshold);
}
if (this.moderationAutoplayInput instanceof HTMLInputElement) {
this.moderationAutoplayInput.value = String(
normalized.autoplayBlockThreshold,
);
}
if (this.moderationMuteHideInput instanceof HTMLInputElement) {
this.moderationMuteHideInput.value = String(
normalized.trustedMuteHideThreshold,
);
}
if (this.moderationSpamHideInput instanceof HTMLInputElement) {
this.moderationSpamHideInput.value = String(
normalized.trustedSpamHideThreshold,
);
}
this.updateTrustedHideControlsVisibility();
this.updateModerationTrustStats();
this.refreshModerationOverridesUi();
this.applyModerationSettingsControlState({ resetStatus: true });
}
getModerationTrustStats() {
const summary = {
trustedContactsCount: 0,
trustedMuteContributors: 0,
trustedReportContributors: 0,
trustedSeedOnly: false,
};
const service = this.moderationService;
if (!service) {
return summary;
}
if (typeof service.isTrustedSeedOnly === "function") {
summary.trustedSeedOnly = service.isTrustedSeedOnly();
} else if (typeof service.trustedSeedOnly === "boolean") {
summary.trustedSeedOnly = service.trustedSeedOnly;
}
const trustedContacts =
service.trustedContacts instanceof Set
? service.trustedContacts
: Array.isArray(service.trustedContacts)
? new Set(service.trustedContacts)
: new Set();
summary.trustedContactsCount = trustedContacts.size;
const adminSnapshot =
typeof service.getAdminListSnapshot === "function"
? service.getAdminListSnapshot()
: null;
const resolveStatus = (candidate) => {
if (typeof service.getAccessControlStatus === "function") {
return service.getAccessControlStatus(candidate, adminSnapshot);
}
return {
hex: this.normalizeHexPubkey(candidate),
whitelisted: false,
blacklisted: false,
};
};
const isBlocked = (pubkey) =>
typeof service.isPubkeyBlockedByViewer === "function"
? service.isPubkeyBlockedByViewer(pubkey)
: false;
const isTrustedCandidate = (status) => {
if (!status || !status.hex) {
return false;
}
if (status.blacklisted) {
return false;
}
if (isBlocked(status.hex)) {
return false;
}
return Boolean(status.whitelisted || trustedContacts.has(status.hex));
};
if (service.trustedMuteLists instanceof Map) {
const trustedMuteOwners = new Set();
for (const owner of service.trustedMuteLists.keys()) {
const status = resolveStatus(owner);
if (isTrustedCandidate(status)) {
trustedMuteOwners.add(status.hex);
}
}
summary.trustedMuteContributors = trustedMuteOwners.size;
}
if (service.reportEvents instanceof Map) {
const trustedReporters = new Set();
for (const eventReports of service.reportEvents.values()) {
if (!(eventReports instanceof Map)) {
continue;
}
for (const reporter of eventReports.keys()) {
const status = resolveStatus(reporter);
if (!isTrustedCandidate(status)) {
continue;
}
trustedReporters.add(status.hex);
}
}
summary.trustedReportContributors = trustedReporters.size;
}
return summary;
}
updateModerationTrustStats() {
if (
!(this.moderationTrustedContactsCount instanceof HTMLElement) &&
!(this.moderationTrustedMuteCount instanceof HTMLElement) &&
!(this.moderationTrustedReportCount instanceof HTMLElement) &&
!(this.moderationSeedOnlyIndicator instanceof HTMLElement)
) {
return;
}
const summary = this.getModerationTrustStats();
if (this.moderationTrustedContactsCount instanceof HTMLElement) {
this.moderationTrustedContactsCount.textContent = String(
summary.trustedContactsCount,
);
}
if (this.moderationTrustedMuteCount instanceof HTMLElement) {
this.moderationTrustedMuteCount.textContent = String(
summary.trustedMuteContributors,
);
}
if (this.moderationTrustedReportCount instanceof HTMLElement) {
this.moderationTrustedReportCount.textContent = String(
summary.trustedReportContributors,
);
}
if (this.moderationSeedOnlyIndicator instanceof HTMLElement) {
this.moderationSeedOnlyIndicator.hidden = !summary.trustedSeedOnly;
}
}
async handleModerationSettingsSave() {
const service = this.getModerationSettingsService();
const context = {
success: false,
reason: null,
error: null,
settings: null,
};
if (!service) {
return context;
}
const inputState = this.applyModerationSettingsControlState();
if (!inputState.valid) {
const message =
"Enter non-negative whole numbers for moderation thresholds.";
this.updateModerationSettingsStatus(message, "error");
this.showError(message);
context.reason = "invalid-input";
context.error = message;
return context;
}
const payload = {};
if (Object.prototype.hasOwnProperty.call(inputState.overrides, "blurThreshold")) {
payload.blurThreshold = inputState.overrides.blurThreshold;
}
if (
Object.prototype.hasOwnProperty.call(
inputState.overrides,
"autoplayBlockThreshold",
)
) {
payload.autoplayBlockThreshold = inputState.overrides.autoplayBlockThreshold;
}
if (
Object.prototype.hasOwnProperty.call(
inputState.overrides,
"trustedMuteHideThreshold",
)
) {
payload.trustedMuteHideThreshold =
inputState.overrides.trustedMuteHideThreshold;
}
if (
Object.prototype.hasOwnProperty.call(
inputState.overrides,
"trustedSpamHideThreshold",
)
) {
payload.trustedSpamHideThreshold =
inputState.overrides.trustedSpamHideThreshold;
}
try {
const updated =
typeof service.updateModerationSettings === "function"
? await service.updateModerationSettings(payload)
: inputState.values;
const normalized = this.normalizeModerationSettings(updated);
this.currentModerationSettings = normalized;
if (this.moderationBlurInput instanceof HTMLInputElement) {
this.moderationBlurInput.value = String(normalized.blurThreshold);
}
if (this.moderationAutoplayInput instanceof HTMLInputElement) {
this.moderationAutoplayInput.value = String(
normalized.autoplayBlockThreshold,
);
}
if (this.moderationMuteHideInput instanceof HTMLInputElement) {
this.moderationMuteHideInput.value = String(
normalized.trustedMuteHideThreshold,
);
}
if (this.moderationSpamHideInput instanceof HTMLInputElement) {
this.moderationSpamHideInput.value = String(
normalized.trustedSpamHideThreshold,
);
}
this.applyModerationSettingsControlState();
this.updateModerationSettingsStatus("Moderation settings saved.", "success");
this.showSuccess("Moderation settings saved.");
context.success = true;
context.reason = "saved";
context.settings = normalized;
this.callbacks.onModerationSettingsChange({
settings: normalized,
controller: this,
reason: "saved",
});
} catch (error) {
const fallbackMessage = "Failed to update moderation settings.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
this.updateModerationSettingsStatus(detail, "error");
this.showError(detail);
context.error = detail;
context.reason = error?.code || "service-error";
}
return context;
}
async handleModerationSettingsReset() {
const service = this.getModerationSettingsService();
const context = {
success: false,
reason: null,
error: null,
settings: null,
};
if (!service) {
return context;
}
try {
const updated =
typeof service.resetModerationSettings === "function"
? await service.resetModerationSettings()
: createInternalDefaultModerationSettings();
const normalized = this.normalizeModerationSettings(updated);
this.currentModerationSettings = normalized;
if (this.moderationBlurInput instanceof HTMLInputElement) {
this.moderationBlurInput.value = String(normalized.blurThreshold);
}
if (this.moderationAutoplayInput instanceof HTMLInputElement) {
this.moderationAutoplayInput.value = String(
normalized.autoplayBlockThreshold,
);
}
if (this.moderationMuteHideInput instanceof HTMLInputElement) {
this.moderationMuteHideInput.value = String(
normalized.trustedMuteHideThreshold,
);
}
if (this.moderationSpamHideInput instanceof HTMLInputElement) {
this.moderationSpamHideInput.value = String(
normalized.trustedSpamHideThreshold,
);
}
this.updateTrustedHideControlsVisibility();
this.applyModerationSettingsControlState({ resetStatus: true });
this.updateModerationSettingsStatus(
"Moderation defaults restored.",
"success",
);
this.showSuccess("Moderation defaults restored.");
context.success = true;
context.reason = "reset";
context.settings = normalized;
this.callbacks.onModerationSettingsChange({
settings: normalized,
controller: this,
reason: "reset",
});
} catch (error) {
const fallbackMessage = "Failed to restore moderation defaults.";
const detail =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: fallbackMessage;
this.updateModerationSettingsStatus(detail, "error");
this.showError(detail);
context.error = detail;
context.reason = error?.code || "service-error";
}
return context;
}
storeAdminEmptyMessages() {
const capture = (element) => {
if (element instanceof HTMLElement && !element.dataset.defaultMessage) {
element.dataset.defaultMessage = element.textContent || "";
}
};
capture(this.moderatorEmpty);
capture(this.whitelistEmpty);
capture(this.blacklistEmpty);
}
setAdminLoading(isLoading) {
this.storeAdminEmptyMessages();
if (this.panes.admin instanceof HTMLElement) {
this.panes.admin.setAttribute("aria-busy", isLoading ? "true" : "false");
}
const toggleMessage = (element, message) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (isLoading) {
element.textContent = message;
element.classList.remove("hidden");
} else {
element.textContent = element.dataset.defaultMessage || element.textContent;
}
};
toggleMessage(this.moderatorEmpty, "Loading moderators…");
toggleMessage(this.whitelistEmpty, "Loading whitelist…");
toggleMessage(this.blacklistEmpty, "Loading blacklist…");
}
clearAdminLists() {
this.storeAdminEmptyMessages();
if (this.adminModeratorList) {
this.adminModeratorList.innerHTML = "";
}
if (this.whitelistList) {
this.whitelistList.innerHTML = "";
}
if (this.blacklistList) {
this.blacklistList.innerHTML = "";
}
if (this.moderatorEmpty instanceof HTMLElement) {
this.moderatorEmpty.textContent =
this.moderatorEmpty.dataset.defaultMessage ||
this.moderatorEmpty.textContent;
this.moderatorEmpty.classList.remove("hidden");
}
if (this.whitelistEmpty instanceof HTMLElement) {
this.whitelistEmpty.textContent =
this.whitelistEmpty.dataset.defaultMessage ||
this.whitelistEmpty.textContent;
this.whitelistEmpty.classList.remove("hidden");
}
if (this.blacklistEmpty instanceof HTMLElement) {
this.blacklistEmpty.textContent =
this.blacklistEmpty.dataset.defaultMessage ||
this.blacklistEmpty.textContent;
this.blacklistEmpty.classList.remove("hidden");
}
}
normalizeAdminListEntries(entries) {
const collected = [];
const seen = new Set();
const append = (value) => {
if (typeof value !== "string") {
return;
}
const trimmed = value.trim();
if (!trimmed || seen.has(trimmed)) {
return;
}
seen.add(trimmed);
collected.push(trimmed);
};
if (Array.isArray(entries)) {
entries.forEach(append);
} else if (entries && typeof entries?.[Symbol.iterator] === "function") {
for (const entry of entries) {
append(entry);
}
} else if (entries && typeof entries === "object") {
Object.values(entries).forEach(append);
}
try {
collected.sort((a, b) => a.localeCompare(b));
} catch (error) {
devLogger.warn(
"[profileModal] Failed to sort admin list entries, using fallback order.",
error,
);
}
return collected;
}
renderAdminList(listEl, emptyEl, entries, options = {}) {
if (!(listEl instanceof HTMLElement) || !(emptyEl instanceof HTMLElement)) {
return;
}
const {
onRemove,
removeLabel = "Remove",
confirmMessage,
removable = true,
overlapSet,
overlapLabel,
} = options;
const formatNpub =
typeof this.formatShortNpub === "function"
? (value) => this.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const entriesNeedingFetch = new Set();
listEl.innerHTML = "";
const values = this.normalizeAdminListEntries(entries);
const toggleHiddenState = (element, shouldHide) => {
if (!(element instanceof HTMLElement)) {
return;
}
if (shouldHide) {
element.classList.add("hidden");
element.setAttribute("hidden", "");
} else {
element.classList.remove("hidden");
element.removeAttribute("hidden");
}
};
if (!values.length) {
toggleHiddenState(emptyEl, false);
toggleHiddenState(listEl, true);
return;
}
toggleHiddenState(emptyEl, true);
toggleHiddenState(listEl, false);
values.forEach((npub) => {
const item = document.createElement("li");
item.className =
"card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between";
const normalizedNpub = typeof npub === "string" ? npub.trim() : "";
const comparableNpub =
this.normalizeNpubValue(normalizedNpub) || normalizedNpub;
const decodedHex =
normalizedNpub && normalizedNpub.startsWith("npub1")
? this.safeDecodeNpub(normalizedNpub)
: null;
const normalizedHex =
decodedHex && /^[0-9a-f]{64}$/i.test(decodedHex)
? decodedHex.toLowerCase()
: null;
let cachedProfile = null;
if (normalizedHex) {
const cacheEntry = this.services.getProfileCacheEntry(normalizedHex);
cachedProfile = cacheEntry?.profile || null;
if (!cacheEntry) {
entriesNeedingFetch.add(normalizedHex);
}
}
const encodedNpub =
normalizedHex && typeof this.safeEncodeNpub === "function"
? this.safeEncodeNpub(normalizedHex)
: normalizedNpub;
const displayNpub = formatNpub(encodedNpub) || encodedNpub || normalizedNpub;
const displayName =
cachedProfile?.name?.trim() || displayNpub || "Unknown profile";
const avatarSrc =
cachedProfile?.picture || FALLBACK_PROFILE_AVATAR;
const summary = this.createCompactProfileSummary({
displayName,
displayNpub,
avatarSrc,
});
if (
summary &&
overlapLabel &&
overlapSet instanceof Set &&
comparableNpub &&
overlapSet.has(comparableNpub)
) {
const overlapBadge = document.createElement("span");
overlapBadge.className = "badge whitespace-nowrap";
overlapBadge.dataset.variant = "warning";
overlapBadge.textContent = overlapLabel;
summary.appendChild(overlapBadge);
}
const actions = document.createElement("div");
actions.className =
"flex flex-wrap items-center justify-end gap-2 sm:flex-none";
const viewButton = this.createViewChannelButton({
targetNpub: encodedNpub,
displayNpub,
});
if (viewButton) {
actions.appendChild(viewButton);
}
const copyButton = this.createCopyNpubButton({
targetNpub: encodedNpub,
displayNpub,
});
if (copyButton) {
actions.appendChild(copyButton);
}
if (removable && typeof onRemove === "function") {
const removeBtn = this.createRemoveButton({
label: removeLabel,
confirmMessage,
confirmValue: displayNpub,
onRemove: (button) => onRemove(npub, button),
});
if (removeBtn) {
actions.appendChild(removeBtn);
}
}
item.appendChild(summary);
if (actions.childElementCount > 0) {
item.appendChild(actions);
}
listEl.appendChild(item);
});
if (
entriesNeedingFetch.size &&
typeof this.services.batchFetchProfiles === "function"
) {
this.services.batchFetchProfiles(entriesNeedingFetch);
}
}
populateAdminLists() {
const actorNpub = this.services.getCurrentUserNpub();
if (!actorNpub || !this.services.accessControl.canEditAdminLists(actorNpub)) {
this.clearAdminLists();
return;
}
const isSuperAdmin = this.services.accessControl.isSuperAdmin(actorNpub);
const editors = this.normalizeAdminListEntries(
this.services.accessControl.getEditors(),
).filter((npub) => npub && npub !== this.adminSuperNpub);
const whitelist = this.normalizeAdminListEntries(
this.services.accessControl.getWhitelist(),
);
const blacklist = this.normalizeAdminListEntries(
this.services.accessControl.getBlacklist(),
);
const normalizeForCompare = (value) =>
this.normalizeNpubValue(value) ||
(typeof value === "string" ? value.trim() : "");
const whitelistCompare = new Set(
whitelist.map(normalizeForCompare).filter(Boolean),
);
const blacklistCompare = new Set(
blacklist.map(normalizeForCompare).filter(Boolean),
);
this.renderAdminList(
this.adminModeratorList,
this.moderatorEmpty,
editors,
{
onRemove: (npub, button) => this.handleRemoveModerator(npub, button),
removeLabel: "Remove",
confirmMessage:
"Remove moderator {npub}? They will immediately lose access to the admin panel.",
removable: isSuperAdmin,
},
);
this.renderAdminList(
this.whitelistList,
this.whitelistEmpty,
whitelist,
{
onRemove: (npub, button) =>
this.handleAdminListMutation("whitelist", "remove", npub, button),
removeLabel: "Remove",
confirmMessage: "Remove {npub} from the whitelist?",
removable: true,
overlapSet: blacklistCompare,
overlapLabel: "Also blacklisted",
},
);
this.renderAdminList(
this.blacklistList,
this.blacklistEmpty,
blacklist,
{
onRemove: (npub, button) =>
this.handleAdminListMutation("blacklist", "remove", npub, button),
removeLabel: "Unblock",
confirmMessage: "Remove {npub} from the blacklist?",
removable: true,
overlapSet: whitelistCompare,
overlapLabel: "Also whitelisted",
},
);
}
async refreshAdminPaneState() {
const adminNav = this.navButtons.admin;
const adminPane = this.panes.admin;
let loadError = null;
this.setAdminLoading(true);
this.showStatus("Fetching moderation filters…");
try {
const ensureResult = await this.runAdminMutation({
action: "ensure-ready",
});
if (ensureResult?.error && ensureResult.ok === false) {
loadError = ensureResult.error;
}
} catch (error) {
loadError = error;
}
const actorNpub = this.services.getCurrentUserNpub();
const canEdit =
!!actorNpub && this.services.accessControl.canEditAdminLists(actorNpub);
const isSuperAdmin =
!!actorNpub && this.services.accessControl.isSuperAdmin(actorNpub);
if (adminNav instanceof HTMLElement) {
adminNav.classList.toggle("hidden", !canEdit);
if (!canEdit) {
adminNav.setAttribute("aria-selected", "false");
}
}
if (adminPane instanceof HTMLElement) {
if (!canEdit) {
adminPane.classList.add("hidden");
adminPane.setAttribute("aria-hidden", "true");
} else {
const isActive = this.getActivePane() === "admin";
adminPane.classList.toggle("hidden", !isActive);
adminPane.setAttribute("aria-hidden", (!isActive).toString());
}
}
if (loadError) {
if (loadError?.code === "nostr-unavailable") {
devLogger.info("Moderation lists are still syncing with relays.");
return;
}
userLogger.error("Failed to load admin lists:", loadError);
this.showStatus(null);
this.showError("Unable to load moderation lists. Please try again.");
this.clearAdminLists();
this.setAdminLoading(false);
return;
}
if (!canEdit) {
this.clearAdminLists();
this.showStatus(null);
this.setAdminLoading(false);
if (
adminNav instanceof HTMLElement &&
adminNav.dataset.state === "active"
) {
this.selectPane("account");
}
return;
}
if (this.moderatorSection instanceof HTMLElement) {
this.moderatorSection.classList.toggle("hidden", !isSuperAdmin);
this.moderatorSection.setAttribute(
"aria-hidden",
(!isSuperAdmin).toString(),
);
}
this.populateAdminLists();
this.showStatus(null);
this.setAdminLoading(false);
}
normalizeNpubValue(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("npub1")) {
return trimmed;
}
const normalizedHex = this.normalizeHexPubkey(trimmed);
if (!normalizedHex) {
return null;
}
return this.safeEncodeNpub(normalizedHex);
}
ensureAdminActor(requireSuperAdmin = false) {
const actorNpub = this.services.getCurrentUserNpub();
if (!actorNpub) {
this.showError("Please login with a Nostr account to manage admin settings.");
return null;
}
if (!this.services.accessControl.canEditAdminLists(actorNpub)) {
this.showError("You do not have permission to manage bitvid moderation lists.");
return null;
}
if (requireSuperAdmin && !this.services.accessControl.isSuperAdmin(actorNpub)) {
this.showError("Only the Super Admin can manage moderators or whitelist mode.");
return null;
}
return actorNpub;
}
async handleAddModerator() {
const input = this.moderatorInput || null;
const rawValue = typeof input?.value === "string" ? input.value : "";
const trimmed = rawValue.trim();
const normalizedValue = this.normalizeNpubValue(trimmed);
const context = {
input,
rawValue,
value: trimmed,
normalizedValue,
actorNpub: null,
success: false,
reason: null,
error: null,
result: null,
};
let preloadError = null;
try {
const ensureResult = await this.runAdminMutation({ action: "ensure-ready" });
if (ensureResult?.error && ensureResult.ok === false) {
preloadError = ensureResult.error;
}
} catch (error) {
preloadError = error;
userLogger.error("Failed to load admin lists before adding moderator:", error);
}
if (preloadError) {
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
context.reason = preloadError.code || "storage-error";
context.error = preloadError;
this.callbacks.onAdminAddModerator(context, this);
return context;
}
const actorNpub = this.ensureAdminActor(true);
context.actorNpub = actorNpub;
if (!actorNpub || !input) {
context.reason = actorNpub ? "missing-input" : "unauthorized";
this.callbacks.onAdminAddModerator(context, this);
return context;
}
if (!trimmed) {
this.showError("Enter an npub to add as a moderator.");
context.reason = "empty";
this.callbacks.onAdminAddModerator(context, this);
return context;
}
if (!normalizedValue) {
this.showError("Enter a valid npub before adding it as a moderator.");
context.reason = "invalid";
this.callbacks.onAdminAddModerator(context, this);
return context;
}
if (this.addModeratorButton) {
this.addModeratorButton.disabled = true;
this.addModeratorButton.setAttribute("aria-busy", "true");
}
try {
const mutationResult = await this.runAdminMutation({
action: "add-moderator",
actorNpub,
targetNpub: normalizedValue,
});
context.result = mutationResult?.result || null;
if (!mutationResult?.ok) {
const errorCode = mutationResult?.result?.error || mutationResult?.error?.code;
this.showError(this.describeAdminError(errorCode || "service-error"));
context.reason = errorCode || "service-error";
context.error = mutationResult?.error || mutationResult?.result || null;
return context;
}
this.moderatorInput.value = "";
this.showSuccess("Moderator added successfully.");
await this.services.onAccessControlUpdated();
context.success = true;
context.reason = "added";
} finally {
if (this.addModeratorButton) {
this.addModeratorButton.disabled = false;
this.addModeratorButton.removeAttribute("aria-busy");
}
this.callbacks.onAdminAddModerator(context, this);
}
return context;
}
async handleRemoveModerator(npub, button) {
const context = {
npub,
normalizedNpub: this.normalizeNpubValue(npub),
button,
actorNpub: null,
success: false,
reason: null,
error: null,
result: null,
};
const releaseButton = () => {
if (button instanceof HTMLElement) {
button.disabled = false;
button.removeAttribute("aria-busy");
}
};
if (!context.normalizedNpub) {
this.showError("Unable to remove moderator: invalid npub.");
context.reason = "invalid";
releaseButton();
this.callbacks.onAdminRemoveModerator(context, this);
return context;
}
let preloadError = null;
try {
const ensureResult = await this.runAdminMutation({ action: "ensure-ready" });
if (ensureResult?.error && ensureResult.ok === false) {
preloadError = ensureResult.error;
}
} catch (error) {
preloadError = error;
userLogger.error("Failed to load admin lists before removing moderator:", error);
}
if (preloadError) {
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
context.reason = preloadError.code || "storage-error";
context.error = preloadError;
releaseButton();
this.callbacks.onAdminRemoveModerator(context, this);
return context;
}
const actorNpub = this.ensureAdminActor(true);
context.actorNpub = actorNpub;
if (!actorNpub) {
context.reason = "unauthorized";
releaseButton();
this.callbacks.onAdminRemoveModerator(context, this);
return context;
}
const mutationResult = await this.runAdminMutation({
action: "remove-moderator",
actorNpub,
targetNpub: context.normalizedNpub,
});
context.result = mutationResult?.result || null;
if (!mutationResult?.ok) {
const errorCode = mutationResult?.result?.error || mutationResult?.error?.code;
this.showError(this.describeAdminError(errorCode || "service-error"));
context.reason = errorCode || "service-error";
context.error = mutationResult?.error || mutationResult?.result || null;
releaseButton();
this.callbacks.onAdminRemoveModerator(context, this);
return context;
}
this.showSuccess("Moderator removed.");
await this.services.onAccessControlUpdated();
context.success = true;
context.reason = "removed";
releaseButton();
this.callbacks.onAdminRemoveModerator(context, this);
return context;
}
async handleAdminListMutation(listType, action, explicitNpub = null, sourceButton = null) {
const isWhitelist = listType === "whitelist";
const input = isWhitelist ? this.whitelistInput : this.blacklistInput;
const addButton = isWhitelist ? this.addWhitelistButton : this.addBlacklistButton;
const isAdd = action === "add";
let buttonToToggle = sourceButton || (isAdd ? addButton : null);
const context = {
listType,
action,
explicitNpub,
sourceButton,
actorNpub: null,
targetNpub: null,
success: false,
reason: null,
error: null,
result: null,
notificationResult: null,
notificationError: null,
};
const callbackMap = {
whitelist: {
add: this.callbacks.onAdminAddWhitelist,
remove: this.callbacks.onAdminRemoveWhitelist,
},
blacklist: {
add: this.callbacks.onAdminAddBlacklist,
remove: this.callbacks.onAdminRemoveBlacklist,
},
};
const adminCallback = callbackMap[listType]?.[action] || noop;
const setBusy = (element, busy) => {
if (!(element instanceof HTMLElement)) {
return;
}
element.disabled = !!busy;
if (busy) {
element.setAttribute("aria-busy", "true");
} else {
element.removeAttribute("aria-busy");
}
};
const finalize = () => {
setBusy(buttonToToggle, false);
adminCallback(context, this);
};
let preloadError = null;
try {
const ensureResult = await this.runAdminMutation({ action: "ensure-ready" });
if (ensureResult?.error && ensureResult.ok === false) {
preloadError = ensureResult.error;
}
} catch (error) {
preloadError = error;
userLogger.error("Failed to load admin lists before updating entries:", error);
}
if (preloadError) {
this.showError(this.describeAdminError(preloadError.code || "storage-error"));
context.reason = preloadError.code || "storage-error";
context.error = preloadError;
finalize();
return context;
}
const actorNpub = this.ensureAdminActor(false);
context.actorNpub = actorNpub;
if (!actorNpub) {
context.reason = "unauthorized";
finalize();
return context;
}
let target = typeof explicitNpub === "string" ? explicitNpub.trim() : "";
if (!target && input instanceof HTMLInputElement) {
target = input.value.trim();
}
context.targetNpub = target;
if (isAdd && !target) {
this.showError("Enter an npub before adding it to the list.");
context.reason = "empty";
finalize();
return context;
}
buttonToToggle = buttonToToggle || (isAdd ? addButton : null);
setBusy(buttonToToggle, true);
const mutationResult = await this.runAdminMutation({
action: "list-mutation",
listType,
mode: action,
actorNpub,
targetNpub: target,
});
context.result = mutationResult?.result || null;
if (!mutationResult?.ok) {
const errorCode = mutationResult?.result?.error || mutationResult?.error?.code;
this.showError(this.describeAdminError(errorCode || "service-error"));
context.reason = errorCode || "service-error";
context.error = mutationResult?.error || mutationResult?.result || null;
finalize();
return context;
}
if (isAdd && input instanceof HTMLInputElement) {
input.value = "";
}
const successMessage = isWhitelist
? isAdd
? "Added to the whitelist."
: "Removed from the whitelist."
: isAdd
? "Added to the blacklist."
: "Removed from the blacklist.";
this.showSuccess(successMessage);
await this.services.onAccessControlUpdated();
context.success = true;
context.reason = isAdd ? "added" : "removed";
if (isAdd) {
try {
const notifyResult = await this.sendAdminListNotification({
listType,
actorNpub,
targetNpub: target,
});
context.notificationResult = notifyResult;
if (!notifyResult?.ok) {
const errorMessage = this.describeNotificationError(notifyResult?.error);
if (errorMessage) {
this.showError(errorMessage);
}
if (isDevMode && notifyResult?.error) {
userLogger.warn(
"[admin] Failed to send list notification DM:",
notifyResult,
);
}
this.notifyAdminError({
listType,
action,
actorNpub,
targetNpub: target,
error: notifyResult?.error || null,
result: notifyResult,
});
}
} catch (error) {
context.notificationError = error;
userLogger.error("Failed to send list notification DM:", error);
devLogger.warn(
"List update succeeded, but DM notification threw an unexpected error.",
error,
);
this.notifyAdminError({
listType,
action,
actorNpub,
targetNpub: target,
error,
});
}
}
finalize();
return context;
}
describeAdminError(code) {
if (typeof this.describeAdminErrorService === "function") {
const result = this.describeAdminErrorService(code);
if (typeof result === "string" && result) {
return result;
}
}
switch (code) {
case "invalid npub":
return "Please provide a valid npub address.";
case "immutable":
return "That account cannot be modified.";
case "self":
return "You cannot blacklist yourself.";
case "forbidden":
return "You do not have permission to perform that action.";
case "nostr-unavailable":
return "Unable to reach the configured Nostr relays. Please retry once your connection is restored.";
case "nostr-extension-missing":
return "Connect a Nostr extension before editing moderation lists.";
case "signature-failed":
return "We couldn’t sign the update with your Nostr key. Please reconnect your extension and try again.";
case "publish-failed":
return "Failed to publish the update to Nostr relays. Please try again.";
case "storage-error":
return "Unable to update moderation settings. Please try again.";
default:
return "Unable to update moderation settings. Please try again.";
}
}
describeNotificationError(code) {
if (typeof this.describeNotificationErrorService === "function") {
const result = this.describeNotificationErrorService(code);
if (typeof result === "string") {
return result;
}
}
switch (code) {
case "nostr-extension-missing":
return "List updated, but the DM notification failed because no Nostr extension is connected.";
case "nostr-uninitialized":
return "List updated, but the DM notification system is still connecting to Nostr relays. Please try again in a moment.";
case "nip04-unavailable":
return "List updated, but your Nostr extension does not support NIP-04 encryption, so the DM notification was not sent.";
case "sign-event-unavailable":
return "List updated, but your Nostr extension could not sign the DM notification.";
case "missing-actor-pubkey":
return "List updated, but we could not determine your public key to send the DM notification.";
case "publish-failed":
return "List updated, but the DM notification could not be delivered to any relay.";
case "encryption-failed":
case "signature-failed":
return "List updated, but the DM notification failed while preparing the encrypted message.";
case "invalid-target":
case "empty-message":
return "";
default:
return "List updated, but the DM notification could not be sent.";
}
}
async sendAdminListNotification({ listType, actorNpub, targetNpub }) {
if (typeof this.sendAdminListNotificationService === "function") {
return this.sendAdminListNotificationService({ listType, actorNpub, targetNpub });
}
const normalizedTarget = this.normalizeNpubValue(targetNpub);
if (!normalizedTarget) {
return { ok: false, error: "invalid-target" };
}
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
if (!activeHex) {
return { ok: false, error: "missing-actor-pubkey" };
}
const fallbackActor = this.safeEncodeNpub(activeHex) || "a bitvid moderator";
const actorDisplay = this.normalizeNpubValue(actorNpub) || fallbackActor;
const isWhitelist = listType === "whitelist";
const formatNpub =
typeof this.formatShortNpub === "function"
? (value) => this.formatShortNpub(value)
: (value) => (typeof value === "string" ? value : "");
const displayTarget = formatNpub(normalizedTarget) || normalizedTarget;
const displayActor = formatNpub(actorDisplay) || actorDisplay;
const introLine = isWhitelist
? `Great news—your npub ${displayTarget} has been added to the bitvid whitelist by ${displayActor}.`
: `We wanted to let you know that your npub ${displayTarget} has been placed on the bitvid blacklist by ${displayActor}.`;
const statusLine = isWhitelist
? `You now have full creator access across bitvid (${this.bitvidWebsiteUrl}).`
: `This hides your channel and prevents uploads across bitvid (${this.bitvidWebsiteUrl}) for now.`;
const followUpLine = isWhitelist
? "Please take a moment to review our community guidelines (https://bitvid.network/#view=community-guidelines), and reply to this DM if you have any questions."
: "Please review our community guidelines (https://bitvid.network/#view=community-guidelines). If you believe this was a mistake, you can submit an appeal at https://bitvid.network/?modal=appeals to request reinstatement, or reply to this DM with any questions.";
const messageBody = [
"Hi there,",
"",
introLine,
"",
statusLine,
"",
followUpLine,
"",
"— the bitvid team",
].join("\n");
const message = `![bitvid status update](${this.adminDmImageUrl})\n\n${messageBody}`;
return this.services.nostrClient.sendDirectMessage(
normalizedTarget,
message,
activeHex,
);
}
async requestSwitchProfile({ pubkey, entry, providerId } = {}) {
const callback = this.callbacks.onRequestSwitchProfile;
if (callback && callback !== noop) {
return callback({ controller: this, pubkey, entry, providerId });
}
if (!pubkey) {
throw new Error("Missing target pubkey for switch request.");
}
if (providerId) {
return this.services.switchProfile(pubkey, { providerId });
}
return this.services.switchProfile(pubkey);
}
async runRelayOperation({
action,
url,
activePubkey,
skipPublishIfUnchanged = true,
} = {}) {
const callback = this.callbacks.onRelayOperation;
if (callback && callback !== noop) {
const result = await callback({
controller: this,
action,
url,
activePubkey,
skipPublishIfUnchanged,
});
if (result !== undefined) {
return result;
}
}
const context = {
ok: false,
changed: false,
reason: null,
error: null,
publishResult: null,
operationResult: null,
};
if (!activePubkey) {
context.reason = "no-active-pubkey";
return context;
}
const previous = this.services.relayManager.snapshot();
const runOperation = () => {
switch (action) {
case "add":
return this.services.relayManager.addRelay(url);
case "remove":
return this.services.relayManager.removeRelay(url);
case "restore":
return this.services.relayManager.restoreDefaults();
case "mode-toggle":
return this.services.relayManager.cycleRelayMode(url);
default:
throw Object.assign(new Error("Unknown relay operation."), {
code: "invalid-operation",
});
}
};
let operationResult;
try {
operationResult = runOperation();
context.operationResult = operationResult;
} catch (error) {
context.reason = error?.code || "operation-error";
context.error = error;
return context;
}
context.changed = Boolean(operationResult?.changed);
if (!context.changed && skipPublishIfUnchanged) {
context.reason = operationResult?.reason || "unchanged";
return context;
}
try {
const publishResult = await this.services.relayManager.publishRelayList(
activePubkey,
);
if (!publishResult?.ok) {
throw new Error("No relays accepted the update.");
}
context.ok = true;
context.publishResult = publishResult;
return context;
} catch (error) {
this.services.relayManager.setEntries(previous, { allowEmpty: false });
context.reason = error?.code || "publish-failed";
context.error = error;
return context;
}
}
async mutateBlocklist({ action, actorHex, targetHex } = {}) {
const callback = this.callbacks.onBlocklistMutation;
if (callback && callback !== noop) {
const result = await callback({
controller: this,
action,
actorHex,
targetHex,
});
if (result !== undefined) {
return result;
}
}
const context = { ok: false, reason: null, error: null };
if (!actorHex || !targetHex) {
context.reason = "invalid-target";
return context;
}
try {
await this.services.userBlocks.ensureLoaded(actorHex);
const isBlocked = this.services.userBlocks.isBlocked(targetHex);
if (action === "add") {
if (isBlocked) {
context.reason = "already-blocked";
return context;
}
await this.services.userBlocks.addBlock(targetHex, actorHex);
context.ok = true;
context.reason = "blocked";
} else if (action === "remove") {
if (!isBlocked) {
context.reason = "not-blocked";
return context;
}
await this.services.userBlocks.removeBlock(targetHex, actorHex);
context.ok = true;
context.reason = "unblocked";
} else {
context.reason = "invalid-action";
return context;
}
if (context.ok) {
try {
await this.services.onVideosShouldRefresh({
reason: `blocklist-${action}`,
actorHex,
targetHex,
});
} catch (refreshError) {
userLogger.warn(
"[ProfileModalController] Failed to refresh videos after blocklist mutation:",
refreshError,
);
}
}
return context;
} catch (error) {
context.error = error;
context.reason = error?.code || "service-error";
return context;
}
}
async persistWalletSettings({
nwcUri,
defaultZap,
lastChecked,
activePubkey,
} = {}) {
const callback = this.callbacks.onWalletPersist;
if (callback && callback !== noop) {
const result = await callback({
controller: this,
nwcUri,
defaultZap,
lastChecked,
activePubkey,
});
if (result !== undefined) {
return result;
}
}
return this.services.nwcSettings.handleProfileWalletPersist({
nwcUri,
defaultZap,
lastChecked,
});
}
async testWalletConnection({ nwcUri, defaultZap, activePubkey } = {}) {
const callback = this.callbacks.onWalletTestRequest;
if (callback && callback !== noop) {
const result = await callback({
controller: this,
nwcUri,
defaultZap,
activePubkey,
});
if (result !== undefined) {
return result;
}
}
return this.services.nwcSettings.ensureWallet({ nwcUri, defaultZap });
}
async disconnectWallet({ activePubkey } = {}) {
const callback = this.callbacks.onWalletDisconnectRequest;
if (callback && callback !== noop) {
const result = await callback({ controller: this, activePubkey });
if (result !== undefined) {
return result;
}
}
return this.services.nwcSettings.updateActiveNwcSettings(
this.services.nwcSettings.createDefaultNwcSettings(),
);
}
async runAdminMutation(payload = {}) {
const callback = this.callbacks.onAdminMutation;
if (callback && callback !== noop) {
const result = await callback({ ...payload, controller: this });
if (result !== undefined) {
return result;
}
}
const action = payload?.action;
const resultContext = { ok: false, error: null, result: null };
try {
switch (action) {
case "ensure-ready":
await this.services.accessControl.ensureReady();
resultContext.ok = true;
break;
case "add-moderator":
resultContext.result = await this.services.accessControl.addModerator(
payload.actorNpub,
payload.targetNpub,
);
resultContext.ok = !!resultContext.result?.ok;
break;
case "remove-moderator":
resultContext.result =
await this.services.accessControl.removeModerator(
payload.actorNpub,
payload.targetNpub,
);
resultContext.ok = !!resultContext.result?.ok;
break;
case "list-mutation":
if (payload.listType === "whitelist") {
resultContext.result = payload.mode === "add"
? await this.services.accessControl.addToWhitelist(
payload.actorNpub,
payload.targetNpub,
)
: await this.services.accessControl.removeFromWhitelist(
payload.actorNpub,
payload.targetNpub,
);
} else {
resultContext.result = payload.mode === "add"
? await this.services.accessControl.addToBlacklist(
payload.actorNpub,
payload.targetNpub,
)
: await this.services.accessControl.removeFromBlacklist(
payload.actorNpub,
payload.targetNpub,
);
}
resultContext.ok = !!resultContext.result?.ok;
break;
default:
resultContext.error = Object.assign(
new Error("Unknown admin mutation."),
{ code: "invalid-action" },
);
}
} catch (error) {
resultContext.error = error;
return resultContext;
}
return resultContext;
}
notifyAdminError(payload = {}) {
const callback = this.callbacks.onAdminNotifyError;
if (callback && callback !== noop) {
callback({ ...payload, controller: this });
}
}
updateFocusTrap() {
const container =
this.profileModalPanel instanceof HTMLElement
? this.profileModalPanel
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
if (!container) {
this.focusableElements = [];
return;
}
const selector =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
const nodes = Array.from(container.querySelectorAll(selector));
this.focusableElements = nodes.filter((node) => {
if (!(node instanceof HTMLElement)) {
return false;
}
const style = window.getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden") {
return false;
}
if (node.hasAttribute("disabled")) {
return false;
}
if (node.getAttribute("aria-hidden") === "true") {
return false;
}
return true;
});
this.bindFocusTrap(container);
}
bindFocusTrap(container) {
const targetContainer =
container ||
(this.profileModalPanel instanceof HTMLElement
? this.profileModalPanel
: this.profileModal instanceof HTMLElement
? this.profileModal
: null);
if (!targetContainer) {
return;
}
if (this.focusTrapSuspended) {
this.focusTrapContainer = targetContainer;
return;
}
if (!this.boundKeydown) {
this.boundKeydown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.hide();
return;
}
if (event.key !== "Tab") {
return;
}
if (!this.focusableElements.length) {
event.preventDefault();
const fallback =
this.profileModalPanel || this.profileModal || targetContainer;
if (typeof fallback?.focus === "function") {
fallback.focus();
}
return;
}
const first = this.focusableElements[0];
const last = this.focusableElements[this.focusableElements.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !targetContainer.contains(active)) {
event.preventDefault();
if (typeof last?.focus === "function") {
last.focus();
}
}
return;
}
if (active === last) {
event.preventDefault();
if (typeof first?.focus === "function") {
first.focus();
}
}
};
}
if (!this.boundFocusIn) {
this.boundFocusIn = (event) => {
const modalRoot = this.profileModalRoot || this.profileModal;
if (
!modalRoot ||
modalRoot.classList.contains("hidden") ||
modalRoot.contains(event.target)
) {
return;
}
const fallback =
this.focusableElements[0] ||
this.profileModalPanel ||
this.profileModal;
if (typeof fallback?.focus === "function") {
fallback.focus();
}
};
}
if (
this.focusTrapContainer &&
this.focusTrapContainer !== targetContainer &&
this.boundKeydown
) {
this.focusTrapContainer.removeEventListener("keydown", this.boundKeydown);
}
targetContainer.addEventListener("keydown", this.boundKeydown);
this.focusTrapContainer = targetContainer;
document.addEventListener("focusin", this.boundFocusIn);
}
getModalRootElement() {
if (this.profileModalRoot instanceof HTMLElement) {
return this.profileModalRoot;
}
if (this.profileModal instanceof HTMLElement) {
return this.profileModal;
}
return null;
}
getModalPanelElement() {
if (this.profileModalPanel instanceof HTMLElement) {
return this.profileModalPanel;
}
return this.getModalRootElement();
}
suspendFocusTrap() {
this.focusTrapSuspendCount += 1;
this.focusTrapSuspended = true;
if (this.focusTrapSuspendCount > 1) {
return this.focusTrapSuspendCount;
}
if (
this.boundKeydown &&
this.focusTrapContainer instanceof HTMLElement
) {
this.focusTrapContainer.removeEventListener(
"keydown",
this.boundKeydown,
);
}
if (this.boundFocusIn) {
document.removeEventListener("focusin", this.boundFocusIn);
}
const modalRoot = this.getModalRootElement();
if (modalRoot) {
this.focusTrapAriaHiddenBeforeSuspend = modalRoot.getAttribute(
"aria-hidden",
);
this.focusTrapNestedModalActiveBeforeSuspend =
typeof modalRoot.dataset.nestedModalActive === "string"
? modalRoot.dataset.nestedModalActive
: null;
modalRoot.dataset.nestedModalActive = "true";
modalRoot.setAttribute("aria-hidden", "true");
} else {
this.focusTrapAriaHiddenBeforeSuspend = null;
this.focusTrapNestedModalActiveBeforeSuspend = null;
}
const panel = this.getModalPanelElement();
if (panel) {
panel.setAttribute("inert", "");
}
return this.focusTrapSuspendCount;
}
resumeFocusTrap() {
if (this.focusTrapSuspendCount === 0) {
return 0;
}
this.focusTrapSuspendCount = Math.max(
0,
this.focusTrapSuspendCount - 1,
);
if (this.focusTrapSuspendCount > 0) {
return this.focusTrapSuspendCount;
}
this.focusTrapSuspended = false;
const modalRoot = this.getModalRootElement();
if (modalRoot) {
delete modalRoot.dataset.nestedModalActive;
if (!modalRoot.classList.contains("hidden")) {
if (this.focusTrapAriaHiddenBeforeSuspend === null) {
modalRoot.removeAttribute("aria-hidden");
} else {
modalRoot.setAttribute(
"aria-hidden",
this.focusTrapAriaHiddenBeforeSuspend,
);
}
}
if (this.focusTrapNestedModalActiveBeforeSuspend === null) {
delete modalRoot.dataset.nestedModalActive;
} else {
modalRoot.dataset.nestedModalActive =
this.focusTrapNestedModalActiveBeforeSuspend;
}
}
const panel = this.getModalPanelElement();
if (panel) {
panel.removeAttribute("inert");
}
this.focusTrapAriaHiddenBeforeSuspend = null;
this.focusTrapNestedModalActiveBeforeSuspend = null;
this.updateFocusTrap();
return 0;
}
bringLoginModalToFront() {
if (typeof document === "undefined") {
return;
}
const loginModal = document.getElementById("loginModal");
if (!(loginModal instanceof HTMLElement)) {
return;
}
const container =
this.modalContainer instanceof HTMLElement
? this.modalContainer
: loginModal.parentElement;
if (!container) {
return;
}
if (loginModal.parentElement !== container) {
container.appendChild(loginModal);
return;
}
if (container.lastElementChild !== loginModal) {
container.appendChild(loginModal);
}
}
ensureModalOrder(modalRoot) {
if (!(modalRoot instanceof HTMLElement)) {
return;
}
const container =
this.modalContainer instanceof HTMLElement ? this.modalContainer : null;
const parentElement = modalRoot.parentElement;
if (container) {
if (parentElement !== container) {
container.appendChild(modalRoot);
return;
}
if (container.lastElementChild !== modalRoot) {
container.appendChild(modalRoot);
}
return;
}
if (parentElement) {
if (parentElement.lastElementChild !== modalRoot) {
parentElement.appendChild(modalRoot);
}
return;
}
if (typeof document !== "undefined" && document.body) {
document.body.appendChild(modalRoot);
}
}
applyModalStackingOverrides() {
const modalRoot =
this.profileModalRoot instanceof HTMLElement
? this.profileModalRoot
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
if (modalRoot) {
modalRoot.dataset.modalStack = "top";
}
}
async show(targetPane = "account") {
const pane =
typeof targetPane === "string" && targetPane.trim()
? targetPane.trim()
: "account";
const activeElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const modalRoot =
this.profileModalRoot instanceof HTMLElement
? this.profileModalRoot
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
if (activeElement && modalRoot && modalRoot.contains(activeElement)) {
this.previouslyFocusedElement = null;
} else {
this.previouslyFocusedElement = activeElement;
}
this.renderSavedProfiles();
this.refreshWalletPaneState();
this.refreshModerationSettingsUi();
this.syncLinkPreviewSettingsUi();
const hasBlockHydrator =
this.services.userBlocks &&
typeof this.services.userBlocks.ensureLoaded === "function";
if (hasBlockHydrator) {
this.setBlockListLoadingState("loading");
} else {
this.populateBlockedList();
}
this.open(pane);
const backgroundTasks = [];
backgroundTasks.push(
Promise.resolve()
.then(() => this.refreshAdminPaneState())
.catch((error) => {
userLogger.error(
"Failed to refresh admin pane while opening profile modal:",
error,
);
}),
);
backgroundTasks.push(
Promise.resolve().then(() => {
try {
this.populateProfileRelays();
} catch (error) {
userLogger.warn(
"Failed to populate relay list while opening profile modal:",
error,
);
}
}),
);
if (hasBlockHydrator) {
const activeHex = this.normalizeHexPubkey(this.getActivePubkey());
backgroundTasks.push(
Promise.resolve()
.then(() => this.services.userBlocks.ensureLoaded(activeHex))
.then(() => {
try {
this.populateBlockedList();
} catch (error) {
userLogger.warn(
"Failed to render blocked creators after hydration:",
error,
);
this.setBlockListLoadingState("error", {
message:
"Blocked creators may be out of date. Try again later.",
});
}
})
.catch((error) => {
userLogger.warn(
"Failed to refresh user block list while opening profile modal:",
error,
);
this.setBlockListLoadingState("error", {
message:
"Blocked creators may be out of date. Try again later.",
});
try {
this.populateBlockedList();
} catch (populateError) {
userLogger.warn(
"Failed to render blocked creators after hydration failure:",
populateError,
);
}
}),
);
}
if (backgroundTasks.length) {
void Promise.allSettled(backgroundTasks);
}
return true;
}
showWalletPane() {
return this.show("wallet");
}
open(pane = "account") {
const modalRoot =
this.profileModalRoot instanceof HTMLElement
? this.profileModalRoot
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
if (!modalRoot) {
return;
}
this.ensureModalOrder(modalRoot);
this.applyModalStackingOverrides();
modalRoot.classList.remove("hidden");
modalRoot.setAttribute("aria-hidden", "false");
if (document.body) {
document.body.classList.add("modal-open");
}
if (document.documentElement) {
document.documentElement.classList.add("modal-open");
}
this.setGlobalModalState("profile", true);
const preserveMenu = this.isMobileLayoutActive();
this.selectPane(pane, { keepMenuView: preserveMenu });
const focusTarget =
this.focusableElements[0] ||
this.profileModalPanel ||
modalRoot;
window.requestAnimationFrame(() => {
if (typeof focusTarget?.focus === "function") {
focusTarget.focus();
}
});
}
hide(options = {}) {
const { silent = false } =
options && typeof options === "object" ? options : {};
this.pauseProfileMessages();
const modalElement =
this.profileModalRoot instanceof HTMLElement
? this.profileModalRoot
: this.profileModal instanceof HTMLElement
? this.profileModal
: null;
if (modalElement) {
modalElement.classList.add("hidden");
modalElement.setAttribute("aria-hidden", "true");
delete modalElement.dataset.nestedModalActive;
delete modalElement.dataset.modalStack;
if (document.body) {
document.body.classList.remove("modal-open");
}
if (document.documentElement) {
document.documentElement.classList.remove("modal-open");
}
this.setGlobalModalState("profile", false);
this.setMobileView("menu", { skipFocusTrap: true });
if (this.boundKeydown && this.focusTrapContainer) {
this.focusTrapContainer.removeEventListener(
"keydown",
this.boundKeydown,
);
}
}
if (this.boundFocusIn) {
document.removeEventListener("focusin", this.boundFocusIn);
}
const panel = this.getModalPanelElement();
if (panel) {
panel.removeAttribute("inert");
}
this.focusTrapContainer = null;
this.focusTrapSuspendCount = 0;
this.focusTrapSuspended = false;
this.focusTrapAriaHiddenBeforeSuspend = null;
this.focusTrapNestedModalActiveBeforeSuspend = null;
if (
this.boundProfileHistoryVisibility &&
typeof document !== "undefined" &&
typeof document.removeEventListener === "function"
) {
document.removeEventListener(
"visibilitychange",
this.boundProfileHistoryVisibility,
);
this.boundProfileHistoryVisibility = null;
}
if (this.profileHistoryRenderer) {
try {
if (typeof this.profileHistoryRenderer.destroy === "function") {
this.profileHistoryRenderer.destroy();
}
} catch (error) {
userLogger.warn(
"[profileModal] Failed to reset watch history renderer on close:",
error,
);
}
this.profileHistoryRenderer = null;
this.profileHistoryRendererConfig = null;
}
this.setActivePane(null);
this.setWalletPaneBusy(false);
const previous = this.previouslyFocusedElement;
this.previouslyFocusedElement = null;
if (
previous &&
typeof previous.focus === "function" &&
(!modalElement || !modalElement.contains(previous))
) {
const shouldRestore =
"isConnected" in previous ? previous.isConnected !== false : true;
if (shouldRestore) {
window.requestAnimationFrame(() => {
try {
previous.focus();
} catch (error) {
userLogger.warn(
"[profileModal] Failed to restore focus after closing modal:",
error,
);
}
});
}
}
if (!silent) {
try {
this.callbacks.onClose(this);
} catch (error) {
userLogger.warn(
"[profileModal] onClose callback threw while hiding modal:",
error,
);
}
}
}
async handleAuthLogin(detail = {}) {
const postLoginPromise =
detail && typeof detail.postLoginPromise?.then === "function"
? detail.postLoginPromise
: Promise.resolve(detail?.postLogin ?? null);
const savedProfiles = Array.isArray(detail?.savedProfiles)
? detail.savedProfiles
: null;
if (savedProfiles) {
try {
this.setSavedProfiles(savedProfiles, {
persist: false,
persistActive: false,
});
} catch (error) {
userLogger.warn(
"[profileModal] Failed to sync saved profiles during login:",
error,
);
}
}
const activePubkey =
detail?.activeProfilePubkey ?? detail?.pubkey ?? undefined;
if (activePubkey !== undefined) {
this.setActivePubkey(activePubkey);
}
const walletPromise = this.hydrateActiveWalletSettings(activePubkey);
const adminPromise = this.refreshAdminPaneState().catch((error) => {
userLogger.warn("Failed to refresh admin pane after login:", error);
});
this.renderSavedProfiles();
// Trigger aggressive parallel fetches
if (activePubkey) {
if (this.services.userBlocks) {
this.services.userBlocks.loadBlocks(activePubkey).catch(noop);
}
if (this.subscriptionsService) {
this.subscriptionsService.loadSubscriptions(activePubkey).catch(noop);
}
}
this.populateBlockedList();
void this.populateSubscriptionsList();
void this.populateFriendsList();
this.populateProfileRelays();
this.refreshWalletPaneState();
this.populateHashtagPreferences();
this.handleActiveDmIdentityChanged(activePubkey);
void this.refreshDmRelayPreferences({ force: true });
// Ensure critical state is settled if possible, but don't block initial rendering
Promise.all([walletPromise, adminPromise, postLoginPromise])
.then(() => {
// Re-run population to ensure any late-arriving data is reflected
this.populateBlockedList();
void this.populateSubscriptionsList();
void this.populateFriendsList();
this.populateProfileRelays();
this.refreshWalletPaneState();
this.populateHashtagPreferences();
void this.refreshDmRelayPreferences({ force: true });
})
.catch((error) => {
userLogger.warn(
"[profileModal] Failed to hydrate deferred login data:",
error,
);
});
return true;
}
async handleAuthLogout(detail = {}) {
const savedProfiles = Array.isArray(detail?.savedProfiles)
? detail.savedProfiles
: null;
if (savedProfiles) {
try {
this.setSavedProfiles(savedProfiles, {
persist: false,
persistActive: false,
});
} catch (error) {
userLogger.warn(
"[profileModal] Failed to sync saved profiles during logout:",
error,
);
}
}
if (detail?.activeProfilePubkey !== undefined) {
this.setActivePubkey(detail.activeProfilePubkey);
} else {
this.setActivePubkey(null);
}
this.profileSwitcherSelectionPubkey = null;
this.renderSavedProfiles();
try {
await this.refreshAdminPaneState();
} catch (error) {
userLogger.warn("Failed to refresh admin pane after logout:", error);
}
if (
this.subscriptionsService &&
typeof this.subscriptionsService.reset === "function"
) {
try {
this.subscriptionsService.reset();
} catch (error) {
devLogger.warn(
"[profileModal] Failed to reset subscriptions service on logout:",
error,
);
}
}
this.populateBlockedList();
this.clearSubscriptionsList();
this.clearFriendsList();
this.populateProfileRelays();
this.refreshWalletPaneState();
this.populateHashtagPreferences();
this.clearHashtagInputs();
this.setHashtagStatus("", "muted");
this.handleActiveDmIdentityChanged(null);
this.setMessagesUnreadIndicator(false);
this.populateDmRelayPreferences();
this.setDmRelayPreferencesStatus("");
return true;
}
handleProfileUpdated(detail = {}) {
const previousActive = this.normalizeHexPubkey(this.getActivePubkey());
if (Array.isArray(detail?.savedProfiles)) {
try {
this.setSavedProfiles(detail.savedProfiles, {
persist: false,
persistActive: false,
});
} catch (error) {
userLogger.warn(
"[profileModal] Failed to sync saved profiles after profile update:",
error,
);
}
}
if (detail?.activeProfilePubkey !== undefined) {
this.setActivePubkey(detail.activeProfilePubkey);
} else if (detail?.pubkey) {
this.setActivePubkey(detail.pubkey);
}
this.renderSavedProfiles();
void this.populateSubscriptionsList();
const nextActive = this.normalizeHexPubkey(this.getActivePubkey());
if (previousActive !== nextActive) {
this.handleActiveDmIdentityChanged(nextActive);
}
}
removeSavedProfile(pubkey) {
if (!pubkey) {
return { removed: false };
}
let result;
try {
result = this.services.removeSavedProfile(pubkey) || { removed: false };
} catch (error) {
userLogger.error("Failed to remove saved profile:", error);
result = { removed: false, error };
}
if (result?.removed) {
if (
this.normalizeHexPubkey(pubkey) ===
this.normalizeHexPubkey(this.getActivePubkey())
) {
this.setActivePubkey(null);
}
this.renderSavedProfiles();
}
return result;
}
async hydrateActiveWalletSettings(pubkey) {
const service = this.services.nwcSettings;
const hydrate = service?.hydrateNwcSettingsForPubkey;
if (typeof hydrate !== "function") {
return null;
}
const normalized = this.normalizeHexPubkey(
pubkey !== undefined ? pubkey : this.getActivePubkey(),
);
if (!normalized) {
return null;
}
try {
return await hydrate.call(service, normalized);
} catch (error) {
userLogger.warn(
`[ProfileModalController] Failed to hydrate wallet settings for ${normalized}:`,
error,
);
return null;
}
}
async switchProfile(pubkey, { entry, providerId } = {}) {
if (!pubkey) {
return { switched: false, reason: "missing-pubkey" };
}
let result;
try {
result = await this.requestSwitchProfile({ pubkey, entry, providerId });
} catch (error) {
const message =
error && typeof error.message === "string" && error.message.trim()
? error.message.trim()
: "Failed to switch profiles. Please try again.";
this.showError(message);
return {
switched: false,
error,
reason: error?.code || "switch-error",
};
}
if (!result?.switched) {
this.hide();
return result || { switched: false };
}
await this.hydrateActiveWalletSettings(pubkey);
this.profileSwitcherSelectionPubkey = null;
this.renderSavedProfiles();
this.hide();
return result;
}
getSavedProfiles() {
return this.state.getSavedProfiles();
}
setSavedProfiles(...args) {
return this.state.setSavedProfiles(...args);
}
normalizeProviderId(providerId) {
if (typeof providerId !== "string") {
return null;
}
const trimmed = providerId.trim();
if (!trimmed) {
return null;
}
return trimmed;
}
getEntryProviderId(entry) {
if (!entry || typeof entry !== "object") {
return null;
}
const explicit = this.normalizeProviderId(entry.providerId);
if (explicit) {
return explicit;
}
return this.normalizeProviderId(entry.authType);
}
resolveEntryProviderMetadata(entry) {
const providerId = this.getEntryProviderId(entry);
if (providerId) {
return getProviderMetadata(providerId);
}
return getProviderMetadata();
}
persistSavedProfiles(...args) {
if (typeof this.services.persistSavedProfiles === "function") {
try {
return this.services.persistSavedProfiles(...args);
} catch (error) {
userLogger.warn(
"[ProfileModalController] Persist saved profiles service threw:",
error,
);
}
}
return this.state.persistSavedProfiles(...args);
}
getActivePubkey() {
return this.state.getActivePubkey();
}
setActivePubkey(...args) {
return this.state.setActivePubkey(...args);
}
getCachedProfileSelection() {
return this.state.getCachedSelection();
}
setCachedProfileSelection(...args) {
return this.state.setCachedSelection(...args);
}
getActivePane() {
return this.state.getActivePane();
}
setActivePane(...args) {
return this.state.setActivePane(...args);
}
isWalletBusy() {
return Boolean(this.state.getWalletBusy());
}
setWalletPaneBusy(isBusy) {
const result = this.state.setWalletBusy(Boolean(isBusy));
if (this.panes.wallet instanceof HTMLElement) {
this.panes.wallet.setAttribute(
"aria-busy",
this.isWalletBusy() ? "true" : "false",
);
}
this.applyWalletControlState();
return result;
}
}
export default ProfileModalController;