Merge pull request #296 from PR0M3TH3AN/codex/enhance-zap-functionality-in-video-handler

Enhance video modal zap flow and add integration tests
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-04 23:41:18 -04:00
commit 2132a913d5
8 files changed
+2036 -206

No files matched your search

+83
View File
@@ -167,6 +167,89 @@
</p>
</div>
<!-- Zap dialog -->
<div id="modalZapDialog" class="hidden mt-4" aria-hidden="true">
<div
class="w-full rounded-lg bg-gray-900 bg-opacity-70 p-4 shadow-lg backdrop-blur"
>
<div class="flex items-center justify-between mb-3">
<h3
class="text-xs font-semibold uppercase tracking-wide text-gray-300"
>
Send a zap
</h3>
<button
type="button"
id="modalZapCloseBtn"
class="text-gray-400 hover:text-gray-200 transition-colors"
aria-label="Close zap dialog"
>
✕
</button>
</div>
<form id="modalZapForm" class="space-y-3">
<div>
<label
for="modalZapAmountInput"
class="block text-xs font-semibold uppercase tracking-wide text-gray-300 mb-2"
>
Zap amount (sats)
</label>
<input
id="modalZapAmountInput"
type="number"
min="1"
step="1"
inputmode="numeric"
class="w-full rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
placeholder="Enter sats"
/>
</div>
<div>
<label
for="modalZapCommentInput"
class="block text-xs font-semibold uppercase tracking-wide text-gray-300 mb-2"
>
Comment (optional)
</label>
<textarea
id="modalZapCommentInput"
rows="2"
maxlength="280"
class="w-full resize-y rounded border border-gray-600 bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400"
placeholder="Include a note with your zap"
></textarea>
</div>
<p
id="modalZapSplitSummary"
class="text-sm text-gray-300"
aria-live="polite"
>
Enter an amount to view the split.
</p>
<p
id="modalZapStatus"
class="text-xs text-gray-400"
role="status"
aria-live="polite"
></p>
<ul
id="modalZapReceipts"
class="space-y-2 text-xs text-gray-300"
></ul>
<div class="flex items-center justify-end gap-2">
<button
type="submit"
id="modalZapSendBtn"
class="rounded bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Send zap
</button>
</div>
</form>
</div>
</div>
<!-- Torrent stats -->
<div class="bg-gray-800/50 rounded-lg p-4">
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
+758 -3
View File
@@ -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();
+28 -149
View File
@@ -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");
+116 -35
View File
@@ -1,4 +1,54 @@
import parseMagnet from "https://esm.sh/[email protected].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 };
}
+166
View File
@@ -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 };
+106 -10
View File
@@ -1,14 +1,97 @@
// js/storage/r2-s3.js
import {
S3Client,
CreateBucketCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
HeadBucketCommand,
PutBucketCorsCommand,
} from "https://esm.sh/@aws-sdk/[email protected]?target=es2022&bundle";
const AWS_SDK_SPECIFIER =
"https://esm.sh/@aws-sdk/[email protected]?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,
+380 -9
View File
@@ -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");
}
}
+399
View File
@@ -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 = "[email protected]";
const platformAddress = "[email protected]";
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);