From 99e8bd0c7859d5987513a963ab37f1b63dcce459 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 4 Oct 2025 23:40:51 -0400 Subject: [PATCH] Fix modal zap flow tests and dependencies --- components/video-modal.html | 83 ++++ js/app.js | 761 ++++++++++++++++++++++++++++++++- js/channelProfile.js | 177 ++------ js/magnets.js | 151 +++++-- js/payments/zapSharedState.js | 166 +++++++ js/storage/r2-s3.js | 116 ++++- js/ui/components/VideoModal.js | 389 ++++++++++++++++- tests/video-modal-zap.test.mjs | 399 +++++++++++++++++ 8 files changed, 2036 insertions(+), 206 deletions(-) create mode 100644 js/payments/zapSharedState.js create mode 100644 tests/video-modal-zap.test.mjs diff --git a/components/video-modal.html b/components/video-modal.html index 5b63b7c3..39339e5f 100644 --- a/components/video-modal.html +++ b/components/video-modal.html @@ -167,6 +167,89 @@

+ + +
diff --git a/js/app.js b/js/app.js index 633b90e1..88a28b9f 100644 --- a/js/app.js +++ b/js/app.js @@ -49,6 +49,30 @@ import { clearNwcSettings, createDefaultNwcSettings, } from "./nwcSettings.js"; +import { splitAndZap as splitAndZapDefault } from "./payments/zapSplit.js"; +import { + calculateZapShares, + fetchLightningMetadata, + formatMinRequirement, + getCachedLightningEntry, + getCachedMetadataByUrl, + getCachedPlatformLightningAddress, + isMetadataEntryFresh, + normalizeLightningAddressKey, + rememberLightningMetadata, + setCachedPlatformLightningAddress, + validateInvoiceAmount, +} from "./payments/zapSharedState.js"; +import { + resolveLightningAddress, + fetchPayServiceData, + requestInvoice, +} from "./payments/lnurl.js"; +import { getPlatformLightningAddress } from "./payments/platformAddress.js"; +import { + ensureWallet as ensureWalletDefault, + sendPayment as sendPaymentDefault, +} from "./payments/nwcClient.js"; import { formatAbsoluteTimestamp as formatAbsoluteTimestampUtil, formatTimeAgo as formatTimeAgoUtil, @@ -186,6 +210,10 @@ class Application { this.nostrService = services.nostrService || nostrService; this.r2Service = services.r2Service || r2Service; this.feedEngine = services.feedEngine || createFeedEngine(); + this.payments = services.payments || null; + this.splitAndZap = + (services.payments && services.payments.splitAndZap) || + splitAndZapDefault; if ( this.feedEngine && typeof this.feedEngine.run !== "function" && @@ -421,6 +449,10 @@ class Application { this.downloaded = document.getElementById("downloaded") || null; this.cleanupPromise = null; + this.modalZapInFlight = false; + this.modalZapRetryState = null; + this.modalZapAmountValue = 0; + this.modalZapCommentValue = ""; this.videoModal = (typeof ui.videoModal === "function" @@ -462,13 +494,41 @@ class Application { "creator:navigate", this.boundVideoModalCreatorHandler ); - this.boundVideoModalZapHandler = () => { - window.alert("Zaps coming soon."); + this.boundVideoModalZapHandler = (event) => { + this.handleVideoModalZap(event); + }; + this.boundVideoModalZapOpenHandler = (event) => { + this.handleVideoModalZapOpen(event); + }; + this.boundVideoModalZapCloseHandler = (event) => { + this.handleVideoModalZapClose(event); + }; + this.boundVideoModalZapAmountHandler = (event) => { + this.handleVideoModalZapAmountChange(event); + }; + this.boundVideoModalZapCommentHandler = (event) => { + this.handleVideoModalZapCommentChange(event); }; this.videoModal.addEventListener( "video:zap", this.boundVideoModalZapHandler ); + this.videoModal.addEventListener( + "zap:open", + this.boundVideoModalZapOpenHandler + ); + this.videoModal.addEventListener( + "zap:close", + this.boundVideoModalZapCloseHandler + ); + this.videoModal.addEventListener( + "zap:amount-change", + this.boundVideoModalZapAmountHandler + ); + this.videoModal.addEventListener( + "zap:comment-change", + this.boundVideoModalZapCommentHandler + ); // Hide/Show Subscriptions Link this.subscriptionsLink = null; @@ -504,7 +564,6 @@ class Application { this.boundMoreMenuDocumentClick = null; this.boundMoreMenuDocumentKeydown = null; this.nwcSettings = new Map(); - this.payments = null; this.boundNwcSettingsToastHandler = null; Object.defineProperty(this, "savedProfiles", { configurable: false, @@ -5672,6 +5731,672 @@ class Application { } } + resetModalZapState({ preserveAmount = false } = {}) { + if (!preserveAmount) { + this.modalZapAmountValue = 0; + } + this.modalZapCommentValue = ""; + this.modalZapInFlight = false; + this.resetModalZapRetryState(); + if (this.videoModal) { + this.videoModal.resetZapForm({ + amount: preserveAmount ? this.modalZapAmountValue || "" : "", + comment: "", + }); + this.videoModal.setZapPending(false); + this.videoModal.setZapSplitSummary("Enter an amount to view the split."); + } + } + + resetModalZapFeedback() { + if (!this.videoModal) { + return; + } + this.videoModal.setZapStatus("", "neutral"); + this.videoModal.clearZapReceipts(); + this.videoModal.setZapRetryPending(false); + this.videoModal.setZapPending(false); + } + + applyModalDefaultZapAmount() { + const settings = this.getActiveNwcSettings(); + let defaultAmount = 0; + if (Number.isFinite(settings?.defaultZap) && settings.defaultZap > 0) { + defaultAmount = Math.max(0, Math.round(settings.defaultZap)); + } + this.modalZapAmountValue = defaultAmount; + if (this.videoModal) { + this.videoModal.setZapAmount(defaultAmount || ""); + } + this.updateModalZapSplitSummary(); + } + + updateModalZapSplitSummary() { + if (!this.videoModal) { + return; + } + + const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0))); + if (!amount) { + this.videoModal.setZapSplitSummary("Enter an amount to view the split."); + return; + } + + const shares = calculateZapShares(amount); + const parts = []; + const lightningAddress = this.currentVideo?.lightningAddress || ""; + const creatorEntry = getCachedLightningEntry(lightningAddress); + const creatorMin = formatMinRequirement(creatorEntry?.metadata); + let creatorText = `Creator: ${shares.creatorShare} sats`; + if (Number.isFinite(creatorMin) && creatorMin > 0) { + creatorText += ` (min ${creatorMin})`; + } + parts.push(creatorText); + + if (shares.platformShare > 0) { + const platformEntry = getCachedLightningEntry( + getCachedPlatformLightningAddress() + ); + const platformMin = formatMinRequirement(platformEntry?.metadata); + let platformText = `Platform: ${shares.platformShare} sats`; + if (Number.isFinite(platformMin) && platformMin > 0) { + platformText += ` (min ${platformMin})`; + } + parts.push(platformText); + } + + this.videoModal.setZapSplitSummary(parts.join(" • ")); + } + + async preloadModalLightningMetadata() { + const lightningAddress = this.currentVideo?.lightningAddress || ""; + if (lightningAddress) { + try { + await fetchLightningMetadata(lightningAddress); + } catch (error) { + if (isDevMode) { + console.warn("[zap] Failed to preload creator metadata:", error); + } + } + } + + const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0))); + if (!amount) { + return; + } + + const shares = calculateZapShares(amount); + if (shares.platformShare > 0) { + let platformAddress = getCachedPlatformLightningAddress(); + if (!platformAddress) { + try { + platformAddress = await getPlatformLightningAddress({ + forceRefresh: false, + }); + setCachedPlatformLightningAddress(platformAddress || ""); + } catch (error) { + if (isDevMode) { + console.warn("[zap] Failed to load platform Lightning address:", error); + } + } + } + if (platformAddress) { + try { + await fetchLightningMetadata(platformAddress); + } catch (error) { + if (isDevMode) { + console.warn("[zap] Failed to preload platform metadata:", error); + } + } + } + } + } + + handleVideoModalZapOpen() { + this.resetModalZapFeedback(); + this.applyModalDefaultZapAmount(); + this.preloadModalLightningMetadata().catch((error) => { + if (isDevMode) { + console.warn("[zap] Preload metadata error:", error); + } + }); + } + + handleVideoModalZapClose() { + this.resetModalZapFeedback(); + } + + handleVideoModalZapAmountChange(event) { + const detail = event?.detail || {}; + const rawAmount = Number(detail.amount); + const amount = Number.isFinite(rawAmount) + ? rawAmount + : this.videoModal?.getZapAmountValue?.() || 0; + this.modalZapAmountValue = Math.max(0, Math.round(amount || 0)); + this.resetModalZapFeedback(); + this.updateModalZapSplitSummary(); + } + + handleVideoModalZapCommentChange(event) { + const detail = event?.detail || {}; + if (typeof detail.comment === "string") { + this.modalZapCommentValue = detail.comment.trim(); + return; + } + const fallback = this.videoModal?.getZapCommentValue?.(); + this.modalZapCommentValue = typeof fallback === "string" ? fallback : ""; + } + + getModalWalletSettingsOrPrompt() { + const settings = this.getActiveNwcSettings(); + const normalizedUri = + typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : ""; + if (!normalizedUri) { + this.showError("Connect a Lightning wallet to send zaps."); + if (typeof this.openWalletPane === "function") { + this.openWalletPane(); + } + return null; + } + return { + ...settings, + nwcUri: normalizedUri, + }; + } + + createModalZapDependencies({ + creatorEntry, + platformEntry, + shares, + shareTracker, + }) { + const lightningAddress = this.currentVideo?.lightningAddress || ""; + const creatorKey = normalizeLightningAddressKey( + creatorEntry?.address || lightningAddress + ); + const platformKey = normalizeLightningAddressKey( + platformEntry?.address || getCachedPlatformLightningAddress() + ); + + const ensureWalletFn = + typeof this.payments?.ensureWallet === "function" + ? (options) => this.payments.ensureWallet(options) + : (options) => ensureWalletDefault(options); + const sendPaymentFn = + typeof this.payments?.sendPayment === "function" + ? (bolt11, params) => this.payments.sendPayment(bolt11, params) + : (bolt11, params) => sendPaymentDefault(bolt11, params); + + let activeShare = null; + + return { + lnurl: { + resolveLightningAddress: (value) => { + const normalized = normalizeLightningAddressKey(value); + if (normalized && normalized === creatorKey) { + activeShare = "creator"; + if (creatorEntry?.resolved) { + return { ...creatorEntry.resolved }; + } + } else if (normalized && normalized === platformKey) { + activeShare = "platform"; + if (platformEntry?.resolved) { + return { ...platformEntry.resolved }; + } + } else { + activeShare = null; + } + + const resolved = resolveLightningAddress(value); + if (normalized) { + rememberLightningMetadata({ + key: normalized, + address: resolved.address || value, + resolved, + fetchedAt: Date.now(), + }); + } + return resolved; + }, + fetchPayServiceData: async (url) => { + const cached = getCachedMetadataByUrl(url); + if (cached?.metadata && isMetadataEntryFresh(cached)) { + return cached.metadata; + } + + const metadata = await fetchPayServiceData(url); + if (cached) { + rememberLightningMetadata({ + ...cached, + metadata, + fetchedAt: Date.now(), + }); + } + return metadata; + }, + validateInvoiceAmount, + requestInvoice, + }, + wallet: { + ensureWallet: (options) => ensureWalletFn(options), + sendPayment: async (bolt11, params) => { + try { + const payment = await sendPaymentFn(bolt11, params); + if (Array.isArray(shareTracker)) { + const shareType = activeShare || "unknown"; + const amount = + shareType === "platform" + ? shares.platformShare + : shares.creatorShare; + const address = + shareType === "platform" + ? platformEntry?.address || getCachedPlatformLightningAddress() + : creatorEntry?.address || lightningAddress; + shareTracker.push({ + type: shareType, + status: "success", + amount, + address, + payment, + }); + } + return payment; + } catch (error) { + if (Array.isArray(shareTracker)) { + const shareType = activeShare || "unknown"; + const amount = + shareType === "platform" + ? shares.platformShare + : shares.creatorShare; + const address = + shareType === "platform" + ? platformEntry?.address || getCachedPlatformLightningAddress() + : creatorEntry?.address || lightningAddress; + shareTracker.push({ + type: shareType, + status: "error", + amount, + address, + error, + }); + } + throw error; + } finally { + activeShare = null; + } + }, + }, + platformAddress: { + getPlatformLightningAddress: async () => { + if (platformEntry?.address) { + return platformEntry.address; + } + const cachedAddress = getCachedPlatformLightningAddress(); + if (cachedAddress) { + return cachedAddress; + } + const fallback = await getPlatformLightningAddress({ + forceRefresh: false, + }); + setCachedPlatformLightningAddress(fallback || ""); + return fallback; + }, + }, + }; + } + + async prepareModalLightningContext({ amount, overrideFee = null }) { + const lightningAddress = this.currentVideo?.lightningAddress || ""; + if (!lightningAddress) { + throw new Error("This creator has not configured a Lightning address yet."); + } + + const shares = calculateZapShares(amount, overrideFee); + if (!shares.total) { + throw new Error("Enter a zap amount greater than zero."); + } + + const creatorEntry = await fetchLightningMetadata(lightningAddress); + if (shares.creatorShare > 0) { + try { + validateInvoiceAmount(creatorEntry.metadata, shares.creatorShare); + } catch (error) { + const detail = error?.message || "Unable to validate creator share."; + throw new Error(`Creator share error: ${detail}`); + } + } + + let platformEntry = null; + let platformAddress = ""; + if (shares.platformShare > 0) { + platformAddress = getCachedPlatformLightningAddress(); + if (!platformAddress) { + platformAddress = await getPlatformLightningAddress({ + forceRefresh: false, + }); + setCachedPlatformLightningAddress(platformAddress || ""); + } + if (!platformAddress) { + throw new Error("Platform Lightning address is unavailable."); + } + + platformEntry = await fetchLightningMetadata(platformAddress); + try { + validateInvoiceAmount(platformEntry.metadata, shares.platformShare); + } catch (error) { + const detail = error?.message || "Unable to validate platform share."; + throw new Error(`Platform share error: ${detail}`); + } + } + + this.updateModalZapSplitSummary(); + + return { + shares, + creatorEntry, + platformEntry, + platformAddress, + }; + } + + getActiveModalVideoEvent() { + const video = this.currentVideo || {}; + const lightningAddress = video.lightningAddress || ""; + return { + kind: typeof video.kind === "number" ? video.kind : 0, + id: typeof video.id === "string" ? video.id : "", + pubkey: typeof video.pubkey === "string" ? video.pubkey : "", + tags: Array.isArray(video.tags) ? [...video.tags] : [], + content: typeof video.content === "string" ? video.content : "", + created_at: Number.isFinite(video.created_at) + ? video.created_at + : Math.floor(Date.now() / 1000), + lightningAddress, + }; + } + + async runModalZapAttempt({ + amount, + overrideFee = null, + walletSettings, + comment, + }) { + const settings = walletSettings || this.getModalWalletSettingsOrPrompt(); + if (!settings) { + return null; + } + + const context = await this.prepareModalLightningContext({ + amount, + overrideFee, + }); + const shareTracker = []; + const dependencies = this.createModalZapDependencies({ + ...context, + shareTracker, + }); + const videoEvent = this.getActiveModalVideoEvent(); + + let previousOverride; + const hasGlobal = typeof globalThis !== "undefined"; + if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) { + previousOverride = globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = overrideFee; + } + + try { + const result = await this.splitAndZap( + { + videoEvent, + amountSats: context.shares.total, + comment: typeof comment === "string" ? comment : "", + walletSettings: settings, + }, + dependencies + ); + return { context, result, shareTracker }; + } catch (error) { + if (Array.isArray(shareTracker) && shareTracker.length) { + error.__zapShareTracker = shareTracker; + } + throw error; + } finally { + if (hasGlobal && typeof overrideFee === "number" && Number.isFinite(overrideFee)) { + if (typeof previousOverride === "number") { + globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__ = previousOverride; + } else if ( + globalThis && + Object.prototype.hasOwnProperty.call(globalThis, "__BITVID_PLATFORM_FEE_OVERRIDE__") + ) { + delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__; + } + } + } + } + + async executeModalZapRetry({ walletSettings, comment }) { + const retryState = this.modalZapRetryState; + const shares = Array.isArray(retryState?.shares) ? retryState.shares : []; + if (!shares.length) { + this.resetModalZapRetryState(); + return null; + } + + const summary = shares + .map((share) => { + const label = + share.type === "platform" + ? "Platform" + : share.type === "creator" + ? "Creator" + : "Lightning"; + return `${label} ${share.amount} sats`; + }) + .join(", "); + this.videoModal?.setZapStatus( + `Retrying failed share(s): ${summary}`, + "warning" + ); + this.videoModal?.clearZapReceipts(); + + const aggregatedReceipts = []; + const aggregatedTracker = []; + + for (const share of shares) { + const overrideFee = share.type === "platform" ? 100 : 0; + try { + const attempt = await this.runModalZapAttempt({ + amount: share.amount, + overrideFee, + walletSettings, + comment: retryState?.comment || comment || "", + }); + if (!attempt) { + this.videoModal?.setZapStatus("", "neutral"); + return null; + } + if (Array.isArray(attempt?.result?.receipts)) { + aggregatedReceipts.push(...attempt.result.receipts); + } else if (Array.isArray(attempt?.shareTracker)) { + aggregatedTracker.push(...attempt.shareTracker); + } + } catch (error) { + const tracker = Array.isArray(error?.__zapShareTracker) + ? error.__zapShareTracker + : []; + if (tracker.length) { + aggregatedTracker.push(...tracker); + } + throw error; + } + } + + if (aggregatedReceipts.length) { + this.videoModal?.renderZapReceipts(aggregatedReceipts, { partial: false }); + } else if (aggregatedTracker.length) { + this.videoModal?.renderZapReceipts(aggregatedTracker, { partial: true }); + } + + const total = shares.reduce((sum, share) => { + const value = Math.max(0, Math.round(Number(share.amount) || 0)); + return sum + value; + }, 0); + const successMessage = + total > 0 + ? `Retried ${total} sats successfully.` + : "Retried zap shares successfully."; + this.videoModal?.setZapStatus(successMessage, "success"); + this.showSuccess("Zap shares retried successfully!"); + this.resetModalZapRetryState(); + return true; + } + + markModalZapRetryPending(shares, comment = "") { + const validShares = Array.isArray(shares) + ? shares.filter((share) => share && share.amount > 0) + : []; + if (!validShares.length) { + this.resetModalZapRetryState(); + return; + } + + this.modalZapRetryState = { + shares: validShares, + comment: typeof comment === "string" ? comment : this.modalZapCommentValue, + createdAt: Date.now(), + }; + + const summary = validShares + .map((share) => { + const label = + share.type === "platform" + ? "Platform" + : share.type === "creator" + ? "Creator" + : "Lightning"; + return `${label} ${share.amount} sats`; + }) + .join(", "); + this.videoModal?.setZapRetryPending(true, { summary }); + } + + resetModalZapRetryState() { + this.modalZapRetryState = null; + this.videoModal?.setZapRetryPending(false); + } + + async handleVideoModalZap(event) { + if (this.modalZapInFlight) { + return; + } + + const detail = event?.detail || {}; + const eventAmount = Number(detail.amount); + let amount = Number.isFinite(eventAmount) + ? eventAmount + : this.modalZapAmountValue; + amount = Math.max(0, Math.round(amount || 0)); + this.modalZapAmountValue = amount; + + const comment = + typeof detail.comment === "string" + ? detail.comment.trim() + : this.modalZapCommentValue; + this.modalZapCommentValue = comment; + + if (!amount) { + const message = "Enter a zap amount greater than zero."; + this.videoModal?.setZapStatus(message, "error"); + this.showError(message); + return; + } + + const walletSettings = this.getModalWalletSettingsOrPrompt(); + if (!walletSettings) { + return; + } + + this.modalZapInFlight = true; + this.videoModal?.setZapPending(true); + this.videoModal?.setZapStatus(`Sending ${amount} sats…`, "warning"); + this.videoModal?.clearZapReceipts(); + + try { + if (Array.isArray(this.modalZapRetryState?.shares) && this.modalZapRetryState.shares.length) { + await this.executeModalZapRetry({ walletSettings, comment }); + this.applyModalDefaultZapAmount(); + return; + } + + const attempt = await this.runModalZapAttempt({ + amount, + walletSettings, + comment, + }); + if (!attempt) { + this.videoModal?.setZapStatus("", "neutral"); + return; + } + + const { context, result } = attempt; + const receipts = Array.isArray(result?.receipts) ? result.receipts : []; + this.videoModal?.renderZapReceipts(receipts, { partial: false }); + + const creatorShare = context.shares.creatorShare; + const platformShare = context.shares.platformShare; + const summary = platformShare + ? `Sent ${context.shares.total} sats (creator ${creatorShare}, platform ${platformShare}).` + : `Sent ${context.shares.total} sats to the creator.`; + this.videoModal?.setZapStatus(summary, "success"); + this.showSuccess("Zap sent successfully!"); + + this.resetModalZapRetryState(); + this.modalZapCommentValue = ""; + this.videoModal?.resetZapForm({ amount: "", comment: "" }); + this.applyModalDefaultZapAmount(); + } catch (error) { + const tracker = Array.isArray(error?.__zapShareTracker) + ? error.__zapShareTracker + : []; + if (tracker.length) { + this.videoModal?.renderZapReceipts(tracker, { partial: true }); + } + + const failureShares = tracker.filter( + (entry) => entry && entry.status !== "success" && entry.amount > 0 + ); + if (failureShares.length) { + this.markModalZapRetryPending(failureShares, comment); + const summary = failureShares + .map((share) => { + const label = + share.type === "platform" + ? "Platform" + : share.type === "creator" + ? "Creator" + : "Lightning"; + return `${label} ${share.amount} sats`; + }) + .join(", "); + const tone = tracker.length > failureShares.length ? "warning" : "error"; + const statusMessage = + tracker.length > failureShares.length + ? `Partial zap failure. Tap zap again to retry: ${summary}.` + : `Zap failed. Tap zap again to retry: ${summary}.`; + this.videoModal?.setZapStatus(statusMessage, tone); + this.showError(error?.message || statusMessage); + } else { + this.resetModalZapRetryState(); + const message = error?.message || "Zap failed. Please try again."; + this.videoModal?.setZapStatus(message, "error"); + this.showError(message); + } + } finally { + this.modalZapInFlight = false; + this.videoModal?.setZapPending(false); + } + } + getShareUrlBase() { try { const current = new URL(window.location.href); @@ -8081,6 +8806,7 @@ class Application { window.history.pushState({}, "", pushUrl); this.setModalZapVisibility(false); + this.resetModalZapState(); let lightningAddress = ""; let creatorProfile = { name: "Unknown", @@ -8147,6 +8873,7 @@ class Application { const sanitizedMagnet = magnetSupported ? usableMagnet : ""; this.setModalZapVisibility(false); + this.resetModalZapState(); trackVideoView({ videoId: @@ -8527,6 +9254,34 @@ class Application { ); this.boundVideoModalZapHandler = null; } + if (this.boundVideoModalZapOpenHandler) { + this.videoModal.removeEventListener( + "zap:open", + this.boundVideoModalZapOpenHandler + ); + this.boundVideoModalZapOpenHandler = null; + } + if (this.boundVideoModalZapCloseHandler) { + this.videoModal.removeEventListener( + "zap:close", + this.boundVideoModalZapCloseHandler + ); + this.boundVideoModalZapCloseHandler = null; + } + if (this.boundVideoModalZapAmountHandler) { + this.videoModal.removeEventListener( + "zap:amount-change", + this.boundVideoModalZapAmountHandler + ); + this.boundVideoModalZapAmountHandler = null; + } + if (this.boundVideoModalZapCommentHandler) { + this.videoModal.removeEventListener( + "zap:comment-change", + this.boundVideoModalZapCommentHandler + ); + this.boundVideoModalZapCommentHandler = null; + } if (typeof this.videoModal.destroy === "function") { try { this.videoModal.destroy(); diff --git a/js/channelProfile.js b/js/channelProfile.js index c73a37db..2b274926 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -10,14 +10,26 @@ import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; import { escapeHTML } from "./utils/domUtils.js"; import { getApplication } from "./applicationContext.js"; -import { PLATFORM_FEE_PERCENT } from "./config.js"; +import { + calculateZapShares, + describeShareType, + fetchLightningMetadata, + formatMinRequirement, + getCachedLightningEntry, + getCachedMetadataByUrl, + getCachedPlatformLightningAddress, + isMetadataEntryFresh, + normalizeLightningAddressKey, + rememberLightningMetadata, + setCachedPlatformLightningAddress, + validateInvoiceAmount, +} from "./payments/zapSharedState.js"; +import { splitAndZap } from "./payments/zapSplit.js"; import { resolveLightningAddress, fetchPayServiceData, - validateInvoiceAmount, requestInvoice, } from "./payments/lnurl.js"; -import { splitAndZap } from "./payments/zapSplit.js"; import { getPlatformLightningAddress } from "./payments/platformAddress.js"; import { ensureWallet, sendPayment } from "./payments/nwcClient.js"; @@ -37,13 +49,8 @@ let cachedZapSplitSummary = null; let cachedZapStatus = null; let cachedZapReceipts = null; -const METADATA_CACHE_TTL_MS = 5 * 60 * 1000; -const lightningMetadataCache = new Map(); -const lightningMetadataByUrl = new Map(); - let pendingZapRetry = null; let zapInFlight = false; -let cachedPlatformLightningAddress = ""; function getChannelZapButton() { if (cachedZapButton && !document.body.contains(cachedZapButton)) { @@ -153,137 +160,6 @@ function getZapReceiptsList() { return cachedZapReceipts; } -function normalizeLightningAddressKey(address) { - return typeof address === "string" ? address.trim().toLowerCase() : ""; -} - -function isMetadataEntryFresh(entry) { - if (!entry || typeof entry.fetchedAt !== "number") { - return false; - } - return Date.now() - entry.fetchedAt < METADATA_CACHE_TTL_MS; -} - -function rememberLightningMetadata(entry) { - if (!entry || !entry.key) { - return entry; - } - lightningMetadataCache.set(entry.key, entry); - if (entry.resolved?.url) { - lightningMetadataByUrl.set(entry.resolved.url, entry); - } - return entry; -} - -function getCachedLightningEntry(address) { - const key = normalizeLightningAddressKey(address); - if (!key) { - return null; - } - const entry = lightningMetadataCache.get(key); - if (!entry) { - return null; - } - if (entry.metadata && isMetadataEntryFresh(entry)) { - return entry; - } - if (entry.metadata && !entry.promise) { - return entry; - } - return entry.metadata ? entry : null; -} - -function getCachedMetadataByUrl(url) { - if (!url) { - return null; - } - return lightningMetadataByUrl.get(url) || null; -} - -async function fetchLightningMetadata(address) { - const key = normalizeLightningAddressKey(address); - if (!key) { - throw new Error("Lightning address is required."); - } - - const cached = lightningMetadataCache.get(key); - if (cached && cached.metadata && isMetadataEntryFresh(cached)) { - return cached; - } - - if (cached?.promise) { - return cached.promise; - } - - const fetchPromise = (async () => { - const resolved = cached?.resolved || resolveLightningAddress(address); - const metadata = await fetchPayServiceData(resolved.url); - const entry = { - key, - address: resolved.address || address, - resolved, - metadata, - fetchedAt: Date.now(), - }; - return rememberLightningMetadata(entry); - })(); - - lightningMetadataCache.set(key, { - ...(cached || {}), - key, - promise: fetchPromise, - }); - - try { - const result = await fetchPromise; - rememberLightningMetadata(result); - return result; - } catch (error) { - const current = lightningMetadataCache.get(key); - if (current?.promise === fetchPromise) { - lightningMetadataCache.delete(key); - } - throw error; - } -} - -function calculateZapShares(amount, overrideFee = null) { - const numericAmount = Math.max(0, Math.round(Number(amount) || 0)); - const percentSource = - typeof overrideFee === "number" && Number.isFinite(overrideFee) - ? overrideFee - : PLATFORM_FEE_PERCENT; - const feePercent = Math.min(100, Math.max(0, Math.round(percentSource))); - const platformShare = Math.floor((numericAmount * feePercent) / 100); - const creatorShare = numericAmount - platformShare; - return { - total: numericAmount, - creatorShare, - platformShare, - feePercent, - }; -} - -function describeShareType(type) { - if (type === "platform") { - return "Platform"; - } - if (type === "creator") { - return "Creator"; - } - return "Lightning"; -} - -function formatMinRequirement(metadata) { - if (!metadata || typeof metadata.minSendable !== "number") { - return null; - } - if (metadata.minSendable <= 0) { - return null; - } - return Math.ceil(metadata.minSendable / 1000); -} - function updateZapSplitSummary({ overrideFee = null } = {}) { const summaryEl = getZapSplitSummaryElement(); if (!summaryEl) { @@ -309,7 +185,9 @@ function updateZapSplitSummary({ overrideFee = null } = {}) { parts.push(creatorText); if (shares.platformShare > 0) { - const platformEntry = getCachedLightningEntry(cachedPlatformLightningAddress); + const platformEntry = getCachedLightningEntry( + getCachedPlatformLightningAddress() + ); const platformMin = formatMinRequirement(platformEntry?.metadata); let platformText = `Platform: ${shares.platformShare} sats`; if (Number.isFinite(platformMin) && platformMin > 0) { @@ -512,10 +390,10 @@ async function prepareLightningContext({ amount, overrideFee = null }) { let platformEntry = null; let platformAddress = ""; if (shares.platformShare > 0) { - platformAddress = cachedPlatformLightningAddress; + platformAddress = getCachedPlatformLightningAddress(); if (!platformAddress) { platformAddress = await getPlatformLightningAddress({ forceRefresh: false }); - cachedPlatformLightningAddress = platformAddress || ""; + setCachedPlatformLightningAddress(platformAddress || ""); } if (!platformAddress) { throw new Error("Platform Lightning address is unavailable."); @@ -545,7 +423,7 @@ function createZapDependencies({ creatorEntry, platformEntry, shares, shareTrack creatorEntry?.address || currentChannelLightningAddress ); const platformKey = normalizeLightningAddressKey( - platformEntry?.address || cachedPlatformLightningAddress + platformEntry?.address || getCachedPlatformLightningAddress() ); let activeShare = null; @@ -611,7 +489,7 @@ function createZapDependencies({ creatorEntry, platformEntry, shares, shareTrack : shares.creatorShare; const address = shareType === "platform" - ? platformEntry?.address || cachedPlatformLightningAddress + ? platformEntry?.address || getCachedPlatformLightningAddress() : creatorEntry?.address || currentChannelLightningAddress; shareTracker.push({ type: shareType, @@ -631,7 +509,7 @@ function createZapDependencies({ creatorEntry, platformEntry, shares, shareTrack : shares.creatorShare; const address = shareType === "platform" - ? platformEntry?.address || cachedPlatformLightningAddress + ? platformEntry?.address || getCachedPlatformLightningAddress() : creatorEntry?.address || currentChannelLightningAddress; shareTracker.push({ type: shareType, @@ -652,11 +530,12 @@ function createZapDependencies({ creatorEntry, platformEntry, shares, shareTrack if (platformEntry?.address) { return platformEntry.address; } - if (cachedPlatformLightningAddress) { - return cachedPlatformLightningAddress; + const cachedAddress = getCachedPlatformLightningAddress(); + if (cachedAddress) { + return cachedAddress; } const fallback = await getPlatformLightningAddress({ forceRefresh: false }); - cachedPlatformLightningAddress = fallback || ""; + setCachedPlatformLightningAddress(fallback || ""); return fallback; }, }, @@ -1078,7 +957,7 @@ export async function initChannelProfileView() { currentChannelNpub = null; currentChannelLightningAddress = ""; currentChannelProfileEvent = null; - cachedPlatformLightningAddress = ""; + setCachedPlatformLightningAddress(""); resetZapRetryState(); clearZapReceipts(); setZapStatus("", "neutral"); diff --git a/js/magnets.js b/js/magnets.js index 208b703c..7527750a 100644 --- a/js/magnets.js +++ b/js/magnets.js @@ -1,4 +1,54 @@ -import parseMagnet from "https://esm.sh/magnet-uri@7.0.7"; +const BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"; + +function decodeBase32ToHex(value) { + if (typeof value !== "string" || !value) { + return ""; + } + + const normalized = value.toLowerCase(); + let bits = 0; + let buffer = 0; + const bytes = []; + + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char); + if (index === -1) { + return ""; + } + buffer = (buffer << 5) | index; + bits += 5; + if (bits >= 8) { + bits -= 8; + const byte = (buffer >> bits) & 0xff; + bytes.push(byte); + } + } + + if (bytes.length === 0) { + return ""; + } + + return bytes.map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function extractInfoHashFromXt(value) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + + const lower = trimmed.toLowerCase(); + if (!lower.startsWith("urn:btih:")) { + return ""; + } + + const payload = trimmed.slice(9); + return normalizeInfoHash(payload); +} function normalizeInfoHash(candidate) { const trimmed = typeof candidate === "string" ? candidate.trim() : ""; @@ -9,13 +59,8 @@ function normalizeInfoHash(candidate) { return trimmed.toLowerCase(); } if (/^[a-z2-7]{32}$/i.test(trimmed)) { - try { - const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`); - const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : ""; - return hash ? hash.toLowerCase() : null; - } catch (err) { - console.warn("Failed to normalize base32 info hash", err); - } + const hex = decodeBase32ToHex(trimmed); + return hex ? hex.toLowerCase() : null; } return null; } @@ -28,39 +73,75 @@ export function infoHashFromMagnet(magnet) { if (direct) { return direct; } - try { - const parsed = parseMagnet(magnet); - const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : ""; - return hash ? hash.toLowerCase() : null; - } catch (err) { - console.warn("Failed to parse magnet for info hash", err); - return null; - } + const parsed = parseMagnetLite(magnet); + const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : ""; + return hash ? hash.toLowerCase() : null; } export function trackersFromMagnet(magnet) { if (typeof magnet !== "string") { return []; } - try { - const parsed = parseMagnet(magnet); - if (!parsed || !Array.isArray(parsed.announce)) { - return []; - } - const deduped = new Set(); - parsed.announce.forEach((url) => { - if (typeof url !== "string") { - return; - } - const trimmed = url.trim(); - if (!trimmed) { - return; - } - deduped.add(trimmed); - }); - return Array.from(deduped); - } catch (err) { - console.warn("Failed to parse magnet trackers", err); + const parsed = parseMagnetLite(magnet); + if (!parsed || !Array.isArray(parsed.announce)) { return []; } + const deduped = new Set(); + parsed.announce.forEach((url) => { + if (typeof url !== "string") { + return; + } + const trimmed = url.trim(); + if (!trimmed) { + return; + } + deduped.add(trimmed); + }); + return Array.from(deduped); +} + +function parseMagnetLite(magnet) { + if (typeof magnet !== "string") { + return { infoHash: "", announce: [] }; + } + + const trimmed = magnet.trim(); + if (!trimmed) { + return { infoHash: "", announce: [] }; + } + + const lower = trimmed.toLowerCase(); + const queryIndex = lower.indexOf("?"); + const paramsPart = queryIndex >= 0 ? trimmed.slice(queryIndex + 1) : ""; + const params = paramsPart.split("&"); + const announce = []; + let infoHash = ""; + + for (const param of params) { + if (!param) { + continue; + } + + const [rawKey, ...rawValueParts] = param.split("="); + const rawValue = rawValueParts.join("="); + const key = decodeURIComponent((rawKey || "").replace(/\+/g, " ")).toLowerCase(); + const value = decodeURIComponent((rawValue || "").replace(/\+/g, " ")); + + if (!key) { + continue; + } + + if (key === "xt") { + const derived = extractInfoHashFromXt(value); + if (derived && !infoHash) { + infoHash = derived; + } + } else if (key === "tr") { + if (value) { + announce.push(value); + } + } + } + + return { infoHash, announce }; } diff --git a/js/payments/zapSharedState.js b/js/payments/zapSharedState.js new file mode 100644 index 00000000..5aaccc04 --- /dev/null +++ b/js/payments/zapSharedState.js @@ -0,0 +1,166 @@ +import { PLATFORM_FEE_PERCENT } from "../config.js"; +import { + resolveLightningAddress as defaultResolveLightningAddress, + fetchPayServiceData as defaultFetchPayServiceData, + validateInvoiceAmount, +} from "./lnurl.js"; + +const METADATA_CACHE_TTL_MS = 5 * 60 * 1000; + +const lightningMetadataCache = new Map(); +const lightningMetadataByUrl = new Map(); +let cachedPlatformLightningAddress = ""; + +export function normalizeLightningAddressKey(address) { + return typeof address === "string" ? address.trim().toLowerCase() : ""; +} + +export function isMetadataEntryFresh(entry) { + if (!entry || typeof entry.fetchedAt !== "number") { + return false; + } + return Date.now() - entry.fetchedAt < METADATA_CACHE_TTL_MS; +} + +export function rememberLightningMetadata(entry) { + if (!entry || !entry.key) { + return entry; + } + lightningMetadataCache.set(entry.key, entry); + if (entry.resolved?.url) { + lightningMetadataByUrl.set(entry.resolved.url, entry); + } + return entry; +} + +export function getCachedLightningEntry(address) { + const key = normalizeLightningAddressKey(address); + if (!key) { + return null; + } + const entry = lightningMetadataCache.get(key); + if (!entry) { + return null; + } + if (entry.metadata && isMetadataEntryFresh(entry)) { + return entry; + } + if (entry.metadata && !entry.promise) { + return entry; + } + return entry.metadata ? entry : null; +} + +export function getCachedMetadataByUrl(url) { + if (!url) { + return null; + } + return lightningMetadataByUrl.get(url) || null; +} + +export async function fetchLightningMetadata( + address, + { + resolveLightningAddress = defaultResolveLightningAddress, + fetchPayServiceData = defaultFetchPayServiceData, + } = {} +) { + const key = normalizeLightningAddressKey(address); + if (!key) { + throw new Error("Lightning address is required."); + } + + const cached = lightningMetadataCache.get(key); + if (cached && cached.metadata && isMetadataEntryFresh(cached)) { + return cached; + } + + if (cached?.promise) { + return cached.promise; + } + + const fetchPromise = (async () => { + const resolved = cached?.resolved || (await resolveLightningAddress(address)); + const metadata = await fetchPayServiceData(resolved.url); + const entry = { + key, + address: resolved.address || address, + resolved, + metadata, + fetchedAt: Date.now(), + }; + rememberLightningMetadata(entry); + return entry; + })(); + + lightningMetadataCache.set(key, { + ...(cached || {}), + key, + promise: fetchPromise, + }); + + try { + return await fetchPromise; + } catch (error) { + const current = lightningMetadataCache.get(key); + if (current?.promise === fetchPromise) { + lightningMetadataCache.delete(key); + } + throw error; + } +} + +export function calculateZapShares(amount, overrideFee = null) { + const numericAmount = Math.max(0, Math.round(Number(amount) || 0)); + const percentSource = + typeof overrideFee === "number" && Number.isFinite(overrideFee) + ? overrideFee + : PLATFORM_FEE_PERCENT; + const feePercent = Math.min(100, Math.max(0, Math.round(percentSource))); + const platformShare = Math.floor((numericAmount * feePercent) / 100); + const creatorShare = numericAmount - platformShare; + return { + total: numericAmount, + creatorShare, + platformShare, + feePercent, + }; +} + +export function describeShareType(type) { + if (type === "platform") { + return "Platform"; + } + if (type === "creator") { + return "Creator"; + } + return "Lightning"; +} + +export function formatMinRequirement(metadata) { + if (!metadata || typeof metadata.minSendable !== "number") { + return null; + } + if (metadata.minSendable <= 0) { + return null; + } + return Math.ceil(metadata.minSendable / 1000); +} + +export function setCachedPlatformLightningAddress(address) { + cachedPlatformLightningAddress = + typeof address === "string" ? address.trim() : ""; + return cachedPlatformLightningAddress; +} + +export function getCachedPlatformLightningAddress() { + return cachedPlatformLightningAddress; +} + +export function clearZapCaches() { + lightningMetadataCache.clear(); + lightningMetadataByUrl.clear(); + cachedPlatformLightningAddress = ""; +} + +export { validateInvoiceAmount }; diff --git a/js/storage/r2-s3.js b/js/storage/r2-s3.js index bfe65b36..a38581cd 100644 --- a/js/storage/r2-s3.js +++ b/js/storage/r2-s3.js @@ -1,14 +1,97 @@ // js/storage/r2-s3.js -import { - S3Client, - CreateBucketCommand, - CreateMultipartUploadCommand, - UploadPartCommand, - CompleteMultipartUploadCommand, - AbortMultipartUploadCommand, - HeadBucketCommand, - PutBucketCorsCommand, -} from "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle"; + +const AWS_SDK_SPECIFIER = + "https://esm.sh/@aws-sdk/client-s3@3.637.0?target=es2022&bundle"; + +const disableNetworkImports = Boolean( + globalThis?.__BITVID_DISABLE_NETWORK_IMPORTS__ +); + +let awsSdk = null; +let awsSdkLoadError = null; +let awsSdkLoadPromise = null; + +function assignAwsSdk(module) { + if (!module) { + return; + } + + awsSdk = { + S3Client: module.S3Client, + CreateBucketCommand: module.CreateBucketCommand, + CreateMultipartUploadCommand: module.CreateMultipartUploadCommand, + UploadPartCommand: module.UploadPartCommand, + CompleteMultipartUploadCommand: module.CompleteMultipartUploadCommand, + AbortMultipartUploadCommand: module.AbortMultipartUploadCommand, + HeadBucketCommand: module.HeadBucketCommand, + PutBucketCorsCommand: module.PutBucketCorsCommand, + }; +} + +async function loadAwsSdkModule() { + if (awsSdk || awsSdkLoadError) { + return awsSdk; + } + + if (!awsSdkLoadPromise) { + if (disableNetworkImports) { + awsSdkLoadError = new Error( + "Cloudflare R2 SDK network import has been disabled." + ); + return null; + } + + awsSdkLoadPromise = import(AWS_SDK_SPECIFIER) + .then((module) => { + assignAwsSdk(module); + return awsSdk; + }) + .catch((error) => { + awsSdkLoadError = error || new Error("Failed to load R2 SDK module"); + throw awsSdkLoadError; + }); + } + + try { + return await awsSdkLoadPromise; + } catch (error) { + awsSdkLoadError = error; + throw error; + } +} + +function requireAwsSdk() { + if (awsSdk) { + return awsSdk; + } + + if (awsSdkLoadError) { + throw awsSdkLoadError; + } + + throw new Error( + "Cloudflare R2 SDK has not finished loading. Call ensureR2SdkLoaded() first." + ); +} + +export function ensureR2SdkLoaded() { + return loadAwsSdkModule(); +} + +export function getR2SdkLoadError() { + return awsSdkLoadError; +} + +export function isR2SdkAvailable() { + return Boolean(awsSdk); +} + +if (!disableNetworkImports) { + // Begin loading immediately in environments that support network imports. + loadAwsSdkModule().catch(() => { + // Errors are captured for later inspection by getR2SdkLoadError(). + }); +} function computeCacheControl(key) { if (!key) { @@ -36,6 +119,8 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) { throw new Error("Missing Cloudflare R2 credentials"); } + const { S3Client } = requireAwsSdk(); + return new S3Client({ region: "auto", endpoint: `https://${accountId}.r2.cloudflarestorage.com`, @@ -45,6 +130,8 @@ export function makeR2Client({ accountId, accessKeyId, secretAccessKey }) { } async function ensureBucketExists({ s3, bucket }) { + const { CreateBucketCommand, HeadBucketCommand } = requireAwsSdk(); + try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); return; @@ -80,6 +167,8 @@ export async function ensureBucketCors({ s3, bucket, origins }) { throw new Error("Bucket name is required to configure CORS"); } + const { PutBucketCorsCommand } = requireAwsSdk(); + const allowedOrigins = (origins || []).filter(Boolean); if (allowedOrigins.length === 0) { return; @@ -131,6 +220,13 @@ export async function multipartUpload({ throw new Error("File is required"); } + const { + CreateMultipartUploadCommand, + UploadPartCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, + } = requireAwsSdk(); + const createCommand = new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js index 232534eb..cb05fd1b 100644 --- a/js/ui/components/VideoModal.js +++ b/js/ui/components/VideoModal.js @@ -45,6 +45,18 @@ export class VideoModal { this.modalMoreBtn = null; this.modalMoreMenu = null; + this.modalZapDialog = null; + this.modalZapForm = null; + this.modalZapAmountInput = null; + this.modalZapCommentInput = null; + this.modalZapSplitSummary = null; + this.modalZapStatusEl = null; + this.modalZapReceipts = null; + this.modalZapSendBtn = null; + this.modalZapCloseBtn = null; + this.modalZapDialogOpen = false; + this.modalZapPending = false; + this.modalPosterCleanup = null; this.videoEventCleanup = null; @@ -179,6 +191,24 @@ export class VideoModal { this.modalMoreMenu = playerModal.querySelector("#moreDropdown-modal") || null; + this.modalZapDialog = + playerModal.querySelector("#modalZapDialog") || null; + this.modalZapForm = playerModal.querySelector("#modalZapForm") || null; + this.modalZapAmountInput = + playerModal.querySelector("#modalZapAmountInput") || null; + this.modalZapCommentInput = + playerModal.querySelector("#modalZapCommentInput") || null; + this.modalZapSplitSummary = + playerModal.querySelector("#modalZapSplitSummary") || null; + this.modalZapStatusEl = + playerModal.querySelector("#modalZapStatus") || null; + this.modalZapReceipts = + playerModal.querySelector("#modalZapReceipts") || null; + this.modalZapSendBtn = + playerModal.querySelector("#modalZapSendBtn") || null; + this.modalZapCloseBtn = + playerModal.querySelector("#modalZapCloseBtn") || null; + const closeButton = playerModal.querySelector("#closeModal"); if (closeButton) { closeButton.addEventListener("click", () => { @@ -262,10 +292,56 @@ export class VideoModal { if (this.modalZapBtn) { this.modalZapBtn.addEventListener("click", (event) => { event?.preventDefault?.(); - this.dispatch("video:zap", { video: this.activeVideo }); + if (this.modalZapBtn?.disabled) { + return; + } + this.openZapDialog(); + this.dispatch("zap:open", { video: this.activeVideo }); }); } + if (this.modalZapCloseBtn) { + this.modalZapCloseBtn.addEventListener("click", (event) => { + event?.preventDefault?.(); + this.closeZapDialog(); + }); + } + + if (this.modalZapForm) { + this.modalZapForm.addEventListener("submit", (event) => { + event?.preventDefault?.(); + if (this.modalZapSendBtn?.disabled) { + return; + } + this.dispatch("video:zap", { + video: this.activeVideo, + amount: this.getZapAmountValue(), + comment: this.getZapCommentValue(), + }); + }); + } + + if (this.modalZapAmountInput) { + const amountHandler = () => { + this.dispatch("zap:amount-change", { + video: this.activeVideo, + amount: this.getZapAmountValue(), + }); + }; + this.modalZapAmountInput.addEventListener("input", amountHandler); + this.modalZapAmountInput.addEventListener("change", amountHandler); + } + + if (this.modalZapCommentInput) { + const commentHandler = () => { + this.dispatch("zap:comment-change", { + video: this.activeVideo, + comment: this.getZapCommentValue(), + }); + }; + this.modalZapCommentInput.addEventListener("input", commentHandler); + } + if (this.creatorAvatar) { this.creatorAvatar.style.cursor = "pointer"; this.creatorAvatar.addEventListener("click", this.handleCreatorNavigation); @@ -456,17 +532,312 @@ export class VideoModal { } setZapVisibility(visible) { - if (!this.modalZapBtn) { + const shouldShow = !!visible; + if (this.modalZapBtn) { + this.modalZapBtn.classList.toggle("hidden", !shouldShow); + const disableButton = !shouldShow || this.modalZapPending; + this.modalZapBtn.disabled = disableButton; + this.modalZapBtn.setAttribute("aria-disabled", (!shouldShow).toString()); + this.modalZapBtn.setAttribute("aria-hidden", (!shouldShow).toString()); + if (shouldShow) { + this.modalZapBtn.removeAttribute("tabindex"); + } 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 { + this.modalZapBtn.removeAttribute("aria-busy"); + this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); + } + } + + if (!shouldShow) { + this.closeZapDialog({ silent: true }); + } + } + + openZapDialog() { + if (!this.modalZapDialog) { return; } - this.modalZapBtn.classList.toggle("hidden", !visible); - this.modalZapBtn.disabled = !visible; - this.modalZapBtn.setAttribute("aria-disabled", (!visible).toString()); - this.modalZapBtn.setAttribute("aria-hidden", (!visible).toString()); - if (visible) { - this.modalZapBtn.removeAttribute("tabindex"); + this.modalZapDialog.classList.remove("hidden"); + this.modalZapDialogOpen = true; + this.modalZapDialog.setAttribute("aria-hidden", "false"); + this.focusZapAmount(); + } + + closeZapDialog({ silent = false } = {}) { + if (!this.modalZapDialog) { + return; + } + if (this.modalZapDialogOpen) { + this.modalZapDialog.classList.add("hidden"); + this.modalZapDialog.setAttribute("aria-hidden", "true"); + this.modalZapDialogOpen = false; + if (!silent) { + this.dispatch("zap:close", { video: this.activeVideo }); + } + } + } + + isZapDialogOpen() { + return !!this.modalZapDialogOpen; + } + + focusZapAmount() { + if ( + this.modalZapAmountInput && + typeof this.modalZapAmountInput.focus === "function" + ) { + this.modalZapAmountInput.focus(); + } + } + + getZapAmountValue() { + if (!this.modalZapAmountInput) { + return 0; + } + const numeric = Number(this.modalZapAmountInput.value); + if (!Number.isFinite(numeric)) { + return 0; + } + return Math.max(0, Math.round(numeric)); + } + + setZapAmount(value) { + if (!this.modalZapAmountInput) { + return; + } + if (value === null || value === undefined || value === "") { + this.modalZapAmountInput.value = ""; + return; + } + const numeric = Number(value); + if (Number.isFinite(numeric)) { + this.modalZapAmountInput.value = Math.max(0, Math.round(numeric)); + return; + } + this.modalZapAmountInput.value = value; + } + + getZapCommentValue() { + if (!this.modalZapCommentInput) { + return ""; + } + return (this.modalZapCommentInput.value || "").trim(); + } + + setZapComment(value) { + if (!this.modalZapCommentInput) { + return; + } + this.modalZapCommentInput.value = typeof value === "string" ? value : ""; + } + + resetZapForm({ amount = "", comment = "" } = {}) { + this.setZapAmount(amount); + this.setZapComment(comment); + this.setZapStatus("", "neutral"); + this.clearZapReceipts(); + this.setZapRetryPending(false); + } + + setZapSplitSummary(text) { + if (!this.modalZapSplitSummary) { + return; + } + const message = typeof text === "string" ? text : ""; + this.modalZapSplitSummary.textContent = message || "Enter an amount to view the split."; + } + + setZapStatus(message, tone = "neutral") { + if (!this.modalZapStatusEl) { + return; + } + + const normalizedTone = typeof tone === "string" ? tone : "neutral"; + const text = typeof message === "string" ? message : ""; + this.modalZapStatusEl.textContent = text; + this.modalZapStatusEl.classList.remove( + "text-gray-300", + "text-gray-400", + "text-green-300", + "text-red-300", + "text-yellow-300" + ); + + if (!text) { + this.modalZapStatusEl.classList.add("text-gray-400"); + return; + } + + if (normalizedTone === "success") { + this.modalZapStatusEl.classList.add("text-green-300"); + } else if (normalizedTone === "error") { + this.modalZapStatusEl.classList.add("text-red-300"); + } else if (normalizedTone === "warning") { + this.modalZapStatusEl.classList.add("text-yellow-300"); } else { - this.modalZapBtn.setAttribute("tabindex", "-1"); + this.modalZapStatusEl.classList.add("text-gray-300"); + } + } + + clearZapReceipts() { + if (!this.modalZapReceipts) { + return; + } + while (this.modalZapReceipts.firstChild) { + this.modalZapReceipts.removeChild(this.modalZapReceipts.firstChild); + } + } + + renderZapReceipts(receipts = [], { partial = false } = {}) { + if (!this.modalZapReceipts || !this.document) { + return; + } + + this.clearZapReceipts(); + + if (!Array.isArray(receipts) || receipts.length === 0) { + if (partial) { + const empty = this.document.createElement("li"); + empty.className = "text-sm text-gray-300"; + empty.textContent = "No zap receipts available."; + this.modalZapReceipts.appendChild(empty); + } + return; + } + + receipts.forEach((receipt) => { + const li = this.document.createElement("li"); + li.className = "rounded border border-gray-700 p-3 bg-gray-800/70"; + + const header = this.document.createElement("div"); + header.className = "flex items-center justify-between gap-2 text-xs text-gray-300"; + + const shareType = receipt.recipientType || receipt.type || "creator"; + const shareLabel = this.document.createElement("span"); + const label = + shareType === "platform" + ? "Platform" + : shareType === "creator" + ? "Creator" + : "Lightning"; + shareLabel.textContent = `${label} • ${Math.max( + 0, + Math.round(Number(receipt.amount || 0)) + )} sats`; + + const status = this.document.createElement("span"); + const isSuccess = receipt.status + ? receipt.status === "success" + : !receipt.error; + status.textContent = isSuccess ? "Success" : "Failed"; + status.className = isSuccess ? "text-green-300" : "text-red-300"; + + header.appendChild(shareLabel); + header.appendChild(status); + li.appendChild(header); + + const address = this.document.createElement("p"); + address.className = "mt-1 text-xs text-gray-300 break-all"; + if (receipt.address) { + address.textContent = receipt.address; + li.appendChild(address); + } + + const detail = this.document.createElement("p"); + detail.className = "mt-2 text-xs text-gray-400"; + if (isSuccess) { + let detailMessage = "Invoice settled."; + const preimage = receipt.payment?.result?.preimage; + if (typeof preimage === "string" && preimage) { + detailMessage = `Preimage: ${preimage.slice(0, 18)}${ + preimage.length > 18 ? "…" : "" + }`; + } + detail.textContent = detailMessage; + } else { + const errorMessage = + (receipt.error && receipt.error.message) || + (typeof receipt.error === "string" + ? receipt.error + : "Payment failed."); + detail.textContent = errorMessage; + } + li.appendChild(detail); + + this.modalZapReceipts.appendChild(li); + }); + } + + setZapPending(pending) { + const isPending = !!pending; + this.modalZapPending = isPending; + + if (this.modalZapSendBtn) { + this.modalZapSendBtn.disabled = isPending; + this.modalZapSendBtn.setAttribute( + "aria-busy", + isPending ? "true" : "false" + ); + this.modalZapSendBtn.classList.toggle("opacity-50", isPending); + this.modalZapSendBtn.classList.toggle("pointer-events-none", isPending); + } + + if (this.modalZapAmountInput) { + this.modalZapAmountInput.disabled = isPending; + } + + if (this.modalZapCommentInput) { + this.modalZapCommentInput.disabled = isPending; + } + + if (this.modalZapCloseBtn) { + this.modalZapCloseBtn.disabled = isPending; + this.modalZapCloseBtn.classList.toggle("opacity-50", isPending); + this.modalZapCloseBtn.classList.toggle("pointer-events-none", isPending); + } + + if (this.modalZapBtn) { + if (isPending) { + this.modalZapBtn.disabled = true; + this.modalZapBtn.setAttribute("aria-busy", "true"); + this.modalZapBtn.classList.add("opacity-50", "pointer-events-none"); + } else if (!this.modalZapBtn.classList.contains("hidden")) { + this.modalZapBtn.disabled = false; + this.modalZapBtn.removeAttribute("aria-busy"); + this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); + } else { + this.modalZapBtn.removeAttribute("aria-busy"); + this.modalZapBtn.classList.remove("opacity-50", "pointer-events-none"); + this.modalZapBtn.disabled = true; + } + } + } + + setZapRetryPending(pending, { summary = "" } = {}) { + if (!this.modalZapSendBtn) { + return; + } + + if (pending) { + this.modalZapSendBtn.dataset.retryPending = "true"; + this.modalZapSendBtn.textContent = "Retry zap"; + this.modalZapSendBtn.setAttribute( + "aria-label", + "Retry failed zap shares" + ); + if (summary) { + this.modalZapSendBtn.title = summary; + } + } else { + delete this.modalZapSendBtn.dataset.retryPending; + this.modalZapSendBtn.textContent = "Send zap"; + this.modalZapSendBtn.setAttribute("aria-label", "Send a zap"); + this.modalZapSendBtn.removeAttribute("title"); } } diff --git a/tests/video-modal-zap.test.mjs b/tests/video-modal-zap.test.mjs new file mode 100644 index 00000000..f71f90ea --- /dev/null +++ b/tests/video-modal-zap.test.mjs @@ -0,0 +1,399 @@ +import "./test-helpers/setup-localstorage.mjs"; +import assert from "node:assert/strict"; + +const noop = () => {}; + +class DummyElement { + constructor() { + this.classList = { + add: noop, + remove: noop, + toggle: noop, + contains: () => false, + }; + this.style = {}; + this.dataset = {}; + this.children = []; + } + appendChild(child) { + this.children.push(child); + return child; + } + removeChild(child) { + const index = this.children.indexOf(child); + if (index >= 0) { + this.children.splice(index, 1); + } + return child; + } + querySelector() { + return null; + } + addEventListener() {} + removeEventListener() {} + setAttribute() {} + removeAttribute() {} + focus() {} + contains() { + return false; + } +} + +function createDocumentStub() { + const doc = { + body: new DummyElement(), + documentElement: new DummyElement(), + head: new DummyElement(), + getElementById: () => null, + querySelector: () => null, + createElement: () => new DummyElement(), + addEventListener: noop, + removeEventListener: noop, + }; + doc.body.contains = () => false; + doc.defaultView = null; + return doc; +} + +function createWindowStub(documentStub) { + const nav = { clipboard: { writeText: async () => {} } }; + const win = { + document: documentStub, + location: { + href: "https://example.com/", + origin: "https://example.com", + pathname: "/", + }, + history: { + pushState: noop, + replaceState: noop, + }, + navigator: nav, + addEventListener: noop, + removeEventListener: noop, + requestAnimationFrame: (fn) => { + if (typeof fn === "function") { + fn(); + } + }, + setTimeout, + clearTimeout, + NostrTools: { + nip19: { + neventEncode: () => "nevent1example", + npubEncode: () => "npub1example", + decode: (value) => { + if (typeof value !== "string") { + throw new Error("Invalid nip19 input"); + } + if (value.startsWith("npub")) { + return { type: "npub", data: "f".repeat(64) }; + } + if (value.startsWith("nevent")) { + return { type: "nevent", data: { id: "f".repeat(64) } }; + } + return null; + }, + }, + }, + }; + win.HTMLElement = DummyElement; + win.HTMLImageElement = DummyElement; + win.setInterval = setInterval; + win.clearInterval = clearInterval; + win.console = console; + return win; +} + +const documentStub = createDocumentStub(); +const windowStub = createWindowStub(documentStub); +windowStub.document = documentStub; +documentStub.defaultView = windowStub; +windowStub.document.defaultView = windowStub; + +globalThis.__BITVID_DISABLE_NETWORK_IMPORTS__ = true; + +if (typeof globalThis.window === "undefined") { + globalThis.window = windowStub; +} else { + Object.assign(globalThis.window, windowStub); +} + +globalThis.HTMLElement = DummyElement; +globalThis.HTMLImageElement = DummyElement; + +if (typeof globalThis.self === "undefined") { + globalThis.self = globalThis.window; +} else if (globalThis.self !== globalThis.window) { + globalThis.self = globalThis.window; +} + +if (typeof globalThis.WebSocket === "undefined") { + globalThis.WebSocket = class { + constructor() { + throw new Error("WebSocket is not available in tests."); + } + close() {} + }; +} + +globalThis.document = documentStub; +globalThis.navigator = windowStub.navigator; +globalThis.location = windowStub.location; + +if (typeof globalThis.fetch === "undefined") { + globalThis.fetch = async () => { + throw new Error("Unexpected fetch call in tests."); + }; +} + +const { + rememberLightningMetadata, + normalizeLightningAddressKey, + setCachedPlatformLightningAddress, + clearZapCaches, +} = await import("../js/payments/zapSharedState.js"); + +function primeLightningMetadata(address, overrides = {}) { + const key = normalizeLightningAddressKey(address); + rememberLightningMetadata({ + key, + address, + resolved: { address, url: `https://lnurl.test/${address}` }, + metadata: { + callback: `https://lnurl.test/${address}/callback`, + minSendable: 1_000, + maxSendable: 10_000_000, + commentAllowed: 280, + allowsNostr: true, + ...overrides, + }, + fetchedAt: Date.now(), + }); +} + +function createVideoModalStub() { + const listeners = new Map(); + return { + setCopyEnabled: noop, + setShareEnabled: noop, + setZapVisibility: noop, + resetStats: noop, + updateViewCountLabel: noop, + setViewCountPointer: noop, + addEventListener(type, handler) { + listeners.set(type, handler); + }, + removeEventListener(type) { + listeners.delete(type); + }, + setZapPendingCalls: [], + statusMessages: [], + receipts: [], + summaries: [], + resetForms: [], + currentAmount: "", + currentComment: "", + setZapPending(value) { + this.setZapPendingCalls.push(Boolean(value)); + }, + setZapStatus(message, tone) { + this.statusMessages.push({ message, tone }); + }, + clearZapReceipts() { + this.receipts.push({ cleared: true }); + }, + renderZapReceipts(list, options) { + this.receipts.push({ list, options }); + }, + setZapSplitSummary(text) { + this.summaries.push(text); + }, + setZapRetryPending(pending, { summary } = {}) { + this.retryState = { pending, summary }; + }, + resetZapForm({ amount, comment }) { + this.resetForms.push({ amount, comment }); + this.currentAmount = amount; + this.currentComment = comment; + }, + setZapAmount(value) { + this.currentAmount = value; + }, + setZapComment(value) { + this.currentComment = value; + }, + getZapCommentValue() { + return this.currentComment || ""; + }, + }; +} + +function createServices({ splitAndZap }) { + const nostrService = { + getVideoSubscription: () => null, + getVideosMap: () => new Map(), + on: () => () => {}, + }; + const feedEngine = { + run: noop, + register: noop, + }; + const playbackService = { + createSession: () => ({ + getPlaybackConfig: () => ({}), + getMagnetForPlayback: () => "", + }), + }; + const authService = { + on: () => () => {}, + }; + return { + nostrService, + feedEngine, + playbackService, + authService, + payments: { + splitAndZap, + ensureWallet: async ({ settings }) => settings, + sendPayment: async () => ({}), + }, + }; +} + +function createHelpers() { + return { + mediaLoaderFactory: () => ({ observe: noop, unobserve: noop }), + }; +} + +async function createApp({ splitAndZap }) { + clearZapCaches(); + const modalStub = createVideoModalStub(); + const { Application } = await import("../js/app.js"); + const app = new Application({ + services: createServices({ splitAndZap }), + helpers: createHelpers(), + ui: { + videoModal: () => modalStub, + }, + }); + + const creatorAddress = "creator@example.com"; + const platformAddress = "platform@example.com"; + setCachedPlatformLightningAddress(platformAddress); + primeLightningMetadata(creatorAddress); + primeLightningMetadata(platformAddress); + + app.showError = noop; + app.showSuccess = noop; + app.showStatus = noop; + + return { app, modalStub, creatorAddress, platformAddress }; +} + +await (async () => { + // Test: wallet required before zapping + const splitCalls = []; + const { app, modalStub, creatorAddress } = await createApp({ + splitAndZap: async (...args) => { + splitCalls.push(args); + return { receipts: [] }; + }, + }); + + let walletPaneCalls = 0; + app.openWalletPane = () => { + walletPaneCalls += 1; + }; + + app.currentVideo = { + id: "event123", + pubkey: "a".repeat(64), + tags: [], + content: "", + created_at: 1_700_000_000, + lightningAddress: creatorAddress, + }; + + await app.handleVideoModalZap({ + detail: { amount: 500, comment: "Hello" }, + }); + + assert.equal(walletPaneCalls, 1, "should prompt to open wallet pane"); + assert.equal(splitCalls.length, 0, "splitAndZap should not be invoked"); + assert.deepEqual(modalStub.setZapPendingCalls, [], "zap controls should remain idle"); + + app.destroy(); +})(); + +await (async () => { + // Test: splitAndZap invoked with video metadata when wallet is present + const splitCalls = []; + const splitAndZap = async (payload, deps) => { + splitCalls.push({ payload, deps }); + return { + receipts: [ + { + recipientType: "creator", + amount: payload.amountSats, + address: payload.videoEvent.lightningAddress, + payment: { result: { preimage: "ff".repeat(16) } }, + }, + ], + }; + }; + const { app, modalStub, creatorAddress } = await createApp({ + splitAndZap, + }); + + const pubkeyHex = "b".repeat(64); + app.pubkey = pubkeyHex; + const normalized = app.normalizeHexPubkey(pubkeyHex); + app.nwcSettings.set(normalized, { + nwcUri: "nostr+walletconnect://example", + defaultZap: null, + lastChecked: null, + version: "", + }); + + app.currentVideo = { + id: "event456", + pubkey: pubkeyHex, + tags: [["d", "video123"]], + content: "", + created_at: 1_700_000_001, + lightningAddress: creatorAddress, + }; + + app.modalZapAmountValue = 1500; + app.modalZapCommentValue = "Great video"; + + await app.handleVideoModalZap({ + detail: { amount: 1500, comment: "Great video" }, + }); + + assert.equal(splitCalls.length, 1, "splitAndZap should be called once"); + const call = splitCalls[0]; + assert.equal(call.payload.amountSats, 1500); + assert.equal(call.payload.comment, "Great video"); + assert.equal( + call.payload.videoEvent.lightningAddress, + creatorAddress, + "video event should include lightning address" + ); + assert.equal(call.payload.walletSettings.nwcUri, "nostr+walletconnect://example"); + assert.equal(call.payload.videoEvent.pubkey, pubkeyHex); + assert.equal(call.payload.videoEvent.id, "event456"); + assert.deepEqual(call.payload.videoEvent.tags, [["d", "video123"]]); + + const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1]; + assert(lastStatus.message.includes("Sent 1500 sats"), "should report zap summary"); + assert.equal(lastStatus.tone, "success"); + assert.equal(modalStub.resetForms.length > 0, true, "form should reset after success"); + assert.equal(modalStub.currentComment, ""); + + app.destroy(); +})(); + +process.exit(0);