// 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 ? `
` : ""; const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; const cardHtml = `
${safeTitle}

${safeTitle}

Placeholder

Loading name...

${timeAgo}
${gearMenu}
`; const t = document.createElement("template"); t.innerHTML = cardHtml.trim(); const cardEl = t.content.firstElementChild; fragment.appendChild(cardEl); }); container.appendChild(fragment); // Lazy-load const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); // Gear menus const gearButtons = container.querySelectorAll("[data-settings-dropdown]"); gearButtons.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idx = btn.getAttribute("data-settings-dropdown"); const dropdown = document.getElementById(`settingsDropdown-${idx}`); if (dropdown) dropdown.classList.toggle("hidden"); }); }); // Edit button const editButtons = container.querySelectorAll("[data-edit-index]"); editButtons.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idx = btn.getAttribute("data-edit-index"); const dropdown = document.getElementById(`settingsDropdown-${idx}`); if (dropdown) dropdown.classList.add("hidden"); window.app?.handleEditVideo(idx); }); }); // Revert const revertButtons = container.querySelectorAll("[data-revert-index]"); revertButtons.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idx = btn.getAttribute("data-revert-index"); const dropdown = document.getElementById(`settingsDropdown-${idx}`); if (dropdown) dropdown.classList.add("hidden"); window.app?.handleRevertVideo(idx); }); }); // Delete All const deleteAllButtons = container.querySelectorAll( "[data-delete-all-index]" ); deleteAllButtons.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idx = btn.getAttribute("data-delete-all-index"); const dd = document.getElementById(`settingsDropdown-${idx}`); if (dd) dd.classList.add("hidden"); window.app?.handleFullDeleteVideo(idx); }); }); // Now fetch author profiles const authorPics = container.querySelectorAll(".author-pic"); const authorNames = container.querySelectorAll(".author-name"); // We only declare localAuthorSet once at the top // so we don't cause a "duplicate" variable error. authorPics.forEach((pic) => { localAuthorSet.add(pic.getAttribute("data-pubkey")); }); authorNames.forEach((nameEl) => { localAuthorSet.add(nameEl.getAttribute("data-pubkey")); }); if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) { window.app.batchFetchProfiles(localAuthorSet); } // Make author name/pic clickable => open channel authorPics.forEach((pic) => { pic.style.cursor = "pointer"; pic.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const pubkey = pic.getAttribute("data-pubkey"); window.app?.goToProfile(pubkey); }); }); authorNames.forEach((nameEl) => { nameEl.style.cursor = "pointer"; nameEl.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); const pubkey = nameEl.getAttribute("data-pubkey"); window.app?.goToProfile(pubkey); }); }); } convertEventToVideo(evt) { try { const content = JSON.parse(evt.content || "{}"); const hasFields = !!(content.title && content.magnet); const versionOk = content.version >= 2; if (!versionOk || !hasFields) { return { id: evt.id, invalid: true }; } return { id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at, videoRootId: content.videoRootId || evt.id, version: content.version, deleted: content.deleted === true, isPrivate: content.isPrivate === true, title: content.title || "", magnet: content.magnet || "", thumbnail: content.thumbnail || "", description: content.description || "", tags: evt.tags || [], invalid: false, }; } catch (err) { return { id: evt.id, invalid: true }; } } dedupeToNewestByRoot(videos) { const map = new Map(); for (const v of videos) { const rootId = v.videoRootId || v.id; const existing = map.get(rootId); if (!existing || v.created_at > existing.created_at) { map.set(rootId, v); } } return Array.from(map.values()); } } export const subscriptions = new SubscriptionsManager();