mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 12:48:35 -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>
|
<span className="text-[10px] text-accent">resumed</span>
|
||||||
)}
|
)}
|
||||||
{ep.value && ep.value.length > 0 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,45 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { usePodcastStore } from "../../stores/podcast";
|
import { usePodcastStore } from "../../stores/podcast";
|
||||||
import { startStreaming, stopStreaming, boost } from "../../lib/podcast/v4v";
|
import { startStreaming, stopStreaming, boost } from "../../lib/podcast/v4v";
|
||||||
|
|
||||||
const RATE_OPTIONS = [5, 10, 21, 50, 100];
|
const RATE_OPTIONS = [5, 10, 21, 50, 100];
|
||||||
const NWC_KEY = "wrystr_nwc_uri";
|
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() {
|
export function V4VIndicator() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [boostAmount, setBoostAmount] = useState("100");
|
const [boostAmount, setBoostAmount] = useState("100");
|
||||||
const [boosting, setBoosting] = useState(false);
|
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 episode = usePodcastStore((s) => s.currentEpisode);
|
||||||
const v4vSatsPerMinute = usePodcastStore((s) => s.v4vSatsPerMinute);
|
const v4vSatsPerMinute = usePodcastStore((s) => s.v4vSatsPerMinute);
|
||||||
const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
|
const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
|
||||||
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
|
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
|
||||||
|
const playbackState = usePodcastStore((s) => s.playbackState);
|
||||||
const { setV4VEnabled, setV4VSatsPerMinute, setV4VStreaming, addStreamedSats } = usePodcastStore.getState();
|
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 nwcUri = localStorage.getItem(NWC_KEY) ?? "";
|
||||||
const hasWallet = !!nwcUri;
|
const hasWallet = !!nwcUri;
|
||||||
const hasRecipients = episode?.value && episode.value.length > 0;
|
const hasRecipients = episode?.value && episode.value.length > 0;
|
||||||
@@ -57,17 +81,30 @@ export function V4VIndicator() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => { setOpen(!open); setShowNudge(false); }}
|
||||||
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors ${
|
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors ${
|
||||||
v4vStreaming
|
v4vStreaming
|
||||||
? "text-amber-400 bg-amber-500/10 animate-pulse"
|
? "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"
|
title="Value 4 Value"
|
||||||
>
|
>
|
||||||
{v4vStreaming ? `${v4vTotalStreamed} sats` : "V4V"}
|
{v4vStreaming ? `⚡ ${v4vTotalStreamed} sats` : hasRecipients ? "⚡ V4V" : "V4V"}
|
||||||
</button>
|
</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 && (
|
{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="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>
|
<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>
|
<span className="text-[9px] text-text-dim">/min</span>
|
||||||
</div>
|
</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 */}
|
{/* Boost */}
|
||||||
<div className="flex items-center gap-1 border-t border-border pt-2">
|
<div className="flex items-center gap-1 border-t border-border pt-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ body {
|
|||||||
animation: fade-in 150ms ease-out;
|
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 */
|
/* Toast slide-in */
|
||||||
@keyframes toast-in {
|
@keyframes toast-in {
|
||||||
from { opacity: 0; transform: translateX(16px); }
|
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> {
|
export async function payInvoiceViaNWC(nwcUri: string, bolt11: string): Promise<string> {
|
||||||
const { walletPubkey, relayUrl, secret } = parseNwcUri(nwcUri);
|
const { walletPubkey, relayUrl, secret } = parseNwcUri(nwcUri);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fetch } from "@tauri-apps/plugin-http";
|
import { fetch } from "@tauri-apps/plugin-http";
|
||||||
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
||||||
import { payInvoiceViaNWC } from "../lightning/nwc";
|
import { payInvoiceViaNWC, payKeysendViaNWC } from "../lightning/nwc";
|
||||||
|
|
||||||
const LNURL_CACHE: Record<string, string> = {};
|
const LNURL_CACHE: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -39,6 +39,38 @@ function getRecipients(episode: PodcastEpisode): V4VRecipient[] {
|
|||||||
return [];
|
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 streamingInterval: number | null = null;
|
||||||
let accumulatedSats = 0;
|
let accumulatedSats = 0;
|
||||||
let accumulatedMinutes = 0;
|
let accumulatedMinutes = 0;
|
||||||
@@ -72,17 +104,13 @@ export function startStreaming(
|
|||||||
accumulatedMinutes = 0;
|
accumulatedMinutes = 0;
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
if (!recipient.address || !recipient.address.includes("@")) continue;
|
|
||||||
|
|
||||||
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
||||||
const recipientSats = Math.max(1, Math.round(satsToSend * share));
|
const recipientSats = Math.max(1, Math.round(satsToSend * share));
|
||||||
const amountMsats = recipientSats * 1000;
|
const amountMsats = recipientSats * 1000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
|
const success = await payRecipient(recipient, amountMsats, nwcUri);
|
||||||
if (!invoice) continue;
|
if (success) onPayment(recipientSats);
|
||||||
await payInvoiceViaNWC(nwcUri, invoice);
|
|
||||||
onPayment(recipientSats);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Payment failed — silently continue
|
// Payment failed — silently continue
|
||||||
}
|
}
|
||||||
@@ -113,17 +141,13 @@ export async function boost(
|
|||||||
let paid = 0;
|
let paid = 0;
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
if (!recipient.address || !recipient.address.includes("@")) continue;
|
|
||||||
|
|
||||||
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
||||||
const recipientSats = Math.max(1, Math.round(totalSats * share));
|
const recipientSats = Math.max(1, Math.round(totalSats * share));
|
||||||
const amountMsats = recipientSats * 1000;
|
const amountMsats = recipientSats * 1000;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
|
const success = await payRecipient(recipient, amountMsats, nwcUri);
|
||||||
if (!invoice) continue;
|
if (success) paid += recipientSats;
|
||||||
await payInvoiceViaNWC(nwcUri, invoice);
|
|
||||||
paid += recipientSats;
|
|
||||||
} catch {
|
} catch {
|
||||||
// continue
|
// continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user