// js/subscriptions.js import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER, } from "./nostrEventSchemas.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { publishEventToRelays, assertAnyRelayAccepted, } from "./nostrPublish.js"; let attachHealthBadgesLoader = null; let attachHealthBadgesErrorLogged = false; function scheduleAttachHealthBadges(container) { if (!container) { return; } if (!attachHealthBadgesLoader) { attachHealthBadgesLoader = import("./gridHealth.js") .then((module) => module?.attachHealthBadges || null) .catch((error) => { if (!attachHealthBadgesErrorLogged) { attachHealthBadgesErrorLogged = true; if (typeof console !== "undefined") { console.warn( "[SubscriptionsManager] Failed to load grid health helpers:", error ); } } return null; }); } attachHealthBadgesLoader .then((fn) => { if (typeof fn === "function") { try { fn(container); } catch (error) { if (typeof console !== "undefined") { console.warn( "[SubscriptionsManager] attachHealthBadges threw:", error ); } } } }) .catch(() => { // Errors are already logged above; no-op here to keep Promise chains quiet. }); } function getAbsoluteShareUrl(nevent) { if (!nevent) { return ""; } if (window.app?.buildShareUrlFromNevent) { const candidate = window.app.buildShareUrlFromNevent(nevent); if (candidate) { return candidate; } } const origin = window.location?.origin || ""; const pathname = window.location?.pathname || ""; let base = origin || pathname ? `${origin}${pathname}` : ""; if (!base) { const href = window.location?.href || ""; base = href ? href.split(/[?#]/)[0] : ""; } if (!base) { return `?v=${encodeURIComponent(nevent)}`; } return `${base}?v=${encodeURIComponent(nevent)}`; } /** * 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": [SUBSCRIPTION_LIST_IDENTIFIER], 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); /* * The subscription list is stored as a NIP-04 message to self, so both * encryption and decryption intentionally use the user's own pubkey. * Extensions are expected to support this encrypt-to-self flow; altering * the target would break loadSubscriptions, which decrypts with the same * pubkey. Any future sharing model (e.g., sharing with another user) will * need a parallel read path and should not overwrite this behavior. */ let cipherText = ""; try { cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr); } catch (err) { console.error("Encryption failed:", err); throw err; } const evt = buildSubscriptionListEvent({ pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), content: cipherText, }); let signedEvent; try { signedEvent = await window.nostr.signEvent(evt); } catch (signErr) { console.error("Failed to sign subscription list:", signErr); throw signErr; } const publishResults = await publishEventToRelays( nostrClient.pool, nostrClient.relays, signedEvent ); let publishSummary; try { publishSummary = assertAnyRelayAccepted(publishResults, { context: "subscription list", }); } catch (publishError) { if (publishError?.relayFailures?.length) { publishError.relayFailures.forEach( ({ url, error: relayError, reason }) => { console.error( `[SubscriptionsManager] Subscription list rejected by ${url}: ${reason}`, relayError || reason ); } ); } throw publishError; } if (publishSummary.failed.length) { publishSummary.failed.forEach(({ url, error: relayError }) => { const reason = relayError instanceof Error ? relayError.message : relayError ? String(relayError) : "publish failed"; console.warn( `[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`, relayError ); }); } this.subsEventId = signedEvent.id; const acceptedUrls = publishSummary.accepted.map(({ url }) => url); console.log( "Subscription list published, event id:", signedEvent.id, "accepted relays:", acceptedUrls ); } /** * 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; } container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…"); // 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; const safeVideos = Array.isArray(videos) ? videos : []; const dedupedVideos = window.app?.dedupeVideosByRoot?.(safeVideos) ?? this.dedupeToNewestByRoot(safeVideos); const filteredVideos = dedupedVideos.filter((video) => { if (!video || typeof video !== "object") { return false; } if ( window.app?.isAuthorBlocked && window.app.isAuthorBlocked(video.pubkey) ) { return false; } return true; }); if (!filteredVideos.length) { container.innerHTML = `

No videos available yet.

`; return; } // Sort newest first filteredVideos.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(); filteredVideos.forEach((video, index) => { if (!video.id || !video.title) { console.error("Missing ID or title:", video); return; } // Keep the global videos map up to date so delegated playback handlers // can reuse the already fetched metadata for this event. window.app?.videosMap?.set(video.id, video); localAuthorSet.add(video.pubkey); const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const shareUrl = getAbsoluteShareUrl(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 moreMenu = `
`; const cardControls = `
${moreMenu}${gearMenu}
`; const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; const playbackUrl = typeof video.url === "string" ? video.url : ""; const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; const trimmedMagnet = typeof video.magnet === "string" ? video.magnet.trim() : ""; const legacyInfoHash = typeof video.infoHash === "string" ? video.infoHash.trim() : ""; const magnetCandidate = trimmedMagnet || legacyInfoHash; const playbackMagnet = magnetCandidate; const magnetProvided = magnetCandidate.length > 0; const magnetSupported = window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false; const urlBadgeHtml = trimmedUrl ? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ?? "" : ""; const torrentHealthBadgeHtml = magnetProvided && magnetSupported ? window.app?.getTorrentHealthBadgeMarkup?.({ includeMargin: false, }) ?? "" : ""; const connectionBadgesHtml = urlBadgeHtml || torrentHealthBadgeHtml ? `
${urlBadgeHtml}${torrentHealthBadgeHtml}
` : ""; const cardHtml = `
${safeTitle}

