Bump to v0.12.12 — podcast subscriptions sync via Nostr (kind 30003)

This commit is contained in:
Jure
2026-05-15 20:35:36 +02:00
parent 7e998be45b
commit 1cbd13031d
11 changed files with 232 additions and 23 deletions
+1
View File
@@ -6,6 +6,7 @@ export { publishReaction, fetchReplyCount, fetchZapCount, fetchReactions, groupR
export type { GroupedReactions, BatchEngagement } from "./engagement";
export { fetchDMConversations, fetchDMThread, sendDM, decryptDM } from "./dms";
export { fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull } from "./bookmarks";
export { fetchPodcastList, publishPodcastList } from "./podcasts";
export { fetchMuteList, publishMuteList } from "./muting";
export { searchNotes, searchUsers, resolveNip05, advancedSearch } from "./search";
export type { AdvancedSearchResults } from "./search";
+83
View File
@@ -0,0 +1,83 @@
import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core";
import type { PodcastShow } from "../../types/podcast";
const KIND_BOOKMARK_SET = 30003 as NDKKind;
const D_TAG = "podcasts";
type StoredMeta = {
title?: string;
author?: string;
artworkUrl?: string;
description?: string;
podcastIndexId?: number;
};
export async function fetchPodcastList(pubkey: string): Promise<{
shows: PodcastShow[];
createdAt: number;
} | null> {
const instance = getNDK();
const filter: NDKFilter = {
kinds: [KIND_BOOKMARK_SET],
authors: [pubkey],
"#d": [D_TAG],
limit: 1,
};
const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
if (events.size === 0) return null;
const event = Array.from(events).sort(
(a, b) => (b.created_at ?? 0) - (a.created_at ?? 0),
)[0];
const feedUrls = event.tags
.filter((t) => t[0] === "r" && t[1])
.map((t) => t[1]);
let metadata: Record<string, StoredMeta> = {};
if (event.content) {
try {
const parsed = JSON.parse(event.content);
if (parsed && typeof parsed === "object") metadata = parsed;
} catch { /* ignore malformed metadata */ }
}
const shows = feedUrls.map<PodcastShow>((feedUrl) => {
const meta = metadata[feedUrl] ?? {};
return {
feedUrl,
title: meta.title ?? "",
author: meta.author ?? "",
artworkUrl: meta.artworkUrl ?? "",
description: meta.description ?? "",
podcastIndexId: meta.podcastIndexId,
};
});
return { shows, createdAt: event.created_at ?? 0 };
}
export async function publishPodcastList(shows: PodcastShow[]): Promise<void> {
const instance = getNDK();
if (!instance.signer) return;
const metadata: Record<string, StoredMeta> = {};
for (const s of shows) {
metadata[s.feedUrl] = {
title: s.title,
author: s.author,
artworkUrl: s.artworkUrl,
description: s.description,
podcastIndexId: s.podcastIndexId,
};
}
const event = new NDKEvent(instance);
event.kind = KIND_BOOKMARK_SET;
event.content = JSON.stringify(metadata);
event.tags = [
["d", D_TAG],
...shows.map((s) => ["r", s.feedUrl]),
];
await event.publish();
}
+116 -18
View File
@@ -1,8 +1,16 @@
import { create } from "zustand";
import type { PodcastShow, PodcastEpisode, PlaybackState } from "../types/podcast";
import { fetchPodcastList, publishPodcastList } from "../lib/nostr/podcasts";
import { getNDK } from "../lib/nostr/core";
import { debug } from "../lib/debug";
const STORAGE_KEY = "wrystr_podcast";
const SUBS_KEY = "wrystr_podcast_subs";
const SUBS_KEY_LEGACY = "wrystr_podcast_subs";
const LEGACY_MIGRATED_KEY = "wrystr_podcast_legacy_migrated";
function subsKey(pubkey: string | null): string {
return pubkey ? `${SUBS_KEY_LEGACY}:${pubkey}` : SUBS_KEY_LEGACY;
}
interface EpisodeProgress {
position: number;
@@ -39,6 +47,54 @@ function persist(partial: Partial<PodcastState>) {
} catch { /* ignore */ }
}
function loadSubscriptions(pubkey: string | null): PodcastShow[] {
try {
return JSON.parse(localStorage.getItem(subsKey(pubkey)) ?? "[]");
} catch {
return [];
}
}
function saveSubscriptions(pubkey: string | null, subs: PodcastShow[]) {
localStorage.setItem(subsKey(pubkey), JSON.stringify(subs));
}
// One-time legacy seed: pre-pubkey installs stored everything under SUBS_KEY_LEGACY.
// On the first account that hydrates after upgrade, adopt that list as the seed
// so users don't appear to lose their podcasts.
function consumeLegacySeed(pubkey: string): PodcastShow[] | null {
if (localStorage.getItem(LEGACY_MIGRATED_KEY) === "1") return null;
if (localStorage.getItem(subsKey(pubkey))) return null;
try {
const raw = localStorage.getItem(SUBS_KEY_LEGACY);
if (!raw) return null;
const legacy: PodcastShow[] = JSON.parse(raw);
if (!Array.isArray(legacy) || legacy.length === 0) return null;
return legacy;
} catch { return null; }
}
// Debounced publish — runs ~1.5s after the last change. Cancelled on account switch
// so a stale list never gets published under the new account's signer.
let publishTimer: number | null = null;
function schedulePublish(shows: PodcastShow[]) {
if (publishTimer !== null) window.clearTimeout(publishTimer);
publishTimer = window.setTimeout(() => {
publishTimer = null;
publishPodcastList(shows).catch((err) => {
debug.warn("[Vega] Failed to publish podcast subscriptions:", err);
});
}, 1500);
}
function cancelPendingPublish() {
if (publishTimer !== null) {
window.clearTimeout(publishTimer);
publishTimer = null;
}
}
interface PodcastState {
currentEpisode: PodcastEpisode | null;
playbackState: PlaybackState;
@@ -54,6 +110,7 @@ interface PodcastState {
progressMap: Record<string, EpisodeProgress>;
playCounter: number;
subscriptions: PodcastShow[];
activePubkey: string | null;
play: (episode: PodcastEpisode) => void;
pause: () => void;
@@ -74,18 +131,8 @@ interface PodcastState {
subscribe: (show: PodcastShow) => void;
unsubscribe: (feedUrl: string) => void;
isSubscribed: (feedUrl: string) => boolean;
}
function loadSubscriptions(): PodcastShow[] {
try {
return JSON.parse(localStorage.getItem(SUBS_KEY) ?? "[]");
} catch {
return [];
}
}
function saveSubscriptions(subs: PodcastShow[]) {
localStorage.setItem(SUBS_KEY, JSON.stringify(subs));
setActiveAccount: (pubkey: string | null) => void;
hydrateSubscriptions: (pubkey: string) => Promise<void>;
}
const persisted = loadPersistedState();
@@ -104,7 +151,8 @@ export const usePodcastStore = create<PodcastState>((set, get) => ({
v4vIntervalId: null,
progressMap: persisted.progressMap,
playCounter: 0,
subscriptions: loadSubscriptions(),
subscriptions: loadSubscriptions(null),
activePubkey: null,
play: (episode) => {
const position = get().loadProgress(episode.guid);
@@ -187,20 +235,70 @@ export const usePodcastStore = create<PodcastState>((set, get) => ({
},
subscribe: (show) => {
const { subscriptions } = get();
const { subscriptions, activePubkey } = get();
if (subscriptions.some((s) => s.feedUrl === show.feedUrl)) return;
const updated = [...subscriptions, show];
set({ subscriptions: updated });
saveSubscriptions(updated);
saveSubscriptions(activePubkey, updated);
if (activePubkey && getNDK().signer) schedulePublish(updated);
},
unsubscribe: (feedUrl) => {
const updated = get().subscriptions.filter((s) => s.feedUrl !== feedUrl);
const { subscriptions, activePubkey } = get();
const updated = subscriptions.filter((s) => s.feedUrl !== feedUrl);
set({ subscriptions: updated });
saveSubscriptions(updated);
saveSubscriptions(activePubkey, updated);
if (activePubkey && getNDK().signer) schedulePublish(updated);
},
isSubscribed: (feedUrl) => {
return get().subscriptions.some((s) => s.feedUrl === feedUrl);
},
setActiveAccount: (pubkey) => {
// Cancel any pending publish — it belongs to the previous account.
cancelPendingPublish();
if (pubkey === get().activePubkey) return;
const subs = loadSubscriptions(pubkey);
set({ activePubkey: pubkey, subscriptions: subs });
},
hydrateSubscriptions: async (pubkey) => {
// 1. One-time legacy seed for first hydrate after upgrade
const legacy = consumeLegacySeed(pubkey);
if (legacy) {
saveSubscriptions(pubkey, legacy);
if (get().activePubkey === pubkey) set({ subscriptions: legacy });
localStorage.setItem(LEGACY_MIGRATED_KEY, "1");
}
// 2. Fetch from relay
let result: { shows: PodcastShow[]; createdAt: number } | null = null;
try {
result = await fetchPodcastList(pubkey);
} catch (err) {
debug.warn("[Vega] Failed to fetch podcast subscriptions:", err);
return;
}
// Account may have switched while we were fetching — bail if no longer active.
if (get().activePubkey !== pubkey) return;
if (result && result.shows.length > 0) {
// Cloud wins: replace local cache with relay state.
saveSubscriptions(pubkey, result.shows);
set({ subscriptions: result.shows });
return;
}
// 3. No cloud list yet — if we have local entries, publish them as initial seed.
const localSubs = get().subscriptions;
if (localSubs.length > 0 && getNDK().signer) {
try {
await publishPodcastList(localSubs);
} catch (err) {
debug.warn("[Vega] Failed to seed podcast subscriptions to relay:", err);
}
}
},
}));
+16
View File
@@ -8,6 +8,7 @@ import { useLightningStore } from "./lightning";
import { useUIStore } from "./ui";
import { useNotificationsStore } from "./notifications";
import { useFeedStore } from "./feed";
import { usePodcastStore } from "./podcast";
import { startNotificationPoller, stopNotificationPoller } from "../lib/notificationPoller";
import { dbLoadProfile } from "../lib/db";
import { debug } from "../lib/debug";
@@ -138,6 +139,8 @@ export const useUserStore = create<UserState>((set, get) => ({
useMuteStore.getState().fetchMuteList(pubkey);
useNotificationsStore.getState().fetchNotifications(pubkey);
startNotificationPoller(pubkey);
usePodcastStore.getState().setActiveAccount(pubkey);
usePodcastStore.getState().hydrateSubscriptions(pubkey);
// Navigate to feed and refresh so the new account's content loads
useUIStore.getState().setView("feed");
@@ -182,6 +185,8 @@ export const useUserStore = create<UserState>((set, get) => ({
useMuteStore.getState().fetchMuteList(pubkey);
useNotificationsStore.getState().fetchNotifications(pubkey);
startNotificationPoller(pubkey);
usePodcastStore.getState().setActiveAccount(pubkey);
usePodcastStore.getState().hydrateSubscriptions(pubkey);
useUIStore.getState().setView("feed");
useFeedStore.getState().loadFeed();
@@ -226,6 +231,8 @@ export const useUserStore = create<UserState>((set, get) => ({
useMuteStore.getState().fetchMuteList(pubkey);
useNotificationsStore.getState().fetchNotifications(pubkey);
startNotificationPoller(pubkey);
usePodcastStore.getState().setActiveAccount(pubkey);
usePodcastStore.getState().hydrateSubscriptions(pubkey);
useUIStore.getState().setView("feed");
useFeedStore.getState().loadFeed();
@@ -304,6 +311,8 @@ export const useUserStore = create<UserState>((set, get) => ({
useMuteStore.getState().fetchMuteList(savedPubkey);
useNotificationsStore.getState().fetchNotifications(savedPubkey);
startNotificationPoller(savedPubkey);
usePodcastStore.getState().setActiveAccount(savedPubkey);
usePodcastStore.getState().hydrateSubscriptions(savedPubkey);
} catch (err) {
debug.warn("Failed to restore NIP-46 session:", err);
}
@@ -325,6 +334,8 @@ export const useUserStore = create<UserState>((set, get) => ({
useMuteStore.getState().fetchMuteList(savedPubkey);
useNotificationsStore.getState().fetchNotifications(savedPubkey);
startNotificationPoller(savedPubkey);
usePodcastStore.getState().setActiveAccount(savedPubkey);
usePodcastStore.getState().hydrateSubscriptions(savedPubkey);
}
// No keychain entry → stay logged out, user re-enters nsec once.
}
@@ -350,6 +361,8 @@ export const useUserStore = create<UserState>((set, get) => ({
get().fetchFollows();
useMuteStore.getState().fetchMuteList(pubkey);
startNotificationPoller(pubkey);
usePodcastStore.getState().setActiveAccount(pubkey);
usePodcastStore.getState().hydrateSubscriptions(pubkey);
useUIStore.getState().setView("feed");
return;
} catch (err) {
@@ -373,6 +386,8 @@ export const useUserStore = create<UserState>((set, get) => ({
get().fetchFollows();
useMuteStore.getState().fetchMuteList(pubkey);
startNotificationPoller(pubkey);
usePodcastStore.getState().setActiveAccount(pubkey);
usePodcastStore.getState().hydrateSubscriptions(pubkey);
useUIStore.getState().setView("feed");
return;
}
@@ -401,6 +416,7 @@ export const useUserStore = create<UserState>((set, get) => ({
set({ pubkey, npub, loggedIn: false, loginError: null, profile: null, follows: [] });
localStorage.setItem("wrystr_pubkey", pubkey);
localStorage.setItem("wrystr_login_type", "nsec");
usePodcastStore.getState().setActiveAccount(pubkey);
}
}
// Always land on feed to avoid stale UI from previous account's view