Add V4V sidebar section with dashboard, settings, and history

- New V4V store with budget tracking, cap enforcement, and history
- Dashboard: live streaming status, per-episode + weekly budget bars, stats
- Settings: auto-enable toggle (guarded by caps), per-episode cap, weekly
  budget, default rate, info icons
- History: expandable list of past V4V episodes with recipient breakdowns
- Budget enforcement: streaming stops with toast when cap is hit
- Auto-streaming: starts automatically for V4V episodes when enabled
- Cap reached state: dashboard shows red card, ticker shows "(capped)"
- V4VIndicator: slimmed popup, AUTO badge, "open v4v" nav link
- Fix duplicate history entries on cap stop
This commit is contained in:
Jure
2026-04-04 19:03:00 +02:00
parent 1d5d43ae78
commit ebf964980b
10 changed files with 659 additions and 39 deletions

View File

@@ -1,6 +1,8 @@
import { fetch } from "@tauri-apps/plugin-http";
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
import { payInvoiceViaNWC, payKeysendViaNWC } from "../lightning/nwc";
import { useV4VStore } from "../../stores/v4v";
import { useToastStore } from "../../stores/toast";
const LNURL_CACHE: Record<string, string> = {};
@@ -74,6 +76,8 @@ async function payRecipient(
let streamingInterval: number | null = null;
let accumulatedSats = 0;
let accumulatedMinutes = 0;
let currentStreamingEpisode: PodcastEpisode | null = null;
let sessionRecipientSats: Record<string, number> = {};
export function startStreaming(
episode: PodcastEpisode,
@@ -85,14 +89,31 @@ export function startStreaming(
accumulatedSats = 0;
accumulatedMinutes = 0;
currentStreamingEpisode = episode;
sessionRecipientSats = {};
const recipients = getRecipients(episode);
if (recipients.length === 0) return -1;
const v4vStore = useV4VStore.getState();
v4vStore.resetCurrentEpisodeSats();
// Normalize splits to sum to 100
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
streamingInterval = window.setInterval(async () => {
// Check budget caps before accumulating
const v4v = useV4VStore.getState();
if (v4v.isCapReached()) {
const reason = v4v.perEpisodeCap > 0 && v4v.currentEpisodeSats >= v4v.perEpisodeCap
? "Per-episode cap reached"
: "Weekly budget reached";
useToastStore.getState().addToast(reason, "warning", 6000);
v4v.setCapReachedReason(reason);
stopStreaming();
return;
}
accumulatedMinutes += 1;
accumulatedSats += satsPerMinute;
@@ -110,7 +131,12 @@ export function startStreaming(
try {
const success = await payRecipient(recipient, amountMsats, nwcUri);
if (success) onPayment(recipientSats);
if (success) {
onPayment(recipientSats);
useV4VStore.getState().addCurrentEpisodeSats(recipientSats);
const key = recipient.name || recipient.address || "unknown";
sessionRecipientSats[key] = (sessionRecipientSats[key] || 0) + recipientSats;
}
} catch {
// Payment failed — silently continue
}
@@ -120,13 +146,41 @@ export function startStreaming(
return streamingInterval;
}
function recordHistory() {
const episode = currentStreamingEpisode;
if (!episode) return;
const v4v = useV4VStore.getState();
const totalStreamed = v4v.currentEpisodeSats;
if (totalStreamed <= 0) return;
const recipients = Object.entries(sessionRecipientSats).map(([name, sats]) => ({
name,
address: "",
sats,
}));
v4v.addHistoryEntry({
episodeGuid: episode.guid,
episodeTitle: episode.title,
showTitle: episode.showTitle || "",
satsStreamed: totalStreamed,
satsBoosted: 0,
recipients,
timestamp: Date.now(),
});
}
export function stopStreaming() {
if (streamingInterval !== null) {
recordHistory();
clearInterval(streamingInterval);
streamingInterval = null;
}
accumulatedSats = 0;
accumulatedMinutes = 0;
currentStreamingEpisode = null;
sessionRecipientSats = {};
}
export async function boost(