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
+1 -1
View File
@@ -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
View 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 }),
}));