mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 12:19:11 -07:00
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user