+ {/* Brief nudge when V4V episode starts — once per episode per session */}
+ {showNudge && (
+
{ setShowNudge(false); setOpen(true); }}
+ style={{ cursor: "pointer" }}
+ >
+ ⚡ This episode supports V4V — stream sats to the creators
+
+ )}
+
{open && (
Value 4 Value
@@ -122,6 +159,23 @@ export function V4VIndicator() {
/min
+ {/* Recipients */}
+ {episode.value && episode.value.length > 0 && (
+
+
Sats go to:
+ {episode.value.map((r, i) => {
+ const totalSplit = episode.value!.reduce((s, v) => s + v.split, 0);
+ const pct = totalSplit > 0 ? Math.round((r.split / totalSplit) * 100) : 0;
+ return (
+
+ {r.name || r.address?.slice(0, 12) + "…"}
+ {pct}%
+
+ );
+ })}
+
+ )}
+
{/* Boost */}
{
+ const { walletPubkey, relayUrl, secret } = parseNwcUri(nwcUri);
+
+ const ndk = new NDK({ explicitRelayUrls: [relayUrl], enableOutboxModel: false });
+ const signer = new NDKPrivateKeySigner(secret);
+ ndk.signer = signer;
+ await ndk.connect();
+
+ await new Promise((resolve) => {
+ const check = () => {
+ const relays = Array.from(ndk.pool?.relays?.values() ?? []);
+ if (relays.some((r) => r.connected)) resolve();
+ else setTimeout(check, 200);
+ };
+ setTimeout(() => resolve(), 8000);
+ check();
+ });
+
+ const walletUser = ndk.getUser({ pubkey: walletPubkey });
+
+ const params: Record = {
+ pubkey,
+ amount: amountMsats,
+ };
+ if (tlvRecords && tlvRecords.length > 0) {
+ params.tlv_records = tlvRecords.map((r) => ({
+ type: r.type,
+ value: r.value,
+ }));
+ }
+
+ const requestContent = JSON.stringify({ method: "pay_keysend", params });
+ const encrypted = await signer.encrypt(walletUser, requestContent, "nip04");
+
+ const requestEvent = new NDKEvent(ndk);
+ requestEvent.kind = NDKKind.NostrWalletConnectReq;
+ requestEvent.content = encrypted;
+ requestEvent.tags = [["p", walletPubkey]];
+ await requestEvent.sign();
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ sub.stop();
+ reject(new Error("NWC keysend timed out (30s)"));
+ }, 30000);
+
+ const filter: NDKFilter = {
+ kinds: [NDKKind.NostrWalletConnectRes],
+ authors: [walletPubkey],
+ "#e": [requestEvent.id!],
+ };
+
+ const sub = ndk.subscribe(filter, { closeOnEose: false });
+
+ sub.on("event", async (event: NDKEvent) => {
+ clearTimeout(timeout);
+ sub.stop();
+ try {
+ const decrypted = await signer.decrypt(walletUser, event.content, "nip04");
+ const response = JSON.parse(decrypted);
+ if (response.error) {
+ reject(new Error(response.error.message || "NWC keysend failed"));
+ } else {
+ resolve(response.result?.preimage ?? "");
+ }
+ } catch (err) {
+ reject(err);
+ }
+ });
+
+ requestEvent.publish();
+ });
+}
+
export async function payInvoiceViaNWC(nwcUri: string, bolt11: string): Promise {
const { walletPubkey, relayUrl, secret } = parseNwcUri(nwcUri);
diff --git a/src/lib/podcast/v4v.ts b/src/lib/podcast/v4v.ts
index 4c55f8f..12fc404 100644
--- a/src/lib/podcast/v4v.ts
+++ b/src/lib/podcast/v4v.ts
@@ -1,6 +1,6 @@
import { fetch } from "@tauri-apps/plugin-http";
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
-import { payInvoiceViaNWC } from "../lightning/nwc";
+import { payInvoiceViaNWC, payKeysendViaNWC } from "../lightning/nwc";
const LNURL_CACHE: Record = {};
@@ -39,6 +39,38 @@ function getRecipients(episode: PodcastEpisode): V4VRecipient[] {
return [];
}
+async function payRecipient(
+ recipient: V4VRecipient,
+ amountMsats: number,
+ nwcUri: string,
+): Promise {
+ if (!recipient.address) return false;
+
+ const isLnAddress = recipient.address.includes("@");
+ const isNodePubkey = /^[0-9a-f]{66}$/i.test(recipient.address);
+
+ if (isLnAddress) {
+ const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
+ if (!invoice) return false;
+ await payInvoiceViaNWC(nwcUri, invoice);
+ return true;
+ }
+
+ if (isNodePubkey) {
+ const tlvRecords: { type: number; value: string }[] = [];
+ if (recipient.customKey && recipient.customValue) {
+ tlvRecords.push({
+ type: parseInt(recipient.customKey, 10),
+ value: recipient.customValue,
+ });
+ }
+ await payKeysendViaNWC(nwcUri, recipient.address, amountMsats, tlvRecords);
+ return true;
+ }
+
+ return false;
+}
+
let streamingInterval: number | null = null;
let accumulatedSats = 0;
let accumulatedMinutes = 0;
@@ -72,17 +104,13 @@ export function startStreaming(
accumulatedMinutes = 0;
for (const recipient of recipients) {
- if (!recipient.address || !recipient.address.includes("@")) continue;
-
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
const recipientSats = Math.max(1, Math.round(satsToSend * share));
const amountMsats = recipientSats * 1000;
try {
- const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
- if (!invoice) continue;
- await payInvoiceViaNWC(nwcUri, invoice);
- onPayment(recipientSats);
+ const success = await payRecipient(recipient, amountMsats, nwcUri);
+ if (success) onPayment(recipientSats);
} catch {
// Payment failed — silently continue
}
@@ -113,17 +141,13 @@ export async function boost(
let paid = 0;
for (const recipient of recipients) {
- if (!recipient.address || !recipient.address.includes("@")) continue;
-
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
const recipientSats = Math.max(1, Math.round(totalSats * share));
const amountMsats = recipientSats * 1000;
try {
- const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
- if (!invoice) continue;
- await payInvoiceViaNWC(nwcUri, invoice);
- paid += recipientSats;
+ const success = await payRecipient(recipient, amountMsats, nwcUri);
+ if (success) paid += recipientSats;
} catch {
// continue
}