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
This commit is contained in:
Jure
2026-04-04 17:58:14 +02:00
parent 5444214041
commit 1d5d43ae78
5 changed files with 188 additions and 18 deletions

View File

@@ -132,7 +132,9 @@ export function EpisodeList({ show, onBack }: EpisodeListProps) {
<span className="text-[10px] text-accent">resumed</span>
)}
{ep.value && ep.value.length > 0 && (
<span className="text-[10px] text-amber-500">V4V</span>
<span className="text-[10px] text-amber-400 bg-amber-500/10 px-1.5 py-0.5 rounded-sm font-medium">
V4V
</span>
)}
</div>
</div>

View File

@@ -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<string>();
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<ReturnType<typeof setTimeout> | 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 (
<div className="relative shrink-0">
<button
onClick={() => setOpen(!open)}
onClick={() => { setOpen(!open); setShowNudge(false); }}
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors ${
v4vStreaming
? "text-amber-400 bg-amber-500/10 animate-pulse"
: "text-text-dim hover:text-text"
: hasRecipients && !v4vStreaming
? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
: "text-text-dim hover:text-text"
}`}
title="Value 4 Value"
>
{v4vStreaming ? `${v4vTotalStreamed} sats` : "V4V"}
{v4vStreaming ? `${v4vTotalStreamed} sats` : hasRecipients ? "⚡ V4V" : "V4V"}
</button>
{/* Brief nudge when V4V episode starts — once per episode per session */}
{showNudge && (
<div
className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-amber-500/15 border border-amber-500/30 rounded-sm text-[10px] text-amber-300 whitespace-nowrap z-50 animate-fade-in"
onClick={() => { setShowNudge(false); setOpen(true); }}
style={{ cursor: "pointer" }}
>
This episode supports V4V stream sats to the creators
</div>
)}
{open && (
<div className="absolute bottom-full right-0 mb-2 w-56 bg-bg border border-border rounded-sm shadow-lg p-3 z-50">
<div className="text-[11px] text-text font-medium mb-2">Value 4 Value</div>
@@ -122,6 +159,23 @@ export function V4VIndicator() {
<span className="text-[9px] text-text-dim">/min</span>
</div>
{/* Recipients */}
{episode.value && episode.value.length > 0 && (
<div className="mb-2 border-t border-border pt-2">
<div className="text-[9px] text-text-dim mb-1">Sats go to:</div>
{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 (
<div key={i} className="flex items-center justify-between text-[9px]">
<span className="text-text-muted truncate">{r.name || r.address?.slice(0, 12) + "…"}</span>
<span className="text-text-dim shrink-0 ml-1">{pct}%</span>
</div>
);
})}
</div>
)}
{/* Boost */}
<div className="flex items-center gap-1 border-t border-border pt-2">
<input

View File

@@ -99,6 +99,17 @@ body {
animation: fade-in 150ms ease-out;
}
/* V4V nudge — slides in, holds, then fades out */
@keyframes nudge-in {
0% { opacity: 0; transform: translateY(4px); }
10% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; }
100% { opacity: 0; }
}
.animate-fade-in {
animation: nudge-in 5s ease-out forwards;
}
/* Toast slide-in */
@keyframes toast-in {
from { opacity: 0; transform: translateX(16px); }

View File

@@ -29,6 +29,85 @@ export function isValidNwcUri(uri: string): boolean {
}
}
export async function payKeysendViaNWC(
nwcUri: string,
pubkey: string,
amountMsats: number,
tlvRecords?: { type: number; value: string }[],
): Promise<string> {
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<void>((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<string, unknown> = {
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<string> {
const { walletPubkey, relayUrl, secret } = parseNwcUri(nwcUri);

View File

@@ -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<string, string> = {};
@@ -39,6 +39,38 @@ function getRecipients(episode: PodcastEpisode): V4VRecipient[] {
return [];
}
async function payRecipient(
recipient: V4VRecipient,
amountMsats: number,
nwcUri: string,
): Promise<boolean> {
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
}