mirror of
https://github.com/hoornet/vega.git
synced 2026-05-14 22:08:36 -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 HashtagFeed = lazy(() => import("./components/feed/HashtagFeed").then(m => ({ default: m.HashtagFeed })));
|
||||||
const PodcastsView = lazy(() => import("./components/podcast/PodcastsView").then(m => ({ default: m.PodcastsView })));
|
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 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 DebugPanel = lazy(() => import("./components/shared/DebugPanel").then(m => ({ default: m.DebugPanel })));
|
||||||
const HelpModal = lazy(() => import("./components/shared/HelpModal").then(m => ({ default: m.HelpModal })));
|
const HelpModal = lazy(() => import("./components/shared/HelpModal").then(m => ({ default: m.HelpModal })));
|
||||||
import { useUIStore } from "./stores/ui";
|
import { useUIStore } from "./stores/ui";
|
||||||
@@ -131,6 +132,7 @@ function App() {
|
|||||||
{currentView === "hashtag" && <HashtagFeed />}
|
{currentView === "hashtag" && <HashtagFeed />}
|
||||||
{currentView === "podcasts" && <PodcastsView />}
|
{currentView === "podcasts" && <PodcastsView />}
|
||||||
{currentView === "follows" && <FollowsView />}
|
{currentView === "follows" && <FollowsView />}
|
||||||
|
{currentView === "v4v" && <V4VView />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { usePodcastStore } from "../../stores/podcast";
|
import { usePodcastStore } from "../../stores/podcast";
|
||||||
|
import { useV4VStore } from "../../stores/v4v";
|
||||||
|
import { useUIStore } from "../../stores/ui";
|
||||||
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 NWC_KEY = "wrystr_nwc_uri";
|
const NWC_KEY = "wrystr_nwc_uri";
|
||||||
|
|
||||||
// Track which episodes have shown the nudge this session (not persisted)
|
// 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 v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
|
||||||
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
|
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
|
||||||
const playbackState = usePodcastStore((s) => s.playbackState);
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
playbackState === "playing" &&
|
playbackState === "playing" &&
|
||||||
episode?.value &&
|
episode?.value &&
|
||||||
episode.value.length > 0 &&
|
episode.value.length > 0 &&
|
||||||
!v4vStreaming &&
|
!v4vStreaming &&
|
||||||
|
!autoEnabled &&
|
||||||
!nudgedGuids.has(episode.guid)
|
!nudgedGuids.has(episode.guid)
|
||||||
) {
|
) {
|
||||||
nudgedGuids.add(episode.guid);
|
nudgedGuids.add(episode.guid);
|
||||||
@@ -38,11 +71,7 @@ export function V4VIndicator() {
|
|||||||
return () => {
|
return () => {
|
||||||
if (nudgeTimer.current) clearTimeout(nudgeTimer.current);
|
if (nudgeTimer.current) clearTimeout(nudgeTimer.current);
|
||||||
};
|
};
|
||||||
}, [playbackState, episode?.guid, v4vStreaming]);
|
}, [playbackState, episode?.guid, v4vStreaming, autoEnabled]);
|
||||||
|
|
||||||
const nwcUri = localStorage.getItem(NWC_KEY) ?? "";
|
|
||||||
const hasWallet = !!nwcUri;
|
|
||||||
const hasRecipients = episode?.value && episode.value.length > 0;
|
|
||||||
|
|
||||||
const toggleStreaming = useCallback(() => {
|
const toggleStreaming = useCallback(() => {
|
||||||
if (!episode || !hasWallet) return;
|
if (!episode || !hasWallet) return;
|
||||||
@@ -52,9 +81,10 @@ export function V4VIndicator() {
|
|||||||
setV4VStreaming(false);
|
setV4VStreaming(false);
|
||||||
setV4VEnabled(false);
|
setV4VEnabled(false);
|
||||||
} else {
|
} else {
|
||||||
|
const rate = autoEnabled ? defaultRate : v4vSatsPerMinute;
|
||||||
const intervalId = startStreaming(
|
const intervalId = startStreaming(
|
||||||
episode,
|
episode,
|
||||||
v4vSatsPerMinute,
|
rate,
|
||||||
nwcUri,
|
nwcUri,
|
||||||
(amount) => addStreamedSats(amount),
|
(amount) => addStreamedSats(amount),
|
||||||
);
|
);
|
||||||
@@ -63,7 +93,7 @@ export function V4VIndicator() {
|
|||||||
setV4VEnabled(true);
|
setV4VEnabled(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [episode, v4vStreaming, v4vSatsPerMinute, nwcUri, hasWallet]);
|
}, [episode, v4vStreaming, v4vSatsPerMinute, defaultRate, autoEnabled, nwcUri, hasWallet]);
|
||||||
|
|
||||||
const handleBoost = useCallback(async () => {
|
const handleBoost = useCallback(async () => {
|
||||||
if (!episode || !hasWallet || boosting) return;
|
if (!episode || !hasWallet || boosting) return;
|
||||||
@@ -82,16 +112,26 @@ export function V4VIndicator() {
|
|||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => { setOpen(!open); setShowNudge(false); }}
|
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 flex items-center gap-1 ${
|
||||||
v4vStreaming
|
capReachedReason
|
||||||
|
? "text-text-dim bg-border/50"
|
||||||
|
: v4vStreaming
|
||||||
? "text-amber-400 bg-amber-500/10 animate-pulse"
|
? "text-amber-400 bg-amber-500/10 animate-pulse"
|
||||||
: hasRecipients && !v4vStreaming
|
: hasRecipients
|
||||||
? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
|
? "text-amber-400 bg-amber-500/10 hover:bg-amber-500/20"
|
||||||
: "text-text-dim hover:text-text"
|
: "text-text-dim hover:text-text"
|
||||||
}`}
|
}`}
|
||||||
title="Value 4 Value"
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Brief nudge when V4V episode starts — once per episode per session */}
|
{/* Brief nudge when V4V episode starts — once per episode per session */}
|
||||||
@@ -107,7 +147,15 @@ export function V4VIndicator() {
|
|||||||
|
|
||||||
{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="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 && (
|
{!hasWallet && (
|
||||||
<div className="text-[10px] text-text-dim mb-2">
|
<div className="text-[10px] text-text-dim mb-2">
|
||||||
@@ -140,25 +188,6 @@ export function V4VIndicator() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 */}
|
{/* Recipients */}
|
||||||
{episode.value && episode.value.length > 0 && (
|
{episode.value && episode.value.length > 0 && (
|
||||||
<div className="mb-2 border-t border-border pt-2">
|
<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: "notifications" as const, label: "notifications", icon: "🔔" },
|
||||||
{ id: "follows" as const, label: "follows", icon: "♺" },
|
{ id: "follows" as const, label: "follows", icon: "♺" },
|
||||||
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
|
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
|
||||||
|
{ id: "v4v" as const, label: "v4v", icon: "⚡" },
|
||||||
{ id: "relays" as const, label: "relays", icon: "⟐" },
|
{ id: "relays" as const, label: "relays", icon: "⟐" },
|
||||||
{ id: "settings" as const, label: "settings", icon: "⚙" },
|
{ id: "settings" as const, label: "settings", icon: "⚙" },
|
||||||
{ id: "about" as const, label: "support", 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 { fetch } from "@tauri-apps/plugin-http";
|
||||||
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
||||||
import { payInvoiceViaNWC, payKeysendViaNWC } from "../lightning/nwc";
|
import { payInvoiceViaNWC, payKeysendViaNWC } from "../lightning/nwc";
|
||||||
|
import { useV4VStore } from "../../stores/v4v";
|
||||||
|
import { useToastStore } from "../../stores/toast";
|
||||||
|
|
||||||
const LNURL_CACHE: Record<string, string> = {};
|
const LNURL_CACHE: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -74,6 +76,8 @@ async function payRecipient(
|
|||||||
let streamingInterval: number | null = null;
|
let streamingInterval: number | null = null;
|
||||||
let accumulatedSats = 0;
|
let accumulatedSats = 0;
|
||||||
let accumulatedMinutes = 0;
|
let accumulatedMinutes = 0;
|
||||||
|
let currentStreamingEpisode: PodcastEpisode | null = null;
|
||||||
|
let sessionRecipientSats: Record<string, number> = {};
|
||||||
|
|
||||||
export function startStreaming(
|
export function startStreaming(
|
||||||
episode: PodcastEpisode,
|
episode: PodcastEpisode,
|
||||||
@@ -85,14 +89,31 @@ export function startStreaming(
|
|||||||
|
|
||||||
accumulatedSats = 0;
|
accumulatedSats = 0;
|
||||||
accumulatedMinutes = 0;
|
accumulatedMinutes = 0;
|
||||||
|
currentStreamingEpisode = episode;
|
||||||
|
sessionRecipientSats = {};
|
||||||
const recipients = getRecipients(episode);
|
const recipients = getRecipients(episode);
|
||||||
|
|
||||||
if (recipients.length === 0) return -1;
|
if (recipients.length === 0) return -1;
|
||||||
|
|
||||||
|
const v4vStore = useV4VStore.getState();
|
||||||
|
v4vStore.resetCurrentEpisodeSats();
|
||||||
|
|
||||||
// Normalize splits to sum to 100
|
// Normalize splits to sum to 100
|
||||||
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
|
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
|
||||||
|
|
||||||
streamingInterval = window.setInterval(async () => {
|
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;
|
accumulatedMinutes += 1;
|
||||||
accumulatedSats += satsPerMinute;
|
accumulatedSats += satsPerMinute;
|
||||||
|
|
||||||
@@ -110,7 +131,12 @@ export function startStreaming(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const success = await payRecipient(recipient, amountMsats, nwcUri);
|
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 {
|
} catch {
|
||||||
// Payment failed — silently continue
|
// Payment failed — silently continue
|
||||||
}
|
}
|
||||||
@@ -120,13 +146,41 @@ export function startStreaming(
|
|||||||
return streamingInterval;
|
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() {
|
export function stopStreaming() {
|
||||||
if (streamingInterval !== null) {
|
if (streamingInterval !== null) {
|
||||||
|
recordHistory();
|
||||||
clearInterval(streamingInterval);
|
clearInterval(streamingInterval);
|
||||||
streamingInterval = null;
|
streamingInterval = null;
|
||||||
}
|
}
|
||||||
accumulatedSats = 0;
|
accumulatedSats = 0;
|
||||||
accumulatedMinutes = 0;
|
accumulatedMinutes = 0;
|
||||||
|
currentStreamingEpisode = null;
|
||||||
|
sessionRecipientSats = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function boost(
|
export async function boost(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
|||||||
|
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
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";
|
type FeedTab = "global" | "following" | "trending";
|
||||||
|
|
||||||
interface ViewStackEntry {
|
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