diff --git a/js/app.js b/js/app.js index 7c0c1466..e9e1b16c 100644 --- a/js/app.js +++ b/js/app.js @@ -57,7 +57,7 @@ import { ingestLocalViewEvent, } from "./viewCounter.js"; import { splitAndZap as splitAndZapDefault } from "./payments/zapSplit.js"; -import { LOGIN_TO_ZAP_MESSAGE } from "./payments/zapMessages.js"; +import { showLoginRequiredToZapNotification } from "./payments/zapNotifications.js"; import { formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, formatTimeAgo as formatTimeAgoUtil, @@ -209,6 +209,7 @@ class Application { this.boundVideoModalCreatorHandler = null; this.boundVideoModalZapHandler = null; this.boundVideoModalZapWalletHandler = null; + this.pendingModalZapOpen = false; this.videoListViewPlaybackHandler = null; this.videoListViewEditHandler = null; this.videoListViewRevertHandler = null; @@ -591,6 +592,8 @@ class Application { typeof nostrClient?.sessionActor?.pubkey === "string" && nostrClient.sessionActor.pubkey.trim() ), + notifyLoginRequired: () => + showLoginRequiredToZapNotification({ app: this }), splitAndZap: (...args) => this.splitAndZap(...args), payments: this.payments, callbacks: { @@ -651,9 +654,11 @@ class Application { this.boundVideoModalZapOpenHandler = (event) => { const requiresLogin = Boolean(event?.detail?.requiresLogin); if (requiresLogin) { - this.showStatus(LOGIN_TO_ZAP_MESSAGE, { autoHideMs: 5000 }); + this.pendingModalZapOpen = true; + } else { + this.pendingModalZapOpen = false; } - this.zapController?.open(); + this.zapController?.open({ requiresLogin }); }; this.boundVideoModalZapCloseHandler = () => { this.zapController?.close(); @@ -3158,6 +3163,27 @@ class Application { } } + const shouldReopenZap = this.pendingModalZapOpen; + this.pendingModalZapOpen = false; + if (shouldReopenZap) { + const hasLightning = Boolean(this.currentVideo?.lightningAddress); + if (hasLightning && this.videoModal?.openZapDialog) { + Promise.resolve() + .then(() => this.videoModal.openZapDialog()) + .then((opened) => { + if (opened) { + this.zapController?.open(); + } + }) + .catch((error) => { + devLogger.warn( + "[Application] Failed to reopen zap dialog after login:", + error, + ); + }); + } + } + this.dispatchAuthChange({ status: "login", loggedIn: true, @@ -3235,6 +3261,7 @@ class Application { async handleAuthLogout(detail = {}) { this.resetViewLoggingState(); + this.pendingModalZapOpen = false; await this.nwcSettingsService.onLogout({ pubkey: detail?.pubkey || this.pubkey, diff --git a/js/channelProfile.js b/js/channelProfile.js index 46a90202..01d86900 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -33,7 +33,7 @@ import { validateInvoiceAmount } from "./payments/zapSharedState.js"; import { splitAndZap } from "./payments/zapSplit.js"; -import { LOGIN_TO_ZAP_MESSAGE } from "./payments/zapMessages.js"; +import { showLoginRequiredToZapNotification } from "./payments/zapNotifications.js"; import { resolveLightningAddress, fetchPayServiceData, @@ -48,168 +48,10 @@ import { const getApp = () => getApplication(); -const LOGIN_NOTIFICATION_AUTO_HIDE_MS = 5000; - -function syncNotificationPortalVisibility(doc) { - if (!doc) { - return; - } - - const HTMLElementCtor = - doc.defaultView?.HTMLElement || - (typeof HTMLElement !== "undefined" ? HTMLElement : null); - - if (!HTMLElementCtor) { - return; - } - - const portal = doc.getElementById("notificationPortal"); - - if (!portal || !(portal instanceof HTMLElementCtor)) { - return; - } - - const banners = portal.querySelectorAll(".notification-banner"); - const hasVisibleBanner = Array.from(banners).some( - (banner) => - banner instanceof HTMLElementCtor && !banner.classList.contains("hidden"), - ); - - portal.classList.toggle("notification-portal--active", hasVisibleBanner); -} - -function showLoginRequiredToZapNotification() { - const app = getApp(); - if (app) { - if (typeof app.showStatus === "function") { - app.showStatus(LOGIN_TO_ZAP_MESSAGE, { - autoHideMs: LOGIN_NOTIFICATION_AUTO_HIDE_MS, - }); - return; - } - - if (typeof app.showError === "function") { - app.showError(LOGIN_TO_ZAP_MESSAGE); - return; - } - } - - const doc = typeof document !== "undefined" ? document : null; - if (!doc) { - return; - } - - const HTMLElementCtor = - doc.defaultView?.HTMLElement || - (typeof HTMLElement !== "undefined" ? HTMLElement : null); - - if (!HTMLElementCtor) { - return; - } - - const statusContainer = doc.getElementById("statusContainer"); - - if (statusContainer && statusContainer instanceof HTMLElementCtor) { - const messageTarget = - statusContainer.querySelector("[data-status-message]") || null; - - if (messageTarget && messageTarget instanceof HTMLElementCtor) { - messageTarget.textContent = LOGIN_TO_ZAP_MESSAGE; - } else { - statusContainer.textContent = LOGIN_TO_ZAP_MESSAGE; - } - - statusContainer.classList.remove("hidden"); - syncNotificationPortalVisibility(doc); - - const schedule = - doc.defaultView?.setTimeout || - (typeof setTimeout === "function" ? setTimeout : null); - - if (typeof schedule === "function") { - const initialTarget = - messageTarget && messageTarget instanceof HTMLElementCtor - ? messageTarget - : statusContainer; - const expectedText = initialTarget.textContent; - schedule(() => { - if (!doc || typeof doc.contains !== "function") { - return; - } - - if (!doc.contains(statusContainer)) { - return; - } - - const activeTarget = - statusContainer.querySelector("[data-status-message]") || null; - const resolvedTarget = - activeTarget && activeTarget instanceof HTMLElementCtor - ? activeTarget - : messageTarget && messageTarget instanceof HTMLElementCtor - ? messageTarget - : statusContainer; - - if (resolvedTarget.textContent !== expectedText) { - return; - } - - if (activeTarget && activeTarget instanceof HTMLElementCtor) { - activeTarget.textContent = ""; - } else if (messageTarget && messageTarget instanceof HTMLElementCtor) { - messageTarget.textContent = ""; - } else { - statusContainer.textContent = ""; - } - - statusContainer.classList.add("hidden"); - syncNotificationPortalVisibility(doc); - }, LOGIN_NOTIFICATION_AUTO_HIDE_MS); - } - - return; - } - - const errorContainer = doc.getElementById("errorContainer"); - - if (!errorContainer || !(errorContainer instanceof HTMLElementCtor)) { - return; - } - - errorContainer.textContent = LOGIN_TO_ZAP_MESSAGE; - errorContainer.classList.remove("hidden"); - syncNotificationPortalVisibility(doc); - - const schedule = - doc.defaultView?.setTimeout || - (typeof setTimeout === "function" ? setTimeout : null); - - if (typeof schedule !== "function") { - return; - } - - schedule(() => { - if (!doc || typeof doc.contains !== "function") { - return; - } - - if (!doc.contains(errorContainer)) { - return; - } - - if (errorContainer.textContent !== LOGIN_TO_ZAP_MESSAGE) { - return; - } - - errorContainer.textContent = ""; - errorContainer.classList.add("hidden"); - syncNotificationPortalVisibility(doc); - }, LOGIN_NOTIFICATION_AUTO_HIDE_MS); -} - let currentChannelHex = null; let currentChannelNpub = null; let currentChannelLightningAddress = ""; +let channelZapPendingOpen = false; const summarizeZapTracker = (tracker) => Array.isArray(tracker) @@ -1913,10 +1755,13 @@ function handleZapButtonClick(event) { const requiresLogin = zapButton.dataset?.requiresLogin === "true"; if (requiresLogin || isSessionActorWithoutLogin()) { + channelZapPendingOpen = true; showLoginRequiredToZapNotification(); return; } + channelZapPendingOpen = false; + if (!zapPopover || zapPopoverTrigger !== zapButton) { setupZapButton({ force: true }); } @@ -4023,6 +3868,7 @@ window.addEventListener("bitvid:auth-changed", (event) => { resetZapRetryState(); setZapStatus("", "neutral"); clearZapReceipts(); + channelZapPendingOpen = false; const amountInput = getZapAmountInput(); if (amountInput) { amountInput.value = ""; @@ -4034,6 +3880,20 @@ window.addEventListener("bitvid:auth-changed", (event) => { zapInFlight = false; resetZapRetryState(); setZapStatus("", "neutral"); + if (channelZapPendingOpen) { + channelZapPendingOpen = false; + const zapButton = getChannelZapButton(); + const requiresLogin = + zapButton?.dataset?.requiresLogin === "true" || + zapButton?.hasAttribute("hidden"); + if (zapButton && !requiresLogin) { + const opened = openZapControls({ focus: true }); + if (!opened) { + setupZapButton({ force: true }); + openZapControls({ focus: true }); + } + } + } return; } }); diff --git a/js/payments/zapNotifications.js b/js/payments/zapNotifications.js new file mode 100644 index 00000000..2875d800 --- /dev/null +++ b/js/payments/zapNotifications.js @@ -0,0 +1,189 @@ +// js/payments/zapNotifications.js + +import { getApplication } from "../applicationContext.js"; +import { LOGIN_TO_ZAP_MESSAGE } from "./zapMessages.js"; + +const LOGIN_NOTIFICATION_AUTO_HIDE_MS = 5000; + +function resolveDocument(candidate) { + if (candidate && typeof candidate === "object") { + return candidate; + } + if (typeof document !== "undefined") { + return document; + } + return null; +} + +function resolveHTMLElementCtor(doc) { + if (!doc) { + return null; + } + return ( + doc.defaultView?.HTMLElement || + (typeof HTMLElement !== "undefined" ? HTMLElement : null) + ); +} + +export function syncNotificationPortalVisibility(docCandidate) { + const doc = resolveDocument(docCandidate); + if (!doc) { + return; + } + + const HTMLElementCtor = resolveHTMLElementCtor(doc); + if (!HTMLElementCtor) { + return; + } + + const portal = doc.getElementById("notificationPortal"); + if (!portal || !(portal instanceof HTMLElementCtor)) { + return; + } + + const banners = portal.querySelectorAll(".notification-banner"); + const hasVisibleBanner = Array.from(banners).some((banner) => { + return ( + banner instanceof HTMLElementCtor && !banner.classList.contains("hidden") + ); + }); + + portal.classList.toggle("notification-portal--active", hasVisibleBanner); +} + +function scheduleAutoHide({ + container, + messageTarget, + expectedText, + doc, + autoHideMs, +}) { + if (!Number.isFinite(autoHideMs) || autoHideMs <= 0) { + return; + } + + const scheduler = + doc?.defaultView?.setTimeout || + (typeof setTimeout === "function" ? setTimeout : null); + + if (typeof scheduler !== "function") { + return; + } + + scheduler(() => { + if (!doc || typeof doc.contains !== "function") { + return; + } + + if (!doc.contains(container)) { + return; + } + + const HTMLElementCtor = resolveHTMLElementCtor(doc); + if (!HTMLElementCtor) { + return; + } + + const activeTarget = + container.querySelector("[data-status-message]") || messageTarget; + const resolvedTarget = + activeTarget && activeTarget instanceof HTMLElementCtor + ? activeTarget + : messageTarget && messageTarget instanceof HTMLElementCtor + ? messageTarget + : container; + + if (resolvedTarget.textContent !== expectedText) { + return; + } + + if (resolvedTarget !== container) { + resolvedTarget.textContent = ""; + } else { + container.textContent = ""; + } + + container.classList.add("hidden"); + syncNotificationPortalVisibility(doc); + }, autoHideMs); +} + +export function showLoginRequiredToZapNotification({ + app: appCandidate, + document: docCandidate, + autoHideMs = LOGIN_NOTIFICATION_AUTO_HIDE_MS, +} = {}) { + const app = appCandidate || getApplication(); + + if (app && typeof app.showStatus === "function") { + app.showStatus(LOGIN_TO_ZAP_MESSAGE, { autoHideMs }); + return true; + } + + if (app && typeof app.showError === "function") { + app.showError(LOGIN_TO_ZAP_MESSAGE); + return true; + } + + const doc = resolveDocument(docCandidate); + if (!doc) { + return false; + } + + const HTMLElementCtor = resolveHTMLElementCtor(doc); + if (!HTMLElementCtor) { + return false; + } + + const statusContainer = doc.getElementById("statusContainer"); + + if (statusContainer instanceof HTMLElementCtor) { + const messageTarget = + statusContainer.querySelector("[data-status-message]") || null; + + if (messageTarget && messageTarget instanceof HTMLElementCtor) { + messageTarget.textContent = LOGIN_TO_ZAP_MESSAGE; + } else { + statusContainer.textContent = LOGIN_TO_ZAP_MESSAGE; + } + + statusContainer.classList.remove("hidden"); + syncNotificationPortalVisibility(doc); + + const expectedText = + messageTarget && messageTarget instanceof HTMLElementCtor + ? messageTarget.textContent + : statusContainer.textContent; + + scheduleAutoHide({ + container: statusContainer, + messageTarget, + expectedText, + doc, + autoHideMs, + }); + + return true; + } + + const errorContainer = doc.getElementById("errorContainer"); + + if (errorContainer instanceof HTMLElementCtor) { + errorContainer.textContent = LOGIN_TO_ZAP_MESSAGE; + errorContainer.classList.remove("hidden"); + syncNotificationPortalVisibility(doc); + + scheduleAutoHide({ + container: errorContainer, + messageTarget: null, + expectedText: LOGIN_TO_ZAP_MESSAGE, + doc, + autoHideMs, + }); + + return true; + } + + return false; +} + diff --git a/js/ui/zapController.js b/js/ui/zapController.js index f7b9541b..53c99cde 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -1,4 +1,5 @@ import { isDevMode } from "../config.js"; +import { LOGIN_TO_ZAP_MESSAGE } from "../payments/zapMessages.js"; import { calculateZapShares, fetchLightningMetadata, @@ -36,6 +37,7 @@ export default class ZapController { hasSessionActor, hasActiveWalletConnection, splitAndZap, + notifyLoginRequired, payments = {}, callbacks = {}, requestWalletPane, @@ -74,6 +76,8 @@ export default class ZapController { } this.splitAndZap = typeof splitAndZap === "function" ? splitAndZap : () => Promise.resolve(); + this.notifyLoginRequired = + typeof notifyLoginRequired === "function" ? notifyLoginRequired : null; this.payments = payments || {}; this.callbacks = { onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null, @@ -127,16 +131,32 @@ export default class ZapController { } } - open() { + open(options = {}) { + const config = + options && typeof options === "object" ? options : Object.create(null); + const requiresLogin = Boolean(config.requiresLogin); this.resetFeedback(); + if (requiresLogin) { + if (this.notifyLoginRequired) { + this.notifyLoginRequired(); + } else { + this.notifyError(LOGIN_TO_ZAP_MESSAGE); + } + return false; + } if (!this.isUserLoggedIn()) { - this.notifyError("Log in with your Nostr profile to send zaps."); - return; + if (this.notifyLoginRequired) { + this.notifyLoginRequired(); + } else { + this.notifyError(LOGIN_TO_ZAP_MESSAGE); + } + return false; } this.applyDefaultAmount(); this.preloadLightningMetadata().catch((error) => { devLogger.warn("[zap] Preload metadata error:", error); }); + return true; } close() {