mirror of
https://github.com/hoornet/vega.git
synced 2026-05-09 21:59:12 -07:00
Add podcast playback, Fountain.fm cards, V4V streaming, fix notifications
Podcast feature: - Podcast discovery via Podcast Index API (trending + search) - Persistent player bar with play/pause, seek, speed (1x/1.5x/2x), volume - Audio persists across view navigation, resumes from saved position - Fountain.fm URL detection in feed with rich playable cards - "Play in Wrystr" button on inline audio blocks - V4V streaming sats via NWC (LNURL-pay, 5min accumulation, split payments) - Share what you're listening to (publish note with confirm) - Space key toggles play/pause globally Notification fixes: - Per-notification read tracking (click to mark read) instead of mark-all-on-open - Read notifications persist at 50% opacity, unread get accent border - Always fetches last 7 days, keeps 15 most recent - Filter out own replies from notifications - Sidebar badge shows only unread count
This commit is contained in:
@@ -2,27 +2,40 @@ import { create } from "zustand";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { fetchMentions } from "../lib/nostr";
|
||||
|
||||
const NOTIF_SEEN_KEY = "wrystr_notif_last_seen";
|
||||
const NOTIF_READ_KEY = "wrystr_notif_read_ids";
|
||||
const DM_SEEN_KEY = "wrystr_dm_last_seen";
|
||||
const MAX_NOTIFICATIONS = 15;
|
||||
|
||||
interface NotificationsState {
|
||||
notifications: NDKEvent[];
|
||||
unreadCount: number;
|
||||
lastSeenAt: number;
|
||||
readIds: Set<string>;
|
||||
loading: boolean;
|
||||
currentPubkey: string | null;
|
||||
dmLastSeen: Record<string, number>;
|
||||
dmUnreadCount: number;
|
||||
|
||||
fetchNotifications: (pubkey: string) => Promise<void>;
|
||||
markRead: (eventId: string) => void;
|
||||
markAllRead: () => void;
|
||||
isRead: (eventId: string) => boolean;
|
||||
markDMRead: (partnerPubkey: string) => void;
|
||||
computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => void;
|
||||
}
|
||||
|
||||
function loadLastSeen(): number {
|
||||
const stored = parseInt(localStorage.getItem(NOTIF_SEEN_KEY) ?? "0");
|
||||
return stored || Math.floor(Date.now() / 1000) - 86400;
|
||||
function loadReadIds(): Set<string> {
|
||||
try {
|
||||
const arr = JSON.parse(localStorage.getItem(NOTIF_READ_KEY) ?? "[]");
|
||||
return new Set(arr);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
// Only keep the most recent entries to avoid unbounded growth
|
||||
const arr = Array.from(ids).slice(-200);
|
||||
localStorage.setItem(NOTIF_READ_KEY, JSON.stringify(arr));
|
||||
}
|
||||
|
||||
function loadDMLastSeen(): Record<string, number> {
|
||||
@@ -36,7 +49,7 @@ function loadDMLastSeen(): Record<string, number> {
|
||||
export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
lastSeenAt: loadLastSeen(),
|
||||
readIds: loadReadIds(),
|
||||
loading: false,
|
||||
currentPubkey: null,
|
||||
dmLastSeen: loadDMLastSeen(),
|
||||
@@ -50,11 +63,16 @@ export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
}
|
||||
set({ loading: true });
|
||||
try {
|
||||
const lastSeenAt = isNewAccount ? loadLastSeen() : get().lastSeenAt;
|
||||
const events = await fetchMentions(pubkey, lastSeenAt);
|
||||
const newEvents = events.filter((e) => (e.created_at ?? 0) > lastSeenAt);
|
||||
const unreadCount = newEvents.length;
|
||||
set({ notifications: events, unreadCount, lastSeenAt });
|
||||
// Always fetch recent notifications (last 7 days), keep up to MAX_NOTIFICATIONS
|
||||
const since = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
// Fetch more than we need since we filter out own events
|
||||
const events = await fetchMentions(pubkey, since, MAX_NOTIFICATIONS * 3);
|
||||
// Filter out own events — your replies shouldn't be notifications
|
||||
const others = events.filter((e) => e.pubkey !== pubkey);
|
||||
const sorted = others.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, MAX_NOTIFICATIONS);
|
||||
const { readIds } = get();
|
||||
const unreadCount = sorted.filter((e) => !readIds.has(e.id!)).length;
|
||||
set({ notifications: sorted, unreadCount });
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
@@ -62,10 +80,28 @@ export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
markRead: (eventId: string) => {
|
||||
const { readIds, notifications } = get();
|
||||
if (readIds.has(eventId)) return;
|
||||
const updated = new Set(readIds);
|
||||
updated.add(eventId);
|
||||
saveReadIds(updated);
|
||||
const unreadCount = notifications.filter((e) => !updated.has(e.id!)).length;
|
||||
set({ readIds: updated, unreadCount });
|
||||
},
|
||||
|
||||
markAllRead: () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
localStorage.setItem(NOTIF_SEEN_KEY, String(now));
|
||||
set({ lastSeenAt: now, unreadCount: 0 });
|
||||
const { notifications, readIds } = get();
|
||||
const updated = new Set(readIds);
|
||||
for (const e of notifications) {
|
||||
if (e.id) updated.add(e.id);
|
||||
}
|
||||
saveReadIds(updated);
|
||||
set({ readIds: updated, unreadCount: 0 });
|
||||
},
|
||||
|
||||
isRead: (eventId: string) => {
|
||||
return get().readIds.has(eventId);
|
||||
},
|
||||
|
||||
markDMRead: (partnerPubkey: string) => {
|
||||
|
||||
170
src/stores/podcast.ts
Normal file
170
src/stores/podcast.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { create } from "zustand";
|
||||
import type { PodcastEpisode, PlaybackState } from "../types/podcast";
|
||||
|
||||
const STORAGE_KEY = "wrystr_podcast";
|
||||
|
||||
interface EpisodeProgress {
|
||||
position: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function loadPersistedState(): {
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
v4vEnabled: boolean;
|
||||
v4vSatsPerMinute: number;
|
||||
progressMap: Record<string, EpisodeProgress>;
|
||||
} {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { volume: 1, playbackRate: 1, v4vEnabled: false, v4vSatsPerMinute: 10, progressMap: {} };
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { volume: 1, playbackRate: 1, v4vEnabled: false, v4vSatsPerMinute: 10, progressMap: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function persist(partial: Partial<PodcastState>) {
|
||||
try {
|
||||
const prev = loadPersistedState();
|
||||
const next = {
|
||||
volume: partial.volume ?? prev.volume,
|
||||
playbackRate: partial.playbackRate ?? prev.playbackRate,
|
||||
v4vEnabled: partial.v4vEnabled ?? prev.v4vEnabled,
|
||||
v4vSatsPerMinute: partial.v4vSatsPerMinute ?? prev.v4vSatsPerMinute,
|
||||
progressMap: partial.progressMap ?? prev.progressMap,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface PodcastState {
|
||||
currentEpisode: PodcastEpisode | null;
|
||||
playbackState: PlaybackState;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
v4vEnabled: boolean;
|
||||
v4vSatsPerMinute: number;
|
||||
v4vTotalStreamed: number;
|
||||
v4vStreaming: boolean;
|
||||
v4vIntervalId: number | null;
|
||||
progressMap: Record<string, EpisodeProgress>;
|
||||
playCounter: number;
|
||||
|
||||
play: (episode: PodcastEpisode) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
seek: (seconds: number) => void;
|
||||
setRate: (rate: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setPlaybackState: (state: PlaybackState) => void;
|
||||
setCurrentTime: (t: number) => void;
|
||||
setDuration: (d: number) => void;
|
||||
saveProgress: () => void;
|
||||
loadProgress: (guid: string) => number;
|
||||
addStreamedSats: (amount: number) => void;
|
||||
setV4VEnabled: (enabled: boolean) => void;
|
||||
setV4VSatsPerMinute: (sats: number) => void;
|
||||
setV4VStreaming: (streaming: boolean, intervalId?: number | null) => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
const persisted = loadPersistedState();
|
||||
|
||||
export const usePodcastStore = create<PodcastState>((set, get) => ({
|
||||
currentEpisode: null,
|
||||
playbackState: "idle",
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: persisted.playbackRate,
|
||||
volume: persisted.volume,
|
||||
v4vEnabled: persisted.v4vEnabled,
|
||||
v4vSatsPerMinute: persisted.v4vSatsPerMinute,
|
||||
v4vTotalStreamed: 0,
|
||||
v4vStreaming: false,
|
||||
v4vIntervalId: null,
|
||||
progressMap: persisted.progressMap,
|
||||
playCounter: 0,
|
||||
|
||||
play: (episode) => {
|
||||
const position = get().loadProgress(episode.guid);
|
||||
set({
|
||||
currentEpisode: episode,
|
||||
playbackState: "loading",
|
||||
currentTime: position,
|
||||
duration: episode.duration || 0,
|
||||
playCounter: get().playCounter + 1,
|
||||
});
|
||||
},
|
||||
|
||||
pause: () => set({ playbackState: "paused" }),
|
||||
|
||||
resume: () => set({ playbackState: "playing" }),
|
||||
|
||||
seek: (seconds) => set({ currentTime: seconds }),
|
||||
|
||||
setRate: (rate) => {
|
||||
set({ playbackRate: rate });
|
||||
persist({ playbackRate: rate });
|
||||
},
|
||||
|
||||
setVolume: (v) => {
|
||||
set({ volume: v });
|
||||
persist({ volume: v });
|
||||
},
|
||||
|
||||
setPlaybackState: (state) => set({ playbackState: state }),
|
||||
|
||||
setCurrentTime: (t) => set({ currentTime: t }),
|
||||
|
||||
setDuration: (d) => set({ duration: d }),
|
||||
|
||||
saveProgress: () => {
|
||||
const { currentEpisode, currentTime, progressMap } = get();
|
||||
if (!currentEpisode) return;
|
||||
const updated = {
|
||||
...progressMap,
|
||||
[currentEpisode.guid]: { position: currentTime, timestamp: Date.now() },
|
||||
};
|
||||
set({ progressMap: updated });
|
||||
persist({ progressMap: updated });
|
||||
},
|
||||
|
||||
loadProgress: (guid) => {
|
||||
const entry = get().progressMap[guid];
|
||||
return entry?.position ?? 0;
|
||||
},
|
||||
|
||||
addStreamedSats: (amount) => set((s) => ({ v4vTotalStreamed: s.v4vTotalStreamed + amount })),
|
||||
|
||||
setV4VEnabled: (enabled) => {
|
||||
set({ v4vEnabled: enabled });
|
||||
persist({ v4vEnabled: enabled });
|
||||
},
|
||||
|
||||
setV4VSatsPerMinute: (sats) => {
|
||||
set({ v4vSatsPerMinute: sats });
|
||||
persist({ v4vSatsPerMinute: sats });
|
||||
},
|
||||
|
||||
setV4VStreaming: (streaming, intervalId) => set({
|
||||
v4vStreaming: streaming,
|
||||
v4vIntervalId: intervalId ?? null,
|
||||
}),
|
||||
|
||||
stop: () => {
|
||||
const { v4vIntervalId } = get();
|
||||
if (v4vIntervalId !== null) clearInterval(v4vIntervalId);
|
||||
get().saveProgress();
|
||||
set({
|
||||
currentEpisode: null,
|
||||
playbackState: "idle",
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
v4vStreaming: false,
|
||||
v4vIntervalId: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "podcasts" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type FeedTab = "global" | "following" | "trending";
|
||||
|
||||
interface UIState {
|
||||
|
||||
Reference in New Issue
Block a user