diff --git a/benchmarks/render_loop_overhead_bench.js b/benchmarks/render_loop_overhead_bench.js new file mode 100644 index 00000000..e4c3e559 --- /dev/null +++ b/benchmarks/render_loop_overhead_bench.js @@ -0,0 +1,71 @@ +// benchmarks/render_loop_overhead_bench.js + +class WatchHistoryTelemetry { + persistMetadataForVideo(video, pointerInfo) { + // Deprecated - effectively a no-op in real code + } +} + +class ModalCoordinator { + constructor() { + this.watchHistoryTelemetry = new WatchHistoryTelemetry(); + } + persistWatchHistoryMetadataForVideo(video, pointerInfo) { + if (this.watchHistoryTelemetry) { + this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo); + return; + } + } +} + +class App { + constructor() { + this._modal = new ModalCoordinator(); + this._coordinatorsReady = true; // Simulating initialized state + } + + _initCoordinators() { + if (this._coordinatorsReady) return; + this._coordinatorsReady = true; + } + + persistWatchHistoryMetadataForVideo(...args) { + this._initCoordinators(); + return this._modal.persistWatchHistoryMetadataForVideo(...args); + } +} + +const app = new App(); +const video = { id: "test", title: "test" }; +const pointerInfo = { key: "test" }; + +const ITERATIONS = 100_000_000; + +console.log(`Benchmarking overhead for ${ITERATIONS.toLocaleString()} iterations...`); + +const startWithCall = performance.now(); +for (let i = 0; i < ITERATIONS; i++) { + if ( + pointerInfo && + typeof app?.persistWatchHistoryMetadataForVideo === "function" + ) { + app.persistWatchHistoryMetadataForVideo(video, pointerInfo); + } +} +const endWithCall = performance.now(); +const durationWithCall = endWithCall - startWithCall; + +const startWithoutCall = performance.now(); +for (let i = 0; i < ITERATIONS; i++) { + // Simulating the removal of the call + if (false) { // Condition becomes false or code is removed + // Removed + } +} +const endWithoutCall = performance.now(); +const durationWithoutCall = endWithoutCall - startWithoutCall; + +console.log(`With call: ${durationWithCall.toFixed(2)}ms`); +console.log(`Without call: ${durationWithoutCall.toFixed(2)}ms`); +console.log(`Improvement: ${(durationWithCall - durationWithoutCall).toFixed(2)}ms`); +console.log(`Relative improvement: ${(durationWithCall / durationWithoutCall).toFixed(2)}x`); diff --git a/js/app.js b/js/app.js index 64d50fde..335fd813 100644 --- a/js/app.js +++ b/js/app.js @@ -22,6 +22,7 @@ import { DEFAULT_BLUR_THRESHOLD, DEFAULT_TRUSTED_MUTE_HIDE_THRESHOLD, DEFAULT_TRUSTED_SPAM_HIDE_THRESHOLD, + FEED_TYPES, } from "./constants.js"; import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; @@ -138,22 +139,10 @@ import { import { getDesignSystemMode as getCanonicalDesignSystemMode } from "./designSystem.js"; import { getHashViewName, setHashView } from "./hashView.js"; import { - getPubkey as getStoredPubkey, - setPubkey as setStoredPubkey, - getCurrentUserNpub as getStoredCurrentUserNpub, - setCurrentUserNpub as setStoredCurrentUserNpub, - getCurrentVideo as getStoredCurrentVideo, - setCurrentVideo as setStoredCurrentVideo, setModalState as setGlobalModalState, subscribeToAppStateKey, } from "./state/appState.js"; import { - getSavedProfiles, - getActiveProfilePubkey, - setActiveProfilePubkey as setStoredActiveProfilePubkey, - setSavedProfiles, - persistSavedProfiles, - getProfileCacheMap, getCachedUrlHealth as readCachedUrlHealth, storeUrlHealth as persistUrlHealth, setInFlightUrlProbe, @@ -211,35 +200,43 @@ const RELAY_UI_BATCH_DELAY_MS = 250; */ class Application { get pubkey() { - return getStoredPubkey(); + return this.authService.pubkey; } set pubkey(value) { - setStoredPubkey(value ?? null); + this.authService.pubkey = value; } get currentUserNpub() { - return getStoredCurrentUserNpub(); + return this.authService.currentUserNpub; } set currentUserNpub(value) { - setStoredCurrentUserNpub(value ?? null); + this.authService.currentUserNpub = value; } get currentVideo() { - return getStoredCurrentVideo(); + return this.playbackService.currentVideo; } set currentVideo(value) { - setStoredCurrentVideo(value ?? null); + this.playbackService.currentVideo = value; } get activeProfilePubkey() { - return getActiveProfilePubkey(); + return this.authService.activeProfilePubkey; + } + + set activeProfilePubkey(value) { + this.authService.setActiveProfilePubkey(value, { persist: false }); } get savedProfiles() { - return getSavedProfiles(); + return this.authService.savedProfiles; + } + + set savedProfiles(value) { + this.authService.setSavedProfiles(value, { persist: false, persistActive: false }); } constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) { @@ -603,7 +600,7 @@ class Application { queueSignEvent, bootstrapTrustedSeeds, getModerationSettings, - getActiveProfilePubkey, + getActiveProfilePubkey: () => this.authService.activeProfilePubkey, })), writable: true, configurable: true, @@ -688,7 +685,7 @@ class Application { } persistActiveProfileSelection(pubkey, { persist = true } = {}) { - setStoredActiveProfilePubkey(pubkey, { persist }); + this.authService.setActiveProfilePubkey(pubkey, { persist }); this.renderSavedProfiles(); } @@ -718,9 +715,7 @@ class Application { const uniqueAuthors = new Set(activeVideos.map((v) => v.pubkey)); // 3) For each author, fetchAndRenderProfile with forceRefresh = true - for (const authorPubkey of uniqueAuthors) { - this.authService.fetchAndRenderProfile(authorPubkey, true); - } + this.batchFetchProfiles(uniqueAuthors, { forceRefresh: true }); } getCurrentUserNpub() { @@ -756,237 +751,333 @@ class Application { return false; } - async init() { - try { - if (typeof this.loadView !== "function") { - const module = await import("./viewManager.js"); - this.loadView = module?.loadView || null; - } + async _initViewManager() { + if (typeof this.loadView !== "function") { + const module = await import("./viewManager.js"); + this.loadView = module?.loadView || null; + } + } - if (nostrClient && typeof nostrClient.warmupExtension === "function") { - nostrClient.warmupExtension(); - } + _warmupNostrExtension() { + if (nostrClient && typeof nostrClient.warmupExtension === "function") { + nostrClient.warmupExtension(); + } + } - // Force update of any registered service workers to ensure latest code is used. - if ("serviceWorker" in navigator) { - navigator.serviceWorker.getRegistrations().then((registrations) => { - registrations.forEach((registration) => registration.update()); - }); - } + _initServiceWorker() { + // Force update of any registered service workers to ensure latest code is used. + if ("serviceWorker" in navigator) { + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => registration.update()); + }); + } + } - this.authService.hydrateFromStorage(); - this.renderSavedProfiles(); + _initAuthAndModeration() { + this.authService.hydrateFromStorage(); + this.renderSavedProfiles(); - if ( - this.nostrService && - typeof this.nostrService.setDmBlockChecker === "function" - ) { - this.nostrService.setDmBlockChecker((pubkey) => - this.isAuthorBlocked(pubkey), - ); - } - - loadModerationOverridesFromStorage(); - loadModerationSettingsFromStorage(); - loadDmPrivacySettingsFromStorage(); - this.moderationSettings = this.normalizeModerationSettings( - getModerationSettings(), + if ( + this.nostrService && + typeof this.nostrService.setDmBlockChecker === "function" + ) { + this.nostrService.setDmBlockChecker((pubkey) => + this.isAuthorBlocked(pubkey), ); + } - const videoModalPromise = this.videoModal.load().then(() => { - const modalRoot = this.videoModal.getRoot(); - if (modalRoot) { - this.attachMoreMenuHandlers(modalRoot); + loadModerationOverridesFromStorage(); + loadModerationSettingsFromStorage(); + loadDmPrivacySettingsFromStorage(); + this.moderationSettings = this.normalizeModerationSettings( + getModerationSettings(), + ); + } + + _bindVideoModalEvents() { + const modalRoot = this.videoModal.getRoot(); + if (modalRoot) { + this.attachMoreMenuHandlers(modalRoot); + } + if ( + this.videoModal && + typeof this.videoModal.addEventListener === "function" + ) { + this.videoModal.addEventListener("video:share-nostr", (event) => { + this.openShareNostrModal({ + video: event?.detail?.video || null, + triggerElement: event?.detail?.trigger || null, + }); + }); + + this.videoModal.addEventListener("video:copy-cdn", (event) => { + const video = event?.detail?.video || this.currentVideo; + const url = video?.url || ""; + if (!url) { + this.showError("No CDN link available to copy."); + return; } - if ( - this.videoModal && - typeof this.videoModal.addEventListener === "function" - ) { - this.videoModal.addEventListener("video:share-nostr", (event) => { - this.openShareNostrModal({ - video: event?.detail?.video || null, - triggerElement: event?.detail?.trigger || null, - }); - }); + navigator.clipboard + .writeText(url) + .then(() => this.showSuccess("CDN link copied to clipboard!")) + .catch(() => this.showError("Failed to copy CDN link.")); + }); - this.videoModal.addEventListener("video:copy-cdn", (event) => { - const video = event?.detail?.video || this.currentVideo; - const url = video?.url || ""; - if (!url) { - this.showError("No CDN link available to copy."); - return; - } - navigator.clipboard - .writeText(url) - .then(() => this.showSuccess("CDN link copied to clipboard!")) - .catch(() => this.showError("Failed to copy CDN link.")); - }); + this.videoModal.addEventListener("video:copy-magnet", () => { + this.handleCopyMagnet(); + }); - this.videoModal.addEventListener("video:copy-magnet", () => { - this.handleCopyMagnet(); - }); + this.videoModal.addEventListener("playback:switch-source", (event) => { + const detail = event?.detail || {}; + const { source } = detail; + if (!source) { + return; + } + const modalVideo = detail?.video || null; + const fallbackVideo = this.currentVideo || null; + const video = { + ...(fallbackVideo || {}), + ...(modalVideo || {}), + }; + const urlCandidate = + typeof video.url === "string" ? video.url.trim() : ""; + const magnetCandidate = + typeof video.magnet === "string" ? video.magnet.trim() : ""; - this.videoModal.addEventListener("playback:switch-source", (event) => { - const detail = event?.detail || {}; - const { source } = detail; - if (!source) { - return; - } - const modalVideo = detail?.video || null; - const fallbackVideo = this.currentVideo || null; - const video = { - ...(fallbackVideo || {}), - ...(modalVideo || {}), - }; - const urlCandidate = - typeof video.url === "string" ? video.url.trim() : ""; - const magnetCandidate = - typeof video.magnet === "string" ? video.magnet.trim() : ""; + if (!modalVideo && !fallbackVideo) { + devLogger.warn("[app] Playback source switch missing video data."); + return; + } - if (!modalVideo && !fallbackVideo) { - devLogger.warn("[app] Playback source switch missing video data."); - return; - } + const magnetAvailable = Boolean(magnetCandidate); + const cachedStreamHealth = + video?.id && this.streamHealthSnapshots instanceof Map + ? this.streamHealthSnapshots.get(video.id) + : null; + const cachedPeers = Number.isFinite(cachedStreamHealth?.peers) + ? cachedStreamHealth.peers + : null; + const hasActivePeers = + cachedPeers === null ? null : cachedPeers > 0; + const cachedUrlHealth = + video?.id && urlCandidate + ? this.getCachedUrlHealth(video.id, urlCandidate) + : null; + const cdnUnavailable = + !urlCandidate || + ["offline", "timeout"].includes(cachedUrlHealth?.status); - const magnetAvailable = Boolean(magnetCandidate); - const cachedStreamHealth = - video?.id && this.streamHealthSnapshots instanceof Map - ? this.streamHealthSnapshots.get(video.id) - : null; - const cachedPeers = Number.isFinite(cachedStreamHealth?.peers) - ? cachedStreamHealth.peers - : null; - const hasActivePeers = - cachedPeers === null ? null : cachedPeers > 0; - const cachedUrlHealth = - video?.id && urlCandidate - ? this.getCachedUrlHealth(video.id, urlCandidate) - : null; - const cdnUnavailable = - !urlCandidate || - ["offline", "timeout"].includes(cachedUrlHealth?.status); + if (source === "torrent" && !magnetAvailable) { + userLogger.warn( + "[app] Unable to switch to torrent playback: missing magnet.", + ); + this.showError( + "Torrent playback is unavailable for this video. No magnet was provided.", + ); + return; + } - if (source === "torrent" && !magnetAvailable) { - userLogger.warn( - "[app] Unable to switch to torrent playback: missing magnet.", - ); - this.showError( - "Torrent playback is unavailable for this video. No magnet was provided.", - ); - return; - } + if (source === "torrent" && hasActivePeers === false) { + userLogger.warn( + "[app] Switching to torrent playback despite 0 active peers detected.", + ); + this.showStatus( + "Warning: No peers detected. Playback may fail or stall.", + { autoHideMs: 5000 } + ); + // Proceed anyway + } - if (source === "torrent" && hasActivePeers === false) { - userLogger.warn( - "[app] Switching to torrent playback despite 0 active peers detected.", - ); - this.showStatus( - "Warning: No peers detected. Playback may fail or stall.", - { autoHideMs: 5000 } - ); - // Proceed anyway - } + if (source === "url" && cdnUnavailable) { + userLogger.warn( + "[app] Unable to switch to CDN playback: URL unavailable.", + ); + this.showError( + "CDN playback is unavailable right now, staying on the torrent stream.", + ); + return; + } - if (source === "url" && cdnUnavailable) { - userLogger.warn( - "[app] Unable to switch to CDN playback: URL unavailable.", - ); - this.showError( - "CDN playback is unavailable right now, staying on the torrent stream.", - ); - return; - } + if (this.playSource && source === this.playSource) { + return; + } - if (this.playSource && source === this.playSource) { - return; - } + this.playVideoWithFallback({ + url: urlCandidate, + magnet: magnetCandidate, + forcedSource: source, + }).catch((error) => { + devLogger.warn( + "[app] Failed to switch playback source:", + error, + ); + }); + }); + } + } - this.playVideoWithFallback({ - url: urlCandidate, - magnet: magnetCandidate, - forcedSource: source, - }).catch((error) => { + _initModals() { + const videoModalPromise = this.videoModal.load().then(() => { + this._bindVideoModalEvents(); + }); + + const uploadModalPromise = this.uploadModal.load().catch((error) => { + devLogger.error("initUploadModal failed:", error); + this.showError(`Failed to initialize upload modal: ${error.message}`); + }); + + const editModalPromise = this.editModal.load().catch((error) => { + devLogger.error("Failed to load edit modal:", error); + this.showError(`Failed to initialize edit modal: ${error.message}`); + }); + + const profileModalPromise = this.profileController + ? this.profileController + .load() + .then(() => { + try { + this.renderSavedProfiles(); + } catch (error) { devLogger.warn( - "[app] Failed to switch playback source:", + "[profileModal] Failed to render saved profiles after load:", error, ); - }); - }); + } + + try { + this.profileController.refreshWalletPaneState(); + } catch (error) { + devLogger.warn( + "[profileModal] Failed to refresh wallet pane after load:", + error, + ); + } + return true; + }) + .catch((error) => { + devLogger.error("Failed to load profile modal:", error); + return false; + }) + : Promise.resolve(false); + + return Promise.all([ + videoModalPromise, + uploadModalPromise, + editModalPromise, + profileModalPromise, + ]); + } + + async _initTrustedSeeds() { + try { + await bootstrapTrustedSeeds(); + } catch (error) { + devLogger.warn("[app.init()] Trusted seed bootstrap failed:", error); + } + } + + _initViewCounter() { + try { + initViewCounter({ nostrClient }); + } catch (error) { + devLogger.warn("Failed to initialize view counter:", error); + } + } + + _initAccessControl() { + accessControl + .refresh() + .then(() => { + if ( + accessControl.lastError && + accessControl.lastError?.code === "nostr-unavailable" + ) { + devLogger.warn( + "[app.init()] Access control refresh should not run before nostrClient.init()", + accessControl.lastError + ); } + }) + .catch((error) => { + devLogger.warn( + "Failed to refresh admin lists after connecting to Nostr:", + error + ); }); - const uploadModalPromise = this.uploadModal.load().catch((error) => { - devLogger.error("initUploadModal failed:", error); - this.showError(`Failed to initialize upload modal: ${error.message}`); - }); + if (this.profileController) { + Promise.resolve() + .then(() => this.profileController.refreshAdminPaneState()) + .catch((error) => { + devLogger.warn( + "Failed to update admin pane after connecting to Nostr:", + error, + ); + }); + } + } - const editModalPromise = this.editModal.load().catch((error) => { - devLogger.error("Failed to load edit modal:", error); - this.showError(`Failed to initialize edit modal: ${error.message}`); - }); + async _syncSessionActorBlacklist(trigger) { + if (this.pubkey) { + return; + } - const profileModalPromise = this.profileController - ? this.profileController - .load() - .then(() => { - try { - this.renderSavedProfiles(); - } catch (error) { - devLogger.warn( - "[profileModal] Failed to render saved profiles after load:", - error, - ); - } + const sessionActorPubkey = nostrClient.sessionActor?.pubkey; + if (!sessionActorPubkey) { + return; + } - try { - this.profileController.refreshWalletPaneState(); - } catch (error) { - devLogger.warn( - "[profileModal] Failed to refresh wallet pane after load:", - error, - ); - } - return true; - }) - .catch((error) => { - devLogger.error("Failed to load profile modal:", error); - return false; - }) - : Promise.resolve(false); + const blacklist = accessControl.getBlacklist(); + try { + await userBlocks.seedBaselineDelta( + sessionActorPubkey, + Array.from(blacklist || []), + ); + } catch (error) { + devLogger.warn( + `[app.init()] Failed to sync session actor blacklist${ + trigger ? ` (${trigger})` : "" + }:`, + error, + ); + } + } - const modalBootstrapPromise = Promise.all([ - videoModalPromise, - uploadModalPromise, - editModalPromise, - profileModalPromise, - ]); + async _handleSessionActorReady({ pubkey, reason } = {}) { + if (this.pubkey) { + return; + } - // Initialize the pool early to unblock bootstrapTrustedSeeds, - // but do NOT await the full connection process here. + const normalizedPubkey = this.normalizeHexPubkey(pubkey); + if (!normalizedPubkey) { + return; + } + + const triggerLabel = reason ? `session-actor-${reason}` : "session-actor"; + await this._syncSessionActorBlacklist(triggerLabel); + + const refreshReason = "session-actor-ready"; + if (typeof this.refreshVisibleModerationUi === "function") { try { - await nostrClient.ensurePool(); - } catch (poolError) { - devLogger.warn("[app.init()] Pool ensure failed:", poolError); - } - - // Kick off relay connection in the background. - const nostrInitPromise = nostrClient.init().catch((err) => { - devLogger.warn("[app.init()] Background nostrClient.init failed:", err); - }); - - await modalBootstrapPromise; - - try { - await bootstrapTrustedSeeds(); + this.refreshVisibleModerationUi({ reason: refreshReason }); } catch (error) { - devLogger.warn("[app.init()] Trusted seed bootstrap failed:", error); - } - - try { - initViewCounter({ nostrClient }); - } catch (error) { - devLogger.warn("Failed to initialize view counter:", error); + devLogger.warn( + "[app.init()] Failed to refresh moderation UI after session actor:", + error, + ); } + } else { + this.refreshAllVideoGrids({ + reason: refreshReason, + forceMainReload: true, + }).catch((error) => { + devLogger.warn( + "[app.init()] Failed to refresh video grids after session actor:", + error, + ); + }); + } + } accessControl .refresh() @@ -1003,10 +1094,12 @@ class Application { }) .catch((error) => { devLogger.warn( - "Failed to refresh admin lists after connecting to Nostr:", - error + "[app.init()] Failed to process session actor change:", + error, ); }); + }); + } if (this.profileController) { Promise.resolve() @@ -1019,176 +1112,139 @@ class Application { }); } - const syncSessionActorBlacklist = async (trigger) => { - if (this.pubkey) { + // Kick off relay connection in the background. + nostrClient.init().catch((err) => { + devLogger.warn("[app.init()] Background nostrClient.init failed:", err); + }); + } + + _initAccessControlListeners() { + if (typeof accessControl.onBlacklistChange === "function") { + accessControl.onBlacklistChange(() => { + this._syncSessionActorBlacklist("blacklist-change"); + if (this.isUserLoggedIn()) { return; } - const sessionActorPubkey = nostrClient.sessionActor?.pubkey; - if (!sessionActorPubkey) { - return; - } - - const blacklist = accessControl.getBlacklist(); - try { - await userBlocks.seedBaselineDelta( - sessionActorPubkey, - Array.from(blacklist || []), - ); - } catch (error) { + const refreshReason = "admin-blacklist-change"; + this.refreshAllVideoGrids({ + reason: refreshReason, + forceMainReload: true, + }).catch((error) => { devLogger.warn( - `[app.init()] Failed to sync session actor blacklist${ - trigger ? ` (${trigger})` : "" - }:`, + "[app.init()] Failed to refresh video grids after admin blacklist change:", error, ); - } - }; + }); - const handleSessionActorReady = async ({ pubkey, reason } = {}) => { - if (this.pubkey) { - return; - } - - const normalizedPubkey = this.normalizeHexPubkey(pubkey); - if (!normalizedPubkey) { - return; - } - - const triggerLabel = reason ? `session-actor-${reason}` : "session-actor"; - await syncSessionActorBlacklist(triggerLabel); - - const refreshReason = "session-actor-ready"; if (typeof this.refreshVisibleModerationUi === "function") { try { this.refreshVisibleModerationUi({ reason: refreshReason }); } catch (error) { devLogger.warn( - "[app.init()] Failed to refresh moderation UI after session actor:", + "[app.init()] Failed to refresh moderation UI after admin blacklist change:", error, ); } - } else { - this.refreshAllVideoGrids({ - reason: refreshReason, - forceMainReload: true, - }).catch((error) => { - devLogger.warn( - "[app.init()] Failed to refresh video grids after session actor:", - error, - ); - }); } - }; - - if (typeof nostrClient.onSessionActorChange === "function") { - nostrClient.onSessionActorChange((detail) => { - handleSessionActorReady(detail).catch((error) => { - devLogger.warn( - "[app.init()] Failed to process session actor change:", - error, - ); - }); + }); + } + + if (typeof accessControl.onWhitelistChange === "function") { + accessControl.onWhitelistChange(() => { + const refreshReason = "admin-whitelist-change"; + this.refreshAllVideoGrids({ + reason: refreshReason, + forceMainReload: true, + }).catch((error) => { + devLogger.warn( + "[app.init()] Failed to refresh video grids after admin whitelist change:", + error, + ); }); - } + }); + } + } - await syncSessionActorBlacklist("post-refresh"); + _initUI() { + // Grab the "Subscriptions" link by its id in the sidebar + this.subscriptionsLink = document.getElementById("subscriptionsLink"); - if (typeof accessControl.onBlacklistChange === "function") { - accessControl.onBlacklistChange(() => { - syncSessionActorBlacklist("blacklist-change"); - if (this.isUserLoggedIn()) { - return; - } + this.syncAuthUiState(); - const refreshReason = "admin-blacklist-change"; - this.refreshAllVideoGrids({ - reason: refreshReason, - forceMainReload: true, - }).catch((error) => { - devLogger.warn( - "[app.init()] Failed to refresh video grids after admin blacklist change:", - error, - ); - }); + if (typeof window !== "undefined") { + window.addEventListener("bitvid:auth-changed", (event) => { + const detail = event.detail || {}; + const previousPubkey = detail.previousPubkey; - if (typeof this.refreshVisibleModerationUi === "function") { - try { - this.refreshVisibleModerationUi({ reason: refreshReason }); - } catch (error) { - devLogger.warn( - "[app.init()] Failed to refresh moderation UI after admin blacklist change:", - error, - ); - } - } - }); - } + if (previousPubkey && typeof storageService !== "undefined" && typeof storageService.lock === "function") { + storageService.lock(previousPubkey); + } - if (typeof accessControl.onWhitelistChange === "function") { - accessControl.onWhitelistChange(() => { - const refreshReason = "admin-whitelist-change"; - this.refreshAllVideoGrids({ - reason: refreshReason, - forceMainReload: true, - }).catch((error) => { - devLogger.warn( - "[app.init()] Failed to refresh video grids after admin whitelist change:", - error, - ); - }); - }); - } - - // Grab the "Subscriptions" link by its id in the sidebar - this.subscriptionsLink = document.getElementById("subscriptionsLink"); - - this.syncAuthUiState(); - - if (typeof window !== "undefined") { - window.addEventListener("bitvid:auth-changed", (event) => { - const detail = event.detail || {}; - const previousPubkey = detail.previousPubkey; - - if (previousPubkey && typeof storageService !== "undefined" && typeof storageService.lock === "function") { - storageService.lock(previousPubkey); - } - - if (this.uploadModal && typeof this.uploadModal.refreshState === "function") { - this.uploadModal.refreshState(); - } - }); - } - - const savedPubKey = this.activeProfilePubkey; - if (savedPubKey) { - // Auto-login if a pubkey was saved - try { - await this.authService.login(savedPubKey, { persistActive: false }); - } catch (error) { - devLogger.error("Auto-login failed:", error); - if (error && error.code === "site-lockdown") { - const message = this.describeLoginError( - error, - "Auto-login failed. Please sign in again once lockdown ends.", - ); - this.showStatus(message, { autoHideMs: 12000 }); - } + if (this.uploadModal && typeof this.uploadModal.refreshState === "function") { + this.uploadModal.refreshState(); + } + }); + } + } + + async _initAutoLogin() { + const savedPubKey = this.activeProfilePubkey; + if (savedPubKey) { + // Auto-login if a pubkey was saved + try { + await this.authService.login(savedPubKey, { persistActive: false }); + } catch (error) { + devLogger.error("Auto-login failed:", error); + if (error && error.code === "site-lockdown") { + const message = this.describeLoginError( + error, + "Auto-login failed. Please sign in again once lockdown ends.", + ); + this.showStatus(message, { autoHideMs: 12000 }); } } + } + } + + async _initWatchHistory() { + const watchHistoryInitPromise = + this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => { + devLogger.warn( + "[app.init()] Failed to initialize watch history metadata sync:", + error + ); + }) || Promise.resolve(); + + await watchHistoryInitPromise; + } + + async init() { + try { + await this._initViewManager(); + this._warmupNostrExtension(); + this._initServiceWorker(); + this._initAuthAndModeration(); + + const modalBootstrapPromise = this._initModals(); + + await this._initNostr(); + + await modalBootstrapPromise; + + await this._initTrustedSeeds(); + this._initViewCounter(); + this._initAccessControl(); + + await this._initSessionActor(); + this._initAccessControlListeners(); + this._initUI(); + await this._initAutoLogin(); // 5. Setup general event listeners this.setupEventListeners(); - const watchHistoryInitPromise = - this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => { - devLogger.warn( - "[app.init()] Failed to initialize watch history metadata sync:", - error - ); - }) || Promise.resolve(); - - await watchHistoryInitPromise; + await this._initWatchHistory(); // 9. Check URL ?v= param this.checkUrlParams(); @@ -2659,9 +2715,10 @@ class Application { return this.authService.fetchAndRenderProfile(pubkey, forceRefresh); } - async batchFetchProfiles(authorSet) { + async batchFetchProfiles(authorSet, { forceRefresh = false } = {}) { return batchFetchProfilesFromRelays({ authorSet, + forceRefresh, getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), setProfileCacheEntry: (pubkey, profile) => this.setProfileCacheEntry(pubkey, profile), @@ -3945,22 +4002,22 @@ class Application { async refreshForYouFeed(...args) { this._initCoordinators(); - return this._feed.refreshForYouFeed(...args); + return this._feed.refreshFeed(FEED_TYPES.FOR_YOU, ...args); } refreshKidsFeed(...args) { this._initCoordinators(); - return this._feed.refreshKidsFeed(...args); + return this._feed.refreshFeed(FEED_TYPES.KIDS, ...args); } refreshExploreFeed(...args) { this._initCoordinators(); - return this._feed.refreshExploreFeed(...args); + return this._feed.refreshFeed(FEED_TYPES.EXPLORE, ...args); } refreshRecentFeed(...args) { this._initCoordinators(); - return this._feed.refreshRecentFeed(...args); + return this._feed.refreshFeed("recent", ...args); } checkRelayHealthWarning(...args) { @@ -3968,6 +4025,11 @@ class Application { return this._feed.checkRelayHealthWarning(...args); } + async loadFeedVideos(...args) { + this._initCoordinators(); + return this._feed.loadFeedVideos(...args); + } + /** * Subscribe to videos (older + new) and render them as they come in. */ diff --git a/js/app/feedCoordinator.js b/js/app/feedCoordinator.js index 06d6237d..11863bb1 100644 --- a/js/app/feedCoordinator.js +++ b/js/app/feedCoordinator.js @@ -785,48 +785,92 @@ export function createFeedCoordinator(deps) { }; }, - async refreshForYouFeed({ reason, fallbackVideos } = {}) { - let watchHistoryItems = []; + async refreshFeed(feedType, { reason, fallbackVideos } = {}) { + const normalizedReason = typeof reason === "string" ? reason : undefined; + const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; + let runtime = {}; + let metadataModifier = (m) => m; + + // 1. Prepare Runtime try { - watchHistoryItems = await watchHistoryService.loadLatest(undefined, { - allowStale: true, - }); - } catch (error) { - devLogger.warn( - "[Application] Failed to preload watch history for For You feed:", - error, + switch (feedType) { + case FEED_TYPES.FOR_YOU: { + let watchHistoryItems = []; + try { + watchHistoryItems = await watchHistoryService.loadLatest( + undefined, + { allowStale: true }, + ); + } catch (err) { + devLogger.warn( + "[Application] Failed to preload watch history for For You feed:", + err, + ); + } + runtime = this.buildForYouFeedRuntime({ watchHistoryItems }); + break; + } + case FEED_TYPES.KIDS: + runtime = this.buildKidsFeedRuntime(); + metadataModifier = (m) => { + if (runtime?.ageGroup && !m.ageGroup) + m.ageGroup = runtime.ageGroup; + return m; + }; + break; + case FEED_TYPES.EXPLORE: + runtime = this.buildExploreFeedRuntime(); + metadataModifier = (m) => { + const next = { ...m }; + if (!next.sortOrder) next.sortOrder = FEED_TYPES.EXPLORE; + next.preserveOrder = true; + return next; + }; + break; + case "recent": + runtime = this.buildRecentFeedRuntime(); + break; + default: + devLogger.warn(`[Application] Unknown feed type: ${feedType}`); + return Promise.resolve({ + videos: fallback, + metadata: { reason: normalizedReason, engine: "unknown" }, + }); + } + } catch (err) { + devLogger.error( + `[Application] Failed to build runtime for ${feedType}:`, + err, ); } - const runtime = this.buildForYouFeedRuntime({ watchHistoryItems }); - const normalizedReason = typeof reason === "string" ? reason : undefined; - const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; - + // 2. Check Engine if (!this.feedEngine || typeof this.feedEngine.run !== "function") { - const metadata = { + const metadata = metadataModifier({ reason: normalizedReason, engine: "unavailable", - }; + }); this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { this.videoListView.state.videosMap = this.videosMap; } - this.updateForYouTelemetryMetadata([], metadata); + // Telemetry update for unavailable engine? + if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) { + this.updateFeedTelemetryMetadata(feedType, [], metadata); + } this.renderVideoList({ videos: fallback, metadata }); return Promise.resolve({ videos: fallback, metadata }); } + // 3. Run Engine return this.feedEngine - .run(FEED_TYPES.FOR_YOU, { runtime }) + .run(feedType, { runtime }) .then((result) => { const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = { - ...(result?.metadata || {}), - }; - if (normalizedReason) { - metadata.reason = normalizedReason; - } + let metadata = { ...(result?.metadata || {}) }; + if (normalizedReason) metadata.reason = normalizedReason; + metadata = metadataModifier(metadata); this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); @@ -835,141 +879,8 @@ export function createFeedCoordinator(deps) { } const items = Array.isArray(result?.items) ? result.items : []; - this.updateForYouTelemetryMetadata(items, metadata); - - const payload = { videos, metadata }; - this.renderVideoList(payload); - return payload; - }) - .catch((error) => { - devLogger.error("[Application] Failed to run for-you feed:", error); - const metadata = { - reason: normalizedReason || "error:for-you-feed", - error: true, - }; - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.updateForYouTelemetryMetadata([], metadata); - const payload = { videos: fallback, metadata }; - this.renderVideoList(payload); - return payload; - }); - }, - - refreshKidsFeed({ reason, fallbackVideos } = {}) { - const runtime = this.buildKidsFeedRuntime(); - const normalizedReason = typeof reason === "string" ? reason : undefined; - const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; - - if (!this.feedEngine || typeof this.feedEngine.run !== "function") { - const metadata = { - reason: normalizedReason, - engine: "unavailable", - }; - if (runtime?.ageGroup && !metadata.ageGroup) { - metadata.ageGroup = runtime.ageGroup; - } - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, [], metadata); - this.renderVideoList({ videos: fallback, metadata }); - return Promise.resolve({ videos: fallback, metadata }); - } - - return this.feedEngine - .run(FEED_TYPES.KIDS, { runtime }) - .then((result) => { - const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = { - ...(result?.metadata || {}), - }; - if (normalizedReason) { - metadata.reason = normalizedReason; - } - if (runtime?.ageGroup && !metadata.ageGroup) { - metadata.ageGroup = runtime.ageGroup; - } - - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - - const items = Array.isArray(result?.items) ? result.items : []; - this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, items, metadata); - - const payload = { videos, metadata }; - this.renderVideoList(payload); - return payload; - }) - .catch((error) => { - devLogger.error("[Application] Failed to run kids feed:", error); - const metadata = { - reason: normalizedReason || "error:kids-feed", - error: true, - }; - if (runtime?.ageGroup && !metadata.ageGroup) { - metadata.ageGroup = runtime.ageGroup; - } - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.updateFeedTelemetryMetadata(FEED_TYPES.KIDS, [], metadata); - const payload = { videos: fallback, metadata }; - this.renderVideoList(payload); - return payload; - }); - }, - - refreshExploreFeed({ reason, fallbackVideos } = {}) { - const runtime = this.buildExploreFeedRuntime(); - const normalizedReason = typeof reason === "string" ? reason : undefined; - const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; - const applyExploreOrderingMetadata = (source) => { - const next = source && typeof source === "object" ? { ...source } : {}; - if (!next.sortOrder) { - next.sortOrder = FEED_TYPES.EXPLORE; - } - next.preserveOrder = true; - return next; - }; - - if (!this.feedEngine || typeof this.feedEngine.run !== "function") { - const metadata = applyExploreOrderingMetadata({ - reason: normalizedReason, - engine: "unavailable", - }); - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.renderVideoList({ videos: fallback, metadata }); - return Promise.resolve({ videos: fallback, metadata }); - } - - return this.feedEngine - .run(FEED_TYPES.EXPLORE, { runtime }) - .then((result) => { - const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = applyExploreOrderingMetadata({ - ...(result?.metadata || {}), - ...(normalizedReason ? { reason: normalizedReason } : {}), - }); - - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; + if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) { + this.updateFeedTelemetryMetadata(feedType, items, metadata); } const payload = { videos, metadata }; @@ -977,73 +888,26 @@ export function createFeedCoordinator(deps) { return payload; }) .catch((error) => { - devLogger.error("[Application] Failed to run explore feed:", error); - const metadata = applyExploreOrderingMetadata({ - reason: normalizedReason || "error:explore-feed", - error: true, - }); - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - const payload = { videos: fallback, metadata }; - this.renderVideoList(payload); - return payload; - }); - }, - - refreshRecentFeed({ reason, fallbackVideos } = {}) { - const runtime = this.buildRecentFeedRuntime(); - const normalizedReason = typeof reason === "string" ? reason : undefined; - const fallback = Array.isArray(fallbackVideos) ? fallbackVideos : []; - - if (!this.feedEngine || typeof this.feedEngine.run !== "function") { - const metadata = { - reason: normalizedReason, - engine: "unavailable", - }; - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - this.renderVideoList({ videos: fallback, metadata }); - return Promise.resolve({ videos: fallback, metadata }); - } - - return this.feedEngine - .run("recent", { runtime }) - .then((result) => { - const videos = Array.isArray(result?.videos) ? result.videos : []; - const metadata = { - ...(result?.metadata || {}), - }; - if (normalizedReason) { - metadata.reason = normalizedReason; - } - - this.latestFeedMetadata = metadata; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } - - const payload = { videos, metadata }; - this.renderVideoList(payload); - return payload; - }) - .catch((error) => { - devLogger.error("[Application] Failed to run recent feed:", error); - const metadata = { - reason: normalizedReason || "error:recent-feed", + devLogger.error( + `[Application] Failed to run ${feedType} feed:`, + error, + ); + let metadata = { + reason: normalizedReason || `error:${feedType}-feed`, error: true, }; + metadata = metadataModifier(metadata); + this.latestFeedMetadata = metadata; this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { this.videoListView.state.videosMap = this.videosMap; } + + if (feedType === FEED_TYPES.FOR_YOU || feedType === FEED_TYPES.KIDS) { + this.updateFeedTelemetryMetadata(feedType, [], metadata); + } + const payload = { videos: fallback, metadata }; this.renderVideoList(payload); return payload; @@ -1085,6 +949,98 @@ export function createFeedCoordinator(deps) { return true; }, + /** + * Unified loader for all feed types. + */ + async loadFeedVideos(feedType, forceFetch = false) { + devLogger.log(`Starting loadFeedVideos(${feedType})... (forceFetch =`, forceFetch, ")"); + this.setFeedTelemetryContext(feedType); + + let includeTags = false; + let loadingMessage = "Fetching videos\u2026"; + let refreshMethod = null; + let shouldCheckRelayHealth = false; + + switch (feedType) { + case "recent": + includeTags = true; + loadingMessage = "Fetching recent videos\u2026"; + refreshMethod = (opts) => this.refreshFeed("recent", opts); + shouldCheckRelayHealth = true; + break; + case FEED_TYPES.FOR_YOU: + includeTags = false; + loadingMessage = "Fetching for-you videos\u2026"; + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.FOR_YOU, opts); + shouldCheckRelayHealth = true; + break; + case FEED_TYPES.KIDS: + includeTags = true; + loadingMessage = "Fetching kids videos\u2026"; + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.KIDS, opts); + shouldCheckRelayHealth = false; + break; + case FEED_TYPES.EXPLORE: + includeTags = false; + loadingMessage = "Fetching explore videos\u2026"; + refreshMethod = (opts) => this.refreshFeed(FEED_TYPES.EXPLORE, opts); + shouldCheckRelayHealth = false; + break; + default: + devLogger.warn( + `[Application] Unknown feed type for loadFeedVideos: ${feedType}`, + ); + return; + } + + if (shouldCheckRelayHealth) { + this.checkRelayHealthWarning(); + } + + const container = this.mountVideoListView({ includeTags }); + const hasCachedVideos = + this.nostrService && + Array.isArray(this.nostrService.getFilteredActiveVideos()) && + this.nostrService.getFilteredActiveVideos().length > 0; + + if (!hasCachedVideos) { + if (this.videoListView && container) { + this.videoListView.showLoading(loadingMessage); + } else if (container) { + container.innerHTML = getSidebarLoadingMarkup(loadingMessage); + } + } + + let initialRefreshPromise = null; + + const videos = await this.nostrService.loadVideos({ + forceFetch, + blacklistedEventIds: this.blacklistedEventIds, + isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), + onVideos: (payload, detail = {}) => { + const promise = refreshMethod({ + reason: detail?.reason, + fallbackVideos: payload, + }); + if (!initialRefreshPromise) { + initialRefreshPromise = promise; + } + }, + }); + + if (initialRefreshPromise) { + await initialRefreshPromise; + } else if (!Array.isArray(videos) || videos.length === 0) { + await refreshMethod({ reason: "initial", fallbackVideos: [] }); + } + + this.videoSubscription = this.nostrService.getVideoSubscription() || null; + this.videosMap = this.nostrService.getVideosMap(); + if (this.videoListView) { + this.videoListView.state.videosMap = this.videosMap; + } + }, + /** * Subscribe to videos (older + new) and render them as they come in. */ @@ -1102,52 +1058,7 @@ export function createFeedCoordinator(deps) { this.lastLoadVideosTime = now; this.loadVideosPromise = (async () => { - devLogger.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext("recent"); - this.checkRelayHealthWarning(); - - const container = this.mountVideoListView(); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching recent videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching recent videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshRecentFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshRecentFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + await this.loadFeedVideos("recent", forceFetch); })(); try { @@ -1158,148 +1069,15 @@ export function createFeedCoordinator(deps) { }, async loadForYouVideos(forceFetch = false) { - devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext(FEED_TYPES.FOR_YOU); - this.checkRelayHealthWarning(); - - const container = this.mountVideoListView({ includeTags: false }); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching for-you videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching for-you videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshForYouFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshForYouFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + return this.loadFeedVideos(FEED_TYPES.FOR_YOU, forceFetch); }, async loadKidsVideos(forceFetch = false) { - devLogger.log("Starting loadKidsVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext(FEED_TYPES.KIDS); - - const container = this.mountVideoListView({ includeTags: true }); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching kids videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching kids videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshKidsFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshKidsFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + return this.loadFeedVideos(FEED_TYPES.KIDS, forceFetch); }, async loadExploreVideos(forceFetch = false) { - devLogger.log("Starting loadExploreVideos... (forceFetch =", forceFetch, ")"); - this.setFeedTelemetryContext(FEED_TYPES.EXPLORE); - - const container = this.mountVideoListView({ includeTags: false }); - const hasCachedVideos = - this.nostrService && - Array.isArray(this.nostrService.getFilteredActiveVideos()) && - this.nostrService.getFilteredActiveVideos().length > 0; - - if (!hasCachedVideos) { - if (this.videoListView && container) { - this.videoListView.showLoading("Fetching explore videos\u2026"); - } else if (container) { - container.innerHTML = getSidebarLoadingMarkup("Fetching explore videos\u2026"); - } - } - - let initialRefreshPromise = null; - - const videos = await this.nostrService.loadVideos({ - forceFetch, - blacklistedEventIds: this.blacklistedEventIds, - isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), - onVideos: (payload, detail = {}) => { - const promise = this.refreshExploreFeed({ - reason: detail?.reason, - fallbackVideos: payload, - }); - if (!initialRefreshPromise) { - initialRefreshPromise = promise; - } - }, - }); - - if (initialRefreshPromise) { - await initialRefreshPromise; - } else if (!Array.isArray(videos) || videos.length === 0) { - await this.refreshExploreFeed({ reason: "initial", fallbackVideos: [] }); - } - - this.videoSubscription = this.nostrService.getVideoSubscription() || null; - this.videosMap = this.nostrService.getVideosMap(); - if (this.videoListView) { - this.videoListView.state.videosMap = this.videosMap; - } + return this.loadFeedVideos(FEED_TYPES.EXPLORE, forceFetch); }, async loadOlderVideos(lastTimestamp) { @@ -1313,7 +1091,7 @@ export function createFeedCoordinator(deps) { return; } - await this.refreshRecentFeed({ reason: "older-fetch" }); + await this.refreshFeed("recent", { reason: "older-fetch" }); }, /** diff --git a/js/auth/signingAdapter.js b/js/auth/signingAdapter.js new file mode 100644 index 00000000..cc854512 --- /dev/null +++ b/js/auth/signingAdapter.js @@ -0,0 +1,24 @@ +export function createNip07SigningAdapter({ extension } = {}) { + const resolvedExtension = + extension || + (typeof window !== "undefined" && window?.nostr ? window.nostr : null); + + const getPubkey = async () => { + if (!resolvedExtension) { + throw new Error("NIP-07 extension unavailable."); + } + return resolvedExtension.getPublicKey(); + }; + + const signEvent = async (event) => { + if (!resolvedExtension) { + throw new Error("NIP-07 extension unavailable."); + } + return resolvedExtension.signEvent(event); + }; + + return { + getPubkey, + signEvent, + }; +} diff --git a/js/channelProfile.js b/js/channelProfile.js index 83cf0913..fbe4667c 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4366,10 +4366,23 @@ function getCachedChannelVideoEvents(pubkey) { }; const rawMatches = collectMatches(nostrClient?.rawEvents, (callback) => { - if ( - nostrClient?.rawEvents && - typeof nostrClient.rawEvents.forEach === "function" - ) { + if (!nostrClient?.rawEvents) { + return; + } + + if (typeof nostrClient.rawEvents.getEventsByAuthor === "function") { + const candidates = nostrClient.rawEvents.getEventsByAuthor(normalized); + if (Array.isArray(candidates)) { + candidates.forEach((event) => { + if (event?.kind === 30078) { + callback(event); + } + }); + return; + } + } + + if (typeof nostrClient.rawEvents.forEach === "function") { nostrClient.rawEvents.forEach((event) => { if (event?.kind === 30078) { callback(event); @@ -4690,12 +4703,6 @@ export async function renderChannelVideosFromList({ typeof app?.deriveVideoPointerInfo === "function" ? app.deriveVideoPointerInfo(video) : null; - if ( - pointerInfo && - typeof app?.persistWatchHistoryMetadataForVideo === "function" - ) { - app.persistWatchHistoryMetadataForVideo(video, pointerInfo); - } const shareUrl = typeof app?.buildShareUrlFromEventId === "function" diff --git a/js/gridHealth.js b/js/gridHealth.js index c428d293..19669701 100644 --- a/js/gridHealth.js +++ b/js/gridHealth.js @@ -8,6 +8,11 @@ import { removeTorrentProbeFromStorage, writeTorrentProbeToStorage, } from "./utils/storage.js"; +import { + prioritizeEntries, + getViewportCenter, + PRIORITY_BASELINE, +} from "./gridHealthLogic.js"; const badgeUpdateListeners = new Set(); @@ -226,7 +231,6 @@ const PROBE_CACHE_TTL_MS = 5 * 60 * 1000; const PROBE_TIMEOUT_MS = 20000; const PROBE_CONCURRENCY = 96; const PROBE_POLL_COUNT = 3; -const PRIORITY_BASELINE = 1_000_000; class ProbeQueue { constructor(max = 2) { @@ -308,139 +312,6 @@ const probeQueue = new ProbeQueue(PROBE_CONCURRENCY); const probeCache = new Map(); const probeInflight = new Map(); -function getViewportCenter() { - if (typeof window === "undefined") { - return null; - } - const width = Number(window.innerWidth) || 0; - const height = Number(window.innerHeight) || 0; - if (width <= 0 && height <= 0) { - return null; - } - return { - x: width > 0 ? width / 2 : 0, - y: height > 0 ? height / 2 : 0, - }; -} - -function prioritizeEntries(entries, viewportCenter) { - if (!Array.isArray(entries) || entries.length === 0) { - return []; - } - const filtered = entries - .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement) - .map((entry) => { - const rect = getIntersectionRect(entry); - if (!rect) { - return null; - } - const ratio = - typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; - const centerY = rect.top + rect.height / 2; - const verticalDistance = viewportCenter - ? Math.abs(centerY - viewportCenter.y) - : Number.POSITIVE_INFINITY; - return { entry, ratio, centerY, verticalDistance }; - }) - .filter(Boolean); - - if (filtered.length === 0) { - return []; - } - - if (!viewportCenter) { - return filtered - .sort((a, b) => { - if (b.ratio !== a.ratio) { - return b.ratio - a.ratio; - } - return a.centerY - b.centerY; - }) - .map((item, index) => ({ - entry: item.entry, - priority: PRIORITY_BASELINE - index, - })); - } - - const ordered = filtered - .slice() - .sort((a, b) => a.centerY - b.centerY); - - let centerIndex = 0; - let minDistance = Number.POSITIVE_INFINITY; - for (let i = 0; i < ordered.length; i += 1) { - const candidate = ordered[i]; - if (candidate.verticalDistance < minDistance) { - minDistance = candidate.verticalDistance; - centerIndex = i; - } - } - - const prioritized = []; - const pushCandidate = (candidate) => { - if (!candidate) { - return; - } - prioritized.push(candidate); - }; - - pushCandidate(ordered[centerIndex]); - - let left = centerIndex - 1; - let right = centerIndex + 1; - while (left >= 0 || right < ordered.length) { - const leftCandidate = left >= 0 ? ordered[left] : null; - const rightCandidate = right < ordered.length ? ordered[right] : null; - - if (leftCandidate && rightCandidate) { - const leftDistance = leftCandidate.verticalDistance; - const rightDistance = rightCandidate.verticalDistance; - const distanceDelta = Math.abs(leftDistance - rightDistance); - if (distanceDelta <= 0.5) { - if (rightCandidate.ratio > leftCandidate.ratio) { - pushCandidate(rightCandidate); - right += 1; - } else { - pushCandidate(leftCandidate); - left -= 1; - } - } else if (leftDistance < rightDistance) { - pushCandidate(leftCandidate); - left -= 1; - } else { - pushCandidate(rightCandidate); - right += 1; - } - } else if (rightCandidate) { - pushCandidate(rightCandidate); - right += 1; - } else if (leftCandidate) { - pushCandidate(leftCandidate); - left -= 1; - } - } - - return prioritized.map((candidate, index) => ({ - entry: candidate.entry, - priority: PRIORITY_BASELINE - index, - })); -} - -function getIntersectionRect(entry) { - if (!entry) { - return null; - } - const rect = entry.intersectionRect; - if (rect && rect.width > 0 && rect.height > 0) { - return rect; - } - const fallback = entry.boundingClientRect; - if (fallback && fallback.width > 0 && fallback.height > 0) { - return fallback; - } - return rect || fallback || null; -} - function setBadge(card, state, details) { if (!(card instanceof HTMLElement)) { return; diff --git a/js/gridHealthLogic.js b/js/gridHealthLogic.js new file mode 100644 index 00000000..7b7de952 --- /dev/null +++ b/js/gridHealthLogic.js @@ -0,0 +1,134 @@ +export const PRIORITY_BASELINE = 1_000_000; + +export function getViewportCenter() { + if (typeof window === "undefined") { + return null; + } + const width = Number(window.innerWidth) || 0; + const height = Number(window.innerHeight) || 0; + if (width <= 0 && height <= 0) { + return null; + } + return { + x: width > 0 ? width / 2 : 0, + y: height > 0 ? height / 2 : 0, + }; +} + +export function getIntersectionRect(entry) { + if (!entry) { + return null; + } + const rect = entry.intersectionRect; + if (rect && rect.width > 0 && rect.height > 0) { + return rect; + } + const fallback = entry.boundingClientRect; + if (fallback && fallback.width > 0 && fallback.height > 0) { + return fallback; + } + return null; +} + +export function prioritizeEntries(entries, viewportCenter) { + if (!Array.isArray(entries) || entries.length === 0) { + return []; + } + const filtered = entries + .filter((entry) => entry.isIntersecting && entry.target instanceof HTMLElement) + .map((entry) => { + const rect = getIntersectionRect(entry); + if (!rect) { + return null; + } + const ratio = + typeof entry.intersectionRatio === "number" ? entry.intersectionRatio : 0; + const centerY = rect.top + rect.height / 2; + const verticalDistance = viewportCenter + ? Math.abs(centerY - viewportCenter.y) + : Number.POSITIVE_INFINITY; + return { entry, ratio, centerY, verticalDistance }; + }) + .filter(Boolean); + + if (filtered.length === 0) { + return []; + } + + if (!viewportCenter) { + return filtered + .sort((a, b) => { + if (b.ratio !== a.ratio) { + return b.ratio - a.ratio; + } + return a.centerY - b.centerY; + }) + .map((item, index) => ({ + entry: item.entry, + priority: PRIORITY_BASELINE - index, + })); + } + + const ordered = filtered + .slice() + .sort((a, b) => a.centerY - b.centerY); + + let centerIndex = 0; + let minDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < ordered.length; i += 1) { + const candidate = ordered[i]; + if (candidate.verticalDistance < minDistance) { + minDistance = candidate.verticalDistance; + centerIndex = i; + } + } + + const prioritized = []; + const pushCandidate = (candidate) => { + if (!candidate) { + return; + } + prioritized.push(candidate); + }; + + pushCandidate(ordered[centerIndex]); + + let left = centerIndex - 1; + let right = centerIndex + 1; + while (left >= 0 || right < ordered.length) { + const leftCandidate = left >= 0 ? ordered[left] : null; + const rightCandidate = right < ordered.length ? ordered[right] : null; + + if (leftCandidate && rightCandidate) { + const leftDistance = leftCandidate.verticalDistance; + const rightDistance = rightCandidate.verticalDistance; + const distanceDelta = Math.abs(leftDistance - rightDistance); + if (distanceDelta <= 0.5) { + if (rightCandidate.ratio > leftCandidate.ratio) { + pushCandidate(rightCandidate); + right += 1; + } else { + pushCandidate(leftCandidate); + left -= 1; + } + } else if (leftDistance < rightDistance) { + pushCandidate(leftCandidate); + left -= 1; + } else { + pushCandidate(rightCandidate); + right += 1; + } + } else if (rightCandidate) { + pushCandidate(rightCandidate); + right += 1; + } else if (leftCandidate) { + pushCandidate(leftCandidate); + left -= 1; + } + } + + return prioritized.map((candidate, index) => ({ + entry: candidate.entry, + priority: PRIORITY_BASELINE - index, + })); +} diff --git a/js/nostr/client.js b/js/nostr/client.js index 757eb994..22dcb76d 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -165,6 +165,7 @@ import { sanitizeRelayList, readStoredNip46Session, writeStoredNip46Session, + decryptNip46Session, clearStoredNip46Session, parseNip46ConnectionString, generateNip46Secret, @@ -180,6 +181,8 @@ import { decodeNpubToHex, encodeHexToNpub, normalizeNostrPubkey, +} from "./nip46Client.js"; +import { summarizeHexForLog, summarizeSecretForLog, summarizeMetadataForLog, @@ -188,7 +191,7 @@ import { summarizeRpcParamsForLog, summarizeRpcResultForLog, summarizeRelayPublishResultsForLog, -} from "./nip46Client.js"; +} from "./nip46LoggingUtils.js"; import { profileCache } from "../state/profileCache.js"; import { createPrivateKeyCipherClosures } from "./signerHelpers.js"; import { @@ -202,6 +205,7 @@ import { createNip07Adapter } from "./adapters/nip07Adapter.js"; import { createNsecAdapter } from "./adapters/nsecAdapter.js"; import { createNip46Adapter } from "./adapters/nip46Adapter.js"; import { queueSignEvent } from "./signRequestQueue.js"; +import { EventsMap } from "./eventsMap.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -1270,7 +1274,7 @@ export class NostrClient { * Kept for signature verification and republishing (e.g. NIP-94 mirror). * @public */ - this.rawEvents = new Map(); + this.rawEvents = new EventsMap(); /** * @type {Map} @@ -1479,11 +1483,15 @@ export class NostrClient { const remotePubkey = stored.remotePubkey || ""; const userPubkey = stored.userPubkey || ""; - const canReconnect = Boolean(stored.clientPrivateKey && stored.secret); + const hasEncryptedSession = + typeof stored.encryptedSecrets === "string" && !!stored.encryptedSecrets; + const canReconnect = + Boolean(stored.clientPrivateKey && stored.secret) || hasEncryptedSession; return { hasSession: true, canReconnect, + hasEncryptedSession, remotePubkey, remoteNpub: encodeHexToNpub(remotePubkey), clientPublicKey: stored.clientPublicKey || "", @@ -2234,6 +2242,7 @@ export class NostrClient { onAuthUrl, onStatus, handshakeTimeoutMs, + passphrase, } = {}) { const parsed = parseNip46ConnectionString(connectionString); if (!parsed) { @@ -2552,19 +2561,26 @@ export class NostrClient { encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", permissions: permissions || null, }); - writeStoredNip46Session({ - version: 1, - clientPublicKey, - remotePubkey, - relays, - encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", - permissions, - metadata, - userPubkey, - lastConnectedAt: Date.now(), - }); + await writeStoredNip46Session( + { + version: 1, + clientPublicKey, + remotePubkey, + relays, + encryption: client.encryptionAlgorithm || handshakeAlgorithm || "", + permissions, + metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey, + secret, + }, + passphrase, + ); } else { - devLogger.debug("[nostr] Clearing stored remote signer session per request"); + devLogger.debug( + "[nostr] Clearing stored remote signer session per request", + ); clearStoredNip46Session(); } @@ -2637,15 +2653,40 @@ export class NostrClient { options && typeof options === "object" ? options : {}; const silent = normalizedOptions.silent === true; const forgetOnError = normalizedOptions.forgetOnError === true; - const validator = typeof normalizedOptions.validator === "function" ? normalizedOptions.validator : null; + const passphrase = + typeof normalizedOptions.passphrase === "string" + ? normalizedOptions.passphrase + : null; + const validator = + typeof normalizedOptions.validator === "function" + ? normalizedOptions.validator + : null; - const stored = readStoredNip46Session(); + let stored = readStoredNip46Session(); if (!stored) { - const error = new Error("No remote signer session is stored on this device."); + const error = new Error( + "No remote signer session is stored on this device.", + ); error.code = "no-stored-session"; throw error; } + if (stored.encryptedSecrets) { + if (!passphrase) { + const error = new Error("Passphrase required to unlock session."); + error.code = "passphrase-required"; + throw error; + } + try { + stored = await decryptNip46Session(stored, passphrase); + } catch (decryptError) { + const error = new Error("Failed to decrypt stored session."); + error.code = "decrypt-failed"; + error.cause = decryptError; + throw error; + } + } + const relays = resolveNip46Relays(stored.relays, this.relays); devLogger.debug("[nostr] Reconnecting to stored remote signer", { @@ -2664,7 +2705,9 @@ export class NostrClient { }); if (!stored.clientPrivateKey || !stored.secret) { - const error = new Error("Stored remote signer credentials are unavailable."); + const error = new Error( + "Stored remote signer credentials are unavailable.", + ); error.code = "stored-session-missing-credentials"; throw error; } @@ -2708,17 +2751,22 @@ export class NostrClient { client.metadata = stored.metadata; const signer = await this.installNip46Client(client, { userPubkey }); - writeStoredNip46Session({ - version: 1, - clientPublicKey: stored.clientPublicKey, - remotePubkey: stored.remotePubkey, - relays: stored.relays, - encryption: client.encryptionAlgorithm || stored.encryption || "", - permissions: stored.permissions, - metadata: stored.metadata, - userPubkey, - lastConnectedAt: Date.now(), - }); + await writeStoredNip46Session( + { + version: 1, + clientPublicKey: stored.clientPublicKey, + remotePubkey: stored.remotePubkey, + relays: stored.relays, + encryption: client.encryptionAlgorithm || stored.encryption || "", + permissions: stored.permissions, + metadata: stored.metadata, + userPubkey, + lastConnectedAt: Date.now(), + clientPrivateKey: stored.clientPrivateKey, + secret: stored.secret, + }, + passphrase, + ); devLogger.debug("[nostr] Stored remote signer session refreshed", { remotePubkey: summarizeHexForLog(stored.remotePubkey || ""), diff --git a/js/nostr/eventsMap.js b/js/nostr/eventsMap.js new file mode 100644 index 00000000..a3f7b59e --- /dev/null +++ b/js/nostr/eventsMap.js @@ -0,0 +1,90 @@ +/** + * A Map extension that maintains an author-based index for fast lookups. + * Used for `rawEvents` in NostrClient. + */ +export class EventsMap extends Map { + constructor(entries) { + super(entries); + this.authorIndex = new Map(); // pubkey -> Set + + // If entries provided, index them + if (entries) { + for (const [key, value] of this.entries()) { + this._addToIndex(value); + } + } + } + + _normalizePubkey(pubkey) { + if (typeof pubkey !== "string") return ""; + return pubkey.trim().toLowerCase(); + } + + _addToIndex(event) { + if (!event || typeof event !== "object") return; + const pubkey = this._normalizePubkey(event.pubkey); + if (!pubkey) return; + + let set = this.authorIndex.get(pubkey); + if (!set) { + set = new Set(); + this.authorIndex.set(pubkey, set); + } + set.add(event); + } + + _removeFromIndex(event) { + if (!event || typeof event !== "object") return; + const pubkey = this._normalizePubkey(event.pubkey); + if (!pubkey) return; + + const set = this.authorIndex.get(pubkey); + if (set) { + set.delete(event); + if (set.size === 0) { + this.authorIndex.delete(pubkey); + } + } + } + + set(key, value) { + const existing = this.get(key); + // If the value is identical, we still set it in the map, + // but the index doesn't strictly need updating. + // However, if the key was mapped to a DIFFERENT object with the SAME pubkey, + // we must update the Set (remove old object, add new object). + if (existing) { + if (existing === value) { + // No index change needed + return super.set(key, value); + } + this._removeFromIndex(existing); + } + + super.set(key, value); + this._addToIndex(value); + return this; + } + + delete(key) { + const value = this.get(key); + const result = super.delete(key); + if (result && value) { + this._removeFromIndex(value); + } + return result; + } + + clear() { + super.clear(); + this.authorIndex.clear(); + } + + getEventsByAuthor(pubkey) { + const normalized = this._normalizePubkey(pubkey); + if (!normalized) return []; + const set = this.authorIndex.get(normalized); + // Return array to allow iteration + return set ? Array.from(set) : []; + } +} diff --git a/js/nostr/maxListenerDiagnostics.js b/js/nostr/maxListenerDiagnostics.js index 365fa34f..76793fcf 100644 --- a/js/nostr/maxListenerDiagnostics.js +++ b/js/nostr/maxListenerDiagnostics.js @@ -15,7 +15,7 @@ const MAX_LISTENER_SNIPPETS = [ const MAX_LISTENER_CODES = new Set(["MaxListenersExceededWarning"]); -function collectCandidateStrings(value) { +export function collectCandidateStrings(value) { const candidates = []; if (!value) { @@ -40,7 +40,7 @@ function collectCandidateStrings(value) { return candidates; } -function shouldSuppressWarning(...args) { +export function shouldSuppressWarning(...args) { if (isVerboseDiagnosticsEnabled()) { return false; } diff --git a/js/nostr/nip46Client.js b/js/nostr/nip46Client.js index cb3d021b..b3ab6d76 100644 --- a/js/nostr/nip46Client.js +++ b/js/nostr/nip46Client.js @@ -14,6 +14,11 @@ */ import { isDevMode } from "../config.js"; +import { + encryptSessionPrivateKey, + decryptSessionPrivateKey, + normalizeStoredEncryptionMetadata, +} from "./sessionActor.js"; import { DEFAULT_RELAY_URLS, ensureNostrTools, @@ -27,6 +32,16 @@ import { import { devLogger, userLogger } from "../utils/logger.js"; import { Nip46RequestQueue, NIP46_PRIORITY } from "./nip46Queue.js"; import { HEX64_REGEX } from "../utils/hex.js"; +import { + summarizeHexForLog, + summarizeSecretForLog, + summarizeMetadataForLog, + summarizeUrlForLog, + summarizePayloadPreviewForLog, + summarizeRpcParamsForLog, + summarizeRpcResultForLog, + summarizeRelayPublishResultsForLog, +} from "./nip46LoggingUtils.js"; export const NIP46_RPC_KIND = 24_133; const NIP46_SESSION_STORAGE_KEY = "bitvid:nip46:session:v1"; @@ -223,6 +238,11 @@ export function sanitizeStoredNip46Session(candidate) { lastConnectedAt: Number.isFinite(candidate.lastConnectedAt) ? candidate.lastConnectedAt : Date.now(), + encryptedSecrets: + typeof candidate.encryptedSecrets === "string" + ? candidate.encryptedSecrets.trim() + : "", + keyEncryption: normalizeStoredEncryptionMetadata(candidate.keyEncryption), }; } @@ -251,7 +271,7 @@ export function readStoredNip46Session() { (typeof parsed?.clientPrivateKey === "string" || typeof parsed?.secret === "string") ) { - writeStoredNip46Session(sanitized); + writeStoredNip46SessionSync(sanitized); } return sanitized; } catch (error) { @@ -267,7 +287,7 @@ export function readStoredNip46Session() { } } -export function writeStoredNip46Session(payload) { +export function writeStoredNip46SessionSync(payload) { const storage = getNip46Storage(); if (!storage) { return; @@ -285,6 +305,8 @@ export function writeStoredNip46Session(payload) { metadata: payload.metadata, userPubkey: payload.userPubkey, lastConnectedAt: payload.lastConnectedAt, + encryptedSecrets: payload.encryptedSecrets, + keyEncryption: payload.keyEncryption, } : payload; const normalized = sanitizeStoredNip46Session(sanitizedInput); @@ -304,6 +326,90 @@ export function writeStoredNip46Session(payload) { } } +export async function writeStoredNip46Session(payload, passphrase) { + if (!payload || typeof payload !== "object") { + return; + } + + const update = { ...payload }; + + if ( + passphrase && + typeof passphrase === "string" && + passphrase.trim() && + typeof update.clientPrivateKey === "string" && + update.clientPrivateKey && + typeof update.secret === "string" && + update.secret + ) { + const secrets = JSON.stringify({ + clientPrivateKey: update.clientPrivateKey, + secret: update.secret, + }); + + try { + const encrypted = await encryptSessionPrivateKey( + secrets, + passphrase.trim(), + ); + update.encryptedSecrets = encrypted.ciphertext; + update.keyEncryption = { + salt: encrypted.salt, + iv: encrypted.iv, + iterations: encrypted.iterations, + hash: encrypted.hash, + algorithm: encrypted.algorithm, + version: encrypted.version, + }; + } catch (error) { + devLogger.warn("[nostr] Failed to encrypt remote signer session:", error); + // Fall through to write sanitized (non-sensitive) data only + } + } + + // Ensure plain text keys are never persisted + delete update.clientPrivateKey; + delete update.secret; + + writeStoredNip46SessionSync(update); +} + +export async function decryptNip46Session(session, passphrase) { + if (!session || typeof session !== "object") { + return null; + } + + if ( + !session.encryptedSecrets || + typeof session.encryptedSecrets !== "string" + ) { + return { ...session }; + } + + if (!passphrase || typeof passphrase !== "string" || !passphrase.trim()) { + throw new Error("Passphrase required to decrypt session."); + } + + const payload = { + privateKeyEncrypted: session.encryptedSecrets, + encryption: session.keyEncryption, + }; + + const decryptedJson = await decryptSessionPrivateKey(payload, passphrase); + let secrets = null; + try { + secrets = JSON.parse(decryptedJson); + } catch (error) { + throw new Error("Decrypted session secrets are malformed."); + } + + return { + ...session, + clientPrivateKey: secrets.clientPrivateKey, + secret: secrets.secret, + }; +} + export function clearStoredNip46Session() { const storage = getNip46Storage(); if (!storage) { @@ -317,125 +423,6 @@ export function clearStoredNip46Session() { } } -const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; -const BECH32_CHARSET_MAP = (() => { - const map = new Map(); - for (let index = 0; index < BECH32_CHARSET.length; index += 1) { - map.set(BECH32_CHARSET[index], index); - } - return map; -})(); - -function bech32Polymod(values) { - const GENERATORS = [ - 0x3b6a57b2, - 0x26508e6d, - 0x1ea119fa, - 0x3d4233dd, - 0x2a1462b3, - ]; - - let chk = 1; - for (const value of values) { - const top = chk >> 25; - chk = ((chk & 0x1ffffff) << 5) ^ value; - for (let i = 0; i < GENERATORS.length; i += 1) { - if ((top >> i) & 1) { - chk ^= GENERATORS[i]; - } - } - } - return chk; -} - -function bech32HrpExpand(hrp) { - const result = []; - for (let i = 0; i < hrp.length; i += 1) { - result.push(hrp.charCodeAt(i) >> 5); - } - result.push(0); - for (let i = 0; i < hrp.length; i += 1) { - result.push(hrp.charCodeAt(i) & 31); - } - return result; -} - -function bech32VerifyChecksum(hrp, data) { - return bech32Polymod([...bech32HrpExpand(hrp), ...data]) === 1; -} - -function convertBits(data, fromBits, toBits) { - let accumulator = 0; - let bits = 0; - const result = []; - const maxValue = (1 << toBits) - 1; - const maxAccumulator = (1 << (fromBits + toBits - 1)) - 1; - - for (const value of data) { - if (value < 0 || (value >> fromBits) !== 0) { - return null; - } - accumulator = ((accumulator << fromBits) | value) & maxAccumulator; - bits += fromBits; - while (bits >= toBits) { - bits -= toBits; - result.push((accumulator >> bits) & maxValue); - } - } - - if (bits > 0) { - result.push((accumulator << (toBits - bits)) & maxValue); - } - - return result; -} - -function decodeBech32Npub(value) { - if (typeof value !== "string") { - return ""; - } - - const trimmed = value.trim(); - if (!trimmed) { - return ""; - } - - const normalized = trimmed.toLowerCase(); - const separatorIndex = normalized.lastIndexOf("1"); - if (separatorIndex <= 0 || separatorIndex + 7 > normalized.length) { - return ""; - } - - const hrp = normalized.slice(0, separatorIndex); - if (hrp !== "npub") { - return ""; - } - - const dataPart = normalized.slice(separatorIndex + 1); - const values = []; - for (let i = 0; i < dataPart.length; i += 1) { - const mapped = BECH32_CHARSET_MAP.get(dataPart[i]); - if (typeof mapped !== "number") { - return ""; - } - values.push(mapped); - } - - if (values.length < 7 || !bech32VerifyChecksum(hrp, values)) { - return ""; - } - - const words = values.slice(0, -6); - const bytes = convertBits(words, 5, 8); - if (!bytes || bytes.length !== 32) { - return ""; - } - - return Array.from(bytes) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - export function decodeNpubToHex(npub) { if (typeof npub !== "string") { return ""; @@ -480,11 +467,6 @@ export function decodeNpubToHex(npub) { } } - const manualDecoded = decodeBech32Npub(trimmed); - if (manualDecoded) { - return manualDecoded; - } - if (isDevMode && warnableNpub) { userLogger.warn( `[nostr] Failed to decode npub: ${trimmed}`, @@ -1203,160 +1185,6 @@ export async function decryptNip46PayloadWithKeys(privateKey, remotePubkey, ciph throw failure; } -export function summarizeHexForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const normalized = value.trim().toLowerCase(); - if (normalized.length <= 12) { - return `${normalized} (len:${normalized.length})`; - } - return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; -} - -export function summarizeSecretForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; - return `${visible} (len:${trimmed.length})`; -} - -export function summarizeMetadataForLog(metadata) { - if (!metadata || typeof metadata !== "object") { - return []; - } - return Object.keys(metadata).slice(0, 12); -} - -export function summarizeUrlForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - try { - const url = new URL(trimmed); - return { - origin: url.origin, - pathname: url.pathname, - hasQuery: Boolean(url.search), - hasHash: Boolean(url.hash), - length: trimmed.length, - }; - } catch (error) { - const length = trimmed.length; - if (length <= 64) { - return `${trimmed} (len:${length})`; - } - return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`; - } -} - -export function summarizePayloadPreviewForLog(value) { - if (typeof value !== "string") { - return { type: typeof value }; - } - - const trimmed = value.trim(); - if (!trimmed) { - return { type: "string", length: 0 }; - } - - return { - type: "string", - length: trimmed.length, - preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`, - }; -} - -export function summarizeRpcParamsForLog(method, params) { - if (!Array.isArray(params)) { - return []; - } - - return params.map((param, index) => { - if (typeof param === "string") { - if (method === "connect" && index === 1) { - return { index, secret: summarizeSecretForLog(param) }; - } - const trimmed = param.trim(); - if (!trimmed) { - return { index, type: "string", length: 0 }; - } - if (method === "sign_event") { - return { index, type: "string", length: trimmed.length }; - } - if (trimmed.length <= 64) { - return { index, value: trimmed, length: trimmed.length }; - } - return { - index, - type: "string", - length: trimmed.length, - preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`, - }; - } - - if (param && typeof param === "object") { - return { - index, - type: Array.isArray(param) ? "array" : "object", - keys: Object.keys(param).slice(0, 6), - }; - } - - return { index, type: typeof param }; - }); -} - -export function summarizeRpcResultForLog(method, result) { - if (typeof result === "string") { - const trimmed = result.trim(); - if (!trimmed) { - return { type: "string", length: 0 }; - } - if (method === "connect") { - return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) }; - } - if (trimmed.length <= 96) { - return { type: "string", length: trimmed.length, value: trimmed }; - } - return { - type: "string", - length: trimmed.length, - preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`, - }; - } - - if (!result) { - return { type: typeof result }; - } - - if (typeof result === "object") { - return { - type: Array.isArray(result) ? "array" : "object", - keys: Object.keys(result).slice(0, 6), - }; - } - - return { type: typeof result }; -} - -export function summarizeRelayPublishResultsForLog(results) { - if (!Array.isArray(results)) { - return []; - } - - return results.map((entry) => ({ - relay: entry?.relay || "", - success: Boolean(entry?.ok), - reason: entry?.error ? entry.error?.message || String(entry.error) : null, - })); -} export function createNip46RequestId() { try { diff --git a/js/nostr/nip46LoggingUtils.js b/js/nostr/nip46LoggingUtils.js new file mode 100644 index 00000000..e7973c14 --- /dev/null +++ b/js/nostr/nip46LoggingUtils.js @@ -0,0 +1,158 @@ +/** + * Logging utilities for NIP-46 (Nostr Connect) client and diagnostics. + */ + +export function summarizeHexForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const normalized = value.trim().toLowerCase(); + if (normalized.length <= 12) { + return `${normalized} (len:${normalized.length})`; + } + return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; +} + +export function summarizeSecretForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; + return `${visible} (len:${trimmed.length})`; +} + +export function summarizeMetadataForLog(metadata) { + if (!metadata || typeof metadata !== "object") { + return []; + } + return Object.keys(metadata).slice(0, 12); +} + +export function summarizeUrlForLog(value) { + if (typeof value !== "string" || !value.trim()) { + return ""; + } + + const trimmed = value.trim(); + try { + const url = new URL(trimmed); + return { + origin: url.origin, + pathname: url.pathname, + hasQuery: Boolean(url.search), + hasHash: Boolean(url.hash), + length: trimmed.length, + }; + } catch (error) { + const length = trimmed.length; + if (length <= 64) { + return `${trimmed} (len:${length})`; + } + return `${trimmed.slice(0, 32)}…${trimmed.slice(-8)} (len:${length})`; + } +} + +export function summarizePayloadPreviewForLog(value) { + if (typeof value !== "string") { + return { type: typeof value }; + } + + const trimmed = value.trim(); + if (!trimmed) { + return { type: "string", length: 0 }; + } + + return { + type: "string", + length: trimmed.length, + preview: trimmed.length <= 96 ? trimmed : `${trimmed.slice(0, 64)}…`, + }; +} + +export function summarizeRpcParamsForLog(method, params) { + if (!Array.isArray(params)) { + return []; + } + + return params.map((param, index) => { + if (typeof param === "string") { + if (method === "connect" && index === 1) { + return { index, secret: summarizeSecretForLog(param) }; + } + const trimmed = param.trim(); + if (!trimmed) { + return { index, type: "string", length: 0 }; + } + if (method === "sign_event") { + return { index, type: "string", length: trimmed.length }; + } + if (trimmed.length <= 64) { + return { index, value: trimmed, length: trimmed.length }; + } + return { + index, + type: "string", + length: trimmed.length, + preview: `${trimmed.slice(0, 32)}…${trimmed.slice(-8)}`, + }; + } + + if (param && typeof param === "object") { + return { + index, + type: Array.isArray(param) ? "array" : "object", + keys: Object.keys(param).slice(0, 6), + }; + } + + return { index, type: typeof param }; + }); +} + +export function summarizeRpcResultForLog(method, result) { + if (typeof result === "string") { + const trimmed = result.trim(); + if (!trimmed) { + return { type: "string", length: 0 }; + } + if (method === "connect") { + return { type: "string", length: trimmed.length, secret: summarizeSecretForLog(trimmed) }; + } + if (trimmed.length <= 96) { + return { type: "string", length: trimmed.length, value: trimmed }; + } + return { + type: "string", + length: trimmed.length, + preview: `${trimmed.slice(0, 48)}…${trimmed.slice(-12)}`, + }; + } + + if (!result) { + return { type: typeof result }; + } + + if (typeof result === "object") { + return { + type: Array.isArray(result) ? "array" : "object", + keys: Object.keys(result).slice(0, 6), + }; + } + + return { type: typeof result }; +} + +export function summarizeRelayPublishResultsForLog(results) { + if (!Array.isArray(results)) { + return []; + } + + return results.map((entry) => ({ + relay: entry?.relay || "", + success: Boolean(entry?.ok), + reason: entry?.error ? entry.error?.message || String(entry.error) : null, + })); +} diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 1cd60a93..adbe6b06 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -6,7 +6,6 @@ import { ADMIN_COMMUNITY_BLACKLIST_PREFIX, isDevMode, WATCH_HISTORY_KIND, - WATCH_HISTORY_LIST_IDENTIFIER, WATCH_HISTORY_VERSION_TAG_VALUE, } from "./config.js"; import { @@ -2107,57 +2106,46 @@ export function buildReactionEvent(params) { return event; } -export function buildCommentEvent(params) { - const { - pubkey, - created_at, - videoEventId = "", - videoEventRelay = "", - videoDefinitionAddress = "", - videoDefinitionRelay = "", - rootIdentifier = "", - rootIdentifierRelay = "", - parentCommentId = "", - parentCommentRelay = "", - threadParticipantPubkey = "", - threadParticipantRelay = "", - rootKind, - rootAuthorPubkey = "", - rootAuthorRelay = "", - parentKind, - parentAuthorPubkey = "", - parentAuthorRelay = "", - parentIdentifier = "", - parentIdentifierRelay = "", - additionalTags = [], - content = "", - } = params || {}; - const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); - const tags = []; - +function normalizeCommentInputs(params) { const normalizeString = (value) => typeof value === "string" ? value.trim() : ""; - const normalizedVideoEventId = normalizeString(videoEventId); - const normalizedVideoEventRelay = normalizeString(videoEventRelay); - const normalizedVideoDefinitionAddress = normalizeString(videoDefinitionAddress); - const normalizedVideoDefinitionRelay = normalizeString(videoDefinitionRelay); - const normalizedRootIdentifier = normalizeString(rootIdentifier); - const normalizedRootIdentifierRelay = normalizeString(rootIdentifierRelay); - const normalizedParentCommentId = normalizeString(parentCommentId); - const normalizedParentCommentRelay = normalizeString(parentCommentRelay); - const normalizedParentIdentifier = normalizeString(parentIdentifier); - const normalizedParentIdentifierRelay = normalizeString(parentIdentifierRelay); - const normalizedThreadParticipantPubkey = normalizeString( - threadParticipantPubkey, - ); - const normalizedThreadParticipantRelay = normalizeString( - threadParticipantRelay, - ); + return { + normalizedVideoEventId: normalizeString(params.videoEventId), + normalizedVideoEventRelay: normalizeString(params.videoEventRelay), + normalizedVideoDefinitionAddress: normalizeString(params.videoDefinitionAddress), + normalizedVideoDefinitionRelay: normalizeString(params.videoDefinitionRelay), + normalizedRootIdentifier: normalizeString(params.rootIdentifier), + normalizedRootIdentifierRelay: normalizeString(params.rootIdentifierRelay), + normalizedParentCommentId: normalizeString(params.parentCommentId), + normalizedParentCommentRelay: normalizeString(params.parentCommentRelay), + normalizedThreadParticipantPubkey: normalizeString(params.threadParticipantPubkey), + normalizedThreadParticipantRelay: normalizeString(params.threadParticipantRelay), + normalizedParentIdentifier: normalizeString(params.parentIdentifier), + normalizedParentIdentifierRelay: normalizeString(params.parentIdentifierRelay), + resolvedRootKind: normalizeString(params.rootKind), + resolvedRootAuthorPubkey: normalizeString(params.rootAuthorPubkey), + resolvedRootAuthorRelay: normalizeString(params.rootAuthorRelay), + resolvedParentAuthorPubkey: normalizeString(params.parentAuthorPubkey), + resolvedParentAuthorRelay: normalizeString(params.parentAuthorRelay), + resolvedParentKind: normalizeString(params.parentKind), + }; +} - let resolvedRootKind = normalizeString(rootKind); - let resolvedRootAuthorPubkey = normalizeString(rootAuthorPubkey); - let resolvedRootAuthorRelay = normalizeString(rootAuthorRelay); +function resolveCommentContext(inputs, schema) { + let { + resolvedRootKind, + resolvedRootAuthorPubkey, + resolvedRootAuthorRelay, + resolvedParentAuthorPubkey, + resolvedParentAuthorRelay, + resolvedParentKind, + normalizedVideoDefinitionAddress, + normalizedVideoDefinitionRelay, + normalizedThreadParticipantPubkey, + normalizedThreadParticipantRelay, + normalizedParentCommentId, + } = inputs; if (normalizedVideoDefinitionAddress) { const definitionSegments = normalizedVideoDefinitionAddress.split(":"); @@ -2172,12 +2160,10 @@ export function buildCommentEvent(params) { } } - let resolvedParentAuthorPubkey = normalizeString(parentAuthorPubkey); if (!resolvedParentAuthorPubkey) { resolvedParentAuthorPubkey = normalizedThreadParticipantPubkey; } - let resolvedParentAuthorRelay = normalizeString(parentAuthorRelay); if (!resolvedParentAuthorRelay) { resolvedParentAuthorRelay = normalizedThreadParticipantRelay; } @@ -2196,7 +2182,6 @@ export function buildCommentEvent(params) { resolvedParentAuthorRelay = resolvedRootAuthorRelay; } - let resolvedParentKind = normalizeString(parentKind); if (!resolvedParentKind) { if (normalizedParentCommentId) { resolvedParentKind = String(schema?.kind ?? 1111); @@ -2209,6 +2194,41 @@ export function buildCommentEvent(params) { resolvedRootKind = resolvedParentKind; } + return { + resolvedRootKind, + resolvedRootAuthorPubkey, + resolvedRootAuthorRelay, + resolvedParentAuthorPubkey, + resolvedParentAuthorRelay, + resolvedParentKind, + }; +} + +function buildCommentTags(schema, inputs, context) { + const { + normalizedVideoEventId, + normalizedVideoEventRelay, + normalizedVideoDefinitionAddress, + normalizedVideoDefinitionRelay, + normalizedRootIdentifier, + normalizedRootIdentifierRelay, + normalizedParentCommentId, + normalizedParentCommentRelay, + normalizedParentIdentifier, + normalizedParentIdentifierRelay, + } = inputs; + + const { + resolvedRootKind, + resolvedRootAuthorPubkey, + resolvedRootAuthorRelay, + resolvedParentAuthorPubkey, + resolvedParentAuthorRelay, + resolvedParentKind, + } = context; + + const tags = []; + const rootDefinitionPointerTagName = schema?.rootDefinitionPointerTagName || "A"; const rootEventPointerTagName = schema?.rootEventPointerTagName || "E"; const rootIdentifierPointerTagName = schema?.rootIdentifierPointerTagName || "I"; @@ -2334,6 +2354,22 @@ export function buildCommentEvent(params) { } } + return tags; +} + +export function buildCommentEvent(params) { + const { + pubkey, + created_at, + additionalTags = [], + content = "", + } = params || {}; + const schema = getNostrEventSchema(NOTE_TYPES.VIDEO_COMMENT); + + const inputs = normalizeCommentInputs(params || {}); + const context = resolveCommentContext(inputs, schema); + const tags = buildCommentTags(schema, inputs, context); + const sanitizedAdditionalTags = sanitizeAdditionalTags(additionalTags); if (sanitizedAdditionalTags.length) { tags.push(...sanitizedAdditionalTags.map((tag) => tag.slice())); diff --git a/js/services/authProviders/nip46.js b/js/services/authProviders/nip46.js index f6108a05..3e421c87 100644 --- a/js/services/authProviders/nip46.js +++ b/js/services/authProviders/nip46.js @@ -1,27 +1,9 @@ import { devLogger } from "../../utils/logger.js"; import { accessControl } from "../../accessControl.js"; - -function summarizeHexForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const normalized = value.trim().toLowerCase(); - if (normalized.length <= 12) { - return `${normalized} (len:${normalized.length})`; - } - return `${normalized.slice(0, 8)}…${normalized.slice(-4)} (len:${normalized.length})`; -} - -function summarizeSecretForLog(value) { - if (typeof value !== "string" || !value.trim()) { - return ""; - } - - const trimmed = value.trim(); - const visible = trimmed.length <= 4 ? "*".repeat(trimmed.length) : `${"*".repeat(3)}…`; - return `${visible} (len:${trimmed.length})`; -} +import { + summarizeHexForLog, + summarizeSecretForLog, +} from "../../nostr/nip46LoggingUtils.js"; function sanitizeNip46UriForLog(value) { if (typeof value !== "string" || !value) { diff --git a/js/services/authService.js b/js/services/authService.js index cb84b23b..db27bf70 100644 --- a/js/services/authService.js +++ b/js/services/authService.js @@ -10,6 +10,7 @@ import { userLogger } from "../utils/logger.js"; import { requestDefaultExtensionPermissions } from "../nostr/defaultClient.js"; import { getSavedProfiles, + setSavedProfiles, getActiveProfilePubkey, setActiveProfilePubkey, mutateSavedProfiles, @@ -417,6 +418,38 @@ export default class AuthService { return publish(payload); } + get pubkey() { + return getPubkey(); + } + + set pubkey(value) { + setPubkey(value ?? null); + } + + get currentUserNpub() { + return getCurrentUserNpub(); + } + + set currentUserNpub(value) { + setCurrentUserNpub(value ?? null); + } + + get activeProfilePubkey() { + return getActiveProfilePubkey(); + } + + setActiveProfilePubkey(pubkey, options = {}) { + return setActiveProfilePubkey(pubkey, options); + } + + get savedProfiles() { + return getSavedProfiles(); + } + + setSavedProfiles(profiles, options = {}) { + return setSavedProfiles(profiles, options); + } + getActivePubkey() { return getPubkey(); } diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 438cd33a..50fca17e 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -352,18 +352,15 @@ class HashtagPreferencesService { eventId: this.eventId, createdAt: this.eventCreatedAt, }; - // Use the note type key directly, though "interests" maps to HASHTAG_PREFERENCES - // in profileCache. Using NOTE_TYPES.HASHTAG_PREFERENCES explicitly is safer if we import it, - // but relying on the mapped key "interests" is also consistent with current usage. - // However, the fix requires us to ensure we use the unified path. - // We'll stick to "interests" which maps to NOTE_TYPES.HASHTAG_PREFERENCES in profileCache.js - profileCache.set("interests", payload); + // Use the note type key directly. This maps to the same storage key as the legacy "interests" + // mapping in profileCache, ensuring backward compatibility while using the unified path. + profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, payload); } loadFromCache(pubkey) { // profileCache internally uses activePubkey, but we should verify if it matches try { - let cached = profileCache.getProfileData(pubkey, "interests"); + let cached = profileCache.getProfileData(pubkey, NOTE_TYPES.HASHTAG_PREFERENCES); // Legacy fallback migration if (!cached && typeof localStorage !== "undefined") { @@ -372,7 +369,7 @@ class HashtagPreferencesService { if (legacy) { const parsed = JSON.parse(legacy); // Migrate to unified profile cache - profileCache.set("interests", parsed); + profileCache.set(NOTE_TYPES.HASHTAG_PREFERENCES, parsed); localStorage.removeItem(HASHTAG_IDENTIFIER); cached = parsed; devLogger.log(`${LOG_PREFIX} Migrated legacy preferences to profileCache.`); diff --git a/js/services/playbackService.js b/js/services/playbackService.js index 5502be15..0e4c7359 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -1,6 +1,7 @@ import { userLogger } from "../utils/logger.js"; import { safeDecodeURIComponent } from "../utils/safeDecode.js"; import { PLAYBACK_START_TIMEOUT } from "../constants.js"; +import { getCurrentVideo, setCurrentVideo } from "../state/appState.js"; // js/services/playbackService.js /** @@ -285,6 +286,14 @@ export class PlaybackService { this.probeCacheTtlMs = PROBE_CACHE_TTL_MS; } + get currentVideo() { + return getCurrentVideo(); + } + + set currentVideo(value) { + setCurrentVideo(value); + } + log(...args) { try { this.logger(...args); diff --git a/js/services/relayHealthService.js b/js/services/relayHealthService.js index 98eddea6..47ae5061 100644 --- a/js/services/relayHealthService.js +++ b/js/services/relayHealthService.js @@ -3,6 +3,7 @@ import { devLogger, userLogger } from "../utils/logger.js"; const TELEMETRY_STORAGE_KEY = "bitvid:relay-health-telemetry-opt-in"; const PERSISTENT_FAILURE_THRESHOLD = 3; const USER_LOG_COOLDOWN_MS = 5 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 5000; function resolveLogger(logger) { if (logger && logger.dev && logger.user) { @@ -202,7 +203,16 @@ class RelayHealthService { const start = nowMs(); try { - const relay = await this.nostrClient.pool.ensureRelay(relayUrl); + const relay = await Promise.race([ + this.nostrClient.pool.ensureRelay(relayUrl), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("relay-connection-timeout")), + DEFAULT_TIMEOUT_MS, + ), + ), + ]); + if (!relay) { throw new Error("relay-unavailable"); } diff --git a/js/state/cache.js b/js/state/cache.js index 8adf23da..b399633c 100644 --- a/js/state/cache.js +++ b/js/state/cache.js @@ -30,7 +30,6 @@ const URL_PROBE_TIMEOUT_RETRY_MS = 15 * 1000; // 15 seconds const MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v2"; -const LEGACY_MODERATION_OVERRIDE_STORAGE_KEY = "bitvid:moderationOverrides:v1"; const MODERATION_OVERRIDE_STORAGE_VERSION = 2; const MODERATION_SETTINGS_STORAGE_KEY = "bitvid:moderationSettings:v1"; const MODERATION_SETTINGS_STORAGE_VERSION = 3; @@ -505,6 +504,133 @@ export function persistSavedProfiles({ persistActive = true } = {}) { writeSavedProfilesPayloadToStorage(payload); } +function processSavedProfilesPayload(parsed) { + const result = { + profiles: [], + activePubkey: null, + hasExplicitActiveProfile: false, + needsRewrite: false, + }; + + if ( + !parsed || + typeof parsed !== "object" || + parsed.version !== SAVED_PROFILES_STORAGE_VERSION + ) { + result.needsRewrite = true; + return result; + } + + const entries = Array.isArray(parsed.entries) ? parsed.entries : []; + const seenPubkeys = new Set(); + for (const candidate of entries) { + if (!candidate || typeof candidate !== "object") { + continue; + } + + const normalizedPubkey = normalizeHexPubkey(candidate.pubkey); + if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) { + result.needsRewrite = true; + continue; + } + + seenPubkeys.add(normalizedPubkey); + const rawAuthType = + typeof candidate.authType === "string" ? candidate.authType : null; + let storedAuthType = null; + if (rawAuthType) { + const trimmedAuthType = rawAuthType.trim(); + if (trimmedAuthType) { + storedAuthType = trimmedAuthType; + if (trimmedAuthType !== rawAuthType) { + result.needsRewrite = true; + } + } + } + + if (!storedAuthType) { + storedAuthType = "nip07"; + if (rawAuthType !== "nip07") { + result.needsRewrite = true; + } + } + const rawProviderId = + typeof candidate.providerId === "string" ? candidate.providerId : null; + let storedProviderId = null; + if (rawProviderId) { + const trimmedProviderId = rawProviderId.trim(); + if (trimmedProviderId) { + storedProviderId = trimmedProviderId; + if (trimmedProviderId !== rawProviderId) { + result.needsRewrite = true; + } + } + } + if (!storedProviderId && storedAuthType) { + storedProviderId = storedAuthType; + if (rawProviderId !== storedProviderId) { + result.needsRewrite = true; + } + } + const npub = + typeof candidate.npub === "string" && candidate.npub.trim() + ? candidate.npub.trim() + : null; + + const entry = { + pubkey: normalizedPubkey, + npub, + name: typeof candidate.name === "string" ? candidate.name : "", + picture: typeof candidate.picture === "string" ? candidate.picture : "", + authType: storedAuthType, + providerId: storedProviderId, + }; + + if ( + typeof candidate.npub === "string" && + candidate.npub.trim() !== candidate.npub + ) { + result.needsRewrite = true; + } + + if ( + entry.npub && + typeof candidate.npub !== "string" && + entry.npub !== candidate.npub + ) { + result.needsRewrite = true; + } + + result.profiles.push(entry); + } + + let hasActiveField = false; + let activeCandidate; + if (Object.prototype.hasOwnProperty.call(parsed, "activePubkey")) { + hasActiveField = true; + activeCandidate = parsed.activePubkey; + } else if (Object.prototype.hasOwnProperty.call(parsed, "activePubKey")) { + hasActiveField = true; + activeCandidate = parsed.activePubKey; + } + + if (hasActiveField) { + result.hasExplicitActiveProfile = true; + if (typeof activeCandidate === "string") { + const normalizedActive = normalizeHexPubkey(activeCandidate); + if (normalizedActive && seenPubkeys.has(normalizedActive)) { + result.activePubkey = normalizedActive; + } else if (activeCandidate) { + result.needsRewrite = true; + } + } else if (activeCandidate !== null && activeCandidate !== undefined) { + result.needsRewrite = true; + } + } + + return result; +} + export function loadSavedProfilesFromStorage() { savedProfiles = []; activeProfilePubkey = null; @@ -530,117 +656,24 @@ export function loadSavedProfilesFromStorage() { let needsRewrite = false; try { const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object" && parsed.version === SAVED_PROFILES_STORAGE_VERSION) { - const entries = Array.isArray(parsed.entries) ? parsed.entries : []; - const seenPubkeys = new Set(); - for (const candidate of entries) { - if (!candidate || typeof candidate !== "object") { - continue; - } - - const normalizedPubkey = normalizeHexPubkey(candidate.pubkey); - if (!normalizedPubkey || seenPubkeys.has(normalizedPubkey)) { - needsRewrite = true; - continue; - } - - seenPubkeys.add(normalizedPubkey); - const rawAuthType = - typeof candidate.authType === "string" ? candidate.authType : null; - let storedAuthType = null; - if (rawAuthType) { - const trimmedAuthType = rawAuthType.trim(); - if (trimmedAuthType) { - storedAuthType = trimmedAuthType; - if (trimmedAuthType !== rawAuthType) { - needsRewrite = true; - } - } - } - - if (!storedAuthType) { - storedAuthType = "nip07"; - if (rawAuthType !== "nip07") { - needsRewrite = true; - } - } - const rawProviderId = - typeof candidate.providerId === "string" ? candidate.providerId : null; - let storedProviderId = null; - if (rawProviderId) { - const trimmedProviderId = rawProviderId.trim(); - if (trimmedProviderId) { - storedProviderId = trimmedProviderId; - if (trimmedProviderId !== rawProviderId) { - needsRewrite = true; - } - } - } - if (!storedProviderId && storedAuthType) { - storedProviderId = storedAuthType; - if (rawProviderId !== storedProviderId) { - needsRewrite = true; - } - } - const npub = - typeof candidate.npub === "string" && candidate.npub.trim() - ? candidate.npub.trim() - : null; - - const entry = { - pubkey: normalizedPubkey, - npub, - name: typeof candidate.name === "string" ? candidate.name : "", - picture: typeof candidate.picture === "string" ? candidate.picture : "", - authType: storedAuthType, - providerId: storedProviderId, - }; - - if ( - typeof candidate.npub === "string" && candidate.npub.trim() !== candidate.npub - ) { - needsRewrite = true; - } - - if (entry.npub && typeof candidate.npub !== "string" && entry.npub !== candidate.npub) { - needsRewrite = true; - } - - savedProfiles.push(entry); - } - - let hasActiveField = false; - let activeCandidate; - if (Object.prototype.hasOwnProperty.call(parsed, "activePubkey")) { - hasActiveField = true; - activeCandidate = parsed.activePubkey; - } else if (Object.prototype.hasOwnProperty.call(parsed, "activePubKey")) { - hasActiveField = true; - activeCandidate = parsed.activePubKey; - } - - if (hasActiveField) { - hadExplicitActiveProfile = true; - if (typeof activeCandidate === "string") { - const normalizedActive = normalizeHexPubkey(activeCandidate); - if (normalizedActive && seenPubkeys.has(normalizedActive)) { - activeProfilePubkey = normalizedActive; - } else if (activeCandidate) { - needsRewrite = true; - } - } else if (activeCandidate !== null && activeCandidate !== undefined) { - needsRewrite = true; - } - } - } else if (raw) { - needsRewrite = true; - } + const result = processSavedProfilesPayload(parsed); + savedProfiles = result.profiles; + activeProfilePubkey = result.activePubkey; + hadExplicitActiveProfile = result.hasExplicitActiveProfile; + needsRewrite = result.needsRewrite; } catch (error) { - userLogger.warn("[cache.loadSavedProfilesFromStorage] Failed to parse payload:", error); + userLogger.warn( + "[cache.loadSavedProfilesFromStorage] Failed to parse payload:", + error, + ); needsRewrite = true; } - if (!activeProfilePubkey && savedProfiles.length && !hadExplicitActiveProfile) { + if ( + !activeProfilePubkey && + savedProfiles.length && + !hadExplicitActiveProfile + ) { activeProfilePubkey = savedProfiles[0].pubkey; needsRewrite = true; } @@ -1496,9 +1529,7 @@ export function loadModerationOverridesFromStorage() { return; } - const raw = - localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY) || - localStorage.getItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); + const raw = localStorage.getItem(MODERATION_OVERRIDE_STORAGE_KEY); if (!raw) { return; } @@ -1519,20 +1550,6 @@ export function loadModerationOverridesFromStorage() { } else { shouldRewrite = true; } - } else if (payload.version === 1) { - const legacyEntries = - payload.entries && typeof payload.entries === "object" - ? payload.entries - : {}; - Object.entries(legacyEntries).forEach(([eventId, entry]) => { - entries.push({ - eventId, - authorPubkey: "", - showAnyway: entry?.showAnyway === true, - updatedAt: entry?.updatedAt, - }); - }); - shouldRewrite = true; } else { return; } @@ -1599,7 +1616,6 @@ export function persistModerationOverridesToStorage() { if (entries.length === 0) { try { localStorage.removeItem(MODERATION_OVERRIDE_STORAGE_KEY); - localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); } catch (error) { userLogger.warn( "[cache.persistModerationOverridesToStorage] Failed to clear overrides:", @@ -1620,7 +1636,6 @@ export function persistModerationOverridesToStorage() { MODERATION_OVERRIDE_STORAGE_KEY, JSON.stringify(payload), ); - localStorage.removeItem(LEGACY_MODERATION_OVERRIDE_STORAGE_KEY); } catch (error) { userLogger.warn( "[cache.persistModerationOverridesToStorage] Failed to persist overrides:", diff --git a/js/ui/applicationBootstrap.js b/js/ui/applicationBootstrap.js index 15e75d05..d3d614e5 100644 --- a/js/ui/applicationBootstrap.js +++ b/js/ui/applicationBootstrap.js @@ -867,31 +867,6 @@ export default class ApplicationBootstrap { } }; app.boundNwcSettingsToastHandler = null; - Object.defineProperty(app, "savedProfiles", { - configurable: false, - enumerable: false, - get() { - return getSavedProfiles(); - }, - set(next) { - setSavedProfiles(Array.isArray(next) ? next : [], { - persist: false, - persistActive: false, - }); - }, - }); - - Object.defineProperty(app, "activeProfilePubkey", { - configurable: false, - enumerable: false, - get() { - return getActiveProfilePubkey(); - }, - set(value) { - setStoredActiveProfilePubkey(value, { persist: false }); - }, - }); - Object.defineProperty(app, "profileCache", { configurable: false, enumerable: false, diff --git a/js/ui/loginModalController.js b/js/ui/loginModalController.js index bf8e512d..a739a047 100644 --- a/js/ui/loginModalController.js +++ b/js/ui/loginModalController.js @@ -1561,6 +1561,23 @@ export default class LoginModalController { if (!pendingAuthUrl) { return; } + + let isValid = false; + try { + const parsed = new URL(pendingAuthUrl); + isValid = parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch (error) { + isValid = false; + } + + if (!isValid) { + devLogger.warn( + "[LoginModalController] Blocked unsafe authentication URL:", + pendingAuthUrl, + ); + return; + } + if (this.window && typeof this.window.open === "function") { this.window.open(pendingAuthUrl, "_blank", "noopener,noreferrer"); } else if (this.document && this.document.location) { diff --git a/js/utils/profileBatchFetcher.js b/js/utils/profileBatchFetcher.js index 1c3b20e1..b8a0f98e 100644 --- a/js/utils/profileBatchFetcher.js +++ b/js/utils/profileBatchFetcher.js @@ -10,6 +10,7 @@ const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg"; export async function batchFetchProfilesFromRelays({ authorSet, + forceRefresh = false, getProfileCacheEntry, setProfileCacheEntry, updateProfileInDOM, @@ -50,6 +51,9 @@ export async function batchFetchProfilesFromRelays({ const cacheEntry = getProfileCacheEntry(normalizedPubkey); if (cacheEntry) { updateProfileInDOM(normalizedPubkey, cacheEntry.profile); + if (forceRefresh) { + toFetch.push(normalizedPubkey); + } } else { toFetch.push(normalizedPubkey); } diff --git a/js/webtorrent.js b/js/webtorrent.js index df95e2dd..50715369 100644 --- a/js/webtorrent.js +++ b/js/webtorrent.js @@ -706,34 +706,44 @@ export class TorrentClient { }); } - // Handle Chrome-based browsers - handleChromeTorrent(torrent, videoElement, resolve, reject) { - // Prune demo web seeds/trackers that chronically trip Chromium CORS and - // deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. - torrent.on("warning", (err) => { - if (err && typeof err.message === "string") { - if ( - err.message.includes("CORS") || - err.message.includes("Access-Control-Allow-Origin") - ) { - userLogger.warn( - "CORS warning detected. Attempting to remove the failing webseed/tracker." - ); - if (torrent._opts?.urlList?.length) { - torrent._opts.urlList = torrent._opts.urlList.filter((url) => { - return !url.includes("distribution.bbb3d.renderfarming.net"); - }); - userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList); - } - if (torrent._opts?.announce?.length) { - torrent._opts.announce = torrent._opts.announce.filter((url) => { - return !url.includes("fastcast.nz"); - }); - userLogger.warn("Cleaned up trackers =>", torrent._opts.announce); + handleTorrentStream( + torrent, + videoElement, + resolve, + reject, + context = "chrome" + ) { + const isChrome = context === "chrome"; + const isFirefox = context === "firefox"; + + // Chrome-specific: Prune demo web seeds/trackers that chronically trip Chromium CORS + // and deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround. + if (isChrome) { + torrent.on("warning", (err) => { + if (err && typeof err.message === "string") { + if ( + err.message.includes("CORS") || + err.message.includes("Access-Control-Allow-Origin") + ) { + userLogger.warn( + "CORS warning detected. Attempting to remove the failing webseed/tracker." + ); + if (torrent._opts?.urlList?.length) { + torrent._opts.urlList = torrent._opts.urlList.filter((url) => { + return !url.includes("distribution.bbb3d.renderfarming.net"); + }); + userLogger.warn("Cleaned up webseeds =>", torrent._opts.urlList); + } + if (torrent._opts?.announce?.length) { + torrent._opts.announce = torrent._opts.announce.filter((url) => { + return !url.includes("fastcast.nz"); + }); + userLogger.warn("Cleaned up trackers =>", torrent._opts.announce); + } } } - } - }); + }); + } const file = torrent.files.find((f) => /\.(mp4|webm|mkv)$/i.test(f.name)); if (!file) { @@ -744,18 +754,23 @@ export class TorrentClient { videoElement.crossOrigin = "anonymous"; videoElement.addEventListener("error", (e) => { - this.log("Video error:", e.target.error); + this.log(`Video error (${context} path):`, e.target.error); }); const tryStart = () => { - this.attemptAutoplay(videoElement, "chrome"); + this.attemptAutoplay(videoElement, context); }; videoElement.addEventListener("canplay", tryStart, { once: true }); videoElement.addEventListener("loadeddata", tryStart, { once: true }); try { - file.streamTo(videoElement); + const streamOptions = {}; + if (isFirefox) { + streamOptions.highWaterMark = 256 * 1024; + } + file.streamTo(videoElement, streamOptions); + // If the video is already ready (e.g. from cache or fast load), try starting immediately if (videoElement.readyState >= 3) { tryStart(); @@ -763,54 +778,12 @@ export class TorrentClient { this.currentTorrent = torrent; resolve(torrent); } catch (err) { - this.log("Streaming error (Chrome path):", err); + this.log(`Streaming error (${context} path):`, err); reject(err); } torrent.on("error", (err) => { - this.log("Torrent error (Chrome path):", err); - reject(err); - }); - } - - // Handle Firefox-based browsers - handleFirefoxTorrent(torrent, videoElement, resolve, reject) { - const file = torrent.files.find((f) => - /\.(mp4|webm|mkv)$/.test(f.name.toLowerCase()) - ); - if (!file) { - return reject(new Error("No compatible video file found in torrent")); - } - - // Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots). - videoElement.crossOrigin = "anonymous"; - - videoElement.addEventListener("error", (e) => { - this.log("Video error (Firefox path):", e.target.error); - }); - - const tryStart = () => { - this.attemptAutoplay(videoElement, "firefox"); - }; - - videoElement.addEventListener("canplay", tryStart, { once: true }); - videoElement.addEventListener("loadeddata", tryStart, { once: true }); - - try { - file.streamTo(videoElement, { highWaterMark: 256 * 1024 }); - // If the video is already ready (e.g. from cache or fast load), try starting immediately - if (videoElement.readyState >= 3) { - tryStart(); - } - this.currentTorrent = torrent; - resolve(torrent); - } catch (err) { - this.log("Streaming error (Firefox path):", err); - reject(err); - } - - torrent.on("error", (err) => { - this.log("Torrent error (Firefox path):", err); + this.log(`Torrent error (${context} path):`, err); reject(err); }); } @@ -932,14 +905,14 @@ export class TorrentClient { { ...chromeOptions, maxWebConns: 4 }, (torrent) => { this.log("Torrent added (Firefox path):", torrent.name); - this.handleFirefoxTorrent(torrent, videoElement, resolve, reject); + this.handleTorrentStream(torrent, videoElement, resolve, reject, "firefox"); } ); } else { this.log("Starting torrent download (Chrome path)"); this.client.add(magnetURI, chromeOptions, (torrent) => { this.log("Torrent added (Chrome path):", torrent.name); - this.handleChromeTorrent(torrent, videoElement, resolve, reject); + this.handleTorrentStream(torrent, videoElement, resolve, reject, "chrome"); }); } }); diff --git a/scripts/benchmark-channel-scan.mjs b/scripts/benchmark-channel-scan.mjs new file mode 100644 index 00000000..09792ada --- /dev/null +++ b/scripts/benchmark-channel-scan.mjs @@ -0,0 +1,94 @@ +import { JSDOM } from 'jsdom'; +import { performance } from 'perf_hooks'; +import * as NostrTools from 'nostr-tools'; + +const dom = new JSDOM(`
`, { + url: "http://localhost/#view=channel-profile&npub=npub1test" +}); + +global.window = dom.window; +global.self = dom.window; +global.document = dom.window.document; +global.HTMLElement = dom.window.HTMLElement; +global.NodeList = dom.window.NodeList; +global.URLSearchParams = dom.window.URLSearchParams; +global.URL = dom.window.URL; +global.Blob = dom.window.Blob; +global.TextEncoder = dom.window.TextEncoder; +global.TextDecoder = dom.window.TextDecoder; +// global.crypto = dom.window.crypto; // Node has crypto + +global.window.NostrTools = NostrTools; + +// Mock requestIdleCallback +global.requestIdleCallback = (cb) => setTimeout(cb, 0); +global.cancelIdleCallback = (id) => clearTimeout(id); + +global.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {} +}; + +// We need to mock AccessControl because it's called in refreshActiveChannelVideoGrid +// accessControl.ensureReady() +// It imports accessControl from js/accessControl.js +// We can try to rely on its default behavior or mock the module if necessary. +// But importing real modules is easier if they don't have heavy side effects. + +// Import nostrClient +const { nostrClient } = await import('../js/nostrClientFacade.js'); +// Import channelProfile +const channelProfile = await import('../js/channelProfile.js'); + +async function runBenchmark() { + console.log("Setting up benchmark..."); + + nostrClient.pool = { + list: async () => [], + ensureRelay: async () => {}, + get: async () => null, + }; + nostrClient.relays = ["wss://relay.example.com"]; + + // Populate rawEvents with many events + const eventCount = 1000000; + const targetPubkey = "0000000000000000000000000000000000000000000000000000000000000001"; + + console.log(`Populating ${eventCount} events...`); + const rawEvents = nostrClient.rawEvents; + + for (let i = 0; i < eventCount; i++) { + const id = i.toString(16).padStart(64, '0'); + // Mix of target events and noise + const isTarget = i % 1000 === 0; + const pubkey = isTarget ? targetPubkey : id; // Unique pubkey for noise + const event = { + id, + pubkey, + kind: 30078, + created_at: Math.floor(Date.now() / 1000) - i, + content: "{}", + tags: [] + }; + rawEvents.set(id, event); + } + + console.log(`Populated. Raw events size: ${rawEvents.size}`); + + // Setup test state + channelProfile.__setChannelProfileTestState({ pubkey: targetPubkey }); + + // Benchmark + console.log("Starting benchmark..."); + const start = performance.now(); + + // We call refreshActiveChannelVideoGrid which triggers the scan + await channelProfile.refreshActiveChannelVideoGrid(); + + const end = performance.now(); + console.log(`refreshActiveChannelVideoGrid took ${(end - start).toFixed(2)}ms`); +} + +runBenchmark().catch(console.error); diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs index 361a46f7..2e916946 100644 --- a/tests/app-batch-fetch-profiles.test.mjs +++ b/tests/app-batch-fetch-profiles.test.mjs @@ -133,3 +133,79 @@ test("batchFetchProfiles handles fast and failing relays", async () => { { pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } }, ]); }); + +test("batchFetchProfiles respects forceRefresh", async () => { + const cache = new Map(); + const updates = []; + const setCalls = []; + const authorSet = new Set([ + CACHED_PUBKEY, + ]); + + const getProfileCacheEntry = (pubkey) => { + if (pubkey === CACHED_PUBKEY) { + return { profile: { name: "Cached", picture: "cached.png" } }; + } + return null; + }; + + const setProfileCacheEntry = (pubkey, profile) => { + cache.set(pubkey, { profile }); + setCalls.push({ pubkey, profile }); + }; + + const updateProfileInDOM = (pubkey, profile) => { + updates.push({ pubkey, profile }); + }; + + const originalPool = nostrClient.pool; + const poolListCalls = []; + + nostrClient.pool = { + list: (relays, filters) => { + poolListCalls.push({ relays, filters }); + return Promise.resolve([ + createProfileEvent({ + pubkey: CACHED_PUBKEY, + createdAt: 300, + content: { name: "Refreshed", picture: "refreshed.png" }, + }), + ]); + }, + }; + + try { + await batchFetchProfilesFromRelays({ + authorSet, + forceRefresh: true, + getProfileCacheEntry, + setProfileCacheEntry, + updateProfileInDOM, + }); + } finally { + nostrClient.pool = originalPool; + } + + // Should fetch despite cache presence + assert.ok(poolListCalls.length > 0, "should query relays when forceRefresh is true"); + assert.deepEqual( + poolListCalls[0].filters[0].authors, + [CACHED_PUBKEY], + "should include cached pubkey in fetch list" + ); + + // Should still optimistic update from cache first + assert.deepEqual( + updates[0], + { pubkey: CACHED_PUBKEY, profile: { name: "Cached", picture: "cached.png" } }, + "cached profiles should hydrate immediately even with forceRefresh" + ); + + // Then update with fresh data + const lastUpdate = updates[updates.length - 1]; + assert.deepEqual( + lastUpdate, + { pubkey: CACHED_PUBKEY, profile: { name: "Refreshed", picture: "refreshed.png" } }, + "should update with refreshed data" + ); +}); diff --git a/tests/app/feedCoordinator.test.mjs b/tests/app/feedCoordinator.test.mjs new file mode 100644 index 00000000..be34d9fd --- /dev/null +++ b/tests/app/feedCoordinator.test.mjs @@ -0,0 +1,87 @@ +import { test, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createFeedCoordinator } from '../../js/app/feedCoordinator.js'; +import { FEED_TYPES } from '../../js/constants.js'; + +test('createFeedCoordinator - loadForYouVideos', async (t) => { + const mockDeps = { + devLogger: { log: mock.fn(), warn: mock.fn() }, + userLogger: { warn: mock.fn() }, + nostrClient: {}, + getSidebarLoadingMarkup: mock.fn(() => '
Loading...
'), + watchHistoryService: {}, + subscriptions: {}, + createActiveNostrSource: mock.fn(), + createBlacklistFilterStage: mock.fn(), + createDisinterestFilterStage: mock.fn(), + createDedupeByRootStage: mock.fn(), + createExploreDiversitySorter: mock.fn(), + createExploreScorerStage: mock.fn(), + createKidsAudienceFilterStage: mock.fn(), + createKidsScorerStage: mock.fn(), + createKidsScoreSorter: mock.fn(), + createModerationStage: mock.fn(), + createResolvePostedAtStage: mock.fn(), + createTagPreferenceFilterStage: mock.fn(), + createWatchHistorySuppressionStage: mock.fn(), + createChronologicalSorter: mock.fn(), + createSubscriptionAuthorsSource: mock.fn(), + registerWatchHistoryFeed: mock.fn(), + }; + + const coordinator = createFeedCoordinator(mockDeps); + + // Mock application context + const app = { + ...coordinator, + nostrService: { + getFilteredActiveVideos: mock.fn(() => []), // Simulate no cached videos + loadVideos: mock.fn(async ({ onVideos }) => { + // Simulate fetch completing with videos + onVideos([], { reason: 'test-reason' }); + return []; + }), + getVideoSubscription: mock.fn(() => 'mock-subscription'), + getVideosMap: mock.fn(() => new Map()), + }, + videoListView: { + showLoading: mock.fn(), + state: {}, + }, + mountVideoListView: mock.fn(() => ({ innerHTML: '' })), + checkRelayHealthWarning: mock.fn(), + setFeedTelemetryContext: mock.fn(), + isAuthorBlocked: mock.fn(() => false), + refreshFeed: mock.fn(async () => {}), + blacklistedEventIds: new Set(), + }; + + // Bind methods to app context + const loadForYouVideos = app.loadForYouVideos.bind(app); + + await t.test('loadForYouVideos executes successfully', async () => { + await loadForYouVideos(false); + + // Verify devLogger was called + assert.strictEqual(mockDeps.devLogger.log.mock.callCount() > 0, true, 'devLogger.log should be called'); + + // Verify telemetry context set + assert.strictEqual(app.setFeedTelemetryContext.mock.calls[0].arguments[0], FEED_TYPES.FOR_YOU, 'Should set telemetry context to for-you'); + + // Verify mountVideoListView called with includeTags: false + assert.deepStrictEqual(app.mountVideoListView.mock.calls[0].arguments[0], { includeTags: false }, 'Should mount list view with includeTags: false'); + + // Verify checkRelayHealthWarning called + assert.strictEqual(app.checkRelayHealthWarning.mock.callCount(), 1, 'Should check relay health warning'); + + // Verify nostrService.loadVideos called + assert.strictEqual(app.nostrService.loadVideos.mock.callCount(), 1, 'Should call nostrService.loadVideos'); + + // Verify refreshFeed called via callback + assert.strictEqual(app.refreshFeed.mock.callCount(), 1, 'Should call refreshFeed via callback'); + assert.strictEqual(app.refreshFeed.mock.calls[0].arguments[0], FEED_TYPES.FOR_YOU, 'Should call refreshFeed with FOR_YOU'); + + // Verify subscription updated + assert.strictEqual(app.videoSubscription, 'mock-subscription', 'Should update videoSubscription'); + }); +}); diff --git a/tests/auth/signingAdapter.test.mjs b/tests/auth/signingAdapter.test.mjs new file mode 100644 index 00000000..b946ae81 --- /dev/null +++ b/tests/auth/signingAdapter.test.mjs @@ -0,0 +1,91 @@ +import { test, describe, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; +import { createNip07SigningAdapter } from "../../js/auth/signingAdapter.js"; + +describe("createNip07SigningAdapter", () => { + let originalWindow; + + beforeEach(() => { + // Preserve original window if it exists (for safety, though usually undefined in node) + if (typeof globalThis.window !== "undefined") { + originalWindow = globalThis.window; + } + }); + + afterEach(() => { + // Restore window + if (originalWindow) { + globalThis.window = originalWindow; + } else { + delete globalThis.window; + } + }); + + test("uses explicit extension if provided", async () => { + const mockExtension = { + getPublicKey: mock.fn(async () => "explicit_pubkey"), + signEvent: mock.fn(async (event) => ({ ...event, sig: "signature" })), + }; + + const adapter = createNip07SigningAdapter({ extension: mockExtension }); + + const pubkey = await adapter.getPubkey(); + assert.strictEqual(pubkey, "explicit_pubkey"); + assert.strictEqual(mockExtension.getPublicKey.mock.calls.length, 1); + + const event = { id: "123" }; + const signed = await adapter.signEvent(event); + assert.strictEqual(signed.sig, "signature"); + assert.strictEqual(mockExtension.signEvent.mock.calls.length, 1); + assert.deepStrictEqual(mockExtension.signEvent.mock.calls[0].arguments[0], event); + }); + + test("falls back to window.nostr if no extension provided", async () => { + const mockWindowNostr = { + getPublicKey: mock.fn(async () => "window_pubkey"), + signEvent: mock.fn(async (event) => ({ ...event, sig: "window_sig" })), + }; + + globalThis.window = { nostr: mockWindowNostr }; + + const adapter = createNip07SigningAdapter(); + + const pubkey = await adapter.getPubkey(); + assert.strictEqual(pubkey, "window_pubkey"); + assert.strictEqual(mockWindowNostr.getPublicKey.mock.calls.length, 1); + }); + + test("throws error if no extension available", async () => { + globalThis.window = {}; // No nostr + + const adapter = createNip07SigningAdapter(); + + await assert.rejects( + async () => adapter.getPubkey(), + { message: "NIP-07 extension unavailable." } + ); + + await assert.rejects( + async () => adapter.signEvent({}), + { message: "NIP-07 extension unavailable." } + ); + }); + + test("prioritizes explicit extension over window.nostr", async () => { + const mockExplicit = { + getPublicKey: mock.fn(async () => "explicit"), + }; + const mockWindow = { + getPublicKey: mock.fn(async () => "window"), + }; + + globalThis.window = { nostr: mockWindow }; + + const adapter = createNip07SigningAdapter({ extension: mockExplicit }); + const pubkey = await adapter.getPubkey(); + + assert.strictEqual(pubkey, "explicit"); + assert.strictEqual(mockExplicit.getPublicKey.mock.calls.length, 1); + assert.strictEqual(mockWindow.getPublicKey.mock.calls.length, 0); + }); +}); diff --git a/tests/designSystem/dynamicStyles.test.mjs b/tests/designSystem/dynamicStyles.test.mjs new file mode 100644 index 00000000..7aa060b0 --- /dev/null +++ b/tests/designSystem/dynamicStyles.test.mjs @@ -0,0 +1,276 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; +import { + registerScope, + setVariables, + releaseScope, + getScopeAttributeName +} from '../../js/designSystem/dynamicStyles.js'; + +describe('js/designSystem/dynamicStyles.js', () => { + let dom; + let document; + let window; + let originalCSSStyleSheet; + + beforeEach(() => { + dom = new JSDOM('', { + url: 'http://localhost/', + pretendToBeVisual: true, + }); + window = dom.window; + document = window.document; + + originalCSSStyleSheet = globalThis.CSSStyleSheet; + // Polyfill global CSSStyleSheet for constructable stylesheets support if needed + // JSDOM supports it, but we need to expose it globally for the module to detect it + if (!globalThis.CSSStyleSheet) { + globalThis.CSSStyleSheet = window.CSSStyleSheet; + } + }); + + afterEach(() => { + // Clean up + dom = null; + document = null; + window = null; + globalThis.CSSStyleSheet = originalCSSStyleSheet; + }); + + describe('registerScope', () => { + it('should register a scope and return a string ID', () => { + const scopeId = registerScope('test-scope', [], { documentRef: document }); + assert.equal(typeof scopeId, 'string'); + assert.ok(scopeId.startsWith('test-scope')); + }); + + it('should create a style element or use adoptedStyleSheets', () => { + const scopeId = registerScope('test-scope', [], { documentRef: document }); + + // Check for style element fallback or adoptedStyleSheets + const styleElement = document.querySelector('style[data-ds-dynamic="true"]'); + const adopted = document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0; + + assert.ok(styleElement || adopted, 'Should use either