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
+66 -37
View File
@@ -1,8 +1,9 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { usePodcastStore } from "../../stores/podcast";
import { useV4VStore } from "../../stores/v4v";
import { useUIStore } from "../../stores/ui";
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)
@@ -20,15 +21,47 @@ export function V4VIndicator() {
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();
const { setV4VEnabled, setV4VStreaming, addStreamedSats } = usePodcastStore.getState();
// Show nudge when a V4V episode starts playing and streaming is off
const autoEnabled = useV4VStore((s) => s.autoEnabled);
const defaultRate = useV4VStore((s) => s.defaultRate);
const capReachedReason = useV4VStore((s) => s.capReachedReason);
const nwcUri = localStorage.getItem(NWC_KEY) ?? "";
const hasWallet = !!nwcUri;
const hasRecipients = episode?.value && episode.value.length > 0;
// Auto-start streaming when autoEnabled and V4V episode starts playing
useEffect(() => {
if (
autoEnabled &&
playbackState === "playing" &&
hasRecipients &&
hasWallet &&
!v4vStreaming &&
episode
) {
const intervalId = startStreaming(
episode,
defaultRate,
nwcUri,
(amount) => addStreamedSats(amount),
);
if (intervalId >= 0) {
setV4VStreaming(true, intervalId);
setV4VEnabled(true);
}
}
}, [playbackState, episode?.guid, autoEnabled]);
// Show nudge when a V4V episode starts playing and streaming is off (manual mode)
useEffect(() => {
if (
playbackState === "playing" &&
episode?.value &&
episode.value.length > 0 &&
!v4vStreaming &&
!autoEnabled &&
!nudgedGuids.has(episode.guid)
) {
nudgedGuids.add(episode.guid);
@@ -38,11 +71,7 @@ export function V4VIndicator() {
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;
}, [playbackState, episode?.guid, v4vStreaming, autoEnabled]);
const toggleStreaming = useCallback(() => {
if (!episode || !hasWallet) return;
@@ -52,9 +81,10 @@ export function V4VIndicator() {
setV4VStreaming(false);
setV4VEnabled(false);
} else {
const rate = autoEnabled ? defaultRate : v4vSatsPerMinute;
const intervalId = startStreaming(
episode,
v4vSatsPerMinute,
rate,
nwcUri,
(amount) => addStreamedSats(amount),
);
@@ -63,7 +93,7 @@ export function V4VIndicator() {
setV4VEnabled(true);
}
}
}, [episode, v4vStreaming, v4vSatsPerMinute, nwcUri, hasWallet]);
}, [episode, v4vStreaming, v4vSatsPerMinute, defaultRate, autoEnabled, nwcUri, hasWallet]);
const handleBoost = useCallback(async () => {
if (!episode || !hasWallet || boosting) return;
@@ -82,16 +112,26 @@ export function V4VIndicator() {
<div className="relative shrink-0">
<button
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"
: hasRecipients && !v4vStreaming
? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
: "text-text-dim hover:text-text"
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors flex items-center gap-1 ${
capReachedReason
? "text-text-dim bg-border/50"
: v4vStreaming
? "text-amber-400 bg-amber-500/10 animate-pulse"
: hasRecipients
? "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` : hasRecipients ? "⚡ V4V" : "V4V"}
{v4vStreaming && autoEnabled && (
<span className="text-[8px] text-accent bg-accent/15 px-1 rounded-sm font-medium">AUTO</span>
)}
{capReachedReason
? `${v4vTotalStreamed} sats (capped)`
: v4vStreaming
? `${v4vTotalStreamed} sats`
: hasRecipients ? "⚡ V4V" : "V4V"
}
</button>
{/* Brief nudge when V4V episode starts — once per episode per session */}
@@ -107,7 +147,15 @@ export function V4VIndicator() {
{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>
<div className="flex items-center justify-between mb-2">
<div className="text-[11px] text-text font-medium">Value 4 Value</div>
<button
onClick={() => { setOpen(false); useUIStore.getState().setView("v4v"); }}
className="text-[9px] text-accent hover:text-accent-hover transition-colors"
>
open v4v
</button>
</div>
{!hasWallet && (
<div className="text-[10px] text-text-dim mb-2">
@@ -140,25 +188,6 @@ export function V4VIndicator() {
</button>
</div>
{/* Rate picker */}
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] text-text-dim shrink-0">Rate:</span>
{RATE_OPTIONS.map((rate) => (
<button
key={rate}
onClick={() => setV4VSatsPerMinute(rate)}
className={`text-[10px] px-1.5 py-0.5 rounded-sm transition-colors ${
v4vSatsPerMinute === rate
? "bg-accent/20 text-accent"
: "text-text-dim hover:text-text"
}`}
>
{rate}
</button>
))}
<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">
+1
View File
@@ -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: "♥" },
+128
View File
@@ -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 (
<div className="mb-4">
<div className="flex items-center justify-between text-[11px] mb-1">
<span className="text-text-muted">{label}</span>
<span className={`font-medium ${isOver ? "text-danger" : isNear ? "text-warning" : "text-text"}`}>
{spent} / {total} sats
</span>
</div>
<div className="h-2 bg-border rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
isOver ? "bg-danger" : isNear ? "bg-warning" : "bg-accent"
}`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="text-[9px] text-text-dim mt-0.5 text-right">
{total - spent > 0 ? `${total - spent} sats remaining` : "Budget reached"}
</div>
</div>
);
}
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 (
<div className="max-w-md mx-auto px-6 py-6">
{/* Current streaming status */}
<section className="mb-6">
<h2 className="text-text-dim text-[10px] uppercase tracking-widest mb-3">Now</h2>
{!episode ? (
<div className="text-[12px] text-text-dim">No episode playing.</div>
) : !hasRecipients ? (
<div className="text-[12px] text-text-dim">
Playing <span className="text-text">{episode.title}</span> no V4V recipients.
</div>
) : v4vStreaming ? (
<div className="bg-amber-500/5 border border-amber-500/20 rounded-sm p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-amber-400 animate-pulse text-[12px]">&#9889;</span>
<span className="text-[12px] text-text font-medium">Streaming</span>
{autoEnabled && (
<span className="text-[9px] text-accent bg-accent/10 px-1.5 py-0.5 rounded-sm">AUTO</span>
)}
</div>
<div className="text-[11px] text-text-muted truncate">{episode.title}</div>
<div className="text-[10px] text-text-dim mt-1">
{v4vTotalStreamed} sats this session · {episode.value?.length} recipients
</div>
</div>
) : capReachedReason ? (
<div className="bg-danger/5 border border-danger/20 rounded-sm p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-danger text-[12px]">&#9889;</span>
<span className="text-[12px] text-text font-medium">{capReachedReason}</span>
</div>
<div className="text-[11px] text-text-muted truncate">{episode.title}</div>
<div className="text-[10px] text-text-dim mt-1">
{currentEpisodeSats} sats streamed to {episode.value?.length} recipients
</div>
</div>
) : (
<div className="text-[12px] text-text-dim">
Playing <span className="text-text">{episode.title}</span> V4V available but not streaming.
</div>
)}
</section>
{/* Budget bars */}
{(perEpisodeCap > 0 || weeklyBudget > 0) && (
<section className="mb-6">
<h2 className="text-text-dim text-[10px] uppercase tracking-widest mb-3">Budget</h2>
<BudgetBar label="This episode" spent={currentEpisodeSats} total={perEpisodeCap} />
<BudgetBar label="This week" spent={weeklySpent} total={weeklyBudget} />
</section>
)}
{/* Quick stats */}
<section>
<h2 className="text-text-dim text-[10px] uppercase tracking-widest mb-3">Stats</h2>
<div className="grid grid-cols-3 gap-3">
<div className="bg-bg-raised border border-border rounded-sm p-3 text-center">
<div className="text-[16px] text-text font-medium">{allTimeSats}</div>
<div className="text-[9px] text-text-dim mt-0.5">total sats</div>
</div>
<div className="bg-bg-raised border border-border rounded-sm p-3 text-center">
<div className="text-[16px] text-text font-medium">{episodesSupported}</div>
<div className="text-[9px] text-text-dim mt-0.5">episodes</div>
</div>
<div className="bg-bg-raised border border-border rounded-sm p-3 text-center">
<div className="text-[16px] text-text font-medium">
{autoEnabled ? `${defaultRate}/m` : "off"}
</div>
<div className="text-[9px] text-text-dim mt-0.5">auto-stream</div>
</div>
</div>
</section>
</div>
);
}
+79
View File
@@ -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 (
<div className="border-b border-border">
<button
onClick={() => setExpanded(!expanded)}
className="w-full text-left px-4 py-3 hover:bg-bg-hover transition-colors"
>
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="text-[12px] text-text truncate">{entry.episodeTitle}</div>
<div className="text-[10px] text-text-dim truncate">{entry.showTitle}</div>
</div>
<div className="shrink-0 text-right ml-3">
<div className="text-[12px] text-amber-400 font-medium">{totalSats} sats</div>
<div className="text-[9px] text-text-dim">{formatDate(entry.timestamp)}</div>
</div>
</div>
</button>
{expanded && (
<div className="px-4 pb-3 space-y-1">
{entry.satsStreamed > 0 && (
<div className="text-[10px] text-text-dim">Streamed: {entry.satsStreamed} sats</div>
)}
{entry.satsBoosted > 0 && (
<div className="text-[10px] text-text-dim">Boosted: {entry.satsBoosted} sats</div>
)}
{entry.recipients.length > 0 && (
<div className="mt-1">
<div className="text-[9px] text-text-dim mb-1">Recipients:</div>
{entry.recipients.map((r, i) => (
<div key={i} className="flex items-center justify-between text-[10px]">
<span className="text-text-muted truncate">{r.name || r.address.slice(0, 16) + "..."}</span>
<span className="text-text-dim shrink-0 ml-2">{r.sats} sats</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
export function V4VHistory() {
const history = useV4VStore((s) => s.history);
if (history.length === 0) {
return (
<div className="flex items-center justify-center h-48">
<div className="text-center">
<div className="text-[13px] text-text-dim">No V4V history yet</div>
<div className="text-[11px] text-text-dim/60 mt-1">
Start streaming to see your contributions here.
</div>
</div>
</div>
);
}
return (
<div className="max-w-lg mx-auto">
{history.map((entry, i) => (
<HistoryRow key={`${entry.episodeGuid}-${entry.timestamp}-${i}`} entry={entry} />
))}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { useV4VStore } from "../../stores/v4v";
const RATE_OPTIONS = [5, 10, 21, 50, 100];
function InfoIcon({ title }: { title: string }) {
return (
<span className="text-text-dim cursor-help text-[10px] ml-1" title={title}>
&#9432;
</span>
);
}
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 (
<div className="max-w-md mx-auto px-6 py-6 space-y-6">
{/* Auto-enable */}
<section>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-[12px] text-text font-medium">Auto-stream</span>
<InfoIcon title="When enabled, V4V streaming starts automatically for every episode that supports it. Requires per-episode cap and weekly budget to be set." />
</div>
<button
onClick={() => setAutoEnabled(!autoEnabled)}
className={`w-9 h-5 rounded-full transition-colors relative ${
autoEnabled ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
autoEnabled ? "left-4.5" : "left-0.5"
}`}
/>
</button>
</div>
<p className="text-[10px] text-text-dim mt-1">
Automatically stream sats to creators when playing V4V-enabled episodes.
</p>
{autoEnabled && (
<div className="mt-2 text-[10px] text-success flex items-center gap-1">
<span>&#9679;</span> Auto-streaming is active
</div>
)}
</section>
{/* Per-episode cap */}
<section>
<div className="flex items-center">
<label className="text-[12px] text-text font-medium">Per-episode cap</label>
<InfoIcon title="Maximum sats to stream for a single episode. Streaming stops automatically when this limit is reached." />
</div>
<p className="text-[10px] text-text-dim mt-1 mb-2">
Stop streaming after this many sats per episode.
</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={perEpisodeCap || ""}
onChange={(e) => 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"
/>
<span className="text-[10px] text-text-dim">sats</span>
</div>
</section>
{/* Weekly budget */}
<section>
<div className="flex items-center">
<label className="text-[12px] text-text font-medium">Weekly budget</label>
<InfoIcon title="Total sats allowed across all V4V streaming in a rolling 7-day window. Auto-streaming pauses when the budget is exhausted." />
</div>
<p className="text-[10px] text-text-dim mt-1 mb-2">
Total sats allowed per week across all episodes.
</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={weeklyBudget || ""}
onChange={(e) => 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"
/>
<span className="text-[10px] text-text-dim">sats / week</span>
</div>
</section>
{/* Default rate */}
<section>
<div className="flex items-center">
<label className="text-[12px] text-text font-medium">Default streaming rate</label>
<InfoIcon title="How many sats per minute to stream by default. Can be overridden per session in the player bar." />
</div>
<p className="text-[10px] text-text-dim mt-1 mb-2">
Sats per minute when auto-streaming.
</p>
<div className="flex items-center gap-1">
{RATE_OPTIONS.map((rate) => (
<button
key={rate}
onClick={() => setDefaultRate(rate)}
className={`text-[11px] px-2.5 py-1 rounded-sm transition-colors ${
defaultRate === rate
? "bg-accent/20 text-accent font-medium"
: "text-text-dim hover:text-text bg-bg-raised"
}`}
>
{rate}
</button>
))}
<span className="text-[10px] text-text-dim ml-1">/min</span>
</div>
</section>
{/* Requirements notice */}
{!autoEnabled && (perEpisodeCap <= 0 || weeklyBudget <= 0) && (
<div className="text-[10px] text-text-dim bg-bg-raised border border-border rounded-sm p-3">
Set both a per-episode cap and weekly budget to enable auto-streaming.
</div>
)}
</div>
);
}
+44
View File
@@ -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<Tab>("dashboard");
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border shrink-0">
<div className="flex items-center px-4 pt-3 pb-0">
<h1 className="text-text text-[14px] font-medium mr-6">Value 4 Value</h1>
<div className="flex">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-[11px] border-b-2 transition-colors ${
tab === t
? "border-accent text-accent"
: "border-transparent text-text-dim hover:text-text"
}`}
>
{t}
</button>
))}
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{tab === "dashboard" && <V4VDashboard />}
{tab === "settings" && <V4VSettings />}
{tab === "history" && <V4VHistory />}
</div>
</div>
);
}