@@ -140,25 +188,6 @@ export function V4VIndicator() {
-
{/* Recipients */}
{episode.value && episode.value.length > 0 && (
diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx
index 626df60..aa0f25d 100644
--- a/src/components/sidebar/Sidebar.tsx
+++ b/src/components/sidebar/Sidebar.tsx
@@ -18,6 +18,7 @@ const NAV_ITEMS = [
{ id: "notifications" as const, label: "notifications", icon: "🔔" },
{ id: "follows" as const, label: "follows", icon: "♺" },
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
+ { id: "v4v" as const, label: "v4v", icon: "⚡" },
{ id: "relays" as const, label: "relays", icon: "⟐" },
{ id: "settings" as const, label: "settings", icon: "⚙" },
{ id: "about" as const, label: "support", icon: "♥" },
diff --git a/src/components/v4v/V4VDashboard.tsx b/src/components/v4v/V4VDashboard.tsx
new file mode 100644
index 0000000..f46e878
--- /dev/null
+++ b/src/components/v4v/V4VDashboard.tsx
@@ -0,0 +1,128 @@
+import { usePodcastStore } from "../../stores/podcast";
+import { useV4VStore } from "../../stores/v4v";
+
+function BudgetBar({ label, spent, total }: { label: string; spent: number; total: number }) {
+ if (total <= 0) return null;
+ const pct = Math.min(100, Math.round((spent / total) * 100));
+ const isNear = pct >= 80;
+ const isOver = pct >= 100;
+
+ return (
+
+
+ {label}
+
+ {spent} / {total} sats
+
+
+
+
+ {total - spent > 0 ? `${total - spent} sats remaining` : "Budget reached"}
+
+
+ );
+}
+
+export function V4VDashboard() {
+ const episode = usePodcastStore((s) => s.currentEpisode);
+ const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
+ const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
+
+ const autoEnabled = useV4VStore((s) => s.autoEnabled);
+ const perEpisodeCap = useV4VStore((s) => s.perEpisodeCap);
+ const weeklyBudget = useV4VStore((s) => s.weeklyBudget);
+ const defaultRate = useV4VStore((s) => s.defaultRate);
+ const currentEpisodeSats = useV4VStore((s) => s.currentEpisodeSats);
+ const capReachedReason = useV4VStore((s) => s.capReachedReason);
+ const history = useV4VStore((s) => s.history);
+ const weeklySpent = useV4VStore((s) => s.weeklySpent)();
+
+ const hasRecipients = episode?.value && episode.value.length > 0;
+
+ // All-time stats
+ const allTimeSats = history.reduce((sum, e) => sum + e.satsStreamed + e.satsBoosted, 0) + v4vTotalStreamed;
+ const episodesSupported = new Set(history.map((e) => e.episodeGuid)).size;
+
+ return (
+
+ {/* Current streaming status */}
+
+ Now
+
+ {!episode ? (
+ No episode playing.
+ ) : !hasRecipients ? (
+
+ Playing {episode.title} — no V4V recipients.
+
+ ) : v4vStreaming ? (
+
+
+ ⚡
+ Streaming
+ {autoEnabled && (
+ AUTO
+ )}
+
+
{episode.title}
+
+ {v4vTotalStreamed} sats this session · {episode.value?.length} recipients
+
+
+ ) : capReachedReason ? (
+
+
+ ⚡
+ {capReachedReason}
+
+
{episode.title}
+
+ {currentEpisodeSats} sats streamed to {episode.value?.length} recipients
+
+
+ ) : (
+
+ Playing {episode.title} — V4V available but not streaming.
+
+ )}
+
+
+ {/* Budget bars */}
+ {(perEpisodeCap > 0 || weeklyBudget > 0) && (
+
+ )}
+
+ {/* Quick stats */}
+
+ Stats
+
+
+
{allTimeSats}
+
total sats
+
+
+
{episodesSupported}
+
episodes
+
+
+
+ {autoEnabled ? `${defaultRate}/m` : "off"}
+
+
auto-stream
+
+
+
+
+ );
+}
diff --git a/src/components/v4v/V4VHistory.tsx b/src/components/v4v/V4VHistory.tsx
new file mode 100644
index 0000000..21ed00e
--- /dev/null
+++ b/src/components/v4v/V4VHistory.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react";
+import { useV4VStore, type V4VHistoryEntry } from "../../stores/v4v";
+
+function formatDate(ts: number): string {
+ const d = new Date(ts);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
+}
+
+function HistoryRow({ entry }: { entry: V4VHistoryEntry }) {
+ const [expanded, setExpanded] = useState(false);
+ const totalSats = entry.satsStreamed + entry.satsBoosted;
+
+ return (
+
+
+
+ {expanded && (
+
+ {entry.satsStreamed > 0 && (
+
Streamed: {entry.satsStreamed} sats
+ )}
+ {entry.satsBoosted > 0 && (
+
Boosted: {entry.satsBoosted} sats
+ )}
+ {entry.recipients.length > 0 && (
+
+
Recipients:
+ {entry.recipients.map((r, i) => (
+
+ {r.name || r.address.slice(0, 16) + "..."}
+ {r.sats} sats
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+}
+
+export function V4VHistory() {
+ const history = useV4VStore((s) => s.history);
+
+ if (history.length === 0) {
+ return (
+
+
+
No V4V history yet
+
+ Start streaming to see your contributions here.
+
+
+
+ );
+ }
+
+ return (
+
+ {history.map((entry, i) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/v4v/V4VSettings.tsx b/src/components/v4v/V4VSettings.tsx
new file mode 100644
index 0000000..b05c265
--- /dev/null
+++ b/src/components/v4v/V4VSettings.tsx
@@ -0,0 +1,133 @@
+import { useV4VStore } from "../../stores/v4v";
+
+const RATE_OPTIONS = [5, 10, 21, 50, 100];
+
+function InfoIcon({ title }: { title: string }) {
+ return (
+
+ ⓘ
+
+ );
+}
+
+export function V4VSettings() {
+ const autoEnabled = useV4VStore((s) => s.autoEnabled);
+ const perEpisodeCap = useV4VStore((s) => s.perEpisodeCap);
+ const weeklyBudget = useV4VStore((s) => s.weeklyBudget);
+ const defaultRate = useV4VStore((s) => s.defaultRate);
+ const {
+ setAutoEnabled, setPerEpisodeCap, setWeeklyBudget, setDefaultRate,
+ } = useV4VStore.getState();
+
+ return (
+
+ {/* Auto-enable */}
+
+
+
+ Auto-stream
+
+
+
+
+
+ Automatically stream sats to creators when playing V4V-enabled episodes.
+
+ {autoEnabled && (
+
+ ● Auto-streaming is active
+
+ )}
+
+
+ {/* Per-episode cap */}
+
+
+
+
+
+
+ Stop streaming after this many sats per episode.
+
+
+ setPerEpisodeCap(parseInt(e.target.value) || 0)}
+ placeholder="e.g. 100"
+ className="w-24 bg-bg-raised border border-border rounded-sm px-2 py-1 text-[11px] text-text placeholder:text-text-dim/50"
+ />
+ sats
+
+
+
+ {/* Weekly budget */}
+
+
+
+
+
+
+ Total sats allowed per week across all episodes.
+
+
+ setWeeklyBudget(parseInt(e.target.value) || 0)}
+ placeholder="e.g. 500"
+ className="w-24 bg-bg-raised border border-border rounded-sm px-2 py-1 text-[11px] text-text placeholder:text-text-dim/50"
+ />
+ sats / week
+
+
+
+ {/* Default rate */}
+
+
+
+
+
+
+ Sats per minute when auto-streaming.
+
+
+ {RATE_OPTIONS.map((rate) => (
+
+ ))}
+ /min
+
+
+
+ {/* Requirements notice */}
+ {!autoEnabled && (perEpisodeCap <= 0 || weeklyBudget <= 0) && (
+
+ Set both a per-episode cap and weekly budget to enable auto-streaming.
+
+ )}
+
+ );
+}
diff --git a/src/components/v4v/V4VView.tsx b/src/components/v4v/V4VView.tsx
new file mode 100644
index 0000000..42c2b1e
--- /dev/null
+++ b/src/components/v4v/V4VView.tsx
@@ -0,0 +1,44 @@
+import { useState } from "react";
+import { V4VDashboard } from "./V4VDashboard";
+import { V4VSettings } from "./V4VSettings";
+import { V4VHistory } from "./V4VHistory";
+
+const TABS = ["dashboard", "settings", "history"] as const;
+type Tab = (typeof TABS)[number];
+
+export function V4VView() {
+ const [tab, setTab] = useState
("dashboard");
+
+ return (
+
+ {/* Header */}
+
+
+
Value 4 Value
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+
+
+ {/* Content */}
+
+ {tab === "dashboard" && }
+ {tab === "settings" && }
+ {tab === "history" && }
+
+
+ );
+}
diff --git a/src/lib/podcast/v4v.ts b/src/lib/podcast/v4v.ts
index 12fc404..8f2a27c 100644
--- a/src/lib/podcast/v4v.ts
+++ b/src/lib/podcast/v4v.ts
@@ -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 = {};
@@ -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 = {};
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(
diff --git a/src/stores/ui.ts b/src/stores/ui.ts
index 27f4af9..b9cb46a 100644
--- a/src/stores/ui.ts
+++ b/src/stores/ui.ts
@@ -2,7 +2,7 @@ import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk";
-type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "podcasts" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag" | "follows";
+type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "podcasts" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag" | "follows" | "v4v";
type FeedTab = "global" | "following" | "trending";
interface ViewStackEntry {
diff --git a/src/stores/v4v.ts b/src/stores/v4v.ts
new file mode 100644
index 0000000..8ad9b97
--- /dev/null
+++ b/src/stores/v4v.ts
@@ -0,0 +1,150 @@
+import { create } from "zustand";
+import { useToastStore } from "./toast";
+
+const STORAGE_KEY = "wrystr_v4v";
+const MAX_HISTORY = 500;
+const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+
+export interface V4VHistoryEntry {
+ episodeGuid: string;
+ episodeTitle: string;
+ showTitle: string;
+ satsStreamed: number;
+ satsBoosted: number;
+ recipients: { name: string; address: string; sats: number }[];
+ timestamp: number;
+}
+
+interface V4VPersistedState {
+ autoEnabled: boolean;
+ perEpisodeCap: number;
+ weeklyBudget: number;
+ defaultRate: number;
+ history: V4VHistoryEntry[];
+}
+
+interface V4VState extends V4VPersistedState {
+ currentEpisodeSats: number;
+ capReachedReason: string | null; // Set when a cap stops streaming
+
+ // Computed
+ weeklySpent: () => number;
+ weeklyRemaining: () => number;
+ isCapReached: () => boolean;
+
+ // Actions
+ setAutoEnabled: (v: boolean) => void;
+ setPerEpisodeCap: (v: number) => void;
+ setWeeklyBudget: (v: number) => void;
+ setDefaultRate: (v: number) => void;
+ setCapReachedReason: (reason: string | null) => void;
+ addHistoryEntry: (entry: V4VHistoryEntry) => void;
+ addCurrentEpisodeSats: (amount: number) => void;
+ resetCurrentEpisodeSats: () => void;
+}
+
+function loadPersisted(): V4VPersistedState {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return { autoEnabled: false, perEpisodeCap: 0, weeklyBudget: 0, defaultRate: 10, history: [] };
+ return JSON.parse(raw);
+ } catch {
+ return { autoEnabled: false, perEpisodeCap: 0, weeklyBudget: 0, defaultRate: 10, history: [] };
+ }
+}
+
+function persist(state: V4VPersistedState) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ } catch { /* ignore */ }
+}
+
+function getPersistable(s: V4VState): V4VPersistedState {
+ return {
+ autoEnabled: s.autoEnabled,
+ perEpisodeCap: s.perEpisodeCap,
+ weeklyBudget: s.weeklyBudget,
+ defaultRate: s.defaultRate,
+ history: s.history,
+ };
+}
+
+const initial = loadPersisted();
+
+export const useV4VStore = create((set, get) => ({
+ ...initial,
+ currentEpisodeSats: 0,
+ capReachedReason: null,
+
+ weeklySpent: () => {
+ const cutoff = Date.now() - WEEK_MS;
+ return get().history
+ .filter((e) => e.timestamp > cutoff)
+ .reduce((sum, e) => sum + e.satsStreamed + e.satsBoosted, 0);
+ },
+
+ weeklyRemaining: () => {
+ const { weeklyBudget } = get();
+ if (weeklyBudget <= 0) return Infinity;
+ return Math.max(0, weeklyBudget - get().weeklySpent());
+ },
+
+ isCapReached: () => {
+ const { perEpisodeCap, currentEpisodeSats } = get();
+ if (perEpisodeCap > 0 && currentEpisodeSats >= perEpisodeCap) return true;
+ if (get().weeklyRemaining() <= 0) return true;
+ return false;
+ },
+
+ setAutoEnabled: (v) => {
+ const { perEpisodeCap, weeklyBudget } = get();
+ if (v && (perEpisodeCap <= 0 || weeklyBudget <= 0)) {
+ useToastStore.getState().addToast(
+ "Set per-episode cap and weekly budget before enabling auto-stream",
+ "warning",
+ );
+ return;
+ }
+ set({ autoEnabled: v });
+ persist(getPersistable({ ...get(), autoEnabled: v }));
+ },
+
+ setPerEpisodeCap: (v) => {
+ set({ perEpisodeCap: v });
+ const s = { ...get(), perEpisodeCap: v };
+ if (s.autoEnabled && v <= 0) {
+ set({ autoEnabled: false });
+ s.autoEnabled = false;
+ }
+ persist(getPersistable(s));
+ },
+
+ setWeeklyBudget: (v) => {
+ set({ weeklyBudget: v });
+ const s = { ...get(), weeklyBudget: v };
+ if (s.autoEnabled && v <= 0) {
+ set({ autoEnabled: false });
+ s.autoEnabled = false;
+ }
+ persist(getPersistable(s));
+ },
+
+ setDefaultRate: (v) => {
+ set({ defaultRate: v });
+ persist(getPersistable({ ...get(), defaultRate: v }));
+ },
+
+ setCapReachedReason: (reason) => set({ capReachedReason: reason }),
+
+ addHistoryEntry: (entry) => {
+ const history = [entry, ...get().history].slice(0, MAX_HISTORY);
+ set({ history });
+ persist(getPersistable({ ...get(), history }));
+ },
+
+ addCurrentEpisodeSats: (amount) => {
+ set((s) => ({ currentEpisodeSats: s.currentEpisodeSats + amount }));
+ },
+
+ resetCurrentEpisodeSats: () => set({ currentEpisodeSats: 0, capReachedReason: null }),
+}));