From 1d5d43ae78a7a6de0551e69df042cb5001895f81 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:58:14 +0200 Subject: [PATCH] V4V: keysend payments, recipient breakdown, and episode nudge - Add payKeysendViaNWC for node pubkey recipients with TLV records - Route V4V payments to keysend or LNURL-pay based on recipient type - Show recipient split breakdown in V4V panel (name + percentage) - Add V4V nudge: brief tooltip when V4V episode starts (once per session) - Highlight V4V button in amber when episode has recipients but streaming off - Enhanced V4V badge in episode list with lightning icon and pill style --- src/components/podcast/EpisodeList.tsx | 4 +- src/components/podcast/V4VIndicator.tsx | 62 +++++++++++++++++-- src/index.css | 11 ++++ src/lib/lightning/nwc.ts | 79 +++++++++++++++++++++++++ src/lib/podcast/v4v.ts | 50 ++++++++++++---- 5 files changed, 188 insertions(+), 18 deletions(-) diff --git a/src/components/podcast/EpisodeList.tsx b/src/components/podcast/EpisodeList.tsx index cf47a7e..84ba9f1 100644 --- a/src/components/podcast/EpisodeList.tsx +++ b/src/components/podcast/EpisodeList.tsx @@ -132,7 +132,9 @@ export function EpisodeList({ show, onBack }: EpisodeListProps) { resumed )} {ep.value && ep.value.length > 0 && ( - V4V + + ⚡ V4V + )} diff --git a/src/components/podcast/V4VIndicator.tsx b/src/components/podcast/V4VIndicator.tsx index 70ce7af..a456ca6 100644 --- a/src/components/podcast/V4VIndicator.tsx +++ b/src/components/podcast/V4VIndicator.tsx @@ -1,21 +1,45 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { usePodcastStore } from "../../stores/podcast"; import { startStreaming, stopStreaming, boost } from "../../lib/podcast/v4v"; const RATE_OPTIONS = [5, 10, 21, 50, 100]; const NWC_KEY = "wrystr_nwc_uri"; +// Track which episodes have shown the nudge this session (not persisted) +const nudgedGuids = new Set(); + export function V4VIndicator() { const [open, setOpen] = useState(false); const [boostAmount, setBoostAmount] = useState("100"); const [boosting, setBoosting] = useState(false); + const [showNudge, setShowNudge] = useState(false); + const nudgeTimer = useRef | null>(null); const episode = usePodcastStore((s) => s.currentEpisode); const v4vSatsPerMinute = usePodcastStore((s) => s.v4vSatsPerMinute); const v4vStreaming = usePodcastStore((s) => s.v4vStreaming); const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed); + const playbackState = usePodcastStore((s) => s.playbackState); const { setV4VEnabled, setV4VSatsPerMinute, setV4VStreaming, addStreamedSats } = usePodcastStore.getState(); + // Show nudge when a V4V episode starts playing and streaming is off + useEffect(() => { + if ( + playbackState === "playing" && + episode?.value && + episode.value.length > 0 && + !v4vStreaming && + !nudgedGuids.has(episode.guid) + ) { + nudgedGuids.add(episode.guid); + setShowNudge(true); + nudgeTimer.current = setTimeout(() => setShowNudge(false), 5000); + } + return () => { + if (nudgeTimer.current) clearTimeout(nudgeTimer.current); + }; + }, [playbackState, episode?.guid, v4vStreaming]); + const nwcUri = localStorage.getItem(NWC_KEY) ?? ""; const hasWallet = !!nwcUri; const hasRecipients = episode?.value && episode.value.length > 0; @@ -57,17 +81,30 @@ export function V4VIndicator() { return (
+ {/* 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 }