diff --git a/js/channelProfile.js b/js/channelProfile.js index c280050e..544f80e8 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2965,6 +2965,79 @@ async function handleZapSend(event) { const { context, result } = attempt; const receipts = Array.isArray(result?.receipts) ? result.receipts : []; + + const failureReceipts = receipts.filter((receipt) => { + if (!receipt || typeof receipt !== "object") { + return false; + } + if (typeof receipt.status === "string" && receipt.status !== "success") { + return true; + } + return Boolean(receipt.error); + }); + + if (failureReceipts.length) { + renderZapReceipts(receipts, { partial: true }); + + const failureShares = failureReceipts + .map((receipt) => { + const amount = Math.max(0, Math.round(Number(receipt.amount) || 0)); + return { + type: receipt.recipientType || receipt.type || "creator", + amount, + address: + typeof receipt.address === "string" && receipt.address + ? receipt.address + : undefined, + error: receipt.error + }; + }) + .filter((share) => share.amount > 0); + + if (failureShares.length) { + markZapRetryPending(failureShares); + } + + const shareSummary = failureShares + .map((share) => `${describeShareType(share.type)} ${share.amount} sats`) + .join(", "); + + const failureMessage = failureReceipts + .map((receipt) => { + if (typeof receipt?.error?.message === "string") { + return receipt.error.message; + } + if (typeof receipt?.error === "string") { + return receipt.error; + } + return ""; + }) + .find((message) => message); + + const isBudgetExceeded = + typeof failureMessage === "string" && /budget exceeded/i.test(failureMessage); + + let warningMessage; + if (isBudgetExceeded) { + warningMessage = + "Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then retry the remaining shares."; + } else if (failureMessage) { + warningMessage = failureMessage; + } else { + warningMessage = + "Some zap shares failed. Press Send again to retry the remaining shares."; + } + + if (shareSummary) { + const normalized = warningMessage.replace(/\.*$/, ""); + warningMessage = `${normalized}. Remaining share(s): ${shareSummary}.`; + } + + setZapStatus(warningMessage, "warning"); + app?.showError?.(warningMessage); + return; + } + renderZapReceipts(receipts, { partial: false }); const creatorShare = context.shares.creatorShare; diff --git a/js/ui/zapController.js b/js/ui/zapController.js index 1b27377a..ac5a9056 100644 --- a/js/ui/zapController.js +++ b/js/ui/zapController.js @@ -246,6 +246,88 @@ export default class ZapController { const { context, result } = attempt; const receipts = Array.isArray(result?.receipts) ? result.receipts : []; + + const failureReceipts = receipts.filter((receipt) => { + if (!receipt || typeof receipt !== "object") { + return false; + } + if (typeof receipt.status === "string" && receipt.status !== "success") { + return true; + } + return Boolean(receipt.error); + }); + + if (failureReceipts.length) { + this.videoModal?.renderZapReceipts(receipts, { partial: true }); + + const failureShares = failureReceipts + .map((receipt) => { + const amount = Math.max(0, Math.round(Number(receipt.amount) || 0)); + return { + type: receipt.recipientType || receipt.type || "creator", + amount, + address: + typeof receipt.address === "string" && receipt.address + ? receipt.address + : undefined, + error: receipt.error, + }; + }) + .filter((share) => share.amount > 0); + + if (failureShares.length) { + this.markRetryPending(failureShares, trimmedComment); + } + + const shareSummary = failureShares + .map((share) => { + const label = + share.type === "platform" + ? "Platform" + : share.type === "creator" + ? "Creator" + : "Lightning"; + return `${label} ${share.amount} sats`; + }) + .join(", "); + + const failureMessage = failureReceipts + .map((receipt) => { + if (typeof receipt?.error?.message === "string") { + return receipt.error.message; + } + if (typeof receipt?.error === "string") { + return receipt.error; + } + return ""; + }) + .find((message) => message); + + const isBudgetExceeded = + typeof failureMessage === "string" && + /budget exceeded/i.test(failureMessage); + + let warningMessage; + if (isBudgetExceeded) { + warningMessage = + "Budget exceeded. Increase your wallet zap limit or reduce the platform fee, then retry the remaining shares."; + } else if (failureMessage) { + warningMessage = failureMessage; + } else { + warningMessage = + "Some zap shares failed. Press Send again to retry the remaining shares."; + } + + if (shareSummary) { + const normalized = warningMessage.replace(/\.*$/, ""); + warningMessage = `${normalized}. Remaining share(s): ${shareSummary}.`; + } + + this.videoModal?.setZapStatus(warningMessage, "warning"); + this.notifyError(warningMessage); + return; + } + this.videoModal?.renderZapReceipts(receipts, { partial: false }); const creatorShare = context.shares.creatorShare; diff --git a/tests/video-modal-zap.test.mjs b/tests/video-modal-zap.test.mjs index 4481ae77..5869df5b 100644 --- a/tests/video-modal-zap.test.mjs +++ b/tests/video-modal-zap.test.mjs @@ -303,6 +303,23 @@ async function createApp({ splitAndZap }) { }, }); + app.boundVideoModalZapOpenHandler = (event = {}) => { + const requiresLogin = Boolean(event?.detail?.requiresLogin); + app.pendingModalZapOpen = requiresLogin; + + const openResult = app.zapController?.open({ requiresLogin }); + if (!openResult) { + event?.preventDefault?.(); + if (!requiresLogin) { + app.pendingModalZapOpen = false; + } + modalStub.closeZapDialog?.({ silent: true, restoreFocus: false }); + return; + } + + app.pendingModalZapOpen = false; + }; + const creatorAddress = "creator@example.com"; const platformAddress = "platform@example.com"; setCachedPlatformLightningAddress(platformAddress); @@ -420,6 +437,9 @@ await (async () => { nostrClient.sessionActor = { pubkey: sessionPubkey, privateKey: "priv" }; app.pubkey = sessionPubkey; + app.isUserLoggedIn = () => false; + app.zapController.isUserLoggedIn = () => false; + app.zapController.hasSessionActor = () => true; modalStub.zapVisibilityCalls = []; app.zapController.setVisibility(true); @@ -543,6 +563,119 @@ await (async () => { app.destroy(); })(); +await (async () => { + // Test: platform receipt failure keeps retry state active and surfaces warning + const platformError = new Error("Budget exceeded"); + const receipts = [ + { + recipientType: "creator", + status: "success", + amount: 900, + address: "creator@example.com", + payment: { result: { preimage: "cc".repeat(16) } }, + }, + { + recipientType: "platform", + status: "error", + amount: 100, + address: "platform@example.com", + error: platformError, + }, + ]; + + const { app, modalStub, creatorAddress, platformAddress } = await createApp({ + splitAndZap: async () => ({ receipts }), + }); + + const pubkeyHex = "c".repeat(64); + app.pubkey = pubkeyHex; + const normalized = app.normalizeHexPubkey(pubkeyHex); + app.nwcSettingsService.cache.set(normalized, { + nwcUri: "nostr+walletconnect://example", + defaultZap: null, + lastChecked: null, + version: "", + }); + + app.currentVideo = { + id: "event789", + pubkey: pubkeyHex, + tags: [["d", "video789"]], + content: "", + created_at: 1_700_000_100, + lightningAddress: creatorAddress, + }; + + const initialResetCount = modalStub.resetForms.length; + const initialCompletedCount = modalStub.completedStates.length; + + await app.zapController.sendZap({ amount: 1000, comment: "Nice work" }); + + const lastReceiptEntry = modalStub.receipts[modalStub.receipts.length - 1]; + assert.equal( + lastReceiptEntry.options?.partial, + true, + "should render zap receipts in partial mode when failures occur", + ); + + assert.equal( + app.zapController.modalZapRetryState?.shares?.length, + 1, + "controller should queue failing receipt for retry", + ); + assert.equal( + modalStub.retryState?.pending, + true, + "modal should display retry pending state for failed share", + ); + + const lastStatus = modalStub.statusMessages[modalStub.statusMessages.length - 1]; + assert.equal(lastStatus.tone, "warning", "warning tone should be emitted for receipt failure"); + assert.ok( + /increase your wallet zap limit or reduce the platform fee/i.test(lastStatus.message), + "status message should translate budget exceeded guidance", + ); + assert.ok( + /Remaining share\(s\): Platform 100 sats\./.test(lastStatus.message), + "status message should summarize remaining platform share", + ); + + assert.equal( + modalStub.resetForms.length, + initialResetCount, + "zap form should not reset while a share remains unpaid", + ); + assert.equal( + modalStub.completedStates.length, + initialCompletedCount + 1, + "zap completion calls should only reflect the in-flight attempt", + ); + assert.equal( + modalStub.completedStates[modalStub.completedStates.length - 1], + false, + "zap modal should not mark zap as completed on partial failure", + ); + assert.equal( + app.zapController.modalZapCommentValue, + "Nice work", + "zap controller should retain comment value for retry", + ); + + // Ensure platform retry summary references the expected address context + assert.equal( + app.zapController.modalZapRetryState?.shares?.[0]?.type, + "platform", + "retry state should track platform share type", + ); + assert.equal( + app.zapController.modalZapRetryState?.shares?.[0]?.amount, + 100, + "retry state should preserve remaining platform amount", + ); + + app.destroy(); +})(); + await (async () => { // Test: allowance exhaustion surfaces guidance and skips retry queue const { app, modalStub, creatorAddress, platformAddress } = await createApp({