Files
bitvid/js/ui/zapController.js
T
google-labs-jules[bot] b83f5009e4 I have updated the zap dialog to display the "Done" button on success.
I modified `js/channelProfile.js` to implement the logic that toggles the "Send" button to "Done" and closes the dialog when clicked. Additionally, I updated `js/ui/zapController.js` to prevent the form from resetting entirely on success, which preserves receipts; instead, it now manually clears the comment and sets the completed state.

These changes ensure consistent "Done" button behavior across both the Channel Profile and Video Modal zap interfaces. I also included minor `package.json` updates for linting dependencies.
2026-01-18 01:58:56 +00:00

1206 lines
38 KiB
JavaScript

import { isDevMode } from "../config.js";
import { LOGIN_TO_ZAP_MESSAGE } from "../payments/zapMessages.js";
import {
calculateZapShares,
fetchLightningMetadata,
formatMinRequirement,
getCachedLightningEntry,
getCachedMetadataByUrl,
getCachedPlatformLightningAddress,
isZapAllowanceExhaustedError,
isMetadataEntryFresh,
normalizeLightningAddressKey,
rememberLightningMetadata,
setCachedPlatformLightningAddress,
buildZapAllowanceExhaustedMessage,
validateInvoiceAmount,
} from "../payments/zapSharedState.js";
import {
resolveLightningAddress,
fetchPayServiceData,
requestInvoice,
} from "../payments/lnurl.js";
import { getPlatformLightningAddress } from "../payments/platformAddress.js";
import { devLogger, userLogger } from "../utils/logger.js";
import {
ensureWallet as ensureWalletDefault,
sendPayment as sendPaymentDefault,
resetWalletClient as resetWalletClientDefault,
} from "../payments/nwcClient.js";
const DEFAULT_SPLIT_SUMMARY = "Enter an amount to view the split.";
export default class ZapController {
constructor({
videoModal,
getCurrentVideo,
nwcSettings,
getActiveNwcSettings,
isUserLoggedIn,
hasSessionActor,
hasActiveWalletConnection,
splitAndZap,
notifyLoginRequired,
payments = {},
callbacks = {},
requestWalletPane,
} = {}) {
this.videoModal = videoModal || null;
this.getCurrentVideo =
typeof getCurrentVideo === "function" ? getCurrentVideo : () => null;
this.nwcSettings =
nwcSettings &&
typeof nwcSettings === "object" &&
typeof nwcSettings.getActiveNwcSettings === "function"
? nwcSettings
: null;
if (this.nwcSettings) {
this.getActiveNwcSettings = () =>
this.nwcSettings.getActiveNwcSettings();
} else if (typeof getActiveNwcSettings === "function") {
this.getActiveNwcSettings = getActiveNwcSettings;
} else {
this.getActiveNwcSettings = () => ({});
}
this.isUserLoggedIn =
typeof isUserLoggedIn === "function" ? isUserLoggedIn : () => false;
this.hasSessionActor =
typeof hasSessionActor === "function" ? hasSessionActor : () => false;
if (
this.nwcSettings &&
typeof this.nwcSettings.hasActiveWalletConnection === "function"
) {
this.hasActiveWalletConnection = () =>
this.nwcSettings.hasActiveWalletConnection();
} else if (typeof hasActiveWalletConnection === "function") {
this.hasActiveWalletConnection = hasActiveWalletConnection;
} else {
this.hasActiveWalletConnection = () => false;
}
this.splitAndZap =
typeof splitAndZap === "function" ? splitAndZap : () => Promise.resolve();
this.notifyLoginRequired =
typeof notifyLoginRequired === "function" ? notifyLoginRequired : null;
this.payments = payments || {};
this.callbacks = {
onSuccess: typeof callbacks.onSuccess === "function" ? callbacks.onSuccess : null,
onError: typeof callbacks.onError === "function" ? callbacks.onError : null,
};
this.requestWalletPane =
typeof requestWalletPane === "function" ? requestWalletPane : null;
this.modalZapInFlight = false;
this.modalZapRetryState = null;
this.modalZapAmountValue = 0;
this.modalZapCommentValue = "";
}
/** Public API **/
setVisibility(visible) {
const lightningVisible = !!visible;
const loggedIn = this.isUserLoggedIn();
const shouldShow = lightningVisible;
const requiresLogin = shouldShow && !loggedIn;
const hasWallet = this.hasActiveWalletConnection();
if (this.videoModal) {
this.videoModal.setZapVisibility({
visible: shouldShow,
requiresLogin,
});
this.videoModal.setWalletPromptVisible(
shouldShow && !requiresLogin && !hasWallet,
);
}
}
resetState({ preserveAmount = false } = {}) {
if (!preserveAmount) {
this.modalZapAmountValue = 0;
}
this.modalZapCommentValue = "";
this.modalZapInFlight = false;
this.resetRetryState();
if (this.videoModal) {
this.videoModal.setZapCompleted(false);
this.videoModal.resetZapForm({
amount: preserveAmount ? this.modalZapAmountValue || "" : "",
comment: "",
});
this.videoModal.setZapPending(false);
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
}
}
open(options = {}) {
const config =
options && typeof options === "object" ? options : Object.create(null);
const requiresLogin = Boolean(config.requiresLogin);
this.resetFeedback();
if (requiresLogin) {
if (this.notifyLoginRequired) {
this.notifyLoginRequired();
} else {
this.notifyError(LOGIN_TO_ZAP_MESSAGE);
}
return false;
}
if (!this.isUserLoggedIn()) {
if (this.notifyLoginRequired) {
this.notifyLoginRequired();
} else {
this.notifyError(LOGIN_TO_ZAP_MESSAGE);
}
return false;
}
this.applyDefaultAmount();
this.preloadLightningMetadata().catch((error) => {
devLogger.warn("[zap] Preload metadata error:", error);
});
return true;
}
close() {
this.resetFeedback();
}
setAmount(amount) {
const rawAmount = Number(amount);
const fallback = this.videoModal?.getZapAmountValue?.() || 0;
const value = Number.isFinite(rawAmount) ? rawAmount : fallback;
this.modalZapAmountValue = Math.max(0, Math.round(value || 0));
this.resetFeedback();
this.updateSplitSummary();
}
setComment(comment) {
if (typeof comment === "string") {
this.modalZapCommentValue = comment.trim();
return;
}
const fallback = this.videoModal?.getZapCommentValue?.();
this.modalZapCommentValue = typeof fallback === "string" ? fallback : "";
}
async sendZap({ amount, comment } = {}) {
if (this.modalZapInFlight) {
return;
}
this.videoModal?.setZapCompleted(false);
const numericAmount = Number.isFinite(Number(amount))
? Number(amount)
: this.modalZapAmountValue;
const roundedAmount = Math.max(0, Math.round(numericAmount || 0));
this.modalZapAmountValue = roundedAmount;
const trimmedComment =
typeof comment === "string" ? comment.trim() : this.modalZapCommentValue;
this.modalZapCommentValue = trimmedComment;
if (!roundedAmount) {
const message = "Enter a zap amount greater than zero.";
this.videoModal?.setZapStatus(message, "error");
this.notifyError(message);
return;
}
const walletSettings = this.getWalletSettingsOrPrompt();
if (!walletSettings) {
return;
}
this.modalZapInFlight = true;
this.videoModal?.setZapPending(true);
this.videoModal?.setZapStatus(`Sending ${roundedAmount} sats…`, "warning");
this.videoModal?.clearZapReceipts();
try {
if (
Array.isArray(this.modalZapRetryState?.shares) &&
this.modalZapRetryState.shares.length
) {
const retrySuccess = await this.executeRetry({
walletSettings,
comment: trimmedComment,
});
if (retrySuccess) {
this.modalZapCommentValue = "";
this.videoModal?.resetZapForm({ amount: "", comment: "" });
this.videoModal?.setZapCompleted(true);
}
this.applyDefaultAmount();
return;
}
const attempt = await this.runZapAttempt({
amount: roundedAmount,
walletSettings,
comment: trimmedComment,
});
if (!attempt) {
this.videoModal?.setZapStatus("", "neutral");
return;
}
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 budgetReceipt = failureReceipts.find((receipt) =>
isZapAllowanceExhaustedError(receipt?.error)
);
const isBudgetExceeded =
Boolean(budgetReceipt) ||
(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;
}
const unvalidatedReceipts = receipts.filter((receipt) => {
if (!receipt || typeof receipt !== "object") {
return false;
}
const status =
typeof receipt.status === "string" ? receipt.status.toLowerCase() : "success";
if (status !== "success" && status !== "") {
return false;
}
const validationStatus =
typeof receipt.validation?.status === "string"
? receipt.validation.status.toLowerCase()
: "";
if (!validationStatus || validationStatus === "skipped") {
return false;
}
return validationStatus !== "passed";
});
if (unvalidatedReceipts.length) {
this.videoModal?.renderZapReceipts(receipts, { partial: true });
const warningSummary = unvalidatedReceipts
.map((receipt) => {
const type = receipt.recipientType || receipt.type || "creator";
const label =
type === "platform" ? "Platform" : type === "creator" ? "Creator" : "Lightning";
const address =
typeof receipt.address === "string" && receipt.address
? ` (${receipt.address})`
: "";
const reason =
typeof receipt.validation?.reason === "string" && receipt.validation.reason
? ` — ${receipt.validation.reason}`
: " — Awaiting compliant receipt.";
return `${label}${address}${reason}`;
})
.join(" ");
const warningMessage = warningSummary
? `Awaiting validated zap receipt. ${warningSummary}`
: "Awaiting validated zap receipt from the recipient.";
this.videoModal?.setZapStatus(warningMessage, "warning");
this.notifyError(warningMessage);
this.resetRetryState();
this.modalZapCommentValue = "";
this.videoModal?.resetZapForm({ amount: "", comment: "" });
this.videoModal?.setZapCompleted(true);
this.applyDefaultAmount();
return;
}
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.notifySuccess("Zap sent successfully!");
this.resetRetryState();
this.modalZapCommentValue = "";
this.videoModal?.setZapComment("");
this.videoModal?.setZapCompleted(true);
this.applyDefaultAmount();
} catch (error) {
this.handleZapError({ error, amount: roundedAmount, comment: trimmedComment, walletSettings });
} finally {
this.modalZapInFlight = false;
this.videoModal?.setZapPending(false);
}
}
handleWalletLink() {
if (this.requestWalletPane) {
Promise.resolve()
.then(() => this.requestWalletPane())
.catch((error) => {
userLogger.error("Failed to open wallet pane:", error);
});
}
}
/** Internal helpers **/
resetFeedback() {
if (!this.videoModal) {
return;
}
this.videoModal.setZapCompleted(false);
this.videoModal.setZapStatus("", "neutral");
this.videoModal.clearZapReceipts();
this.videoModal.setZapRetryPending(false);
this.videoModal.setZapPending(false);
}
applyDefaultAmount() {
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.updateSplitSummary();
}
updateSplitSummary() {
if (!this.videoModal) {
return;
}
const amount = Math.max(0, Math.round(Number(this.modalZapAmountValue || 0)));
if (!amount) {
this.videoModal.setZapSplitSummary(DEFAULT_SPLIT_SUMMARY);
return;
}
const shares = calculateZapShares(amount);
const parts = [];
const lightningAddress = this.getCurrentVideo()?.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 preloadLightningMetadata() {
const lightningAddress = this.getCurrentVideo()?.lightningAddress || "";
if (lightningAddress) {
try {
await fetchLightningMetadata(lightningAddress);
} catch (error) {
devLogger.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) {
devLogger.warn("[zap] Failed to load platform Lightning address:", error);
}
}
if (platformAddress) {
try {
await fetchLightningMetadata(platformAddress);
} catch (error) {
devLogger.warn("[zap] Failed to preload platform metadata:", error);
}
}
}
}
getWalletSettingsOrPrompt() {
const settings = this.getActiveNwcSettings();
const normalizedUri =
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
if (!normalizedUri) {
this.notifyError("Connect a Lightning wallet to send zaps.");
if (this.requestWalletPane) {
this.handleWalletLink();
}
return null;
}
return {
...settings,
nwcUri: normalizedUri,
};
}
createDependencies({ creatorEntry, platformEntry, shares, shareTracker }) {
const summarizeTracker = (tracker) =>
Array.isArray(tracker)
? tracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount:
Number.isFinite(entry?.amount) && entry.amount >= 0
? entry.amount
: undefined,
address: entry?.address || undefined,
hasPayment: Boolean(entry?.payment),
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
}))
: undefined;
const logZapError = (stage, details, error) => {
const summary = {
stage,
shareType: details?.shareType,
address: details?.address,
amount:
Number.isFinite(details?.amount) && details.amount >= 0
? details.amount
: undefined,
overrideFee:
typeof details?.overrideFee === "number"
? details.overrideFee
: undefined,
commentLength:
typeof details?.comment === "string"
? details.comment.length
: undefined,
wallet:
details?.walletSettings
? {
hasUri: Boolean(details.walletSettings.nwcUri),
type:
details.walletSettings.type ||
details.walletSettings.name ||
details.walletSettings.client ||
undefined,
}
: undefined,
shares: details?.context?.shares
? {
total: details.context.shares.total,
creator: details.context.shares.creatorShare,
platform: details.context.shares.platformShare,
}
: undefined,
tracker: summarizeTracker(details?.tracker),
retryAttempt:
Number.isInteger(details?.retryAttempt) && details.retryAttempt >= 0
? details.retryAttempt
: undefined,
};
userLogger.error("[zap] Modal zap failure", summary, error);
};
const lightningAddress = this.getCurrentVideo()?.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);
const resetWalletClientFn =
typeof this.payments?.resetWalletClient === "function"
? () => this.payments.resetWalletClient()
: () => resetWalletClientDefault();
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: async (options) => {
try {
return await ensureWalletFn(options);
} catch (error) {
logZapError(
"wallet.ensureWallet",
{
tracker: shareTracker,
context: { shares },
walletSettings: options?.settings,
},
error
);
throw error;
}
},
sendPayment: async (bolt11, params) => {
const shareType = activeShare || "unknown";
const amount =
shareType === "platform"
? shares.platformShare
: shares.creatorShare;
const address =
shareType === "platform"
? platformEntry?.address || getCachedPlatformLightningAddress()
: creatorEntry?.address || lightningAddress;
try {
const payment = await sendPaymentFn(bolt11, params);
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
status: "success",
amount,
address,
payment,
});
}
return payment;
} catch (error) {
const allowanceExhausted = isZapAllowanceExhaustedError(error);
if (allowanceExhausted) {
error.__zapAllowanceExhausted = true;
}
if (Array.isArray(shareTracker)) {
shareTracker.push({
type: shareType,
status: "error",
amount,
address,
error,
allowanceExhausted,
});
}
if (
typeof error?.message === "string" &&
error.message.toLowerCase().includes("timed out")
) {
try {
resetWalletClientFn();
} catch (resetError) {
devLogger.warn(
"[zap] Failed to reset wallet client after timeout",
resetError
);
}
}
logZapError(
"wallet.sendPayment",
{
shareType,
amount,
address,
tracker: shareTracker,
context: { shares },
},
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 prepareLightningContext({ amount, overrideFee = null }) {
const lightningAddress = this.getCurrentVideo()?.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.updateSplitSummary();
return {
shares,
creatorEntry,
platformEntry,
platformAddress,
};
}
getActiveVideoEvent() {
const video = this.getCurrentVideo() || {};
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 runZapAttempt({ amount, overrideFee = null, walletSettings, comment }) {
const settings = walletSettings || this.getWalletSettingsOrPrompt();
if (!settings) {
return null;
}
let context;
try {
context = await this.prepareLightningContext({
amount,
overrideFee,
});
} catch (error) {
userLogger.error(
"[zap] Modal lightning context failed",
{
amount,
overrideFee,
commentLength: typeof comment === "string" ? comment.length : undefined,
wallet: {
hasUri: Boolean(settings?.nwcUri),
type:
settings?.type || settings?.name || settings?.client || undefined,
},
},
error
);
throw error;
}
const shareTracker = [];
const dependencies = this.createDependencies({
...context,
shareTracker,
});
const videoEvent = this.getActiveVideoEvent();
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;
}
userLogger.error(
"[zap] splitAndZap failed",
{
amount,
overrideFee,
commentLength: typeof comment === "string" ? comment.length : undefined,
wallet: {
hasUri: Boolean(settings?.nwcUri),
type:
settings?.type || settings?.name || settings?.client || undefined,
},
shares: {
total: context?.shares?.total,
creator: context?.shares?.creatorShare,
platform: context?.shares?.platformShare,
},
tracker: Array.isArray(shareTracker)
? shareTracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount: entry?.amount,
address: entry?.address,
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
}))
: undefined,
},
error
);
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 executeRetry({ walletSettings, comment }) {
const retryState = this.modalZapRetryState;
const shares = Array.isArray(retryState?.shares) ? retryState.shares : [];
if (!shares.length) {
this.resetRetryState();
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.runZapAttempt({
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
: [];
userLogger.error(
"[zap] Modal zap retry failed",
{
shareType: share?.type || "unknown",
amount: share?.amount,
address: share?.address,
commentLength:
typeof (retryState?.comment || comment) === "string"
? (retryState?.comment || comment).length
: undefined,
tracker: tracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount: entry?.amount,
address: entry?.address,
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
})),
},
error
);
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.notifySuccess("Zap shares retried successfully!");
this.resetRetryState();
return true;
}
markRetryPending(shares, comment = "") {
const validShares = Array.isArray(shares)
? shares.filter((share) => share && share.amount > 0)
: [];
if (!validShares.length) {
this.resetRetryState();
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 });
}
resetRetryState() {
this.modalZapRetryState = null;
this.videoModal?.setZapRetryPending(false);
}
handleZapError({ error, amount, comment, walletSettings }) {
const tracker = Array.isArray(error?.__zapShareTracker)
? error.__zapShareTracker
: [];
userLogger.error(
"[zap] Modal zap attempt failed",
{
amount,
commentLength: typeof comment === "string" ? comment.length : undefined,
wallet: {
hasUri: Boolean(walletSettings?.nwcUri),
type:
walletSettings?.type ||
walletSettings?.name ||
walletSettings?.client ||
undefined,
},
tracker: tracker.map((entry) => ({
type: entry?.type || "unknown",
status: entry?.status || "unknown",
amount: entry?.amount,
address: entry?.address,
errorMessage:
typeof entry?.error?.message === "string"
? entry.error.message
: entry?.error
? String(entry.error)
: undefined,
})),
retryPending: Array.isArray(this.modalZapRetryState?.shares)
? this.modalZapRetryState.shares.length
: 0,
},
error
);
if (tracker.length) {
this.videoModal?.renderZapReceipts(tracker, { partial: true });
}
const failureShares = tracker.filter(
(entry) => entry && entry.status !== "success" && entry.amount > 0
);
const allowanceExhausted =
isZapAllowanceExhaustedError(error) ||
failureShares.some((share) =>
isZapAllowanceExhaustedError(share?.error)
);
if (allowanceExhausted) {
this.resetRetryState();
}
if (failureShares.length) {
if (allowanceExhausted) {
const allowanceShare = failureShares.find((share) =>
isZapAllowanceExhaustedError(share?.error)
);
const allowanceSource = isZapAllowanceExhaustedError(error)
? error
: allowanceShare?.error;
const allowanceMessage = buildZapAllowanceExhaustedMessage(
allowanceSource
);
const partialSuccess = tracker.length > failureShares.length;
const tone = partialSuccess ? "warning" : "error";
const statusMessage = partialSuccess
? `Some zap shares succeeded before your wallet allowance was exhausted. ${allowanceMessage}`
: allowanceMessage;
this.videoModal?.setZapStatus(statusMessage, tone);
this.notifyError(statusMessage);
} else {
this.markRetryPending(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. Press Send again to retry: ${summary}.`
: `Zap failed. Press Send again to retry: ${summary}.`;
this.videoModal?.setZapStatus(statusMessage, tone);
this.notifyError(error?.message || statusMessage);
}
} else {
this.resetRetryState();
if (allowanceExhausted) {
const allowanceMessage = buildZapAllowanceExhaustedMessage(error);
this.videoModal?.setZapStatus(allowanceMessage, "error");
this.notifyError(allowanceMessage);
} else {
const message = error?.message || "Zap failed. Please try again.";
this.videoModal?.setZapStatus(message, "error");
this.notifyError(message);
}
}
}
notifySuccess(message) {
if (this.callbacks.onSuccess) {
this.callbacks.onSuccess(message);
}
}
notifyError(message) {
if (this.callbacks.onError) {
this.callbacks.onError(message);
}
}
}