mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 03:12:31 +00:00
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:
8 files changed
+2036
-206
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user