mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 12:19:11 -07:00
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:
@@ -24,6 +24,7 @@ const BookmarkView = lazy(() => import("./components/bookmark/BookmarkView").the
|
||||
const HashtagFeed = lazy(() => import("./components/feed/HashtagFeed").then(m => ({ default: m.HashtagFeed })));
|
||||
const PodcastsView = lazy(() => import("./components/podcast/PodcastsView").then(m => ({ default: m.PodcastsView })));
|
||||
const FollowsView = lazy(() => import("./components/follows/FollowsView").then(m => ({ default: m.FollowsView })));
|
||||
const V4VView = lazy(() => import("./components/v4v/V4VView").then(m => ({ default: m.V4VView })));
|
||||
const DebugPanel = lazy(() => import("./components/shared/DebugPanel").then(m => ({ default: m.DebugPanel })));
|
||||
const HelpModal = lazy(() => import("./components/shared/HelpModal").then(m => ({ default: m.HelpModal })));
|
||||
import { useUIStore } from "./stores/ui";
|
||||
@@ -131,6 +132,7 @@ function App() {
|
||||
{currentView === "hashtag" && <HashtagFeed />}
|
||||
{currentView === "podcasts" && <PodcastsView />}
|
||||
{currentView === "follows" && <FollowsView />}
|
||||
{currentView === "v4v" && <V4VView />}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
src/components/v4v/V4VDashboard.tsx
Normal file
128
src/components/v4v/V4VDashboard.tsx
Normal 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]">⚡</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]">⚡</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
src/components/v4v/V4VHistory.tsx
Normal file
79
src/components/v4v/V4VHistory.tsx
Normal 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
src/components/v4v/V4VSettings.tsx
Normal file
133
src/components/v4v/V4VSettings.tsx
Normal 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}>
|
||||
ⓘ
|
||||
</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>●</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
src/components/v4v/V4VView.tsx
Normal file
44
src/components/v4v/V4VView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
150
src/stores/v4v.ts
Normal file
150
src/stores/v4v.ts
Normal file
@@ -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<V4VState>((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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user