// js/subscriptions.js import { nostrClient } from "./nostr.js"; /** * Manages the user's subscription list (kind=30002) *privately*, * using NIP-04 encryption for the content field. * Also handles fetching and rendering subscribed channels' videos * in the same card style as your home page. */ class SubscriptionsManager { constructor() { this.subscribedPubkeys = new Set(); this.subsEventId = null; this.loaded = false; } /** * Decrypt the subscription list from kind=30002 (d="subscriptions"). */ async loadSubscriptions(userPubkey) { if (!userPubkey) { console.warn("[SubscriptionsManager] No pubkey => cannot load subs."); return; } try { const filter = { kinds: [30002], authors: [userPubkey], "#d": ["subscriptions"], limit: 1, }; const events = []; for (const url of nostrClient.relays) { try { const result = await nostrClient.pool.list([url], [filter]); if (result && result.length) { events.push(...result); } } catch (err) { console.error(`[SubscriptionsManager] Relay error at ${url}`, err); } } if (!events.length) { this.subscribedPubkeys.clear(); this.subsEventId = null; this.loaded = true; return; } // Sort by created_at desc, pick newest events.sort((a, b) => b.created_at - a.created_at); const newest = events[0]; this.subsEventId = newest.id; let decryptedStr = ""; try { decryptedStr = await window.nostr.nip04.decrypt( userPubkey, newest.content ); } catch (errDecrypt) { console.error("[SubscriptionsManager] Decryption failed:", errDecrypt); this.subscribedPubkeys.clear(); this.subsEventId = null; this.loaded = true; return; } const parsed = JSON.parse(decryptedStr); const subArray = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; this.subscribedPubkeys = new Set(subArray); this.loaded = true; } catch (err) { console.error("[SubscriptionsManager] Failed to load subs:", err); } } isSubscribed(channelHex) { return this.subscribedPubkeys.has(channelHex); } async addChannel(channelHex, userPubkey) { if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); } if (this.subscribedPubkeys.has(channelHex)) { console.log("Already subscribed to", channelHex); return; } this.subscribedPubkeys.add(channelHex); await this.publishSubscriptionList(userPubkey); } async removeChannel(channelHex, userPubkey) { if (!userPubkey) { throw new Error("No user pubkey => cannot removeChannel."); } if (!this.subscribedPubkeys.has(channelHex)) { console.log("Channel not found in subscription list:", channelHex); return; } this.subscribedPubkeys.delete(channelHex); await this.publishSubscriptionList(userPubkey); } /** * Encrypt (NIP-04) + publish the updated subscription set * as kind=30002 with ["d", "subscriptions"] to be replaceable. */ async publishSubscriptionList(userPubkey) { if (!userPubkey) { throw new Error("No pubkey => cannot publish subscription list."); } const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) }; const plainStr = JSON.stringify(plainObj); let cipherText = ""; try { cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr); } catch (err) { console.error("Encryption failed:", err); throw err; } const evt = { kind: 30002, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["d", "subscriptions"]], content: cipherText, }; try { const signedEvent = await window.nostr.signEvent(evt); await Promise.all( nostrClient.relays.map(async (relay) => { try { await nostrClient.pool.publish([relay], signedEvent); } catch (e) { console.error( `[SubscriptionsManager] Failed to publish to ${relay}`, e ); } }) ); this.subsEventId = signedEvent.id; console.log("Subscription list published, event id:", signedEvent.id); } catch (signErr) { console.error("Failed to sign/publish subscription list:", signErr); } } /** * If not loaded, load subs, then fetch + render videos * in #subscriptionsVideoList with the same style as app.renderVideoList. */ async showSubscriptionVideos( userPubkey, containerId = "subscriptionsVideoList" ) { if (!userPubkey) { const c = document.getElementById(containerId); if (c) c.innerHTML = "
Please log in first.
"; return; } if (!this.loaded) { await this.loadSubscriptions(userPubkey); } const channelHexes = Array.from(this.subscribedPubkeys); const container = document.getElementById(containerId); if (!container) return; if (!channelHexes.length) { container.innerHTML = "No subscriptions found.
"; return; } // Gather all videos const videos = await this.fetchSubscribedVideos(channelHexes); this.renderSameGridStyle(videos, containerId); } /** * Pull all events from subscribed authors, convert, dedupe => newest */ async fetchSubscribedVideos(authorPubkeys) { try { const filter = { kinds: [30078], "#t": ["video"], authors: authorPubkeys, limit: 500, }; const allEvents = []; for (const relay of nostrClient.relays) { try { const res = await nostrClient.pool.list([relay], [filter]); allEvents.push(...res); } catch (rErr) { console.error(`[SubscriptionsManager] Error at ${relay}`, rErr); } } const videos = []; for (const evt of allEvents) { const vid = this.convertEventToVideo(evt); if (!vid.invalid && !vid.deleted) videos.push(vid); } const deduped = this.dedupeToNewestByRoot(videos); deduped.sort((a, b) => b.created_at - a.created_at); return deduped; } catch (err) { console.error("fetchSubscribedVideos error:", err); return []; } } /** * Renders the feed in the same style as home. * This includes gear menu, time-ago, lazy load, clickable authors, etc. */ renderSameGridStyle(videos, containerId) { const container = document.getElementById(containerId); if (!container) return; if (!videos.length) { container.innerHTML = `No videos available yet.
`; return; } // Sort newest first videos.sort((a, b) => b.created_at - a.created_at); const fullAllEventsArray = Array.from(nostrClient.allEvents.values()); const fragment = document.createDocumentFragment(); // Only declare localAuthorSet once const localAuthorSet = new Set(); videos.forEach((video, index) => { if (!video.id || !video.title) { console.error("Missing ID or title:", video); return; } localAuthorSet.add(video.pubkey); const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const shareUrl = `${window.location.pathname}?v=${encodeURIComponent( nevent )}`; const canEdit = window.app?.pubkey === video.pubkey; const highlightClass = video.isPrivate && canEdit ? "border-2 border-yellow-500" : "border-none"; const timeAgo = window.app?.formatTimeAgo ? window.app.formatTimeAgo(video.created_at) : new Date(video.created_at * 1000).toLocaleString(); let hasOlder = false; if (canEdit && video.videoRootId && window.app?.hasOlderVersion) { hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray); } const revertButton = hasOlder ? ` ` : ""; const gearMenu = canEdit ? `