mirror of
https://github.com/hoornet/vega.git
synced 2026-07-25 01:38:11 -07:00
Bump to v0.12.12 — podcast subscriptions sync via Nostr (kind 30003)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user