Files
bitvid/js/ui/profileModalController.js
T

876 lines
27 KiB
JavaScript

const noop = () => {};
const SERVICE_CONTRACT = [
{
key: "normalizeHexPubkey",
type: "function",
description:
"Normalizes a provided pubkey (hex or npub) so profile lookups are stable.",
},
{
key: "safeEncodeNpub",
type: "function",
description:
"Encodes a hex pubkey as an npub for display. Expected to be resilient to bad input.",
},
{
key: "safeDecodeNpub",
type: "function",
description:
"Decodes an npub back to its hex form. Should return null/undefined for invalid payloads.",
},
{
key: "truncateMiddle",
type: "function",
description:
"Utility used for shortening long identifiers in the modal without losing context.",
},
{
key: "getProfileCacheEntry",
type: "function",
description:
"Returns the cached profile metadata for a normalized pubkey (if available).",
},
{
key: "batchFetchProfiles",
type: "function",
description:
"Fetches and caches profile metadata for a set of pubkeys so the modal can hydrate avatars/names in bulk.",
},
{
key: "switchProfile",
type: "function",
description:
"Switches the active application profile to the provided pubkey and updates state accordingly.",
},
{
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: "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.",
},
{
key: "getActiveNwcSettings",
type: "function",
description:
"Reads the active Nostr Wallet Connect settings (uri/default zap) for the logged in profile.",
},
{
key: "updateActiveNwcSettings",
type: "function",
description: "Persists updates to the active wallet settings and returns the new state.",
},
{
key: "createDefaultNwcSettings",
type: "function",
description: "Creates a baseline wallet settings object when none exists.",
},
{
key: "ensureWallet",
type: "function",
description:
"Guarantees a wallet connection exists before issuing wallet related operations (connect/test).",
},
{
key: "loadVideos",
type: "function",
description: "Triggers a video reload so UI reflects profile or permission changes.",
},
{
key: "sendAdminListNotification",
type: "function",
description:
"Sends an administrative notification when moderators/whitelist/blacklist entries change.",
},
{
key: "describeAdminError",
type: "function",
description:
"Maps low-level admin errors to readable copy for surfaced error messages.",
},
{
key: "describeNotificationError",
type: "function",
description:
"Maps notification send errors to human readable descriptions for toast/banner messaging.",
},
{
key: "onAccessControlUpdated",
type: "function",
description:
"Callback invoked after access control mutations so the app can refresh dependent UI.",
},
];
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;
},
},
];
function buildServicesContract(services = {}) {
const resolved = {};
const missing = [];
SERVICE_CONTRACT.forEach((definition) => {
const value = services[definition.key];
if (value == null) {
missing.push(`- ${definition.key}: ${definition.description}`);
return;
}
if (definition.type === "function" && typeof value !== "function") {
throw new TypeError(
`Expected service \"${definition.key}\" to be a function. Received ${typeof value}.`,
);
}
if (definition.type === "object" && (typeof value !== "object" || value === null)) {
throw new TypeError(
`Expected service \"${definition.key}\" to be an object. Received ${typeof value}.`,
);
}
resolved[definition.key] = value;
});
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 = {},
} = options;
this.modalContainer = modalContainer;
this.removeTrackingScripts = removeTrackingScripts;
this.createWatchHistoryRenderer = createWatchHistoryRenderer;
this.setGlobalModalState = setGlobalModalState;
this.showError = showError;
this.showSuccess = showSuccess;
this.showStatus = showStatus;
this.internalState = {
savedProfiles: [],
activePubkey: null,
cachedSelection: null,
activePane: "account",
walletBusy: false,
};
this.services = buildServicesContract(services);
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.getProfileCacheEntry = this.services.getProfileCacheEntry;
this.batchFetchProfiles = this.services.batchFetchProfiles;
this.switchProfile = this.services.switchProfile;
this.relayManager = this.services.relayManager;
this.userBlocks = this.services.userBlocks;
this.nostrClient = this.services.nostrClient;
this.accessControl = this.services.accessControl;
this.getCurrentUserNpub = this.services.getCurrentUserNpub;
this.getActiveNwcSettings = this.services.getActiveNwcSettings;
this.updateActiveNwcSettings = this.services.updateActiveNwcSettings;
this.createDefaultNwcSettings = this.services.createDefaultNwcSettings;
this.ensureWallet = this.services.ensureWallet;
this.loadVideos = this.services.loadVideos;
this.sendAdminListNotification = this.services.sendAdminListNotification;
this.describeAdminError = this.services.describeAdminError;
this.describeNotificationError = this.services.describeNotificationError;
this.onAccessControlUpdated = this.services.onAccessControlUpdated;
this.callbacks = {
onClose: callbacks.onClose || noop,
onLogout: callbacks.onLogout || noop,
onChannelLink: callbacks.onChannelLink || noop,
onAddAccount: callbacks.onAddAccount || 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,
};
this.profileModal = null;
this.closeButton = null;
this.logoutButton = null;
this.channelLink = null;
this.addAccountButton = null;
this.navButtons = {
account: null,
relays: null,
wallet: null,
blocked: null,
history: null,
admin: null,
};
this.panes = {
account: null,
relays: null,
wallet: null,
blocked: null,
history: null,
admin: null,
};
this.relayList = null;
this.relayInput = null;
this.relayAddButton = null;
this.relayRestoreButton = null;
this.blockList = null;
this.blockInput = null;
this.blockAddButton = null;
this.walletUriInput = null;
this.walletDefaultZapInput = null;
this.walletSaveButton = null;
this.walletTestButton = null;
this.walletDisconnectButton = null;
this.adminAddModeratorButton = null;
this.adminModeratorInput = null;
this.adminAddWhitelistButton = null;
this.adminWhitelistInput = null;
this.adminAddBlacklistButton = null;
this.adminBlacklistInput = null;
this.profileHistoryRenderer = null;
this.boundKeydown = null;
this.boundFocusIn = null;
this.focusableElements = [];
this.setActivePane(this.getActivePane());
this.setWalletPaneBusy(this.isWalletBusy());
this.adminEmptyMessages = new Map();
}
async load() {
if (!(this.modalContainer instanceof HTMLElement)) {
throw new Error("profile modal container missing");
}
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 wrapper = document.createElement("div");
wrapper.innerHTML = html;
this.removeTrackingScripts(wrapper);
this.modalContainer.appendChild(wrapper);
this.cacheDomReferences();
this.registerEventListeners();
this.updateFocusTrap();
this.callbacks.onPaneShown(this.getActivePane(), { controller: this });
return true;
}
cacheDomReferences() {
this.profileModal = document.getElementById("profileModal") || null;
this.closeButton = document.getElementById("closeProfileModal") || null;
this.logoutButton = document.getElementById("profileLogoutBtn") || null;
this.channelLink = document.getElementById("profileChannelLink") || null;
this.addAccountButton =
document.getElementById("profileAddAccountBtn") || null;
this.navButtons.account =
document.getElementById("profileNavAccount") || null;
this.navButtons.relays = document.getElementById("profileNavRelays") || null;
this.navButtons.wallet = document.getElementById("profileNavWallet") || null;
this.navButtons.blocked =
document.getElementById("profileNavBlocked") || null;
this.navButtons.history =
document.getElementById("profileNavHistory") || null;
this.navButtons.admin = document.getElementById("profileNavAdmin") || null;
this.panes.account = document.getElementById("profilePaneAccount") || null;
this.panes.relays = document.getElementById("profilePaneRelays") || null;
this.panes.wallet = document.getElementById("profilePaneWallet") || null;
this.panes.blocked = document.getElementById("profilePaneBlocked") || null;
this.panes.history = document.getElementById("profilePaneHistory") || null;
this.panes.admin = document.getElementById("profilePaneAdmin") || null;
this.relayList = document.getElementById("relayList") || null;
this.relayInput = document.getElementById("relayInput") || null;
this.relayAddButton = document.getElementById("addRelayBtn") || null;
this.relayRestoreButton =
document.getElementById("restoreRelaysBtn") || null;
this.blockList = document.getElementById("blockedList") || null;
this.blockInput = document.getElementById("blockedInput") || null;
this.blockAddButton = document.getElementById("addBlockedBtn") || null;
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.adminAddModeratorButton =
document.getElementById("adminAddModeratorBtn") || null;
this.adminModeratorInput =
document.getElementById("adminModeratorInput") || null;
this.adminAddWhitelistButton =
document.getElementById("adminAddWhitelistBtn") || null;
this.adminWhitelistInput =
document.getElementById("adminWhitelistInput") || null;
this.adminAddBlacklistButton =
document.getElementById("adminAddBlacklistBtn") || null;
this.adminBlacklistInput =
document.getElementById("adminBlacklistInput") || null;
if (!this.profileHistoryRenderer && this.createWatchHistoryRenderer) {
this.profileHistoryRenderer = this.createWatchHistoryRenderer({
viewSelector: "#profilePaneHistory",
gridSelector: "#profileHistoryGrid",
loadingSelector: "#profileHistoryLoading",
statusSelector: "#profileHistoryStatus",
emptySelector: "#profileHistoryEmpty",
sentinelSelector: "#profileHistorySentinel",
scrollContainerSelector: "#profileHistoryScroll",
errorBannerSelector: "#profileHistoryError",
clearButtonSelector: "#profileHistoryClear",
republishButtonSelector: "#profileHistoryRepublish",
featureBannerSelector: "#profileHistoryFeatureBanner",
toastRegionSelector: "#profileHistoryToastRegion",
sessionWarningSelector: "#profileHistorySessionWarning",
metadataToggleSelector: "#profileHistoryMetadataToggle",
metadataThumbSelector: "#profileHistoryMetadataThumb",
metadataLabelSelector: "#profileHistoryMetadataLabel",
metadataDescriptionSelector: "#profileHistoryMetadataDescription",
remove: (payload) => this.callbacks.onHistoryReady(payload, this),
});
}
}
registerEventListeners() {
if (this.closeButton instanceof HTMLElement) {
this.closeButton.addEventListener("click", () => {
this.hide();
this.callbacks.onClose(this);
});
}
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", () => {
this.callbacks.onAddAccount(this);
});
}
Object.entries(this.navButtons).forEach(([name, button]) => {
if (button instanceof HTMLElement) {
button.addEventListener("click", () => {
this.selectPane(name);
});
}
});
if (this.relayAddButton instanceof HTMLElement) {
this.relayAddButton.addEventListener("click", () => {
this.callbacks.onAddRelay(this.relayInput, this);
});
}
if (this.relayRestoreButton instanceof HTMLElement) {
this.relayRestoreButton.addEventListener("click", () => {
this.callbacks.onRestoreRelays(this);
});
}
if (this.blockAddButton instanceof HTMLElement) {
this.blockAddButton.addEventListener("click", () => {
this.callbacks.onAddBlocked(this.blockInput, this);
});
}
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.addEventListener("input", () => {
this.callbacks.onWalletInputChange?.(this.walletUriInput, this);
});
}
if (this.walletDefaultZapInput instanceof HTMLElement) {
this.walletDefaultZapInput.addEventListener("input", () => {
this.callbacks.onWalletInputChange?.(this.walletDefaultZapInput, this);
});
}
if (this.walletSaveButton instanceof HTMLElement) {
this.walletSaveButton.addEventListener("click", () => {
this.callbacks.onWalletSave(this);
});
}
if (this.walletTestButton instanceof HTMLElement) {
this.walletTestButton.addEventListener("click", () => {
this.callbacks.onWalletTest(this);
});
}
if (this.walletDisconnectButton instanceof HTMLElement) {
this.walletDisconnectButton.addEventListener("click", () => {
this.callbacks.onWalletDisconnect(this);
});
}
if (this.adminAddModeratorButton instanceof HTMLElement) {
this.adminAddModeratorButton.addEventListener("click", () => {
this.callbacks.onAdminAddModerator(this.adminModeratorInput, this);
});
}
if (this.adminModeratorInput instanceof HTMLElement) {
this.adminModeratorInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.callbacks.onAdminAddModerator(this.adminModeratorInput, this);
}
});
}
if (this.adminAddWhitelistButton instanceof HTMLElement) {
this.adminAddWhitelistButton.addEventListener("click", () => {
this.callbacks.onAdminAddWhitelist(this.adminWhitelistInput, this);
});
}
if (this.adminWhitelistInput instanceof HTMLElement) {
this.adminWhitelistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.callbacks.onAdminAddWhitelist(this.adminWhitelistInput, this);
}
});
}
if (this.adminAddBlacklistButton instanceof HTMLElement) {
this.adminAddBlacklistButton.addEventListener("click", () => {
this.callbacks.onAdminAddBlacklist(this.adminBlacklistInput, this);
});
}
if (this.adminBlacklistInput instanceof HTMLElement) {
this.adminBlacklistInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.callbacks.onAdminAddBlacklist(this.adminBlacklistInput, this);
}
});
}
}
selectPane(name = "account") {
const normalized = typeof name === "string" ? name.toLowerCase() : "account";
if (this.getActivePane() === normalized) {
this.callbacks.onSelectPane(normalized, { controller: this });
return;
}
Object.entries(this.panes).forEach(([key, pane]) => {
if (pane instanceof HTMLElement) {
const isActive = key === normalized;
pane.classList.toggle("hidden", !isActive);
pane.setAttribute("aria-hidden", String(!isActive));
}
});
Object.entries(this.navButtons).forEach(([key, button]) => {
if (button instanceof HTMLElement) {
const isActive = key === normalized;
button.setAttribute("aria-selected", isActive ? "true" : "false");
button.classList.toggle("bg-gray-800", isActive);
button.classList.toggle("text-white", isActive);
button.classList.toggle("text-gray-400", !isActive);
}
});
this.setActivePane(normalized);
this.updateFocusTrap();
this.callbacks.onSelectPane(normalized, { controller: this });
this.callbacks.onPaneShown(normalized, { controller: this });
}
updateFocusTrap() {
if (!(this.profileModal instanceof HTMLElement)) {
this.focusableElements = [];
return;
}
const selector =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
const nodes = Array.from(
this.profileModal.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();
}
bindFocusTrap() {
if (!(this.profileModal instanceof HTMLElement)) {
return;
}
if (!this.boundKeydown) {
this.boundKeydown = (event) => {
if (event.key === "Escape") {
event.preventDefault();
this.hide();
this.callbacks.onClose(this);
return;
}
if (event.key !== "Tab") {
return;
}
if (!this.focusableElements.length) {
event.preventDefault();
if (typeof this.profileModal.focus === "function") {
this.profileModal.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 || !this.profileModal.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) => {
if (
!this.profileModal ||
this.profileModal.classList.contains("hidden") ||
this.profileModal.contains(event.target)
) {
return;
}
const target = this.focusableElements[0] || this.profileModal;
if (typeof target?.focus === "function") {
target.focus();
}
};
}
this.profileModal.addEventListener("keydown", this.boundKeydown);
document.addEventListener("focusin", this.boundFocusIn);
}
open(pane = "account") {
if (!(this.profileModal instanceof HTMLElement)) {
return;
}
this.profileModal.classList.remove("hidden");
this.profileModal.setAttribute("aria-hidden", "false");
this.setGlobalModalState("profile", true);
this.selectPane(pane);
const focusTarget = this.focusableElements[0] || this.profileModal;
window.requestAnimationFrame(() => {
if (typeof focusTarget?.focus === "function") {
focusTarget.focus();
}
});
}
hide() {
if (!(this.profileModal instanceof HTMLElement)) {
return;
}
this.profileModal.classList.add("hidden");
this.profileModal.setAttribute("aria-hidden", "true");
this.setGlobalModalState("profile", false);
if (this.boundKeydown) {
this.profileModal.removeEventListener("keydown", this.boundKeydown);
}
if (this.boundFocusIn) {
document.removeEventListener("focusin", this.boundFocusIn);
}
}
getSavedProfiles() {
return this.state.getSavedProfiles();
}
setSavedProfiles(...args) {
return this.state.setSavedProfiles(...args);
}
persistSavedProfiles(...args) {
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) {
return this.state.setWalletBusy(Boolean(isBusy));
}
}
export default ProfileModalController;