mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-30 01:12:39 +00:00
Enhance channel zap flow with UI and metadata caching
This commit is contained in:
1 parent
dbc8a5278a
commit
ce94e2c411
2 files changed
+990
-75
No files matched your search
+889
-6
@@ -10,15 +10,40 @@ 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 {
|
||||
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";
|
||||
|
||||
const getApp = () => getApplication();
|
||||
|
||||
let currentChannelHex = null;
|
||||
let currentChannelNpub = null;
|
||||
let currentChannelLightningAddress = "";
|
||||
let currentChannelProfileEvent = null;
|
||||
|
||||
let cachedZapButton = null;
|
||||
let cachedChannelShareButton = null;
|
||||
let cachedChannelMenu = null;
|
||||
let cachedZapControls = null;
|
||||
let cachedZapAmountInput = null;
|
||||
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)) {
|
||||
@@ -32,6 +57,7 @@ function getChannelZapButton() {
|
||||
|
||||
function setChannelZapVisibility(visible) {
|
||||
const zapButton = getChannelZapButton();
|
||||
const controls = getZapControlsContainer();
|
||||
if (!zapButton) {
|
||||
return;
|
||||
}
|
||||
@@ -44,6 +70,16 @@ function setChannelZapVisibility(visible) {
|
||||
zapButton.removeAttribute("tabindex");
|
||||
} else {
|
||||
zapButton.setAttribute("tabindex", "-1");
|
||||
if (controls) {
|
||||
controls.classList.add("hidden");
|
||||
}
|
||||
resetZapRetryState();
|
||||
zapInFlight = false;
|
||||
clearZapReceipts();
|
||||
setZapStatus("", "neutral");
|
||||
}
|
||||
if (controls) {
|
||||
controls.classList.toggle("hidden", !shouldShow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +103,811 @@ function getChannelMenuElement() {
|
||||
return cachedChannelMenu;
|
||||
}
|
||||
|
||||
function getZapControlsContainer() {
|
||||
if (cachedZapControls && !document.body.contains(cachedZapControls)) {
|
||||
cachedZapControls = null;
|
||||
}
|
||||
if (!cachedZapControls) {
|
||||
cachedZapControls = document.getElementById("zapControls");
|
||||
}
|
||||
return cachedZapControls;
|
||||
}
|
||||
|
||||
function getZapAmountInput() {
|
||||
if (cachedZapAmountInput && !document.body.contains(cachedZapAmountInput)) {
|
||||
cachedZapAmountInput = null;
|
||||
}
|
||||
if (!cachedZapAmountInput) {
|
||||
cachedZapAmountInput = document.getElementById("zapAmountInput");
|
||||
}
|
||||
return cachedZapAmountInput;
|
||||
}
|
||||
|
||||
function getZapSplitSummaryElement() {
|
||||
if (cachedZapSplitSummary && !document.body.contains(cachedZapSplitSummary)) {
|
||||
cachedZapSplitSummary = null;
|
||||
}
|
||||
if (!cachedZapSplitSummary) {
|
||||
cachedZapSplitSummary = document.getElementById("zapSplitSummary");
|
||||
}
|
||||
return cachedZapSplitSummary;
|
||||
}
|
||||
|
||||
function getZapStatusElement() {
|
||||
if (cachedZapStatus && !document.body.contains(cachedZapStatus)) {
|
||||
cachedZapStatus = null;
|
||||
}
|
||||
if (!cachedZapStatus) {
|
||||
cachedZapStatus = document.getElementById("zapStatus");
|
||||
}
|
||||
return cachedZapStatus;
|
||||
}
|
||||
|
||||
function getZapReceiptsList() {
|
||||
if (cachedZapReceipts && !document.body.contains(cachedZapReceipts)) {
|
||||
cachedZapReceipts = null;
|
||||
}
|
||||
if (!cachedZapReceipts) {
|
||||
cachedZapReceipts = document.getElementById("zapReceipts");
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const amountInput = getZapAmountInput();
|
||||
const numeric = Math.max(0, Math.round(Number(amountInput?.value || 0)));
|
||||
if (!numeric) {
|
||||
summaryEl.textContent = "Enter an amount to view the split.";
|
||||
return;
|
||||
}
|
||||
|
||||
const shares = calculateZapShares(numeric, overrideFee);
|
||||
|
||||
const parts = [];
|
||||
const creatorEntry = getCachedLightningEntry(currentChannelLightningAddress);
|
||||
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(cachedPlatformLightningAddress);
|
||||
const platformMin = formatMinRequirement(platformEntry?.metadata);
|
||||
let platformText = `Platform: ${shares.platformShare} sats`;
|
||||
if (Number.isFinite(platformMin) && platformMin > 0) {
|
||||
platformText += ` (min ${platformMin})`;
|
||||
}
|
||||
parts.push(platformText);
|
||||
}
|
||||
|
||||
summaryEl.textContent = parts.join(" • ");
|
||||
}
|
||||
|
||||
function setZapStatus(message, tone = "neutral") {
|
||||
const statusEl = getZapStatusElement();
|
||||
if (!statusEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedTone = typeof tone === "string" ? tone : "neutral";
|
||||
statusEl.textContent = message || "";
|
||||
statusEl.classList.remove(
|
||||
"text-gray-300",
|
||||
"text-gray-400",
|
||||
"text-green-300",
|
||||
"text-red-300",
|
||||
"text-yellow-300"
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
statusEl.classList.add("text-gray-400");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (normalizedTone) {
|
||||
case "success":
|
||||
statusEl.classList.add("text-green-300");
|
||||
break;
|
||||
case "error":
|
||||
statusEl.classList.add("text-red-300");
|
||||
break;
|
||||
case "warning":
|
||||
statusEl.classList.add("text-yellow-300");
|
||||
break;
|
||||
default:
|
||||
statusEl.classList.add("text-gray-300");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function clearZapReceipts() {
|
||||
const list = getZapReceiptsList();
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
}
|
||||
|
||||
function renderZapReceipts(receipts, { partial = false } = {}) {
|
||||
const list = getZapReceiptsList();
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = "";
|
||||
|
||||
if (!Array.isArray(receipts) || receipts.length === 0) {
|
||||
if (partial) {
|
||||
const emptyItem = document.createElement("li");
|
||||
emptyItem.className = "rounded border border-gray-700 bg-gray-800/70 p-3 text-gray-300";
|
||||
emptyItem.textContent = "No receipts were returned for this attempt.";
|
||||
list.appendChild(emptyItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
receipts.forEach((receipt) => {
|
||||
if (!receipt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.className = "rounded border border-gray-700 bg-gray-800/70 p-3";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-gray-200";
|
||||
|
||||
const shareType = receipt.recipientType || receipt.type || "creator";
|
||||
const shareLabel = document.createElement("span");
|
||||
shareLabel.textContent = `${describeShareType(shareType)} • ${Math.max(
|
||||
0,
|
||||
Math.round(Number(receipt.amount || 0))
|
||||
)} sats`;
|
||||
|
||||
const status = 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 = document.createElement("p");
|
||||
address.className = "mt-1 text-xs text-gray-300 break-all";
|
||||
const addressValue = receipt.address || "";
|
||||
if (addressValue) {
|
||||
address.textContent = addressValue;
|
||||
li.appendChild(address);
|
||||
}
|
||||
|
||||
const detail = 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);
|
||||
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function resetZapRetryState() {
|
||||
pendingZapRetry = null;
|
||||
const zapButton = getChannelZapButton();
|
||||
if (zapButton) {
|
||||
delete zapButton.dataset.retryPending;
|
||||
zapButton.setAttribute("aria-label", "Send a zap");
|
||||
zapButton.title = "Send a zap";
|
||||
}
|
||||
}
|
||||
|
||||
function markZapRetryPending(shares) {
|
||||
const validShares = Array.isArray(shares)
|
||||
? shares.filter((share) => share && share.amount > 0)
|
||||
: [];
|
||||
if (!validShares.length) {
|
||||
resetZapRetryState();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingZapRetry = { shares: validShares, createdAt: Date.now() };
|
||||
const zapButton = getChannelZapButton();
|
||||
if (zapButton) {
|
||||
zapButton.dataset.retryPending = "true";
|
||||
zapButton.setAttribute("aria-label", "Retry failed zap shares");
|
||||
zapButton.title = "Retry failed zap shares";
|
||||
}
|
||||
}
|
||||
|
||||
function getZapVideoEvent() {
|
||||
const lightningAddress = currentChannelLightningAddress || "";
|
||||
const baseEvent =
|
||||
currentChannelProfileEvent && typeof currentChannelProfileEvent === "object"
|
||||
? { ...currentChannelProfileEvent }
|
||||
: {};
|
||||
return {
|
||||
kind: typeof baseEvent.kind === "number" ? baseEvent.kind : 0,
|
||||
id: baseEvent.id || currentChannelHex || "",
|
||||
pubkey: baseEvent.pubkey || currentChannelHex || "",
|
||||
tags: Array.isArray(baseEvent.tags) ? [...baseEvent.tags] : [],
|
||||
content: typeof baseEvent.content === "string" ? baseEvent.content : "",
|
||||
created_at: baseEvent.created_at || Math.floor(Date.now() / 1000),
|
||||
lightningAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareLightningContext({ amount, overrideFee = null }) {
|
||||
if (!currentChannelLightningAddress) {
|
||||
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(currentChannelLightningAddress);
|
||||
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 = cachedPlatformLightningAddress;
|
||||
if (!platformAddress) {
|
||||
platformAddress = await getPlatformLightningAddress({ forceRefresh: false });
|
||||
cachedPlatformLightningAddress = 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
updateZapSplitSummary();
|
||||
|
||||
return {
|
||||
shares,
|
||||
creatorEntry,
|
||||
platformEntry,
|
||||
platformAddress,
|
||||
};
|
||||
}
|
||||
|
||||
function createZapDependencies({ creatorEntry, platformEntry, shares, shareTracker }) {
|
||||
const creatorKey = normalizeLightningAddressKey(
|
||||
creatorEntry?.address || currentChannelLightningAddress
|
||||
);
|
||||
const platformKey = normalizeLightningAddressKey(
|
||||
platformEntry?.address || cachedPlatformLightningAddress
|
||||
);
|
||||
|
||||
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) => ensureWallet(options),
|
||||
sendPayment: async (bolt11, params) => {
|
||||
try {
|
||||
const payment = await sendPayment(bolt11, params);
|
||||
if (Array.isArray(shareTracker)) {
|
||||
const shareType = activeShare || "unknown";
|
||||
const amount =
|
||||
shareType === "platform"
|
||||
? shares.platformShare
|
||||
: shares.creatorShare;
|
||||
const address =
|
||||
shareType === "platform"
|
||||
? platformEntry?.address || cachedPlatformLightningAddress
|
||||
: creatorEntry?.address || currentChannelLightningAddress;
|
||||
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 || cachedPlatformLightningAddress
|
||||
: creatorEntry?.address || currentChannelLightningAddress;
|
||||
shareTracker.push({
|
||||
type: shareType,
|
||||
status: "error",
|
||||
amount,
|
||||
address,
|
||||
error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
activeShare = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
platformAddress: {
|
||||
getPlatformLightningAddress: async () => {
|
||||
if (platformEntry?.address) {
|
||||
return platformEntry.address;
|
||||
}
|
||||
if (cachedPlatformLightningAddress) {
|
||||
return cachedPlatformLightningAddress;
|
||||
}
|
||||
const fallback = await getPlatformLightningAddress({ forceRefresh: false });
|
||||
cachedPlatformLightningAddress = fallback || "";
|
||||
return fallback;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getWalletSettingsOrPrompt() {
|
||||
const app = getApp();
|
||||
const settings =
|
||||
typeof app?.getActiveNwcSettings === "function"
|
||||
? app.getActiveNwcSettings()
|
||||
: {};
|
||||
const normalizedUri =
|
||||
typeof settings?.nwcUri === "string" ? settings.nwcUri.trim() : "";
|
||||
if (!normalizedUri) {
|
||||
app?.showError?.("Connect a Lightning wallet to send zaps.");
|
||||
if (typeof app?.openWalletPane === "function") {
|
||||
app.openWalletPane();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...settings,
|
||||
nwcUri: normalizedUri,
|
||||
};
|
||||
}
|
||||
|
||||
async function runZapAttempt({ amount, overrideFee = null, walletSettings }) {
|
||||
const settings = walletSettings || getWalletSettingsOrPrompt();
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const context = await prepareLightningContext({ amount, overrideFee });
|
||||
const shareTracker = [];
|
||||
const dependencies = createZapDependencies({
|
||||
...context,
|
||||
shareTracker,
|
||||
});
|
||||
const videoEvent = getZapVideoEvent();
|
||||
|
||||
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 splitAndZap(
|
||||
{
|
||||
videoEvent,
|
||||
amountSats: context.shares.total,
|
||||
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 && "__BITVID_PLATFORM_FEE_OVERRIDE__" in globalThis) {
|
||||
delete globalThis.__BITVID_PLATFORM_FEE_OVERRIDE__;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleZapAmountChange() {
|
||||
resetZapRetryState();
|
||||
updateZapSplitSummary();
|
||||
}
|
||||
|
||||
async function executePendingRetry({ walletSettings }) {
|
||||
const retryState = pendingZapRetry;
|
||||
const shares = Array.isArray(retryState?.shares) ? retryState.shares : [];
|
||||
if (!shares.length) {
|
||||
resetZapRetryState();
|
||||
return;
|
||||
}
|
||||
|
||||
const app = getApp();
|
||||
const summary = shares
|
||||
.map((share) => `${describeShareType(share.type)} ${share.amount} sats`)
|
||||
.join(", ");
|
||||
setZapStatus(`Retrying failed share(s): ${summary}`, "warning");
|
||||
clearZapReceipts();
|
||||
|
||||
const aggregatedReceipts = [];
|
||||
const aggregatedTracker = [];
|
||||
|
||||
for (const share of shares) {
|
||||
const overrideFee = share.type === "platform" ? 100 : 0;
|
||||
try {
|
||||
const attempt = await runZapAttempt({
|
||||
amount: share.amount,
|
||||
overrideFee,
|
||||
walletSettings,
|
||||
});
|
||||
if (!attempt) {
|
||||
setZapStatus("", "neutral");
|
||||
return;
|
||||
}
|
||||
if (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);
|
||||
}
|
||||
const failureShares = tracker.filter(
|
||||
(entry) => entry && entry.status !== "success" && entry.amount > 0
|
||||
);
|
||||
markZapRetryPending(failureShares.length ? failureShares : [share]);
|
||||
const message = error?.message || "Retry failed.";
|
||||
setZapStatus(message, "error");
|
||||
app?.showError?.(message);
|
||||
renderZapReceipts(aggregatedTracker.length ? aggregatedTracker : tracker, {
|
||||
partial: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderZapReceipts(
|
||||
aggregatedReceipts.length ? aggregatedReceipts : aggregatedTracker,
|
||||
{ partial: false }
|
||||
);
|
||||
setZapStatus("Retry completed successfully.", "success");
|
||||
app?.showSuccess?.("Failed shares retried successfully.");
|
||||
resetZapRetryState();
|
||||
}
|
||||
|
||||
async function handleZapButtonClick(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const zapButton = getChannelZapButton();
|
||||
const amountInput = getZapAmountInput();
|
||||
const app = getApp();
|
||||
|
||||
if (!zapButton || !amountInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentChannelLightningAddress) {
|
||||
setZapStatus("This creator has not configured a Lightning address yet.", "error");
|
||||
app?.showError?.("This creator has not configured a Lightning address yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (zapInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const walletSettings = getWalletSettingsOrPrompt();
|
||||
if (!walletSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
zapInFlight = true;
|
||||
zapButton.disabled = true;
|
||||
zapButton.setAttribute("aria-busy", "true");
|
||||
zapButton.classList.add("opacity-50", "pointer-events-none");
|
||||
amountInput.disabled = true;
|
||||
|
||||
try {
|
||||
if (pendingZapRetry?.shares?.length) {
|
||||
await executePendingRetry({ walletSettings });
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = Math.max(0, Math.round(Number(amountInput.value || 0)));
|
||||
if (!amount) {
|
||||
const message = "Enter a zap amount greater than zero.";
|
||||
setZapStatus(message, "error");
|
||||
app?.showError?.(message);
|
||||
return;
|
||||
}
|
||||
|
||||
clearZapReceipts();
|
||||
setZapStatus(`Sending ${amount} sats…`, "warning");
|
||||
|
||||
const attempt = await runZapAttempt({ amount, walletSettings });
|
||||
if (!attempt) {
|
||||
setZapStatus("", "neutral");
|
||||
return;
|
||||
}
|
||||
|
||||
const { context, result } = attempt;
|
||||
const receipts = Array.isArray(result?.receipts) ? result.receipts : [];
|
||||
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.`;
|
||||
setZapStatus(summary, "success");
|
||||
app?.showSuccess?.("Zap sent successfully!");
|
||||
resetZapRetryState();
|
||||
} catch (error) {
|
||||
const tracker = Array.isArray(error?.__zapShareTracker)
|
||||
? error.__zapShareTracker
|
||||
: [];
|
||||
if (tracker.length) {
|
||||
renderZapReceipts(tracker, { partial: true });
|
||||
}
|
||||
|
||||
const failureShares = tracker.filter(
|
||||
(entry) => entry && entry.status !== "success" && entry.amount > 0
|
||||
);
|
||||
if (failureShares.length) {
|
||||
markZapRetryPending(failureShares);
|
||||
const summary = failureShares
|
||||
.map((share) => `${describeShareType(share.type)} ${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}.`;
|
||||
setZapStatus(statusMessage, tone);
|
||||
app?.showError?.(error?.message || statusMessage);
|
||||
} else {
|
||||
resetZapRetryState();
|
||||
const message = error?.message || "Zap failed. Please try again.";
|
||||
setZapStatus(message, "error");
|
||||
app?.showError?.(message);
|
||||
}
|
||||
} finally {
|
||||
zapInFlight = false;
|
||||
zapButton.disabled = false;
|
||||
zapButton.removeAttribute("aria-busy");
|
||||
zapButton.classList.remove("opacity-50", "pointer-events-none");
|
||||
amountInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildChannelShareUrl() {
|
||||
if (!currentChannelNpub) {
|
||||
return "";
|
||||
@@ -235,6 +1076,12 @@ export async function initChannelProfileView() {
|
||||
|
||||
currentChannelHex = null;
|
||||
currentChannelNpub = null;
|
||||
currentChannelLightningAddress = "";
|
||||
currentChannelProfileEvent = null;
|
||||
cachedPlatformLightningAddress = "";
|
||||
resetZapRetryState();
|
||||
clearZapReceipts();
|
||||
setZapStatus("", "neutral");
|
||||
|
||||
// 2) Decode npub => hex pubkey
|
||||
let hexPub;
|
||||
@@ -282,19 +1129,32 @@ export async function initChannelProfileView() {
|
||||
|
||||
function setupZapButton() {
|
||||
const zapButton = getChannelZapButton();
|
||||
if (!zapButton) {
|
||||
const amountInput = getZapAmountInput();
|
||||
const controls = getZapControlsContainer();
|
||||
if (!zapButton || !amountInput || !controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
setChannelZapVisibility(false);
|
||||
|
||||
if (zapButton.dataset.initialized === "true") {
|
||||
updateZapSplitSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
zapButton.addEventListener("click", () => {
|
||||
window.alert("Zaps coming soon.");
|
||||
});
|
||||
const app = getApp();
|
||||
const activeSettings =
|
||||
typeof app?.getActiveNwcSettings === "function"
|
||||
? app.getActiveNwcSettings()
|
||||
: {};
|
||||
if (Number.isFinite(activeSettings?.defaultZap) && activeSettings.defaultZap > 0) {
|
||||
amountInput.value = Math.max(0, Math.round(activeSettings.defaultZap));
|
||||
}
|
||||
|
||||
updateZapSplitSummary();
|
||||
amountInput.addEventListener("input", handleZapAmountChange);
|
||||
amountInput.addEventListener("change", handleZapAmountChange);
|
||||
zapButton.addEventListener("click", handleZapButtonClick);
|
||||
zapButton.dataset.initialized = "true";
|
||||
}
|
||||
|
||||
@@ -360,8 +1220,19 @@ async function loadUserProfile(pubkey) {
|
||||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||||
]);
|
||||
|
||||
if (events.length && events[0].content) {
|
||||
const meta = JSON.parse(events[0].content);
|
||||
let newestEvent = null;
|
||||
for (const event of events) {
|
||||
if (!event || !event.content) {
|
||||
continue;
|
||||
}
|
||||
if (!newestEvent || event.created_at > newestEvent.created_at) {
|
||||
newestEvent = event;
|
||||
}
|
||||
}
|
||||
|
||||
if (newestEvent?.content) {
|
||||
currentChannelProfileEvent = { ...newestEvent };
|
||||
const meta = JSON.parse(newestEvent.content);
|
||||
|
||||
// Banner
|
||||
const bannerEl = document.getElementById("channelBanner");
|
||||
@@ -409,10 +1280,18 @@ async function loadUserProfile(pubkey) {
|
||||
// Lightning Address
|
||||
const lnEl = document.getElementById("channelLightning");
|
||||
const lightningAddress = (meta.lud16 || meta.lud06 || "").trim();
|
||||
currentChannelLightningAddress = lightningAddress;
|
||||
if (lnEl) {
|
||||
lnEl.textContent =
|
||||
lightningAddress || "No lightning address found.";
|
||||
}
|
||||
if (lightningAddress) {
|
||||
fetchLightningMetadata(lightningAddress)
|
||||
.then(() => updateZapSplitSummary())
|
||||
.catch(() => {});
|
||||
} else {
|
||||
updateZapSplitSummary();
|
||||
}
|
||||
setChannelZapVisibility(!!lightningAddress);
|
||||
} else {
|
||||
console.warn("No metadata found for this user.");
|
||||
@@ -421,6 +1300,8 @@ async function loadUserProfile(pubkey) {
|
||||
if (lnEl) {
|
||||
lnEl.textContent = "No lightning address found.";
|
||||
}
|
||||
currentChannelLightningAddress = "";
|
||||
currentChannelProfileEvent = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch user profile data:", err);
|
||||
@@ -429,6 +1310,8 @@ async function loadUserProfile(pubkey) {
|
||||
if (lnEl) {
|
||||
lnEl.textContent = "No lightning address found.";
|
||||
}
|
||||
currentChannelLightningAddress = "";
|
||||
currentChannelProfileEvent = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
-69
@@ -27,85 +27,117 @@
|
||||
<p id="channelNpub" class="text-sm opacity-80">npub...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 self-start sm:self-end">
|
||||
<button
|
||||
id="zapButton"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Send a zap"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/lightning-bolt.svg"
|
||||
alt="Zap"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id="channelShareBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Share channel"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/share-video.svg"
|
||||
alt="Share channel"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
class="relative inline-block overflow-visible"
|
||||
data-more-menu-wrapper="true"
|
||||
>
|
||||
<div class="flex flex-col items-end gap-3 self-start sm:self-end">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
id="channelMoreBtn"
|
||||
id="zapButton"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Channel options"
|
||||
data-more-dropdown="channel-profile"
|
||||
aria-label="Send a zap"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
|
||||
<img
|
||||
src="assets/svg/lightning-bolt.svg"
|
||||
alt="Zap"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id="channelShareBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-label="Share channel"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/share-video.svg"
|
||||
alt="Share channel"
|
||||
class="icon-image"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-channel-profile"
|
||||
class="hidden absolute right-0 top-12 w-48 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
class="relative inline-block overflow-visible"
|
||||
data-more-menu-wrapper="true"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="copy-npub"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Copy npub
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="blacklist-author"
|
||||
data-context="channel-profile"
|
||||
data-blacklist-menu="true"
|
||||
>
|
||||
Blacklist channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="block-author"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Block channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="report"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
<button
|
||||
id="channelMoreBtn"
|
||||
type="button"
|
||||
class="icon-button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
aria-label="Channel options"
|
||||
data-more-dropdown="channel-profile"
|
||||
>
|
||||
<img src="assets/svg/ellipsis.svg" alt="More" class="w-5 h-5" />
|
||||
</button>
|
||||
<div
|
||||
id="moreDropdown-channel-profile"
|
||||
class="hidden absolute right-0 top-12 w-48 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
role="menu"
|
||||
data-more-menu="true"
|
||||
>
|
||||
<div class="py-1">
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="copy-npub"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Copy npub
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="blacklist-author"
|
||||
data-context="channel-profile"
|
||||
data-blacklist-menu="true"
|
||||
>
|
||||
Blacklist channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||
data-action="block-author"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Block channel
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||
data-action="report"
|
||||
data-context="channel-profile"
|
||||
>
|
||||
Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="zapControls" class="hidden w-full max-w-xs sm:max-w-sm">
|
||||
<div class="w-full rounded-lg bg-gray-900 bg-opacity-70 p-4 shadow-lg backdrop-blur">
|
||||
<label for="zapAmountInput" class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-300">
|
||||
Zap amount (sats)
|
||||
</label>
|
||||
<input
|
||||
id="zapAmountInput"
|
||||
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"
|
||||
/>
|
||||
<p
|
||||
id="zapSplitSummary"
|
||||
class="mt-3 text-sm text-gray-300"
|
||||
aria-live="polite"
|
||||
>
|
||||
Enter an amount to view the split.
|
||||
</p>
|
||||
<p
|
||||
id="zapStatus"
|
||||
class="mt-2 text-xs text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></p>
|
||||
<ul id="zapReceipts" class="mt-3 space-y-2 text-xs text-gray-300"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user