// js/subscriptions.js import { getActiveSigner, nostrClient, convertEventToVideo as sharedConvertEventToVideo, requestDefaultExtensionPermissions, DEFAULT_RELAY_URLS, } from "./nostr.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER } from "./nostrEventSchemas.js"; import { getSidebarLoadingMarkup } from "./sidebarLoading.js"; import { publishEventToRelays, assertAnyRelayAccepted } from "./nostrPublish.js"; import { getApplication } from "./applicationContext.js"; import { VideoListView } from "./ui/views/VideoListView.js"; import { ALLOW_NSFW_CONTENT } from "./config.js"; import { devLogger, userLogger } from "./utils/logger.js"; import moderationService from "./services/moderationService.js"; import nostrService from "./services/nostrService.js"; function normalizeHexPubkey(value) { if (typeof value !== "string") { return ""; } const trimmed = value.trim(); return trimmed ? trimmed.toLowerCase() : ""; } function extractEncryptionHints(event) { if (!event || typeof event !== "object") { return []; } const tags = Array.isArray(event.tags) ? event.tags : []; const hints = []; const pushUnique = (scheme) => { if (!scheme || hints.includes(scheme)) { return; } hints.push(scheme); }; for (const tag of tags) { if (!Array.isArray(tag) || tag.length < 2) { continue; } const label = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : ""; if (label !== "encrypted" && label !== "encryption") { continue; } const rawValue = typeof tag[1] === "string" ? tag[1] : ""; if (!rawValue) { continue; } const parts = rawValue .split(/[\s,]+/) .map((part) => part.trim().toLowerCase()) .filter(Boolean); for (const part of parts) { if (part.startsWith("nip44")) { if (/^nip44(?:[_-]?v2|v2)/.test(part)) { pushUnique("nip44_v2"); } else { pushUnique("nip44"); } } else if (part === "nip04" || part === "nip-04") { pushUnique("nip04"); } } } return hints; } function determineDecryptionOrder(event, availableSchemes) { const available = Array.isArray(availableSchemes) ? availableSchemes : []; const availableSet = new Set(available); const prioritized = []; const hints = extractEncryptionHints(event); const aliasMap = { nip04: ["nip04"], nip44: ["nip44", "nip44_v2"], nip44_v2: ["nip44_v2", "nip44"], }; for (const hint of hints) { const candidates = Array.isArray(aliasMap[hint]) ? aliasMap[hint] : [hint]; for (const candidate of candidates) { if (availableSet.has(candidate) && !prioritized.includes(candidate)) { prioritized.push(candidate); break; } } } for (const fallback of ["nip04", "nip44", "nip44_v2"]) { if (availableSet.has(fallback) && !prioritized.includes(fallback)) { prioritized.push(fallback); } } return prioritized.length ? prioritized : available; } const getApp = () => getApplication(); /** * 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; this.subscriptionListView = null; this.lastRunOptions = null; this.lastResult = null; this.lastContainerId = null; this.unsubscribeFromNostrUpdates = null; this.pendingRefreshPromise = null; this.scheduledRefreshDetail = null; this.isRunningFeed = false; this.hasRenderedOnce = false; this.ensureNostrServiceListener(); } /** * Decrypt the subscription list from kind=30002 (d="subscriptions"). */ async loadSubscriptions(userPubkey) { if (!userPubkey) { userLogger.warn("[SubscriptionsManager] No pubkey => cannot load subs."); return; } try { const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; const sessionActorPubkey = normalizeHexPubkey( nostrClient?.sessionActor?.pubkey, ); const authorSet = new Set(); if (normalizedUserPubkey) { authorSet.add(normalizedUserPubkey); } if (sessionActorPubkey) { authorSet.add(sessionActorPubkey); } const authors = Array.from(authorSet); if (!authors.length && userPubkey) { authors.push(userPubkey); } const filter = { kinds: [30002], authors, "#d": [SUBSCRIPTION_LIST_IDENTIFIER], limit: 1 }; const relaySet = new Set(); const addRelayCandidates = (candidates) => { if (!candidates) { return; } const iterable = Array.isArray(candidates) ? candidates : candidates instanceof Set ? Array.from(candidates) : []; for (const candidate of iterable) { if (typeof candidate !== "string") { continue; } const trimmed = candidate.trim(); if (trimmed) { relaySet.add(trimmed); } } }; addRelayCandidates(nostrClient.relays); if (!relaySet.size) { addRelayCandidates(nostrClient.readRelays); } if (!relaySet.size) { addRelayCandidates(DEFAULT_RELAY_URLS); } const relayUrls = Array.from(relaySet); if (!relayUrls.length) { devLogger.warn( "[SubscriptionsManager] No relay URLs available while loading subscriptions.", ); } const relayPromises = relayUrls.map((url) => nostrClient.pool.list([url], [filter]).catch((err) => { userLogger.error(`[SubscriptionsManager] Relay error at ${url}`, err); return []; }), ); const settledResults = await Promise.allSettled(relayPromises); const events = []; for (const outcome of settledResults) { if (outcome.status === "fulfilled" && Array.isArray(outcome.value) && outcome.value.length) { events.push(...outcome.value); } } 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; const signer = getActiveSigner(); const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; const hints = extractEncryptionHints(newest); const requiresNip44 = hints.includes("nip44") || hints.includes("nip44_v2"); const requiresNip04 = !hints.length || hints.includes("nip04") || !requiresNip44; let permissionResult = { ok: true }; const signerCoversRequiredSchemes = (!requiresNip04 || signerHasNip04) && (!requiresNip44 || signerHasNip44); if (!signerCoversRequiredSchemes) { permissionResult = await requestDefaultExtensionPermissions(); } if (!permissionResult.ok) { userLogger.warn( "[SubscriptionsManager] Extension permissions denied while loading subscriptions; treating list as empty.", permissionResult.error, ); this.subscribedPubkeys.clear(); this.subsEventId = null; this.loaded = true; return; } const decryptResult = await this.decryptSubscriptionEvent(newest, userPubkey); if (!decryptResult.ok) { userLogger.error( "[SubscriptionsManager] Failed to decrypt subscription list:", decryptResult.error, ); this.subscribedPubkeys.clear(); this.subsEventId = null; this.loaded = true; return; } const decryptedStr = decryptResult.plaintext; const parsed = JSON.parse(decryptedStr); const subArray = Array.isArray(parsed.subPubkeys) ? parsed.subPubkeys : []; const normalized = subArray .map((value) => normalizeHexPubkey(value)) .filter((value) => Boolean(value)); this.subscribedPubkeys = new Set(normalized); this.loaded = true; } catch (err) { userLogger.error("[SubscriptionsManager] Failed to load subs:", err); } } isSubscribed(channelHex) { const normalized = normalizeHexPubkey(channelHex); if (!normalized) { return false; } return this.subscribedPubkeys.has(normalized); } getSubscribedAuthors() { return Array.from(this.subscribedPubkeys); } async decryptSubscriptionEvent(event, userPubkey) { const ciphertext = typeof event?.content === "string" ? event.content : ""; if (!ciphertext) { const error = new Error("Subscription event is missing ciphertext content."); error.code = "subscriptions-empty-ciphertext"; return { ok: false, error }; } const decryptors = new Map(); const registerDecryptor = (scheme, handler) => { if (!scheme || typeof handler !== "function" || decryptors.has(scheme)) { return; } decryptors.set(scheme, handler); }; const signer = getActiveSigner(); const signerHasNip04 = typeof signer?.nip04Decrypt === "function"; const signerHasNip44 = typeof signer?.nip44Decrypt === "function"; if (signerHasNip04) { registerDecryptor("nip04", (payload) => signer.nip04Decrypt(userPubkey, payload)); } if (signerHasNip44) { registerDecryptor("nip44", (payload) => signer.nip44Decrypt(userPubkey, payload)); } const nostrApi = typeof window !== "undefined" && window && window.nostr ? window.nostr : null; if (nostrApi) { if (!signerHasNip04 && nostrApi.nip04 && typeof nostrApi.nip04.decrypt === "function") { registerDecryptor("nip04", (payload) => nostrApi.nip04.decrypt(userPubkey, payload) ); } const nip44 = nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; if (nip44) { if (!signerHasNip44 && typeof nip44.decrypt === "function") { registerDecryptor("nip44", (payload) => nip44.decrypt(userPubkey, payload)); } const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; if (nip44v2 && typeof nip44v2.decrypt === "function") { registerDecryptor("nip44_v2", (payload) => nip44v2.decrypt(userPubkey, payload) ); if (!decryptors.has("nip44")) { registerDecryptor("nip44", (payload) => nip44v2.decrypt(userPubkey, payload)); } } } } if (!decryptors.size) { const error = new Error( "No active signer or extension decryptors are available for subscriptions." ); error.code = "nostr-extension-missing"; return { ok: false, error }; } const availableSchemes = Array.from(decryptors.keys()); const order = determineDecryptionOrder(event, availableSchemes); const attemptErrors = []; for (const scheme of order) { const decryptFn = decryptors.get(scheme); if (!decryptFn) { continue; } try { const plaintext = await decryptFn(ciphertext); if (typeof plaintext !== "string") { const error = new Error("Decryption returned a non-string payload."); error.code = "subscriptions-invalid-plaintext"; attemptErrors.push({ scheme, error }); continue; } return { ok: true, plaintext, scheme }; } catch (error) { attemptErrors.push({ scheme, error }); } } const error = new Error("Failed to decrypt subscription list with available schemes."); error.code = "subscriptions-decrypt-failed"; if (attemptErrors.length) { error.cause = attemptErrors; } return { ok: false, error, errors: attemptErrors }; } async addChannel(channelHex, userPubkey) { const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { throw new Error("No user pubkey => cannot addChannel."); } if (!normalizedChannel) { devLogger.warn("Attempted to subscribe to invalid pubkey", channelHex); return; } if (this.subscribedPubkeys.has(normalizedChannel)) { devLogger.log("Already subscribed to", channelHex); return; } this.subscribedPubkeys.add(normalizedChannel); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( "[SubscriptionsManager] Failed to refresh after adding subscription:", error ); }); } async removeChannel(channelHex, userPubkey) { const normalizedChannel = normalizeHexPubkey(channelHex); if (!userPubkey) { throw new Error("No user pubkey => cannot removeChannel."); } if (!normalizedChannel) { devLogger.warn("Attempted to remove invalid pubkey from subscriptions", channelHex); return; } if (!this.subscribedPubkeys.has(normalizedChannel)) { devLogger.log("Channel not found in subscription list:", channelHex); return; } this.subscribedPubkeys.delete(normalizedChannel); await this.publishSubscriptionList(userPubkey); this.refreshActiveFeed({ reason: "subscription-update" }).catch((error) => { userLogger.warn( "[SubscriptionsManager] Failed to refresh after removing subscription:", error ); }); } /** * 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 signer = getActiveSigner(); if (!signer) { const error = new Error( "An active signer is required to update subscriptions." ); error.code = "signer-missing"; throw error; } if (signer.type === "extension") { const permissionResult = await requestDefaultExtensionPermissions(); if (!permissionResult.ok) { userLogger.warn( "[SubscriptionsManager] Signer permissions denied while updating subscriptions.", permissionResult.error, ); const error = new Error( "The active signer must allow encryption and signing before updating subscriptions.", ); error.code = "extension-permission-denied"; error.cause = permissionResult.error; throw error; } } if (typeof signer.nip04Encrypt !== "function") { const error = new Error("NIP-04 encryption is required to update subscriptions."); error.code = "nip04-missing"; throw error; } if (typeof signer.signEvent !== "function") { const error = new Error("Active signer missing signEvent support."); error.code = "sign-event-missing"; throw error; } 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 signer.nip04Encrypt(userPubkey, plainStr); } catch (err) { userLogger.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 signer.signEvent(evt); } catch (signErr) { userLogger.error("Failed to sign subscription list:", signErr); throw signErr; } const sanitizeRelayList = (candidate) => Array.isArray(candidate) ? candidate .map((url) => (typeof url === "string" ? url.trim() : "")) .filter(Boolean) : []; const writeRelays = sanitizeRelayList(nostrClient.writeRelays); const fallbackRelays = writeRelays.length ? writeRelays : sanitizeRelayList(nostrClient.relays); const targetRelays = fallbackRelays.length ? fallbackRelays : Array.from(DEFAULT_RELAY_URLS); const publishResults = await publishEventToRelays( nostrClient.pool, targetRelays, signedEvent ); let publishSummary; try { publishSummary = assertAnyRelayAccepted(publishResults, { context: "subscription list" }); } catch (publishError) { if (publishError?.relayFailures?.length) { publishError.relayFailures.forEach( ({ url, error: relayError, reason }) => { userLogger.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"; userLogger.warn( `[SubscriptionsManager] Subscription list not accepted by ${url}: ${reason}`, relayError ); }); } this.subsEventId = signedEvent.id; const acceptedUrls = publishSummary.accepted.map(({ url }) => url); devLogger.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", options = {} ) { const limitCandidate = Number(options?.limit); const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 ? Math.floor(limitCandidate) : null; const reason = typeof options?.reason === "string" && options.reason.trim() ? options.reason.trim() : undefined; this.lastContainerId = containerId; const container = document.getElementById(containerId); if (!userPubkey) { if (container) { container.innerHTML = "
Please log in first.
"; } this.lastRunOptions = null; this.lastResult = null; this.hasRenderedOnce = Boolean(container); return null; } if (!this.loaded) { try { await this.loadSubscriptions(userPubkey); } catch (error) { userLogger.error( "[SubscriptionsManager] Failed to load subscriptions while rendering feed:", error, ); } } const channelHexes = this.getSubscribedAuthors(); if (!container) { this.lastRunOptions = { actorPubkey: userPubkey, limit, containerId }; this.hasRenderedOnce = false; return null; } if (!channelHexes.length) { container.innerHTML = "No subscriptions found.
"; this.lastRunOptions = { actorPubkey: userPubkey, limit, containerId }; this.lastResult = { items: [], metadata: { reason: "no-subscriptions" } }; this.hasRenderedOnce = true; return this.lastResult; } container.innerHTML = getSidebarLoadingMarkup("Fetching subscriptions…"); this.lastRunOptions = { actorPubkey: userPubkey, limit, containerId, reason }; this.ensureFeedRegistered(); this.ensureNostrServiceListener(); if (typeof nostrService?.awaitInitialLoad === "function") { try { await nostrService.awaitInitialLoad(); } catch (error) { devLogger.warn( "[SubscriptionsManager] Failed to await nostrService initial load:", error ); } } const engine = this.getFeedEngine(); if (!engine || typeof engine.run !== "function") { container.innerHTML = "Subscriptions are unavailable right now.
"; this.hasRenderedOnce = true; return null; } const app = getApp(); const runtime = this.buildFeedRuntime({ app, authors: channelHexes, limit, }); const runOptions = { actorPubkey: userPubkey, limit, runtime, hooks: { subscriptions: { resolveAuthors: () => this.getSubscribedAuthors() } } }; try { this.isRunningFeed = true; const result = await engine.run("subscriptions", runOptions); const videos = Array.isArray(result?.items) ? result.items.map((item) => item?.video).filter(Boolean) : []; const metadata = result && typeof result.metadata === "object" ? { ...result.metadata } : {}; if (!metadata.feed) { metadata.feed = "subscriptions"; } if (limit) { metadata.limit = limit; } if (reason) { metadata.reason = reason; } const enrichedResult = { ...result, metadata }; if (app?.videosMap instanceof Map) { videos.forEach((video) => { if (video && typeof video.id === "string" && video.id) { app.videosMap.set(video.id, video); } }); } this.lastResult = enrichedResult; this.renderSameGridStyle(enrichedResult, containerId, { limit, reason, emptyMessage: "No playable subscription videos found yet. We'll keep watching for new posts.", }); this.hasRenderedOnce = true; return enrichedResult; } catch (error) { userLogger.error( "[SubscriptionsManager] Failed to run subscriptions feed:", error ); if (container && this.lastResult) { const fallbackReason = reason ? `${reason}:cached` : "cached-result"; this.renderSameGridStyle(this.lastResult, containerId, { limit, reason: fallbackReason, }); } else if (container) { container.innerHTML = "Unable to load subscriptions right now.
"; } this.hasRenderedOnce = Boolean(container); return this.lastResult; } finally { this.isRunningFeed = false; this.processScheduledRefresh(); } } ensureNostrServiceListener() { if (this.unsubscribeFromNostrUpdates || typeof nostrService?.on !== "function") { return; } this.unsubscribeFromNostrUpdates = nostrService.on( "videos:updated", (detail) => { this.handleNostrVideosUpdated(detail); } ); } handleNostrVideosUpdated(detail) { if (!detail || !Array.isArray(detail.videos) || !detail.videos.length) { return; } this.scheduledRefreshDetail = detail; this.processScheduledRefresh(); } processScheduledRefresh() { if (!this.lastRunOptions || !this.hasRenderedOnce) { return null; } if (!this.scheduledRefreshDetail) { return null; } if (this.isRunningFeed || this.pendingRefreshPromise) { return null; } const detail = this.scheduledRefreshDetail; this.scheduledRefreshDetail = null; const refreshReason = typeof detail?.reason === "string" && detail.reason ? `nostr:${detail.reason}` : "nostr:update"; this.pendingRefreshPromise = this.refreshActiveFeed({ reason: refreshReason }) .catch((error) => { devLogger.warn( "[SubscriptionsManager] Failed to refresh after nostrService update:", error ); }) .finally(() => { this.pendingRefreshPromise = null; if (this.scheduledRefreshDetail) { this.processScheduledRefresh(); } }); return this.pendingRefreshPromise; } ensureFeedRegistered() { const app = getApp(); if (typeof app?.registerSubscriptionsFeed === "function") { try { app.registerSubscriptionsFeed(); } catch (error) { userLogger.warn( "[SubscriptionsManager] Failed to register subscriptions feed:", error ); } } } getFeedEngine() { const app = getApp(); return app?.feedEngine || null; } buildFeedRuntime({ app, authors = [], limit = null } = {}) { const normalizedAuthors = Array.isArray(authors) ? authors .map((author) => normalizeHexPubkey(author)) .filter((author) => Boolean(author)) : []; const blacklist = app?.blacklistedEventIds instanceof Set ? new Set(app.blacklistedEventIds) : new Set(); const isAuthorBlocked = typeof app?.isAuthorBlocked === "function" ? (pubkey) => app.isAuthorBlocked(pubkey) : () => false; const limitCandidate = Number(limit); const normalizedLimit = Number.isFinite(limitCandidate) && limitCandidate > 0 ? Math.floor(limitCandidate) : null; return { subscriptionAuthors: normalizedAuthors, authors: normalizedAuthors, blacklistedEventIds: blacklist, isAuthorBlocked, limit: normalizedLimit }; } /** * Renders the feed in the same style as home. * This includes gear menu, time-ago, lazy load, clickable authors, etc. */ renderSameGridStyle(result, containerId, options = {}) { const container = document.getElementById(containerId); if (!container) { return; } const app = getApp(); const items = Array.isArray(result?.items) ? result.items : []; const metadata = result && typeof result.metadata === "object" ? { ...result.metadata } : {}; if (!metadata.feed) { metadata.feed = "subscriptions"; } const limitCandidate = Number(options?.limit); const limit = Number.isFinite(limitCandidate) && limitCandidate > 0 ? Math.floor(limitCandidate) : null; const limitedItems = limit ? items.slice(0, limit) : items; const videos = limitedItems .map((item) => (item && typeof item === "object" ? item.video : null)) .filter((video) => video && typeof video === "object"); if (!videos.length) { const reasonDetail = typeof metadata.reason === "string" && metadata.reason ? metadata.reason : "empty"; this.renderEmptyState(container, { message: options?.emptyMessage, reason: reasonDetail, metadata, }); return; } const listView = this.getListView(container, app); if (!listView) { container.innerHTML = `Unable to render subscriptions feed.
`; return; } listView.mount(container); if (app?.videosMap instanceof Map) { listView.state.videosMap = app.videosMap; } const enrichedMetadata = { ...metadata, feed: "subscriptions" }; if (limit) { enrichedMetadata.limit = limit; } if (typeof options?.reason === "string" && options.reason) { enrichedMetadata.reason = options.reason; } listView.state.feedMetadata = enrichedMetadata; listView.render(videos, enrichedMetadata); } renderEmptyState(container, { message, reason, metadata } = {}) { if (!container) { return; } const copy = typeof message === "string" && message.trim() ? message.trim() : "No playable subscription videos found yet. We'll keep watching for new posts."; container.innerHTML = getSidebarLoadingMarkup(copy, { showSpinner: false }); if (this.subscriptionListView && this.subscriptionListView.state) { const currentMetadata = this.subscriptionListView.state.feedMetadata && typeof this.subscriptionListView.state.feedMetadata === "object" ? { ...this.subscriptionListView.state.feedMetadata } : {}; if (metadata && typeof metadata === "object") { Object.assign(currentMetadata, metadata); } if (reason && typeof reason === "string") { currentMetadata.reason = reason; } else if (!currentMetadata.reason) { currentMetadata.reason = "empty"; } this.subscriptionListView.state.feedMetadata = currentMetadata; } } getListView(container, app) { if (this.subscriptionListView) { return this.subscriptionListView; } if (!container) { return null; } const doc = container.ownerDocument || document; const baseView = app?.videoListView || null; const badgeHelpers = baseView?.badgeHelpers || { attachHealthBadges: () => {}, attachUrlHealthBadges: () => {} }; const formatTimeAgo = (timestamp) => { if (typeof app?.formatTimeAgo === "function") { return app.formatTimeAgo(timestamp); } if (typeof baseView?.formatters?.formatTimeAgo === "function") { return baseView.formatters.formatTimeAgo(timestamp); } return timestamp; }; const formatViewCountLabel = (total) => { if (typeof baseView?.formatters?.formatViewCountLabel === "function") { return baseView.formatters.formatViewCountLabel(total); } return typeof total === "number" ? total.toLocaleString() : `${total}`; }; const assets = baseView?.assets || { fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg", unsupportedBtihMessage: "This magnet link is missing a compatible BitTorrent v1 info hash." }; const loadedThumbnails = app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : baseView?.state?.loadedThumbnails instanceof Map ? baseView.state.loadedThumbnails : new Map(); const videosMap = app?.videosMap instanceof Map ? app.videosMap : baseView?.state?.videosMap instanceof Map ? baseView.state.videosMap : new Map(); const urlHealthCache = app?.urlHealthSnapshots instanceof Map ? app.urlHealthSnapshots : baseView?.state?.urlHealthByVideoId instanceof Map ? baseView.state.urlHealthByVideoId : new Map(); const streamHealthCache = app?.streamHealthSnapshots instanceof Map ? app.streamHealthSnapshots : baseView?.state?.streamHealthByVideoId instanceof Map ? baseView.state.streamHealthByVideoId : new Map(); const listViewConfig = { document: doc, container, mediaLoader: app?.mediaLoader || baseView?.mediaLoader || null, badgeHelpers, formatters: { formatTimeAgo, formatViewCountLabel }, helpers: { escapeHtml: (value) => app?.escapeHTML?.(value) ?? value, isMagnetSupported: (magnet) => app?.isMagnetUriSupported?.(magnet) ?? false, toLocaleString: (value) => typeof value === "number" ? value.toLocaleString() : value }, assets, state: { loadedThumbnails, videosMap, urlHealthByVideoId: urlHealthCache, streamHealthByVideoId: streamHealthCache }, utils: { dedupeVideos: (videos) => (Array.isArray(videos) ? [...videos] : []), getAllEvents: () => Array.from(nostrClient.allEvents.values()), hasOlderVersion: (video, events) => app?.hasOlderVersion?.(video, events) ?? false, derivePointerInfo: (video) => app?.deriveVideoPointerInfo?.(video) ?? null, persistWatchHistoryMetadata: (video, pointerInfo) => app?.persistWatchHistoryMetadataForVideo?.(video, pointerInfo), getShareUrlBase: () => app?.getShareUrlBase?.() ?? "", buildShareUrlFromNevent: (nevent) => app?.buildShareUrlFromNevent?.(nevent) ?? "", buildShareUrlFromEventId: (eventId) => app?.buildShareUrlFromEventId?.(eventId) ?? "", canManageBlacklist: () => app?.canCurrentUserManageBlacklist?.() ?? false, canEditVideo: (video) => video?.pubkey === app?.pubkey, canDeleteVideo: (video) => video?.pubkey === app?.pubkey, batchFetchProfiles: (authorSet) => app?.batchFetchProfiles?.(authorSet), bindThumbnailFallbacks: (target) => app?.bindThumbnailFallbacks?.(target), handleUrlHealthBadge: (payload) => app?.handleUrlHealthBadge?.(payload), refreshDiscussionCounts: (videosList, { container: root } = {}) => app?.refreshVideoDiscussionCounts?.(videosList, { videoListRoot: root || container || null }), ensureGlobalMoreMenuHandlers: () => app?.ensureGlobalMoreMenuHandlers?.(), closeAllMenus: (options) => app?.closeAllMoreMenus?.(options) }, renderers: { getLoadingMarkup: (message) => getSidebarLoadingMarkup(message) }, allowNsfw: ALLOW_NSFW_CONTENT === true }; const listView = new VideoListView(listViewConfig); listView.setPlaybackHandler((detail) => { if (!detail) { return; } if (detail.videoId) { Promise.resolve( app?.playVideoByEventId?.(detail.videoId, { url: detail.url, magnet: detail.magnet }) ).catch((error) => { userLogger.error( "[SubscriptionsManager] Failed to play by event id:", error ); }); return; } Promise.resolve( app?.playVideoWithFallback?.({ url: detail.url, magnet: detail.magnet }) ).catch((error) => { userLogger.error( "[SubscriptionsManager] Failed to start playback:", error ); }); }); listView.setEditHandler(({ video, index }) => { if (!video?.id) { return; } app?.handleEditVideo?.({ eventId: video.id, index: Number.isFinite(index) ? index : null }); }); listView.setRevertHandler(({ video, index }) => { if (!video?.id) { return; } app?.handleRevertVideo?.({ eventId: video.id, index: Number.isFinite(index) ? index : null }); }); listView.setDeleteHandler(({ video, index }) => { if (!video?.id) { return; } app?.handleFullDeleteVideo?.({ eventId: video.id, index: Number.isFinite(index) ? index : null }); }); listView.setBlacklistHandler(({ video, dataset }) => { const detail = { ...(dataset || {}), author: dataset?.author || video?.pubkey || "", context: dataset?.context || "subscriptions" }; app?.handleMoreMenuAction?.("blacklist-author", detail); }); listView.addEventListener("video:share", (event) => { const detail = event?.detail || {}; const dataset = { ...(detail.dataset || {}), eventId: detail.eventId || detail.dataset?.eventId || detail.video?.id || "", context: detail.dataset?.context || "subscriptions" }; app?.handleMoreMenuAction?.(detail.action || "copy-link", dataset); }); listView.addEventListener("video:context-action", (event) => { const detail = event?.detail || {}; const dataset = { ...(detail.dataset || {}), eventId: detail.dataset?.eventId || detail.video?.id || "", context: detail.dataset?.context || "subscriptions" }; app?.handleMoreMenuAction?.(detail.action, dataset); }); this.subscriptionListView = listView; return this.subscriptionListView; } async refreshActiveFeed(options = {}) { if (!this.lastRunOptions) { return null; } const { actorPubkey, containerId, limit } = this.lastRunOptions; if (!actorPubkey || !containerId) { return null; } const reason = typeof options?.reason === "string" && options.reason.trim() ? options.reason.trim() : this.lastRunOptions.reason; if (typeof moderationService?.awaitUserBlockRefresh === "function") { try { await moderationService.awaitUserBlockRefresh(); } catch (error) { devLogger.warn( "[SubscriptionsManager] Failed to sync moderation before refreshing feed:", error, ); } } return this.showSubscriptionVideos(actorPubkey, containerId, { limit, reason }); } convertEventToVideo(evt) { return sharedConvertEventToVideo(evt); } } export const subscriptions = new SubscriptionsManager();