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);