mirror of
https://github.com/hoornet/vega.git
synced 2026-06-09 22:43:33 -07:00
Bump to v0.12.12 — podcast subscriptions sync via Nostr (kind 30003)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
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')
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vega",
|
||||
"private": true,
|
||||
"version": "0.12.11",
|
||||
"version": "0.12.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5429,7 +5429,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vega"
|
||||
version = "0.12.11"
|
||||
version = "0.12.12"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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