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

@@ -18,6 +18,8 @@ import { DMView } from "./components/dm/DMView";
import { NotificationsView } from "./components/notifications/NotificationsView";
import { BookmarkView } from "./components/bookmark/BookmarkView";
import { HashtagFeed } from "./components/feed/HashtagFeed";
import { PodcastsView } from "./components/podcast/PodcastsView";
import { PodcastPlayerBar } from "./components/podcast/PodcastPlayerBar";
import { HelpModal } from "./components/shared/HelpModal";
import { useUIStore } from "./stores/ui";
import { useUpdater } from "./hooks/useUpdater";
@@ -102,8 +104,10 @@ function App() {
{currentView === "notifications" && <NotificationsView />}
{currentView === "bookmarks" && <BookmarkView />}
{currentView === "hashtag" && <HashtagFeed />}
{currentView === "podcasts" && <PodcastsView />}
</main>
</div>
<PodcastPlayerBar />
{showHelp && <HelpModal onClose={toggleHelp} />}
</div>
);

View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from "react";
import type { ContentSegment } from "../../lib/parsing";
import type { PodcastEpisode } from "../../types/podcast";
import { resolveFountainEpisode } from "../../lib/podcast";
import { usePodcastStore } from "../../stores/podcast";
export function FountainCard({ seg }: { seg: ContentSegment }) {
const [episode, setEpisode] = useState<PodcastEpisode | null>(null);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const play = usePodcastStore((s) => s.play);
useEffect(() => {
resolveFountainEpisode(seg.value).then((ep) => {
if (ep) setEpisode(ep);
else setFailed(true);
setLoading(false);
});
}, [seg.value]);
if (failed) {
// Fallback: render as a regular link
return (
<a
href={seg.value}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
>
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center shrink-0">
<span className="text-blue-400 text-lg font-bold">F</span>
</div>
<div className="min-w-0">
<div className="text-[11px] text-text-muted">Fountain.fm</div>
<div className="text-[12px] text-accent truncate">{seg.value}</div>
</div>
</a>
);
}
if (loading) {
return (
<div className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 animate-pulse">
<div className="w-10 h-10 rounded-sm bg-bg shrink-0" />
<div className="min-w-0 flex-1">
<div className="h-3 bg-bg rounded w-32 mb-1" />
<div className="h-2 bg-bg rounded w-20" />
</div>
</div>
);
}
if (!episode) return null;
const handlePlay = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (episode.enclosureUrl) {
play(episode);
}
};
return (
<div
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
onClick={handlePlay}
>
{episode.artworkUrl ? (
<img
src={episode.artworkUrl}
alt=""
className="w-12 h-12 rounded-sm object-cover shrink-0"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-12 h-12 rounded-sm bg-blue-500/20 flex items-center justify-center shrink-0">
<span className="text-blue-400 text-lg font-bold">F</span>
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-[11px] text-text-muted">Fountain.fm</div>
<div className="text-[12px] text-text truncate">{episode.title}</div>
{episode.showTitle && (
<div className="text-[10px] text-text-dim truncate">{episode.showTitle}</div>
)}
</div>
{episode.enclosureUrl && (
<button
onClick={handlePlay}
className="shrink-0 w-8 h-8 rounded-full border border-border flex items-center justify-center hover:bg-accent/10 transition-colors"
title="Play in Wrystr"
>
<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor" className="text-accent ml-0.5">
<polygon points="0,0 10,6 0,12" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { ContentSegment } from "../../lib/parsing";
import { usePodcastStore } from "../../stores/podcast";
export function VideoBlock({ sources }: { sources: string[] }) {
if (sources.length === 0) return null;
@@ -20,6 +21,7 @@ export function VideoBlock({ sources }: { sources: string[] }) {
}
export function AudioBlock({ sources }: { sources: string[] }) {
const play = usePodcastStore((s) => s.play);
if (sources.length === 0) return null;
return (
<div className="mt-2 flex flex-col gap-2">
@@ -27,7 +29,27 @@ export function AudioBlock({ sources }: { sources: string[] }) {
const filename = src.split("/").pop()?.split("?")[0] ?? src;
return (
<div key={i} className="rounded-sm bg-bg-raised border border-border p-2">
<div className="text-[11px] text-text-muted mb-1 truncate">{filename}</div>
<div className="flex items-center justify-between mb-1">
<div className="text-[11px] text-text-muted truncate">{filename}</div>
<button
onClick={(e) => {
e.stopPropagation();
play({
guid: `audio:${src}`,
title: filename,
enclosureUrl: src,
pubDate: 0,
duration: 0,
description: "",
showTitle: "From note",
showArtworkUrl: "",
});
}}
className="text-[10px] text-accent hover:text-accent-hover transition-colors shrink-0 ml-2"
>
play in wrystr
</button>
</div>
<audio controls preload="metadata" className="w-full h-8" src={src} />
</div>
);

View File

@@ -8,6 +8,7 @@ import { ImageLightbox } from "../shared/ImageLightbox";
import { parseContent } from "../../lib/parsing";
import { renderTextSegments } from "./TextSegments";
import { VideoBlock, AudioBlock, YouTubeCard, VimeoCard, SpotifyCard, TidalCard } from "./MediaCards";
import { FountainCard } from "./FountainCard";
function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) {
const count = images.length;
@@ -159,6 +160,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
const vimeos = segments.filter((s) => s.type === "vimeo");
const spotifys = segments.filter((s) => s.type === "spotify");
const tidals = segments.filter((s) => s.type === "tidal");
const fountains = segments.filter((s) => s.type === "fountain");
const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@@ -185,7 +187,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
// --- Media blocks only (rendered OUTSIDE the clickable wrapper) ---
if (mediaOnly) {
const hasMedia = videos.length > 0 || audios.length > 0 || youtubes.length > 0
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || quoteIds.length > 0;
|| vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0;
if (!hasMedia) return null;
return (
@@ -196,6 +198,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
{spotifys.map((seg, i) => <SpotifyCard key={`sp-${i}`} seg={seg} />)}
{tidals.map((seg, i) => <TidalCard key={`td-${i}`} seg={seg} />)}
{fountains.map((seg, i) => <FountainCard key={`fn-${i}`} seg={seg} />)}
{quoteIds.map((id) => <QuotePreview key={id} eventId={id} />)}
</div>
);
@@ -225,6 +228,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
{spotifys.map((seg, i) => <SpotifyCard key={`sp-${i}`} seg={seg} />)}
{tidals.map((seg, i) => <TidalCard key={`td-${i}`} seg={seg} />)}
{fountains.map((seg, i) => <FountainCard key={`fn-${i}`} seg={seg} />)}
{quoteIds.map((id) => <QuotePreview key={id} eventId={id} />)}
</div>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { useNotificationsStore } from "../../stores/notifications";
@@ -10,24 +10,20 @@ export function NotificationsView() {
const {
notifications,
unreadCount,
lastSeenAt,
loading,
fetchNotifications,
markAllRead,
markRead,
isRead,
} = useNotificationsStore();
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
const filteredNotifications = notifications.filter(
(e) => !mutedPubkeys.includes(e.pubkey) && !contentMatchesMutedKeyword(e.content)
(e) => e.pubkey !== pubkey && !mutedPubkeys.includes(e.pubkey) && !contentMatchesMutedKeyword(e.content)
);
// Capture lastSeenAt at mount time so unread highlights persist during this view session
const prevLastSeenAtRef = useRef(lastSeenAt);
useEffect(() => {
if (!pubkey) return;
fetchNotifications(pubkey).then(() => {
setTimeout(() => markAllRead(), 500);
});
fetchNotifications(pubkey);
}, [pubkey]);
if (!loggedIn || !pubkey) {
@@ -65,11 +61,12 @@ export function NotificationsView() {
)}
{filteredNotifications.map((event) => {
const isUnread = (event.created_at ?? 0) > prevLastSeenAtRef.current;
const read = isRead(event.id!);
return (
<div
key={event.id}
className={isUnread ? "border-l-2 border-accent/40" : ""}
className={`transition-opacity ${read ? "opacity-50" : "border-l-2 border-accent/40"}`}
onClick={() => { if (!read && event.id) markRead(event.id); }}
>
<NoteCard event={event} />
</div>

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from "react";
import type { PodcastShow, PodcastEpisode } from "../../types/podcast";
import { getEpisodes } from "../../lib/podcast";
import { usePodcastStore } from "../../stores/podcast";
function formatDuration(seconds: number): string {
if (!seconds) return "";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatDate(ts: number): string {
if (!ts) return "";
return new Date(ts * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
interface EpisodeListProps {
show: PodcastShow;
onBack: () => void;
}
export function EpisodeList({ show, onBack }: EpisodeListProps) {
const [episodes, setEpisodes] = useState<PodcastEpisode[]>([]);
const [loading, setLoading] = useState(true);
const play = usePodcastStore((s) => s.play);
const currentGuid = usePodcastStore((s) => s.currentEpisode?.guid);
const progressMap = usePodcastStore((s) => s.progressMap);
useEffect(() => {
if (!show.podcastIndexId) return;
setLoading(true);
getEpisodes(show.podcastIndexId).then((eps) => {
// Enrich episodes with show info
setEpisodes(eps.map((ep) => ({
...ep,
showTitle: show.title,
showArtworkUrl: show.artworkUrl,
})));
setLoading(false);
});
}, [show.podcastIndexId]);
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border p-4 flex items-start gap-4 shrink-0">
<button
onClick={onBack}
className="text-text-muted hover:text-accent text-[12px] mt-1 shrink-0"
>
back
</button>
{show.artworkUrl && (
<img
src={show.artworkUrl}
alt=""
className="w-20 h-20 rounded-sm object-cover shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div className="min-w-0">
<h2 className="text-[15px] text-text font-semibold">{show.title}</h2>
<div className="text-[12px] text-text-muted">{show.author}</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-3">
{show.description.replace(/<[^>]+>/g, "").slice(0, 200)}
</div>
)}
</div>
</div>
{/* Episodes */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-4 text-text-dim text-[12px]">Loading episodes...</div>
) : episodes.length === 0 ? (
<div className="p-4 text-text-dim text-[12px]">No episodes found</div>
) : (
episodes.map((ep) => {
const isPlaying = currentGuid === ep.guid;
const progress = progressMap[ep.guid];
const hasProgress = progress && progress.position > 10;
return (
<button
key={ep.guid}
onClick={() => play(ep)}
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-bg-hover transition-colors flex items-center gap-3 ${
isPlaying ? "bg-accent/5" : ""
}`}
>
<div className="w-6 shrink-0 flex items-center justify-center">
{isPlaying ? (
<span className="text-accent text-[12px]">*</span>
) : (
<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor" className="text-text-dim">
<polygon points="0,0 10,6 0,12" />
</svg>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-text truncate">{ep.title}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-text-dim">{formatDate(ep.pubDate)}</span>
{ep.duration > 0 && (
<span className="text-[10px] text-text-dim">{formatDuration(ep.duration)}</span>
)}
{hasProgress && (
<span className="text-[10px] text-accent">resumed</span>
)}
{ep.value && ep.value.length > 0 && (
<span className="text-[10px] text-amber-500">V4V</span>
)}
</div>
</div>
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { PodcastShow } from "../../types/podcast";
interface PodcastCardProps {
show: PodcastShow;
onClick: () => void;
}
export function PodcastCard({ show, onClick }: PodcastCardProps) {
return (
<button
onClick={onClick}
className="flex items-start gap-3 p-3 rounded-sm bg-bg-raised border border-border hover:bg-bg-hover transition-colors text-left w-full"
>
{show.artworkUrl ? (
<img
src={show.artworkUrl}
alt=""
className="w-16 h-16 rounded-sm object-cover shrink-0 bg-bg"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-16 h-16 rounded-sm bg-bg flex items-center justify-center shrink-0 text-2xl text-text-dim">
P
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-[13px] text-text font-medium truncate">{show.title}</div>
<div className="text-[11px] text-text-muted truncate">{show.author}</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-2">
{show.description.replace(/<[^>]+>/g, "").slice(0, 120)}
</div>
)}
</div>
</button>
);
}

View File

@@ -0,0 +1,336 @@
import { useRef, useEffect, useCallback, useState } from "react";
import type { PodcastEpisode } from "../../types/podcast";
import { usePodcastStore } from "../../stores/podcast";
import { publishNote } from "../../lib/nostr";
import { V4VIndicator } from "./V4VIndicator";
function formatTime(seconds: number): string {
if (!seconds || !isFinite(seconds)) return "0:00";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
return `${m}:${String(s).padStart(2, "0")}`;
}
function ShareButton({ episode }: { episode: PodcastEpisode | null }) {
const [state, setState] = useState<"idle" | "confirm" | "shared">("idle");
const handleClick = useCallback(() => {
if (!episode) return;
if (state === "idle") {
setState("confirm");
} else if (state === "confirm") {
const text = `Listening to ${episode.title} from ${episode.showTitle}\n\n${episode.enclosureUrl}`;
publishNote(text).then(() => {
setState("shared");
setTimeout(() => setState("idle"), 3000);
}).catch(() => setState("idle"));
}
}, [episode, state]);
const handleCancel = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setState("idle");
}, []);
return (
<span className="shrink-0 flex items-center gap-1">
<button
onClick={handleClick}
className={`text-[11px] transition-colors ${
state === "shared" ? "text-success"
: state === "confirm" ? "text-accent"
: "text-text-dim hover:text-text"
}`}
title="Share what you're listening to"
>
{state === "shared" ? "shared" : state === "confirm" ? "publish?" : "share"}
</button>
{state === "confirm" && (
<button onClick={handleCancel} className="text-[10px] text-text-dim hover:text-text">x</button>
)}
</span>
);
}
const RATES = [1, 1.5, 2];
export function PodcastPlayerBar() {
const audioRef = useRef<HTMLAudioElement>(null);
const saveTimerRef = useRef<number | null>(null);
const [audioError, setAudioError] = useState<string | null>(null);
const episode = usePodcastStore((s) => s.currentEpisode);
const playbackState = usePodcastStore((s) => s.playbackState);
const currentTime = usePodcastStore((s) => s.currentTime);
const duration = usePodcastStore((s) => s.duration);
const playbackRate = usePodcastStore((s) => s.playbackRate);
const volume = usePodcastStore((s) => s.volume);
const playCounter = usePodcastStore((s) => s.playCounter);
const {
pause, resume, seek, setRate, setVolume,
setPlaybackState, setCurrentTime, setDuration,
saveProgress, stop,
} = usePodcastStore.getState();
// Drive audio element when episode changes
useEffect(() => {
const audio = audioRef.current;
if (!audio || !episode) return;
setAudioError(null);
setPlaybackState("loading");
// Set source and let it load
audio.src = episode.enclosureUrl;
audio.playbackRate = playbackRate;
audio.volume = volume;
// Wait for the audio to be ready, then seek + play
const onCanPlay = () => {
audio.removeEventListener("canplaythrough", onCanPlay);
const savedPosition = usePodcastStore.getState().loadProgress(episode.guid);
if (savedPosition > 0) {
try { audio.currentTime = savedPosition; } catch { /* ignore */ }
}
audio.play().catch(() => setPlaybackState("paused"));
};
audio.addEventListener("canplaythrough", onCanPlay);
return () => audio.removeEventListener("canplaythrough", onCanPlay);
}, [episode, playCounter]);
// Sync playback rate
useEffect(() => {
if (audioRef.current) audioRef.current.playbackRate = playbackRate;
}, [playbackRate]);
// Sync volume
useEffect(() => {
if (audioRef.current) audioRef.current.volume = volume;
}, [volume]);
// Handle play/pause state changes
useEffect(() => {
const audio = audioRef.current;
if (!audio || !episode) return;
if (playbackState === "playing" && audio.paused) {
audio.play().catch(() => {});
} else if (playbackState === "paused" && !audio.paused) {
audio.pause();
}
}, [playbackState]);
// Auto-save progress every 15s
useEffect(() => {
if (!episode) return;
saveTimerRef.current = window.setInterval(() => {
saveProgress();
}, 15000);
return () => {
if (saveTimerRef.current) clearInterval(saveTimerRef.current);
};
}, [episode, playCounter]);
// Space key to toggle play/pause — use audio element state, not store state
useEffect(() => {
if (!episode) return;
const handler = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
if (e.code === "Space") {
e.preventDefault();
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play().catch(() => {});
} else {
audio.pause();
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [episode]);
const handleTimeUpdate = useCallback(() => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
}, []);
const handleLoadedMetadata = useCallback(() => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
}
}, []);
const saveAndStop = useCallback(() => {
// Sync current time from audio element before saving (store value may be stale)
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
audioRef.current.pause();
audioRef.current.removeAttribute("src");
}
saveProgress();
stop();
}, []);
const handleEnded = useCallback(() => {
if (audioRef.current) {
audioRef.current.removeAttribute("src");
}
stop();
}, []);
const handleSeek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value);
seek(t);
if (audioRef.current) {
audioRef.current.currentTime = t;
// Always resume after seeking — clicking the slider can trigger a pause event
// before onChange fires, so checking state here is unreliable
setPlaybackState("loading");
audioRef.current.play().catch(() => {});
}
}, []);
const cycleRate = useCallback(() => {
const idx = RATES.indexOf(playbackRate);
setRate(RATES[(idx + 1) % RATES.length]);
}, [playbackRate]);
const artwork = episode?.artworkUrl || episode?.showArtworkUrl;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<>
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPlay={() => setPlaybackState("playing")}
onPause={() => {
const s = usePodcastStore.getState().playbackState;
// Only mark paused if we were actually playing — ignore pause events from src changes
if (s === "playing") setPlaybackState("paused");
}}
onWaiting={() => {
const s = usePodcastStore.getState().playbackState;
if (s === "playing") setPlaybackState("loading");
}}
onError={() => {
const code = audioRef.current?.error?.code;
const msg = audioRef.current?.error?.message;
setAudioError(`Audio error ${code}: ${msg || "failed to load"}`);
setPlaybackState("paused");
}}
/>
{!episode ? null : (
<div className="shrink-0 h-14 border-t border-border bg-bg flex items-center gap-3 px-3">
{/* Artwork */}
{artwork && (
<img
src={artwork}
alt=""
className="w-10 h-10 rounded-sm object-cover shrink-0 bg-bg-raised"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
{/* Title + show */}
<div className="min-w-0 w-40 shrink-0">
{audioError && <div className="text-[10px] text-danger truncate">{audioError}</div>}
<div className="text-[12px] text-text truncate">{episode.title}</div>
<div className="text-[10px] text-text-dim truncate">{episode.showTitle}</div>
</div>
{/* Play/Pause */}
<button
onClick={() => playbackState === "playing" ? pause() : resume()}
className="w-8 h-8 rounded-full border border-border flex items-center justify-center hover:bg-bg-hover transition-colors shrink-0"
title={playbackState === "playing" ? "Pause" : "Play"}
>
{playbackState === "playing" ? (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" className="text-text">
<rect x="2" y="1" width="3" height="10" rx="0.5" />
<rect x="7" y="1" width="3" height="10" rx="0.5" />
</svg>
) : playbackState === "loading" ? (
<span className="text-[10px] text-text-dim animate-pulse">...</span>
) : (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" className="text-text ml-0.5">
<polygon points="2,1 11,6 2,11" />
</svg>
)}
</button>
{/* Seek bar */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className="text-[10px] text-text-dim w-12 text-right shrink-0">{formatTime(currentTime)}</span>
<div className="flex-1 relative h-4 flex items-center">
<div className="absolute inset-x-0 h-1 bg-border rounded-full">
<div
className="h-full bg-accent rounded-full transition-[width] duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={handleSeek}
className="absolute inset-0 w-full opacity-0 cursor-pointer"
/>
</div>
<span className="text-[10px] text-text-dim w-12 shrink-0">{formatTime(duration)}</span>
</div>
{/* Speed */}
<button
onClick={cycleRate}
className="text-[11px] text-text-muted hover:text-accent transition-colors px-1 shrink-0"
title="Playback speed"
>
{playbackRate}x
</button>
{/* Volume */}
<div className="flex items-center gap-1 shrink-0">
<span className="text-[11px] text-text-dim">vol</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-16 accent-accent h-1 cursor-pointer"
/>
</div>
{/* V4V */}
<V4VIndicator />
{/* Share */}
<ShareButton episode={episode} />
{/* Close */}
<button
onClick={saveAndStop}
className="text-text-dim hover:text-text transition-colors shrink-0 text-[14px]"
title="Stop"
>
x
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,115 @@
import { useState, useEffect, useCallback } from "react";
import type { PodcastShow } from "../../types/podcast";
import { searchPodcasts, getTrending } from "../../lib/podcast";
import { PodcastCard } from "./PodcastCard";
import { EpisodeList } from "./EpisodeList";
type Tab = "trending" | "search";
export function PodcastsView() {
const [tab, setTab] = useState<Tab>("trending");
const [query, setQuery] = useState("");
const [shows, setShows] = useState<PodcastShow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedShow, setSelectedShow] = useState<PodcastShow | null>(null);
// Load trending on mount
useEffect(() => {
setLoading(true);
getTrending().then((results) => {
setShows(results);
setLoading(false);
});
}, []);
const handleSearch = useCallback(async () => {
if (!query.trim()) return;
setTab("search");
setLoading(true);
const results = await searchPodcasts(query.trim());
setShows(results);
setLoading(false);
}, [query]);
const handleTabChange = useCallback(async (t: Tab) => {
setTab(t);
setSelectedShow(null);
if (t === "trending") {
setLoading(true);
const results = await getTrending();
setShows(results);
setLoading(false);
}
}, []);
// Show episode list if a show is selected
if (selectedShow) {
return <EpisodeList show={selectedShow} onBack={() => setSelectedShow(null)} />;
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border px-4 py-3 shrink-0">
<h1 className="text-[14px] text-text font-semibold mb-3">Podcasts</h1>
{/* Search */}
<div className="flex gap-2 mb-3">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search podcasts..."
className="flex-1 bg-bg-raised border border-border rounded-sm px-3 py-1.5 text-[12px] text-text placeholder:text-text-dim focus:outline-none focus:border-accent"
/>
<button
onClick={handleSearch}
disabled={!query.trim()}
className="px-3 py-1.5 bg-accent/10 text-accent text-[12px] rounded-sm hover:bg-accent/20 transition-colors disabled:opacity-40"
>
search
</button>
</div>
{/* Tabs */}
<div className="flex gap-4">
{(["trending", "search"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => handleTabChange(t)}
className={`text-[12px] pb-1 border-b-2 transition-colors ${
tab === t
? "text-accent border-accent"
: "text-text-muted border-transparent hover:text-text"
}`}
>
{t === "trending" ? "Trending" : "Search Results"}
</button>
))}
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="text-text-dim text-[12px]">Loading...</div>
) : shows.length === 0 ? (
<div className="text-text-dim text-[12px]">
{tab === "search" ? "No results. Try a different search." : "No trending podcasts found."}
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{shows.map((show, i) => (
<PodcastCard
key={show.podcastIndexId ?? i}
show={show}
onClick={() => setSelectedShow(show)}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState, useCallback } from "react";
import { usePodcastStore } from "../../stores/podcast";
import { startStreaming, stopStreaming, boost } from "../../lib/podcast/v4v";
const RATE_OPTIONS = [5, 10, 21, 50, 100];
const NWC_KEY = "wrystr_nwc_uri";
export function V4VIndicator() {
const [open, setOpen] = useState(false);
const [boostAmount, setBoostAmount] = useState("100");
const [boosting, setBoosting] = useState(false);
const episode = usePodcastStore((s) => s.currentEpisode);
const v4vSatsPerMinute = usePodcastStore((s) => s.v4vSatsPerMinute);
const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
const { setV4VEnabled, setV4VSatsPerMinute, setV4VStreaming, addStreamedSats } = usePodcastStore.getState();
const nwcUri = localStorage.getItem(NWC_KEY) ?? "";
const hasWallet = !!nwcUri;
const hasRecipients = episode?.value && episode.value.length > 0;
const toggleStreaming = useCallback(() => {
if (!episode || !hasWallet) return;
if (v4vStreaming) {
stopStreaming();
setV4VStreaming(false);
setV4VEnabled(false);
} else {
const intervalId = startStreaming(
episode,
v4vSatsPerMinute,
nwcUri,
(amount) => addStreamedSats(amount),
);
if (intervalId >= 0) {
setV4VStreaming(true, intervalId);
setV4VEnabled(true);
}
}
}, [episode, v4vStreaming, v4vSatsPerMinute, nwcUri, hasWallet]);
const handleBoost = useCallback(async () => {
if (!episode || !hasWallet || boosting) return;
const amount = parseInt(boostAmount);
if (!amount || amount <= 0) return;
setBoosting(true);
const paid = await boost(episode, amount, nwcUri);
if (paid > 0) addStreamedSats(paid);
setBoosting(false);
}, [episode, boostAmount, nwcUri, hasWallet, boosting]);
if (!episode) return null;
return (
<div className="relative shrink-0">
<button
onClick={() => setOpen(!open)}
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors ${
v4vStreaming
? "text-amber-400 bg-amber-500/10 animate-pulse"
: "text-text-dim hover:text-text"
}`}
title="Value 4 Value"
>
{v4vStreaming ? `${v4vTotalStreamed} sats` : "V4V"}
</button>
{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>
{!hasWallet && (
<div className="text-[10px] text-text-dim mb-2">
Connect NWC wallet in Settings to stream sats.
</div>
)}
{!hasRecipients && hasWallet && (
<div className="text-[10px] text-text-dim mb-2">
This episode has no V4V recipients.
</div>
)}
{hasWallet && hasRecipients && (
<>
{/* Toggle */}
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-text-muted">Stream sats</span>
<button
onClick={toggleStreaming}
className={`w-8 h-4 rounded-full transition-colors relative ${
v4vStreaming ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-transform ${
v4vStreaming ? "left-4" : "left-0.5"
}`}
/>
</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>
{/* Boost */}
<div className="flex items-center gap-1 border-t border-border pt-2">
<input
type="number"
value={boostAmount}
onChange={(e) => setBoostAmount(e.target.value)}
className="w-16 bg-bg-raised border border-border rounded-sm px-1.5 py-0.5 text-[10px] text-text"
min="1"
/>
<button
onClick={handleBoost}
disabled={boosting}
className="text-[10px] text-accent hover:text-accent-hover px-2 py-0.5 bg-accent/10 rounded-sm disabled:opacity-40"
>
{boosting ? "..." : "boost"}
</button>
</div>
{v4vTotalStreamed > 0 && (
<div className="text-[9px] text-text-dim mt-2">
Total streamed: {v4vTotalStreamed} sats
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View File

@@ -12,6 +12,7 @@ const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" },
{ id: "articles" as const, label: "articles", icon: "☰" },
{ id: "media" as const, label: "media", icon: "▶" },
{ id: "podcasts" as const, label: "podcasts", icon: "P" },
{ id: "search" as const, label: "search", icon: "⌕" },
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
{ id: "dm" as const, label: "messages", icon: "✉" },

View File

@@ -9,11 +9,12 @@ const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be
const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/;
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g;
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
export interface ContentSegment {
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote";
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "fountain" | "mention" | "hashtag" | "quote";
value: string; // for "quote": the hex event ID
display?: string;
mediaId?: string; // video/embed ID for youtube/vimeo
@@ -84,6 +85,13 @@ export function parseContent(content: string): ContentSegment[] {
length: cleaned.length,
segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] },
});
} else if (FOUNTAIN_REGEX.test(cleaned)) {
const fmMatch = cleaned.match(FOUNTAIN_REGEX);
allMatches.push({
index: match.index,
length: cleaned.length,
segment: { type: "fountain", value: cleaned, mediaType: fmMatch?.[1] ?? "episode", mediaId: fmMatch?.[2] },
});
} else {
// Shorten display URL
let display = cleaned;

View File

@@ -0,0 +1,67 @@
import type { PodcastEpisode } from "../../types/podcast";
export const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
const CACHE_KEY = "wrystr_fountain_cache";
function loadCache(): Record<string, PodcastEpisode> {
try {
return JSON.parse(localStorage.getItem(CACHE_KEY) ?? "{}");
} catch {
return {};
}
}
function saveCache(cache: Record<string, PodcastEpisode>) {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
} catch { /* ignore */ }
}
export async function resolveFountainEpisode(url: string): Promise<PodcastEpisode | null> {
const cache = loadCache();
if (cache[url]) return cache[url];
try {
// Fetch the Fountain.fm page and extract og: meta tags
const res = await fetch(url);
if (!res.ok) return null;
const html = await res.text();
const getMetaContent = (property: string): string => {
const regex = new RegExp(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, "i");
const altRegex = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property}["']`, "i");
const match = html.match(regex) || html.match(altRegex);
return match?.[1] ?? "";
};
const title = getMetaContent("og:title");
const description = getMetaContent("og:description");
const artwork = getMetaContent("og:image");
// Look for an audio URL in the page (meta or direct link)
const audioMatch = html.match(/<meta[^>]+content=["'](https?:\/\/[^"']+\.(mp3|m4a|ogg|opus)[^"']*?)["']/i)
|| html.match(/["'](https?:\/\/[^"'\s]+\.(mp3|m4a|ogg|opus)[^"'\s]*?)["']/i);
const enclosureUrl = audioMatch?.[1] ?? "";
if (!title) return null;
const episode: PodcastEpisode = {
guid: `fountain:${url}`,
title,
enclosureUrl,
pubDate: 0,
duration: 0,
description,
artworkUrl: artwork || undefined,
showTitle: "",
showArtworkUrl: artwork,
};
cache[url] = episode;
saveCache(cache);
return episode;
} catch {
return null;
}
}

2
src/lib/podcast/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { searchPodcasts, getEpisodes, getTrending, getPodcastByFeedUrl } from "./podcastIndex";
export { resolveFountainEpisode, FOUNTAIN_REGEX } from "./fountainFm";

View File

@@ -0,0 +1,101 @@
import type { PodcastShow, PodcastEpisode, V4VRecipient } from "../../types/podcast";
// Free-tier Podcast Index API credentials
const API_KEY = "VKWWTGY25NVCKYJWHSNY";
const API_SECRET = "ves3#2YKqSvp7ZdRSuRhSgdnCLtFP4tEbzFGxAtW";
const API_BASE = "https://api.podcastindex.org/api/1.0";
async function sha1(message: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function apiHeaders(): Promise<Record<string, string>> {
const apiHeaderTime = Math.floor(Date.now() / 1000).toString();
const hash = await sha1(API_KEY + API_SECRET + apiHeaderTime);
return {
"X-Auth-Key": API_KEY,
"X-Auth-Date": apiHeaderTime,
"Authorization": hash,
"User-Agent": "Wrystr/1.0",
};
}
function mapShow(item: Record<string, unknown>): PodcastShow {
return {
feedUrl: (item.url as string) ?? "",
title: (item.title as string) ?? "",
author: (item.author as string) ?? "",
artworkUrl: (item.artwork as string) || (item.image as string) || "",
description: (item.description as string) ?? "",
podcastIndexId: item.id as number,
};
}
function extractV4V(value: Record<string, unknown> | undefined): V4VRecipient[] {
if (!value) return [];
const destinations = value.destinations as Record<string, unknown>[] | undefined;
if (!Array.isArray(destinations)) return [];
return destinations
.filter((d) => d.address)
.map((d) => ({
name: d.name as string | undefined,
type: (d.type as string) ?? "wallet",
address: d.address as string,
split: Number(d.split) || 0,
customKey: d.customKey as string | undefined,
customValue: d.customValue as string | undefined,
}));
}
function mapEpisode(item: Record<string, unknown>, show?: PodcastShow): PodcastEpisode {
return {
guid: (item.guid as string) || String(item.id ?? ""),
title: (item.title as string) ?? "",
enclosureUrl: (item.enclosureUrl as string) ?? "",
pubDate: (item.datePublished as number) ?? 0,
duration: (item.duration as number) ?? 0,
description: (item.description as string) ?? "",
artworkUrl: (item.feedImage as string) || (item.image as string) || undefined,
showTitle: show?.title ?? (item.feedTitle as string) ?? "",
showArtworkUrl: show?.artworkUrl ?? (item.feedImage as string) ?? "",
podcastIndexId: item.feedId as number | undefined,
value: extractV4V(item.value as Record<string, unknown> | undefined),
};
}
export async function searchPodcasts(query: string): Promise<PodcastShow[]> {
const headers = await apiHeaders();
const res = await fetch(`${API_BASE}/search/byterm?q=${encodeURIComponent(query)}`, { headers });
if (!res.ok) return [];
const data = await res.json();
return ((data.feeds as Record<string, unknown>[]) ?? []).map(mapShow);
}
export async function getEpisodes(feedId: number): Promise<PodcastEpisode[]> {
const headers = await apiHeaders();
const res = await fetch(`${API_BASE}/episodes/byfeedid?id=${feedId}&max=50`, { headers });
if (!res.ok) return [];
const data = await res.json();
return ((data.items as Record<string, unknown>[]) ?? []).map((item) => mapEpisode(item));
}
export async function getTrending(): Promise<PodcastShow[]> {
const headers = await apiHeaders();
const res = await fetch(`${API_BASE}/podcasts/trending?max=20&lang=en`, { headers });
if (!res.ok) return [];
const data = await res.json();
return ((data.feeds as Record<string, unknown>[]) ?? []).map(mapShow);
}
export async function getPodcastByFeedUrl(feedUrl: string): Promise<PodcastShow | null> {
const headers = await apiHeaders();
const res = await fetch(`${API_BASE}/podcasts/byfeedurl?url=${encodeURIComponent(feedUrl)}`, { headers });
if (!res.ok) return null;
const data = await res.json();
if (!data.feed) return null;
return mapShow(data.feed);
}

132
src/lib/podcast/v4v.ts Normal file
View File

@@ -0,0 +1,132 @@
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
import { payInvoiceViaNWC } from "../lightning/nwc";
const LNURL_CACHE: Record<string, string> = {};
async function fetchLnurlPayInvoice(lud16: string, amountMsats: number): Promise<string | null> {
try {
const [name, domain] = lud16.split("@");
if (!name || !domain) return null;
// Fetch LNURL-pay endpoint
const wellKnownUrl = `https://${domain}/.well-known/lnurlp/${name}`;
const cacheKey = wellKnownUrl;
let callbackUrl = LNURL_CACHE[cacheKey];
if (!callbackUrl) {
const res = await fetch(wellKnownUrl);
if (!res.ok) return null;
const data = await res.json();
if (!data.callback) return null;
callbackUrl = data.callback;
LNURL_CACHE[cacheKey] = callbackUrl;
}
// Request invoice
const separator = callbackUrl.includes("?") ? "&" : "?";
const invoiceRes = await fetch(`${callbackUrl}${separator}amount=${amountMsats}`);
if (!invoiceRes.ok) return null;
const invoiceData = await invoiceRes.json();
return invoiceData.pr ?? null;
} catch {
return null;
}
}
function getRecipients(episode: PodcastEpisode): V4VRecipient[] {
if (episode.value && episode.value.length > 0) return episode.value;
return [];
}
let streamingInterval: number | null = null;
let accumulatedSats = 0;
let accumulatedMinutes = 0;
export function startStreaming(
episode: PodcastEpisode,
satsPerMinute: number,
nwcUri: string,
onPayment: (amount: number) => void,
): number {
stopStreaming();
accumulatedSats = 0;
accumulatedMinutes = 0;
const recipients = getRecipients(episode);
if (recipients.length === 0) return -1;
// Normalize splits to sum to 100
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
streamingInterval = window.setInterval(async () => {
accumulatedMinutes += 1;
accumulatedSats += satsPerMinute;
// Accumulate for 5 minutes before paying to avoid rate limits
if (accumulatedMinutes < 5 && accumulatedMinutes > 0) return;
const satsToSend = accumulatedSats;
accumulatedSats = 0;
accumulatedMinutes = 0;
for (const recipient of recipients) {
if (!recipient.address || !recipient.address.includes("@")) continue;
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
const recipientSats = Math.max(1, Math.round(satsToSend * share));
const amountMsats = recipientSats * 1000;
try {
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
if (!invoice) continue;
await payInvoiceViaNWC(nwcUri, invoice);
onPayment(recipientSats);
} catch {
// Payment failed — silently continue
}
}
}, 60000); // Every 60 seconds
return streamingInterval;
}
export function stopStreaming() {
if (streamingInterval !== null) {
clearInterval(streamingInterval);
streamingInterval = null;
}
accumulatedSats = 0;
accumulatedMinutes = 0;
}
export async function boost(
episode: PodcastEpisode,
totalSats: number,
nwcUri: string,
): Promise<number> {
const recipients = getRecipients(episode);
if (recipients.length === 0) return 0;
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
let paid = 0;
for (const recipient of recipients) {
if (!recipient.address || !recipient.address.includes("@")) continue;
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
const recipientSats = Math.max(1, Math.round(totalSats * share));
const amountMsats = recipientSats * 1000;
try {
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
if (!invoice) continue;
await payInvoiceViaNWC(nwcUri, invoice);
paid += recipientSats;
} catch {
// continue
}
}
return paid;
}

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 {

33
src/types/podcast.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface PodcastShow {
feedUrl: string;
title: string;
author: string;
artworkUrl: string;
description: string;
podcastIndexId?: number;
}
export interface V4VRecipient {
name?: string;
type: string;
address: string;
split: number;
customKey?: string;
customValue?: string;
}
export interface PodcastEpisode {
guid: string;
title: string;
enclosureUrl: string;
pubDate: number;
duration: number;
description: string;
artworkUrl?: string;
showTitle: string;
showArtworkUrl: string;
podcastIndexId?: number;
value?: V4VRecipient[];
}
export type PlaybackState = "idle" | "loading" | "playing" | "paused";