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:
Jure
2026-03-21 12:53:05 +01:00
parent 1dafb3b456
commit 04180cf186
20 changed files with 1474 additions and 29 deletions

View File

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

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" | "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 {