diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcd5ca4..a903a19 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,16 @@ jobs: > **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install. + ### v0.12.12 — Podcasts that follow your account + + Your "My Podcasts" list now lives on Nostr, keyed to your pubkey. Reinstall the app on a new machine, switch devices, wipe local storage — your subscriptions come back automatically as long as you sign in with the same key. + + - **Published as a NIP-51 kind 30003 bookmark set** with `d="podcasts"`. Feed URLs are standard `r` tags; rich metadata (title, artwork, author, description, Podcast Index ID) rides along in the event content so the list renders offline on first paint. + - **Per-account isolation** — each pubkey has its own list. Switching accounts swaps the subscriptions immediately; a previous account's list never leaks under the new signer. + - **Cloud-wins hydration** on login. If the relay has a newer list, it replaces local. If the cloud is empty but you have local subscriptions, your list is published as the initial seed. + - **Existing users:** your current subscription list is automatically adopted into your active account on first launch. + - **Read-only accounts (`npub` login):** you can still see your list, but changes don't publish (no signer). + ### v0.12.11 — Read-only mode polish If you haven't created a key yet, or you're signed in with just an `npub` (view-only), the app now behaves correctly throughout: no broken Publish buttons, no dead-end "Not logged in" errors. diff --git a/PKGBUILD b/PKGBUILD index 57b5cbc..6b0cb0f 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=vega-nostr -pkgver=0.12.11 +pkgver=0.12.12 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/README.md b/README.md index 90bbb63..6d937e8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ minisign -Vm Vega_0.12.9_amd64.deb -p vega.pub - **Recipient breakdown** — see exactly who gets paid and their split percentages - **V4V nudge** — brief non-intrusive tooltip when a V4V-enabled episode starts playing (once per episode per session) - **V4V badges on episodes** — ⚡ V4V pill on episode cards so you know which shows support it +- **Subscriptions follow your account** — "My Podcasts" is published as a NIP-51 kind 30003 bookmark set (`d="podcasts"`), so your show list syncs across devices and is restored on reinstall as long as you sign in with the same key **Lightning & zaps** - **Per-account NWC wallet** — each account remembers its own Lightning wallet; switching accounts loads the correct one automatically diff --git a/package.json b/package.json index ee1ba98..e93b950 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vega", "private": true, - "version": "0.12.11", + "version": "0.12.12", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9f77184..f4531a9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5429,7 +5429,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vega" -version = "0.12.11" +version = "0.12.12" dependencies = [ "futures-util", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1371824..11a83b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vega" -version = "0.12.11" +version = "0.12.12" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4dc4a33..757eb3c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Vega", - "version": "0.12.11", + "version": "0.12.12", "identifier": "com.hoornet.vega", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 6ccde72..c0c5df9 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -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"; diff --git a/src/lib/nostr/podcasts.ts b/src/lib/nostr/podcasts.ts new file mode 100644 index 0000000..e9668ec --- /dev/null +++ b/src/lib/nostr/podcasts.ts @@ -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 = {}; + 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((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 { + const instance = getNDK(); + if (!instance.signer) return; + + const metadata: Record = {}; + 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(); +} diff --git a/src/stores/podcast.ts b/src/stores/podcast.ts index bfe4995..b4356bc 100644 --- a/src/stores/podcast.ts +++ b/src/stores/podcast.ts @@ -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) { } 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; 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; } const persisted = loadPersistedState(); @@ -104,7 +151,8 @@ export const usePodcastStore = create((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((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); + } + } + }, })); diff --git a/src/stores/user.ts b/src/stores/user.ts index bb2104a..ab193d7 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -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((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((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((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((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((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((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((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((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