${safeTitle}

Placeholder

Loading name...

${timeAgo}
${cardControls}
${connectionBadgesHtml}
`; const t = document.createElement("template"); t.innerHTML = cardHtml.trim(); const cardEl = t.content.firstElementChild; if (cardEl) { cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false"; if (typeof video.pubkey === "string" && video.pubkey) { cardEl.dataset.ownerPubkey = video.pubkey; } else if (cardEl.dataset.ownerPubkey) { delete cardEl.dataset.ownerPubkey; } if (trimmedUrl) { cardEl.dataset.urlHealthState = "checking"; if (cardEl.dataset.urlHealthReason) { delete cardEl.dataset.urlHealthReason; } cardEl.dataset.urlHealthEventId = video.id || ""; cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { cardEl.dataset.urlHealthState = "offline"; cardEl.dataset.urlHealthReason = "missing-source"; if (cardEl.dataset.urlHealthEventId) { delete cardEl.dataset.urlHealthEventId; } if (cardEl.dataset.urlHealthUrl) { delete cardEl.dataset.urlHealthUrl; } } if (magnetProvided && magnetSupported) { cardEl.dataset.streamHealthState = "checking"; if (cardEl.dataset.streamHealthReason) { delete cardEl.dataset.streamHealthReason; } } else { cardEl.dataset.streamHealthState = "unhealthy"; cardEl.dataset.streamHealthReason = magnetProvided ? "unsupported" : "missing-source"; } // Leave the data-play-* attributes empty in the literal markup so we can // assign the raw URL/magnet strings post-parsing without HTML entity // escaping, mirroring the approach in app.js. The URL is encoded so that // special characters survive storage in data-* attributes; the click // handler decodes it right before playback while keeping the magnet raw. const interactiveEls = cardEl.querySelectorAll("[data-video-id]"); interactiveEls.forEach((el) => { if (!el.dataset) return; if (trimmedUrl) { el.dataset.playUrl = encodeURIComponent(trimmedUrl); } else { delete el.dataset.playUrl; } el.dataset.playMagnet = playbackMagnet || ""; }); if (magnetProvided) { cardEl.dataset.magnet = playbackMagnet; } else if (cardEl.dataset.magnet) { delete cardEl.dataset.magnet; } const badgeEl = cardEl.querySelector("[data-url-health-state]"); if (badgeEl) { if (trimmedUrl) { badgeEl.dataset.urlHealthEventId = video.id || ""; badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl); } else { if (badgeEl.dataset.urlHealthEventId) { delete badgeEl.dataset.urlHealthEventId; } if (badgeEl.dataset.urlHealthUrl) { delete badgeEl.dataset.urlHealthUrl; } } } } fragment.appendChild(cardEl); }); container.appendChild(fragment); scheduleAttachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { if (!window.app?.handleUrlHealthBadge) { return; } const video = window.app.videosMap?.get?.(eventId) || { id: eventId }; window.app.handleUrlHealthBadge({ video, url, badgeEl }); }); window.app?.attachVideoListHandler?.(); // Lazy-load const lazyEls = container.querySelectorAll("[data-lazy]"); lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); window.app?.attachMoreMenuHandlers?.(container); // 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 idxAttr = btn.getAttribute("data-edit-index"); const idx = Number.parseInt(idxAttr, 10); const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-edit-event-id") || ""; window.app?.handleEditVideo({ eventId, index: Number.isNaN(idx) ? null : idx, }); }); }); // Revert const revertButtons = container.querySelectorAll("[data-revert-index]"); revertButtons.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idxAttr = btn.getAttribute("data-revert-index"); const idx = Number.parseInt(idxAttr, 10); const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-revert-event-id") || ""; window.app?.handleRevertVideo({ eventId, index: Number.isNaN(idx) ? null : idx, }); }); }); // Delete All const deleteAllButtons = container.querySelectorAll( "[data-delete-all-index]" ); deleteAllButtons.forEach((btn) => { btn.addEventListener("click", (ev) => { ev.stopPropagation(); const idxAttr = btn.getAttribute("data-delete-all-index"); const idx = Number.parseInt(idxAttr, 10); const dd = document.getElementById(`settingsDropdown-${idxAttr}`); if (dd) dd.classList.add("hidden"); const eventId = btn.getAttribute("data-delete-all-event-id") || ""; window.app?.handleFullDeleteVideo({ eventId, index: Number.isNaN(idx) ? null : 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) { return sharedConvertEventToVideo(evt); } 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();