diff --git a/js/app.js b/js/app.js index 6008e37b..930aac96 100644 --- a/js/app.js +++ b/js/app.js @@ -585,14 +585,18 @@ class Application { getCurrentVideo: () => this.currentVideo, nwcSettings: this.nwcSettingsService, isUserLoggedIn: () => this.isUserLoggedIn(), + hasSessionActor: () => + Boolean( + typeof nostrClient?.sessionActor?.pubkey === "string" && + nostrClient.sessionActor.pubkey.trim() + ), splitAndZap: (...args) => this.splitAndZap(...args), payments: this.payments, callbacks: { onSuccess: (message) => this.showSuccess(message), onError: (message) => this.showError(message), }, - requestWalletPane: () => - this.profileController?.showWalletPane?.(), + requestWalletPane: () => this.openWalletPane(), }); this.boundVideoModalCloseHandler = () => { this.hideModal(); @@ -1307,6 +1311,47 @@ class Application { } } + openWalletPane() { + if ( + !this.profileController || + typeof this.profileController.showWalletPane !== "function" + ) { + devLogger.warn( + "[Application] Wallet pane requested before profile controller initialized.", + ); + return Promise.resolve(false); + } + + try { + const result = this.profileController.showWalletPane(); + if (result && typeof result.then === "function") { + return result.catch((error) => { + devLogger.error("[Application] Failed to open wallet pane:", error); + throw error; + }); + } + return Promise.resolve(result); + } catch (error) { + devLogger.error("[Application] Failed to open wallet pane:", error); + return Promise.reject(error); + } + } + + dispatchAuthChange(detail = {}) { + if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") { + return false; + } + + try { + const payload = { ...(typeof detail === "object" && detail ? detail : {}) }; + window.dispatchEvent(new CustomEvent("bitvid:auth-changed", { detail: payload })); + return true; + } catch (error) { + devLogger.warn("[Application] Failed to dispatch auth change event:", error); + return false; + } + } + normalizeModalTrigger(candidate) { if (!candidate) { return null; @@ -3095,6 +3140,23 @@ class Application { identityChanged: Boolean(detail?.identityChanged), }; + if (this.zapController) { + try { + this.zapController.setVisibility( + Boolean(this.currentVideo?.lightningAddress), + ); + } catch (error) { + devLogger.warn("[Application] Failed to refresh zap visibility after login:", error); + } + } + + this.dispatchAuthChange({ + status: "login", + loggedIn: true, + pubkey: loginContext.pubkey || null, + previousPubkey: loginContext.previousPubkey || null, + }); + const nwcPromise = Promise.resolve() .then(() => this.nwcSettingsService.onLogin(loginContext)) .catch((error) => { @@ -3186,6 +3248,51 @@ class Application { this.applyLoggedOutUiState(); + if (this.videoModal?.closeZapDialog) { + try { + this.videoModal.closeZapDialog({ silent: true, restoreFocus: false }); + } catch (error) { + devLogger.warn("Failed to close zap dialog during logout:", error); + } + } + + if (this.zapController) { + try { + this.zapController.resetState(); + this.zapController.setVisibility(Boolean(this.currentVideo?.lightningAddress)); + } catch (error) { + devLogger.warn("Failed to reset zap controller during logout:", error); + } + } + + if (typeof this.nostrService?.clearVideoSubscription === "function") { + try { + this.nostrService.clearVideoSubscription(); + } catch (error) { + devLogger.warn("Failed to clear video subscription during logout:", error); + } + } + + if (typeof this.nostrService?.resetVideosCache === "function") { + try { + this.nostrService.resetVideosCache(); + } catch (error) { + devLogger.warn("Failed to reset cached videos during logout:", error); + } + } + + await this.renderVideoList({ + videos: [], + metadata: { reason: "auth:logout" }, + }); + + this.dispatchAuthChange({ + status: "logout", + loggedIn: false, + pubkey: detail?.pubkey || null, + previousPubkey: detail?.previousPubkey || null, + }); + try { await this.loadVideos(true); } catch (error) { diff --git a/js/channelProfile.js b/js/channelProfile.js index 08f13623..4f422611 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -112,6 +112,8 @@ function logZapError(stage, details = {}, error) { userLogger.error("[zap] Channel zap failure", summary, error); } let currentChannelProfileEvent = null; +let currentChannelProfileSnapshot = null; +let currentChannelProfileHasExplicitPayload = false; let cachedZapButton = null; let cachedChannelShareButton = null; @@ -133,6 +135,7 @@ let zapControlsOpen = false; let zapPopover = null; let zapPopoverTrigger = null; let zapShouldFocusOnOpen = false; +let zapPopoverOpenPromise = null; let channelMenuPopover = null; let channelMenuOpen = false; let currentVideoLoadToken = 0; @@ -144,6 +147,33 @@ const PROFILE_EVENT_CACHE_TTL_MS = 5 * 60 * 1000; const channelProfileMetadataCache = new Map(); +function hasExplicitChannelProfilePayload(profile) { + return ( + profile && + typeof profile === "object" && + Object.keys(profile).length > 0 + ); +} + +function touchChannelProfileCacheEntry(pubkey) { + if (typeof pubkey !== "string" || !pubkey) { + return false; + } + + const existing = channelProfileMetadataCache.get(pubkey); + if (!existing) { + return false; + } + + channelProfileMetadataCache.set(pubkey, { + timestamp: Date.now(), + profile: { ...existing.profile }, + event: existing.event ? { ...existing.event } : null + }); + + return true; +} + const SUPPORTED_BANNER_REFERRER_POLICIES = new Set([ "no-referrer", "origin", @@ -469,7 +499,11 @@ function setChannelZapVisibility(visible) { typeof app?.isUserLoggedIn === "function" ? app.isUserLoggedIn() : Boolean(app?.normalizeHexPubkey?.(app?.pubkey)); - const shouldShow = !!visible && isLoggedIn; + const sessionActorPubkey = getSessionActorPubkey(); + const hasSessionActor = sessionActorPubkey.length > 0; + const sessionOnly = !isLoggedIn && hasSessionActor; + const shouldShow = !!visible && (isLoggedIn || sessionOnly); + const requiresLogin = shouldShow && !isLoggedIn; if ( shouldShow && @@ -480,7 +514,10 @@ function setChannelZapVisibility(visible) { zapButton.toggleAttribute("hidden", !shouldShow); zapButton.disabled = !shouldShow; - zapButton.setAttribute("aria-disabled", (!shouldShow).toString()); + zapButton.setAttribute( + "aria-disabled", + (requiresLogin || !shouldShow).toString() + ); zapButton.setAttribute("aria-hidden", (!shouldShow).toString()); zapButton.setAttribute("aria-expanded", "false"); if (shouldShow) { @@ -492,18 +529,24 @@ function setChannelZapVisibility(visible) { clearZapReceipts(); setZapStatus("", "neutral"); } + if (requiresLogin) { + zapButton.dataset.requiresLogin = "true"; + } else { + delete zapButton.dataset.requiresLogin; + } closeZapControls(); if (controls) { controls.setAttribute("aria-hidden", "true"); controls.hidden = true; } + const shouldEnableInputs = shouldShow && !requiresLogin; if (amountInput) { - amountInput.disabled = !shouldShow; + amountInput.disabled = !shouldEnableInputs; } if (sendButton) { - sendButton.disabled = !shouldShow; - sendButton.setAttribute("aria-hidden", (!shouldShow).toString()); - if (shouldShow) { + sendButton.disabled = !shouldEnableInputs; + sendButton.setAttribute("aria-hidden", (!shouldEnableInputs).toString()); + if (shouldEnableInputs) { sendButton.removeAttribute("tabindex"); sendButton.removeAttribute("aria-busy"); delete sendButton.dataset.state; @@ -525,12 +568,31 @@ function setChannelZapVisibility(visible) { typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; return uri.length > 0; })(); - setZapWalletPromptVisible(shouldShow && !hasWallet); - if (shouldShow) { + setZapWalletPromptVisible(shouldEnableInputs && !hasWallet); + if (shouldEnableInputs) { setupZapWalletLink(); } } +function isSessionActorWithoutLogin() { + const app = getApp(); + const isLoggedIn = + typeof app?.isUserLoggedIn === "function" + ? app.isUserLoggedIn() + : Boolean(app?.normalizeHexPubkey?.(app?.pubkey) || app?.pubkey); + if (isLoggedIn) { + return false; + } + + return getSessionActorPubkey().length > 0; +} + +function getSessionActorPubkey() { + return typeof nostrClient?.sessionActor?.pubkey === "string" + ? nostrClient.sessionActor.pubkey.trim() + : ""; +} + function getChannelShareButton() { if ( cachedChannelShareButton && @@ -804,6 +866,8 @@ function cacheZapPanelElements(panel) { cachedZapWalletLink = null; cachedZapCloseBtn = null; cachedZapSendBtn = null; + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; return; } @@ -910,7 +974,20 @@ function setupZapWalletLink() { event?.preventDefault?.(); const app = getApp(); if (typeof app?.openWalletPane === "function") { - app.openWalletPane(); + Promise.resolve() + .then(() => app.openWalletPane()) + .catch((error) => { + devLogger.warn( + "[zap] Failed to open wallet pane from channel view:", + error + ); + app?.showError?.("Wallet settings are not available right now."); + }); + } else { + devLogger.warn( + "[zap] Wallet pane requested but application did not expose openWalletPane()." + ); + app?.showError?.("Wallet settings are not available right now."); } }); link.dataset.initialized = "true"; @@ -936,24 +1013,61 @@ function isZapControlsOpen() { function openZapControls({ focus = false } = {}) { const zapButton = getChannelZapButton(); - if (!zapButton || !zapPopover) { + if (!zapButton) { + zapShouldFocusOnOpen = false; return false; } - zapShouldFocusOnOpen = focus; - const result = zapPopover.open(); - if (result && typeof result.then === "function") { - result.catch(() => {}).finally(() => { + + if (!zapPopover || zapPopoverTrigger !== zapButton) { + const initialized = setupZapButton({ force: true }); + if (!initialized || !zapPopover || zapPopoverTrigger !== zapButton) { zapShouldFocusOnOpen = false; - }); + return false; + } + } + + if (zapPopoverOpenPromise) { + if (focus) { + zapShouldFocusOnOpen = true; + } return true; } + + zapShouldFocusOnOpen = focus; + + let result; + try { + result = zapPopover.open(); + } catch (error) { + zapShouldFocusOnOpen = false; + zapPopoverOpenPromise = null; + devLogger.warn("[zap] Failed to open zap popover", error); + return false; + } + + if (result && typeof result.then === "function") { + const pending = result + .catch((error) => { + devLogger.warn("[zap] Zap popover open rejected", error); + return false; + }) + .finally(() => { + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; + }); + zapPopoverOpenPromise = pending; + return true; + } + zapShouldFocusOnOpen = false; + zapPopoverOpenPromise = null; return Boolean(result); } function closeZapControls({ focusButton = false } = {}) { const zapButton = getChannelZapButton(); zapShouldFocusOnOpen = false; + zapPopoverOpenPromise = null; if (zapPopover) { const result = zapPopover.close({ restoreFocus: focusButton }); @@ -1636,10 +1750,27 @@ function handleZapButtonClick(event) { return; } - const popoverIsOpen = - typeof zapPopover?.isOpen === "function" - ? zapPopover.isOpen() - : isZapControlsOpen(); + if (isSessionActorWithoutLogin()) { + const app = getApp(); + const message = "Log in with your Nostr profile to send zaps."; + app?.showError?.(message); + return; + } + + if (!zapPopover || zapPopoverTrigger !== zapButton) { + setupZapButton({ force: true }); + } + + if (!zapPopover) { + return; + } + + if (zapPopoverOpenPromise) { + zapShouldFocusOnOpen = true; + return; + } + + const popoverIsOpen = isZapControlsOpen(); if (!popoverIsOpen) { openZapControls({ focus: true }); @@ -1667,11 +1798,27 @@ async function handleZapSend(event) { return; } + if (!zapPopover || zapPopoverTrigger !== zapButton) { + setupZapButton({ force: true }); + } + if (!isZapControlsOpen()) { + if (zapPopoverOpenPromise) { + zapShouldFocusOnOpen = true; + return; + } openZapControls({ focus: true }); return; } + if (zapPopoverOpenPromise) { + return; + } + + if (!zapPopover) { + return; + } + if (!currentChannelLightningAddress) { const message = "This creator has not configured a Lightning address yet."; setZapStatus(message, "error"); @@ -2174,6 +2321,8 @@ export async function initChannelProfileView() { currentChannelNpub = null; currentChannelLightningAddress = ""; currentChannelProfileEvent = null; + currentChannelProfileSnapshot = null; + currentChannelProfileHasExplicitPayload = false; setCachedPlatformLightningAddress(""); resetZapRetryState(); clearZapReceipts(); @@ -2296,6 +2445,9 @@ function setupZapButton({ force = false } = {}) { zapPopover.destroy(); zapPopover = null; zapPopoverTrigger = null; + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; + delete zapButton.dataset.zapPopoverWarmBound; } const documentRef = @@ -2413,13 +2565,22 @@ function setupZapButton({ force = false } = {}) { }); if (!popover) { + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; return false; } const originalOpen = popover.open?.bind(popover); if (originalOpen) { popover.open = async (...args) => { - const result = await originalOpen(...args); + let result; + try { + result = await originalOpen(...args); + } catch (error) { + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; + throw error; + } if (result) { zapControlsOpen = true; const controls = getZapControlsContainer(); @@ -2454,6 +2615,7 @@ function setupZapButton({ force = false } = {}) { zapButton.setAttribute("aria-expanded", "false"); } zapShouldFocusOnOpen = false; + zapPopoverOpenPromise = null; return result; }; } @@ -2466,6 +2628,8 @@ function setupZapButton({ force = false } = {}) { zapPopover = null; zapPopoverTrigger = null; } + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; cacheZapPanelElements(null); }; } @@ -2473,6 +2637,49 @@ function setupZapButton({ force = false } = {}) { zapPopover = popover; zapPopoverTrigger = zapButton; + const warmZapPopover = () => { + if (!zapPopover || zapPopover !== popover) { + return; + } + if (zapButton.hasAttribute("hidden")) { + return; + } + if (typeof popover.preload !== "function") { + return; + } + try { + popover.preload(); + } catch (error) { + devLogger.warn("[zap] Failed to preload zap controls", error); + } + }; + + if ( + typeof popover.preload === "function" && + !zapButton.hasAttribute("hidden") + ) { + if ( + typeof window !== "undefined" && + typeof window.requestIdleCallback === "function" + ) { + window.requestIdleCallback(() => warmZapPopover(), { timeout: 250 }); + } else { + setTimeout(() => { + warmZapPopover(); + }, 0); + } + } + + if (zapButton.dataset.zapPopoverWarmBound !== "true") { + const handleWarm = () => { + warmZapPopover(); + }; + zapButton.addEventListener("pointerenter", handleWarm, { once: true }); + zapButton.addEventListener("pointerdown", handleWarm, { once: true }); + zapButton.addEventListener("focus", handleWarm, { once: true }); + zapButton.dataset.zapPopoverWarmBound = "true"; + } + if (zapButton.dataset.initialized !== "true") { zapButton.addEventListener("click", handleZapButtonClick); zapButton.dataset.initialized = "true"; @@ -2581,7 +2788,30 @@ function normalizeChannelProfileMetadata(raw = {}) { function rememberChannelProfile(pubkey, { profile = {}, event = null } = {}) { if (typeof pubkey !== "string" || !pubkey) { - return; + return false; + } + + const hasPayload = hasExplicitChannelProfilePayload(profile); + if (!hasPayload) { + touchChannelProfileCacheEntry(pubkey); + return false; + } + + const incomingEventTimestamp = + typeof event?.created_at === "number" ? event.created_at : 0; + const existing = channelProfileMetadataCache.get(pubkey); + const existingEventTimestamp = + typeof existing?.event?.created_at === "number" + ? existing.event.created_at + : 0; + + if ( + existingEventTimestamp && + incomingEventTimestamp && + incomingEventTimestamp < existingEventTimestamp + ) { + touchChannelProfileCacheEntry(pubkey); + return false; } channelProfileMetadataCache.set(pubkey, { @@ -2589,6 +2819,8 @@ function rememberChannelProfile(pubkey, { profile = {}, event = null } = {}) { profile: normalizeChannelProfileMetadata(profile), event: event ? { ...event } : null }); + + return true; } function getCachedChannelProfile(pubkey) { @@ -3062,6 +3294,38 @@ function applyChannelProfileMetadata({ } const normalized = normalizeChannelProfileMetadata(profile); + const hasExplicitProfilePayload = + hasExplicitChannelProfilePayload(profile); + const incomingEventTimestamp = + typeof event?.created_at === "number" ? event.created_at : 0; + const existingEventTimestamp = + typeof currentChannelProfileEvent?.created_at === "number" + ? currentChannelProfileEvent.created_at + : 0; + + if (currentChannelProfileSnapshot) { + const isOlderEvent = + incomingEventTimestamp && + existingEventTimestamp && + incomingEventTimestamp < existingEventTimestamp; + if (isOlderEvent) { + return; + } + + if (!hasExplicitProfilePayload) { + if (currentChannelProfileHasExplicitPayload) { + return; + } + + const shouldIgnoreEmptyPayload = + !incomingEventTimestamp || + incomingEventTimestamp <= existingEventTimestamp; + if (shouldIgnoreEmptyPayload) { + return; + } + } + } + const expectedLoadToken = loadToken !== null ? loadToken : currentProfileLoadToken; @@ -3152,6 +3416,9 @@ function applyChannelProfileMetadata({ currentChannelProfileEvent = null; } + currentChannelProfileSnapshot = { ...normalized }; + currentChannelProfileHasExplicitPayload = hasExplicitProfilePayload; + if (lightningAddress) { if (lightningAddress !== previousLightning) { fetchLightningMetadata(lightningAddress) @@ -3264,18 +3531,27 @@ async function loadUserProfile(pubkey) { return; } - rememberChannelProfile(pubkey, result); + const cachedUpdated = rememberChannelProfile(pubkey, result); - if (typeof app?.setProfileCacheEntry === "function") { + if (cachedUpdated && typeof app?.setProfileCacheEntry === "function") { try { - app.setProfileCacheEntry(pubkey, { - ...result.profile, - lightningAddress: + const profileForCache = + result && typeof result.profile === "object" && result.profile + ? { ...result.profile } + : {}; + + if (!profileForCache.lightningAddress) { + const lightningFallback = result.profile?.lightningAddress || result.profile?.lud16 || result.profile?.lud06 || - "" - }); + ""; + if (lightningFallback) { + profileForCache.lightningAddress = lightningFallback; + } + } + + app.setProfileCacheEntry(pubkey, profileForCache); } catch (error) { userLogger.warn( "Failed to persist channel profile metadata to cache:", @@ -3469,6 +3745,51 @@ window.addEventListener("bitvid:access-control-updated", () => { }); }); +window.addEventListener("bitvid:auth-changed", (event) => { + const hashParams = new URLSearchParams(window.location.hash.slice(1)); + if (hashParams.get("view") !== "channel-profile") { + return; + } + + if (!currentChannelHex) { + return; + } + + const activeNpub = hashParams.get("npub"); + if (currentChannelNpub && activeNpub && activeNpub !== currentChannelNpub) { + return; + } + + setChannelZapVisibility(Boolean(currentChannelLightningAddress)); + syncChannelShareButtonState(); + + const detail = event?.detail; + if (!detail || typeof detail !== "object") { + return; + } + + if (detail.status === "logout") { + zapInFlight = false; + zapPopoverOpenPromise = null; + zapShouldFocusOnOpen = false; + resetZapRetryState(); + setZapStatus("", "neutral"); + clearZapReceipts(); + const amountInput = getZapAmountInput(); + if (amountInput) { + amountInput.value = ""; + } + return; + } + + if (detail.status === "login") { + zapInFlight = false; + resetZapRetryState(); + setZapStatus("", "neutral"); + return; + } +}); + /** * Keep only the newest version of each video root. */ diff --git a/js/services/nostrService.js b/js/services/nostrService.js index ddfab67d..09a5b962 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -218,6 +218,13 @@ export class NostrService { this.emit("videos:cache", { size: map.size }); } + resetVideosCache() { + const map = new Map(); + this.videosMap = map; + setStoredVideosMap(map); + this.emit("videos:cache", { size: 0 }); + } + shouldIncludeVideo(video, { blacklistedEventIds = new Set(), isAuthorBlocked = () => false, diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 7f1264cb..c5e919f3 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -77,7 +77,10 @@ export class VideoModal { this.modalZapWalletLink = null; this.modalZapDialogOpen = false; this.modalZapPending = false; + this.modalZapRequiresLogin = false; this.modalZapPopover = null; + this.modalZapOpenPromise = null; + this.modalZapPendingToggle = null; this.modalMorePopover = null; this.modalMoreMenuPanel = null; @@ -237,6 +240,8 @@ export class VideoModal { this.modalMorePopover.destroy(); } this.modalZapPopover = null; + this.modalZapOpenPromise = null; + this.modalZapPendingToggle = null; this.modalMorePopover = null; this.modalMoreMenuPanel = null; @@ -414,11 +419,24 @@ export class VideoModal { return; } + if (this.modalZapRequiresLogin) { + this.dispatch("zap:open", { + video: this.activeVideo, + requiresLogin: true, + }); + return; + } + const popoverIsOpen = typeof this.modalZapPopover?.isOpen === "function" ? this.modalZapPopover.isOpen() : this.modalZapDialogOpen === true; + if (this.modalZapOpenPromise) { + this.modalZapPendingToggle = "close"; + return; + } + if (popoverIsOpen) { if (this.modalZapPending) { return; @@ -428,6 +446,7 @@ export class VideoModal { return; } + this.modalZapPendingToggle = null; this.openZapDialog(); this.dispatch("zap:open", { video: this.activeVideo }); }); @@ -578,6 +597,8 @@ export class VideoModal { const wasOpen = popover.isOpen?.() === true; const result = originalClose(rest); if (wasOpen && result) { + this.modalZapPendingToggle = null; + this.modalZapOpenPromise = null; this.modalZapDialog.dataset.state = "closed"; this.modalZapDialog.setAttribute("aria-hidden", "true"); this.modalZapDialog.hidden = true; @@ -966,13 +987,29 @@ export class VideoModal { this.shareBtn.classList.toggle("cursor-not-allowed", !enabled); } - setZapVisibility(visible) { - const shouldShow = !!visible; + setZapVisibility(visible, options = {}) { + let config; + if (typeof visible === "object" && visible !== null) { + config = { ...visible }; + } else { + config = { + visible, + ...(options && typeof options === "object" ? options : {}), + }; + } + + const shouldShow = !!config.visible; + const requiresLogin = shouldShow && !!config.requiresLogin; + this.modalZapRequiresLogin = requiresLogin; + if (this.modalZapBtn) { this.modalZapBtn.toggleAttribute("hidden", !shouldShow); - const disableButton = !shouldShow || this.modalZapPending; + const disableButton = + !shouldShow || (this.modalZapPending && !requiresLogin); this.modalZapBtn.disabled = disableButton; - this.modalZapBtn.setAttribute("aria-disabled", (!shouldShow).toString()); + const ariaDisabledValue = + requiresLogin || !shouldShow ? "true" : "false"; + this.modalZapBtn.setAttribute("aria-disabled", ariaDisabledValue); this.modalZapBtn.setAttribute("aria-hidden", (!shouldShow).toString()); this.modalZapBtn.setAttribute("aria-expanded", "false"); if (shouldShow) { @@ -980,16 +1017,26 @@ export class VideoModal { } else { this.modalZapBtn.setAttribute("tabindex", "-1"); } - if (this.modalZapPending) { - this.modalZapBtn.setAttribute("aria-busy", "true"); - this.modalZapBtn.classList.add("opacity-50", "pointer-events-none"); - } else { + if (requiresLogin) { + this.modalZapBtn.dataset.requiresLogin = "true"; this.modalZapBtn.removeAttribute("aria-busy"); this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); + } else { + delete this.modalZapBtn.dataset.requiresLogin; + if (this.modalZapPending) { + this.modalZapBtn.setAttribute("aria-busy", "true"); + this.modalZapBtn.classList.add("opacity-50", "pointer-events-none"); + } else { + this.modalZapBtn.removeAttribute("aria-busy"); + this.modalZapBtn.classList.remove( + "opacity-50", + "pointer-events-none", + ); + } } } - if (!shouldShow) { + if (!shouldShow || requiresLogin) { this.closeZapDialog({ silent: true, restoreFocus: false }); } } @@ -1007,8 +1054,12 @@ export class VideoModal { } async openZapDialog() { - if (this.modalZapPopover?.open) { - try { + if (this.modalZapOpenPromise) { + return this.modalZapOpenPromise; + } + + const runOpen = async () => { + if (this.modalZapPopover?.open) { const opened = await this.modalZapPopover.open(); if (opened) { this.modalZapDialogOpen = true; @@ -1019,27 +1070,43 @@ export class VideoModal { } this.focusZapAmount(); } - return; - } catch (error) { - this.log("[VideoModal] Failed to open zap popover", error); + return opened; } - } - if (!this.modalZapDialog) { - return; - } + if (!this.modalZapDialog) { + return false; + } - this.modalZapDialog.hidden = false; - this.modalZapDialog.dataset.state = "open"; - this.modalZapDialog.setAttribute("aria-hidden", "false"); - this.modalZapDialogOpen = true; - if (this.modalZapBtn) { - this.modalZapBtn.setAttribute("aria-expanded", "true"); - } - this.focusZapAmount(); + this.modalZapDialog.hidden = false; + this.modalZapDialog.dataset.state = "open"; + this.modalZapDialog.setAttribute("aria-hidden", "false"); + this.modalZapDialogOpen = true; + if (this.modalZapBtn) { + this.modalZapBtn.setAttribute("aria-expanded", "true"); + } + this.focusZapAmount(); + return true; + }; + + const promise = runOpen().catch((error) => { + this.log("[VideoModal] Failed to open zap popover", error); + return false; + }); + + this.modalZapOpenPromise = promise.finally(() => { + const shouldClose = this.modalZapPendingToggle === "close"; + this.modalZapOpenPromise = null; + this.modalZapPendingToggle = null; + if (shouldClose && !this.modalZapPending) { + this.closeZapDialog(); + } + }); + + return this.modalZapOpenPromise; } closeZapDialog({ silent = false, restoreFocus } = {}) { + this.modalZapPendingToggle = null; if (this.modalZapPopover?.close) { const options = { silent }; if (restoreFocus !== undefined) { @@ -1292,11 +1359,12 @@ export class VideoModal { } if (this.modalZapBtn) { - if (isPending) { + const buttonHidden = this.modalZapBtn.hasAttribute("hidden"); + if (isPending && !this.modalZapRequiresLogin) { this.modalZapBtn.disabled = true; this.modalZapBtn.setAttribute("aria-busy", "true"); this.modalZapBtn.classList.add("opacity-50", "pointer-events-none"); - } else if (!this.modalZapBtn.hasAttribute("hidden")) { + } else if (!buttonHidden) { this.modalZapBtn.disabled = false; this.modalZapBtn.removeAttribute("aria-busy"); this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); diff --git a/js/ui/overlay/popoverEngine.js b/js/ui/overlay/popoverEngine.js index 138b7d9c..d818c0e4 100644 --- a/js/ui/overlay/popoverEngine.js +++ b/js/ui/overlay/popoverEngine.js @@ -671,6 +671,10 @@ export function createPopover(trigger, render, options = {}) { return panel; } + function preload() { + return Boolean(ensurePanel()); + } + async function updatePosition() { if (!anchor || !panel) { return; @@ -918,6 +922,7 @@ export function createPopover(trigger, render, options = {}) { update: updatePosition, isOpen: () => isOpen, getPanel: () => panel, + preload, }; return api; diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index b5dfc0c5..fc119e35 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -4016,6 +4016,15 @@ export class ProfileModalController { return; } + const parentElement = modalRoot.parentElement; + if (parentElement) { + if (parentElement.lastElementChild !== modalRoot) { + parentElement.appendChild(modalRoot); + } + } else if (typeof document !== "undefined" && document.body) { + document.body.appendChild(modalRoot); + } + modalRoot.classList.remove("hidden"); modalRoot.setAttribute("aria-hidden", "false"); this.setGlobalModalState("profile", true); diff --git a/js/ui/zapController.js b/js/ui/zapController.js index e4135f7b..f7b9541b 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -33,6 +33,7 @@ export default class ZapController { nwcSettings, getActiveNwcSettings, isUserLoggedIn, + hasSessionActor, hasActiveWalletConnection, splitAndZap, payments = {}, @@ -58,6 +59,8 @@ export default class ZapController { } this.isUserLoggedIn = typeof isUserLoggedIn === "function" ? isUserLoggedIn : () => false; + this.hasSessionActor = + typeof hasSessionActor === "function" ? hasSessionActor : () => false; if ( this.nwcSettings && typeof this.nwcSettings.hasActiveWalletConnection === "function" @@ -89,11 +92,20 @@ export default class ZapController { setVisibility(visible) { const lightningVisible = !!visible; - const shouldShow = lightningVisible && this.isUserLoggedIn(); + const loggedIn = this.isUserLoggedIn(); + const sessionActorAvailable = this.hasSessionActor(); + const shouldShow = + lightningVisible && (loggedIn || sessionActorAvailable); + const requiresLogin = shouldShow && !loggedIn; const hasWallet = this.hasActiveWalletConnection(); if (this.videoModal) { - this.videoModal.setZapVisibility(shouldShow); - this.videoModal.setWalletPromptVisible(shouldShow && !hasWallet); + this.videoModal.setZapVisibility({ + visible: shouldShow, + requiresLogin, + }); + this.videoModal.setWalletPromptVisible( + shouldShow && !requiresLogin && !hasWallet, + ); } } @@ -117,6 +129,10 @@ export default class ZapController { open() { this.resetFeedback(); + if (!this.isUserLoggedIn()) { + this.notifyError("Log in with your Nostr profile to send zaps."); + return; + } this.applyDefaultAmount(); this.preloadLightningMetadata().catch((error) => { devLogger.warn("[zap] Preload metadata error:", error);