diff --git a/js/channelProfile.js b/js/channelProfile.js index f6e3af7e..591fe690 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -11,13 +11,9 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; import { getApplication } from "./applicationContext.js"; import { escapeHTML } from "./utils/domUtils.js"; -import positionFloatingPanel from "./ui/utils/positionFloatingPanel.js"; -import { createFloatingPanelStyles } from "./ui/utils/floatingPanelStyles.js"; -import { - getPopupOffsetPx, - getPopupViewportPaddingPx, -} from "./designSystem/metrics.js"; +import createPopover from "./ui/overlay/popoverEngine.js"; import { VideoCard } from "./ui/components/VideoCard.js"; +import { createChannelProfileMenuPanel } from "./ui/components/videoMenuRenderers.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { calculateZapShares, @@ -131,7 +127,10 @@ let cachedZapSendBtn = null; let pendingZapRetry = null; let zapInFlight = false; let zapControlsOpen = false; -let zapControlsPositioner = null; +let zapPopover = null; +let zapShouldFocusOnOpen = false; +let channelMenuPopover = null; +let channelMenuOpen = false; let currentVideoLoadToken = 0; let currentProfileLoadToken = 0; @@ -266,127 +265,218 @@ function getChannelShareButton() { } function getChannelMenuElement() { - if (cachedChannelMenu && !document.body.contains(cachedChannelMenu)) { - cachedChannelMenu = null; + if (cachedChannelMenu && cachedChannelMenu.isConnected) { + return cachedChannelMenu; } - if (!cachedChannelMenu) { - cachedChannelMenu = document.getElementById("moreDropdown-channel-profile"); + + const doc = typeof document !== "undefined" ? document : null; + const existing = doc?.getElementById("channelProfileMoreMenu") || null; + const HTMLElementCtor = + doc?.defaultView?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + if (HTMLElementCtor && existing instanceof HTMLElementCtor) { + cachedChannelMenu = existing; + } else { + cachedChannelMenu = null; } return cachedChannelMenu; } function getZapControlsContainer() { - if (cachedZapControls && !document.body.contains(cachedZapControls)) { + if (cachedZapControls && cachedZapControls.isConnected) { + return cachedZapControls; + } + + const doc = typeof document !== "undefined" ? document : null; + const existing = doc?.getElementById("zapControls") || null; + const HTMLElementCtor = + doc?.defaultView?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null); + if (HTMLElementCtor && existing instanceof HTMLElementCtor) { + cachedZapControls = existing; + } else { cachedZapControls = null; } - if (!cachedZapControls) { - cachedZapControls = document.getElementById("zapControls"); - } - if (cachedZapControls) { - if (!cachedZapControls.dataset.state) { - const isHidden = - cachedZapControls.hasAttribute("hidden") || - cachedZapControls.getAttribute("aria-hidden") === "true"; - cachedZapControls.dataset.state = isHidden ? "closed" : "open"; - } - if (cachedZapControls.dataset.state !== "open") { - cachedZapControls.hidden = true; - cachedZapControls.setAttribute("aria-hidden", "true"); - } - } return cachedZapControls; } function getZapFormElement() { - if (cachedZapForm && !document.body.contains(cachedZapForm)) { - cachedZapForm = null; - } - if (!cachedZapForm) { - cachedZapForm = document.getElementById("zapForm"); + if (cachedZapForm && cachedZapForm.isConnected) { + return cachedZapForm; } + const container = getZapControlsContainer(); + cachedZapForm = container?.querySelector("#zapForm") || null; return cachedZapForm; } function getZapAmountInput() { - if (cachedZapAmountInput && !document.body.contains(cachedZapAmountInput)) { - cachedZapAmountInput = null; - } - if (!cachedZapAmountInput) { - cachedZapAmountInput = document.getElementById("zapAmountInput"); + if (cachedZapAmountInput && cachedZapAmountInput.isConnected) { + return cachedZapAmountInput; } + const container = getZapControlsContainer(); + cachedZapAmountInput = container?.querySelector("#zapAmountInput") || null; return cachedZapAmountInput; } function getZapSplitSummaryElement() { - if (cachedZapSplitSummary && !document.body.contains(cachedZapSplitSummary)) { - cachedZapSplitSummary = null; - } - if (!cachedZapSplitSummary) { - cachedZapSplitSummary = document.getElementById("zapSplitSummary"); + if (cachedZapSplitSummary && cachedZapSplitSummary.isConnected) { + return cachedZapSplitSummary; } + const container = getZapControlsContainer(); + cachedZapSplitSummary = container?.querySelector("#zapSplitSummary") || null; return cachedZapSplitSummary; } function getZapStatusElement() { - if (cachedZapStatus && !document.body.contains(cachedZapStatus)) { - cachedZapStatus = null; - } - if (!cachedZapStatus) { - cachedZapStatus = document.getElementById("zapStatus"); + if (cachedZapStatus && cachedZapStatus.isConnected) { + return cachedZapStatus; } + const container = getZapControlsContainer(); + cachedZapStatus = container?.querySelector("#zapStatus") || null; return cachedZapStatus; } function getZapReceiptsList() { - if (cachedZapReceipts && !document.body.contains(cachedZapReceipts)) { - cachedZapReceipts = null; - } - if (!cachedZapReceipts) { - cachedZapReceipts = document.getElementById("zapReceipts"); + if (cachedZapReceipts && cachedZapReceipts.isConnected) { + return cachedZapReceipts; } + const container = getZapControlsContainer(); + cachedZapReceipts = container?.querySelector("#zapReceipts") || null; return cachedZapReceipts; } function getZapSendButton() { - if (cachedZapSendBtn && !document.body.contains(cachedZapSendBtn)) { - cachedZapSendBtn = null; - } - if (!cachedZapSendBtn) { - cachedZapSendBtn = document.getElementById("zapSendBtn"); + if (cachedZapSendBtn && cachedZapSendBtn.isConnected) { + return cachedZapSendBtn; } + const container = getZapControlsContainer(); + cachedZapSendBtn = container?.querySelector("#zapSendBtn") || null; return cachedZapSendBtn; } function getZapWalletPrompt() { - if (cachedZapWalletPrompt && !document.body.contains(cachedZapWalletPrompt)) { - cachedZapWalletPrompt = null; - } - if (!cachedZapWalletPrompt) { - cachedZapWalletPrompt = document.getElementById("zapWalletPrompt"); + if (cachedZapWalletPrompt && cachedZapWalletPrompt.isConnected) { + return cachedZapWalletPrompt; } + const container = getZapControlsContainer(); + cachedZapWalletPrompt = container?.querySelector("#zapWalletPrompt") || null; return cachedZapWalletPrompt; } function getZapWalletLink() { - if (cachedZapWalletLink && !document.body.contains(cachedZapWalletLink)) { - cachedZapWalletLink = null; - } - if (!cachedZapWalletLink) { - cachedZapWalletLink = document.getElementById("zapWalletLink"); + if (cachedZapWalletLink && cachedZapWalletLink.isConnected) { + return cachedZapWalletLink; } + const container = getZapControlsContainer(); + cachedZapWalletLink = container?.querySelector("#zapWalletLink") || null; return cachedZapWalletLink; } function getZapCloseButton() { - if (cachedZapCloseBtn && !document.body.contains(cachedZapCloseBtn)) { - cachedZapCloseBtn = null; - } - if (!cachedZapCloseBtn) { - cachedZapCloseBtn = document.getElementById("zapCloseBtn"); + if (cachedZapCloseBtn && cachedZapCloseBtn.isConnected) { + return cachedZapCloseBtn; } + const container = getZapControlsContainer(); + cachedZapCloseBtn = container?.querySelector("#zapCloseBtn") || null; return cachedZapCloseBtn; } +function cacheZapPanelElements(panel) { + if (!panel || panel.nodeType !== 1) { + cachedZapControls = null; + cachedZapForm = null; + cachedZapAmountInput = null; + cachedZapSplitSummary = null; + cachedZapStatus = null; + cachedZapReceipts = null; + cachedZapWalletPrompt = null; + cachedZapWalletLink = null; + cachedZapCloseBtn = null; + cachedZapSendBtn = null; + return; + } + + cachedZapControls = panel; + cachedZapForm = panel.querySelector("#zapForm"); + cachedZapAmountInput = panel.querySelector("#zapAmountInput"); + cachedZapSplitSummary = panel.querySelector("#zapSplitSummary"); + cachedZapStatus = panel.querySelector("#zapStatus"); + cachedZapReceipts = panel.querySelector("#zapReceipts"); + cachedZapWalletPrompt = panel.querySelector("#zapWalletPrompt"); + cachedZapWalletLink = panel.querySelector("#zapWalletLink"); + cachedZapCloseBtn = panel.querySelector("#zapCloseBtn"); + cachedZapSendBtn = panel.querySelector("#zapSendBtn"); +} + +function initializeZapPanel() { + const zapButton = getChannelZapButton(); + const amountInput = getZapAmountInput(); + const zapForm = getZapFormElement(); + const sendButton = getZapSendButton(); + if (!zapButton || !amountInput || !zapForm || !sendButton) { + return; + } + + zapButton.setAttribute("aria-expanded", "false"); + + if (zapForm.dataset.initialized !== "true") { + zapForm.addEventListener("submit", handleZapSend); + zapForm.dataset.initialized = "true"; + } + + if (amountInput.dataset.initialized !== "true") { + amountInput.addEventListener("input", handleZapAmountChange); + amountInput.addEventListener("change", handleZapAmountChange); + amountInput.dataset.initialized = "true"; + } + + const closeBtn = getZapCloseButton(); + if (closeBtn && closeBtn.dataset.initialized !== "true") { + closeBtn.addEventListener("click", (event) => { + event?.preventDefault?.(); + closeZapControls({ focusButton: true }); + }); + closeBtn.dataset.initialized = "true"; + } + + setupZapWalletLink(); + + const app = getApp(); + const activeSettings = + typeof app?.getActiveNwcSettings === "function" + ? app.getActiveNwcSettings() + : {}; + + const hasWallet = + typeof app?.hasActiveWalletConnection === "function" + ? app.hasActiveWalletConnection() + : (() => { + const settings = + typeof app?.getActiveNwcSettings === "function" + ? app.getActiveNwcSettings() + : {}; + const uri = + typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; + return uri.length > 0; + })(); + + const shouldShowPrompt = !zapButton.hasAttribute("hidden") && !hasWallet; + setZapWalletPromptVisible(shouldShowPrompt); + + if ( + Number.isFinite(activeSettings?.defaultZap) && + activeSettings.defaultZap > 0 + ) { + amountInput.value = Math.max(0, Math.round(activeSettings.defaultZap)); + } else if (!amountInput.value) { + amountInput.value = ""; + } + + updateZapSplitSummary(); + setZapStatus("", "neutral"); + clearZapReceipts(); +} + function setZapWalletPromptVisible(visible) { const prompt = getZapWalletPrompt(); if (!prompt) { @@ -427,63 +517,57 @@ function focusZapAmountField() { } function isZapControlsOpen() { + if (typeof zapPopover?.isOpen === "function") { + return zapPopover.isOpen(); + } return zapControlsOpen; } function openZapControls({ focus = false } = {}) { - const controls = getZapControlsContainer(); const zapButton = getChannelZapButton(); - if (!controls || !zapButton) { + if (!zapButton || !zapPopover) { return false; } - if (!zapControlsPositioner) { - const styles = createFloatingPanelStyles(controls); - const metricsDocument = - controls?.ownerDocument || - zapButton?.ownerDocument || - (typeof document !== "undefined" ? document : null); - const offset = getPopupOffsetPx({ documentRef: metricsDocument }); - const viewportPadding = getPopupViewportPaddingPx({ - documentRef: metricsDocument, - }); - zapControlsPositioner = positionFloatingPanel(zapButton, controls, { - placement: "bottom", - alignment: "end", - offset, - viewportPadding, - styles, + zapShouldFocusOnOpen = focus; + const result = zapPopover.open(); + if (result && typeof result.then === "function") { + result.catch(() => {}).finally(() => { + zapShouldFocusOnOpen = false; }); + return true; } - if (!zapControlsOpen) { - controls.hidden = false; - controls.dataset.state = "open"; - controls.setAttribute("aria-hidden", "false"); - zapButton.setAttribute("aria-expanded", "true"); - zapControlsOpen = true; - zapControlsPositioner?.update(); - } - if (focus) { - focusZapAmountField(); - } - return true; + zapShouldFocusOnOpen = false; + return Boolean(result); } function closeZapControls({ focusButton = false } = {}) { - const controls = getZapControlsContainer(); const zapButton = getChannelZapButton(); - if (controls) { - controls.dataset.state = "closed"; - controls.setAttribute("aria-hidden", "true"); - controls.hidden = true; + zapShouldFocusOnOpen = false; + + if (zapPopover) { + const result = zapPopover.close({ restoreFocus: focusButton }); + if (!result && focusButton && zapButton && typeof zapButton.focus === "function") { + try { + zapButton.focus({ preventScroll: true }); + } catch (error) { + zapButton.focus(); + } + } + return Boolean(result); } + + zapControlsOpen = false; if (zapButton) { zapButton.setAttribute("aria-expanded", "false"); if (focusButton && typeof zapButton.focus === "function") { - zapButton.focus(); + try { + zapButton.focus({ preventScroll: true }); + } catch (error) { + zapButton.focus(); + } } } - zapControlsOpen = false; - return Boolean(controls && zapButton); + return false; } function updateZapSplitSummary({ overrideFee = null } = {}) { @@ -578,9 +662,11 @@ function renderZapReceipts(receipts, { partial = false } = {}) { list.innerHTML = ""; + const doc = list.ownerDocument || (typeof document !== "undefined" ? document : null); + if (!Array.isArray(receipts) || receipts.length === 0) { if (partial) { - const emptyItem = document.createElement("li"); + const emptyItem = doc?.createElement?.("li"); emptyItem.className = "rounded border border-border bg-panel/70 p-3 text-text"; emptyItem.textContent = "No receipts were returned for this attempt."; @@ -594,21 +680,30 @@ function renderZapReceipts(receipts, { partial = false } = {}) { return; } - const li = document.createElement("li"); + const li = doc?.createElement?.("li"); + if (!li) { + return; + } li.className = "rounded border border-border bg-panel/70 p-3"; - const header = document.createElement("div"); + const header = doc?.createElement?.("div"); + if (!header) { + return; + } header.className = "flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-text"; const shareType = receipt.recipientType || receipt.type || "creator"; - const shareLabel = document.createElement("span"); + const shareLabel = doc?.createElement?.("span"); shareLabel.textContent = `${describeShareType(shareType)} • ${Math.max( 0, Math.round(Number(receipt.amount || 0)) )} sats`; - const status = document.createElement("span"); + const status = doc?.createElement?.("span"); + if (!shareLabel || !status) { + return; + } const isSuccess = receipt.status ? receipt.status === "success" : !receipt.error; @@ -619,15 +714,20 @@ function renderZapReceipts(receipts, { partial = false } = {}) { header.appendChild(status); li.appendChild(header); - const address = document.createElement("p"); - address.className = "mt-1 text-xs text-text break-all"; + const address = doc?.createElement?.("p"); + if (address) { + address.className = "mt-1 text-xs text-text break-all"; + } const addressValue = receipt.address || ""; - if (addressValue) { + if (address && addressValue) { address.textContent = addressValue; li.appendChild(address); } - const detail = document.createElement("p"); + const detail = doc?.createElement?.("p"); + if (!detail) { + return; + } detail.className = "mt-2 text-xs text-muted"; if (isSuccess) { let detailMessage = "Invoice settled."; @@ -1343,12 +1443,185 @@ function setupChannelShareButton() { } function setupChannelMoreMenu() { - const container = document.querySelector(".channel-profile-container"); - if (!container) { + const doc = typeof document !== "undefined" ? document : null; + const moreBtn = doc?.getElementById("channelMoreBtn") || null; + + if (!moreBtn) { + if (channelMenuPopover?.destroy) { + channelMenuPopover.destroy(); + } + channelMenuPopover = null; + cachedChannelMenu = null; + channelMenuOpen = false; return; } + + if (channelMenuPopover?.destroy) { + channelMenuPopover.destroy(); + channelMenuPopover = null; + } + + const documentRef = + moreBtn.ownerDocument || (typeof document !== "undefined" ? document : null); + + const render = ({ document: docRef }) => { + const panel = createChannelProfileMenuPanel({ + document: docRef, + context: "channel-profile", + }); + + if (!panel) { + return null; + } + + panel.id = "channelProfileMoreMenu"; + panel.dataset.state = panel.dataset.state || "closed"; + panel.setAttribute("aria-hidden", "true"); + panel.hidden = true; + + cachedChannelMenu = panel; + + const buttons = panel.querySelectorAll("button[data-action]"); + buttons.forEach((button) => { + if (button.dataset.channelMenuBound === "true") { + return; + } + button.dataset.channelMenuBound = "true"; + button.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + + const dataset = {}; + Object.entries(button.dataset || {}).forEach(([key, value]) => { + if (key === "channelMenuBound") { + return; + } + dataset[key] = value; + }); + if (!dataset.context) { + dataset.context = "channel-profile"; + } + if (!dataset.author && currentChannelHex) { + dataset.author = currentChannelHex; + } + if (!dataset.npub && currentChannelNpub) { + dataset.npub = currentChannelNpub; + } + + const action = dataset.action || ""; + try { + await handleChannelMenuAction(action, dataset); + } finally { + channelMenuPopover?.close(); + } + }); + }); + + updateChannelMenuState(); + + return panel; + }; + + const popover = createPopover(moreBtn, render, { + document: documentRef, + placement: "bottom-end", + restoreFocusOnClose: true, + }); + + if (!popover) { + return; + } + + const originalOpen = popover.open?.bind(popover); + if (originalOpen) { + popover.open = async (...args) => { + const result = await originalOpen(...args); + if (result) { + channelMenuOpen = true; + const panel = getChannelMenuElement(); + if (panel) { + panel.dataset.state = "open"; + panel.hidden = false; + panel.setAttribute("aria-hidden", "false"); + } + moreBtn.setAttribute("aria-expanded", "true"); + } + return result; + }; + } + + const originalClose = popover.close?.bind(popover); + if (originalClose) { + popover.close = (options = {}) => { + const wasOpen = typeof popover.isOpen === "function" && popover.isOpen(); + const result = originalClose(options); + if (wasOpen && result) { + channelMenuOpen = false; + const panel = getChannelMenuElement(); + if (panel) { + panel.dataset.state = "closed"; + panel.hidden = true; + panel.setAttribute("aria-hidden", "true"); + } + moreBtn.setAttribute("aria-expanded", "false"); + } + return result; + }; + } + + const originalDestroy = popover.destroy?.bind(popover); + if (originalDestroy) { + popover.destroy = (...args) => { + originalDestroy(...args); + if (channelMenuPopover === popover) { + channelMenuPopover = null; + } + channelMenuOpen = false; + cachedChannelMenu = null; + }; + } + + channelMenuPopover = popover; + + if (moreBtn.dataset.initialized !== "true") { + moreBtn.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!channelMenuPopover) { + return; + } + channelMenuPopover.toggle(); + }); + moreBtn.dataset.initialized = "true"; + } + + moreBtn.setAttribute("aria-expanded", "false"); +} + +async function handleChannelMenuAction(action, dataset = {}) { + const normalized = typeof action === "string" ? action.trim() : ""; + if (!normalized) { + return; + } + + const detail = { ...dataset }; + if (!detail.context) { + detail.context = "channel-profile"; + } + if (!detail.author && currentChannelHex) { + detail.author = currentChannelHex; + } + if (!detail.npub && currentChannelNpub) { + detail.npub = currentChannelNpub; + } + const app = getApp(); - app?.attachMoreMenuHandlers?.(container); + + try { + await app?.handleMoreMenuAction?.(normalized, detail); + } catch (error) { + userLogger.error("Failed to handle channel menu action:", error); + } } async function updateChannelMenuState() { @@ -1372,7 +1645,7 @@ async function updateChannelMenuState() { const buttons = menu.querySelectorAll("button[data-action]"); buttons.forEach((button) => { - if (!(button instanceof HTMLElement)) { + if (!button || button.nodeType !== 1) { return; } @@ -1519,60 +1792,194 @@ export async function initChannelProfileView() { function setupZapButton() { const zapButton = getChannelZapButton(); - const amountInput = getZapAmountInput(); - const controls = getZapControlsContainer(); - const zapForm = getZapFormElement(); - if ( - !zapButton || - !amountInput || - !controls || - !zapForm || - !getZapSendButton() - ) { + if (!zapButton) { return; } + if (zapPopover?.destroy) { + zapPopover.destroy(); + zapPopover = null; + } + + const documentRef = + zapButton.ownerDocument || + (typeof document !== "undefined" ? document : null); + + const render = ({ document: doc }) => { + if (!doc || typeof doc.createElement !== "function") { + return null; + } + + const panel = doc.createElement("div"); + panel.id = "zapControls"; + panel.className = "popover__panel card w-72 max-w-popover-safe p-4"; + panel.dataset.variant = "zap"; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", "Channel zap dialog"); + panel.setAttribute("aria-hidden", "true"); + panel.dataset.state = panel.dataset.state || "closed"; + panel.hidden = true; + + panel.innerHTML = ` +