diff --git a/docs/bitvid-app-audit.md b/docs/bitvid-app-audit.md new file mode 100644 index 00000000..1268de50 --- /dev/null +++ b/docs/bitvid-app-audit.md @@ -0,0 +1,25 @@ +# Application Responsibility Audit + +This document catalogs the major responsibilities still implemented inside `Application` (formerly `bitvidApp`). Each area is a candidate for future extraction into dedicated service or UI modules. + +## Auth & Profile Coordination +- Instantiates `AuthService`, wires login/logout/profile listeners, and exposes helper methods for cached profile state management.【F:js/app.js†L221-L314】【F:js/app.js†L620-L772】 +- Manages profile modal lifecycle, navigation between panes, and account switching UI state.【F:js/app.js†L283-L420】【F:js/app.js†L1328-L1822】 + +## Playback Orchestration +- Configures `PlaybackService`, logs telemetry callbacks, and tracks the active playback session lifecycle.【F:js/app.js†L224-L323】【F:js/app.js†L2805-L3352】 +- Coordinates view logging, cooldown keys, and watch-count subscriptions for the primary player and modal overlays.【F:js/app.js†L4305-L4592】【F:js/app.js†L4972-L5332】 + +## Modal & UI Composition +- Constructs upload, edit, revert, and video modals; registers their event listeners; and bridges UI events back into application logic.【F:js/app.js†L340-L615】【F:js/app.js†L2382-L2799】 +- Builds and manages the `VideoListView` component, injecting helpers for health badges, share links, and context menu actions.【F:js/app.js†L516-L744】【F:js/app.js†L2149-L2376】 + +## Routing & View Bootstrapping +- Handles initial view loading, hash-based navigation, and video list mounting during application startup.【F:js/app.js†L903-L959】【F:js/app.js†L1932-L2124】 +- Provides navigation helpers for channel/profile transitions tied to hash routing.【F:js/app.js†L934-L968】 + +## State & Watch History Management +- Synchronizes watch-history preferences, local metadata caching, and publication of watch history events to services.【F:js/app.js†L1975-L2093】【F:js/app.js†L4087-L4478】 +- Tracks app-level state such as active intervals, cleanup routines, view counters, and URL/magnet health caches.【F:js/app.js†L200-L323】【F:js/app.js†L3354-L4075】 + +These clusters outline the remaining cross-cutting responsibilities that can be incrementally migrated into focused modules under `js/services` and `js/ui`. diff --git a/index.html b/index.html index fba2d26a..a5b11fe7 100644 --- a/index.html +++ b/index.html @@ -227,7 +227,7 @@ - + diff --git a/js/app.js b/js/app.js index 7b86056e..3fee0dd2 100644 --- a/js/app.js +++ b/js/app.js @@ -1,6 +1,5 @@ // js/app.js -import { loadView } from "./viewManager.js"; import { nostrClient, normalizePointerInput, @@ -48,6 +47,11 @@ import { UploadModal } from "./ui/components/UploadModal.js"; import { EditModal } from "./ui/components/EditModal.js"; import { RevertModal } from "./ui/components/RevertModal.js"; import { VideoListView } from "./ui/views/VideoListView.js"; +import { MediaLoader } from "./utils/mediaLoader.js"; +import { pointerArrayToKey } from "./utils/pointer.js"; +import { fakeDecrypt } from "./utils/fakeDecrypt.js"; +import { isValidMagnetUri } from "./utils/magnetValidators.js"; +import { dedupeToNewestByRoot } from "./utils/videoDeduper.js"; import { getPubkey as getStoredPubkey, setPubkey as setStoredPubkey, @@ -73,37 +77,6 @@ import { urlHealthConstants, } from "./state/cache.js"; -function pointerArrayToKey(pointer) { - if (!Array.isArray(pointer) || pointer.length < 2) { - return ""; - } - - const type = pointer[0] === "a" ? "a" : pointer[0] === "e" ? "e" : ""; - if (!type) { - return ""; - } - - const value = - typeof pointer[1] === "string" ? pointer[1].trim().toLowerCase() : ""; - if (!value) { - return ""; - } - - const relay = - pointer.length > 2 && typeof pointer[2] === "string" - ? pointer[2].trim() - : ""; - - return relay ? `${type}:${value}:${relay}` : `${type}:${value}`; -} - -/** - * Simple "decryption" placeholder for private videos. - */ -function fakeDecrypt(str) { - return str.split("").reverse().join(""); -} - const UNSUPPORTED_BTITH_MESSAGE = "This magnet link is missing a compatible BitTorrent v1 info hash."; @@ -178,143 +151,7 @@ function isValidMagnetUri(magnet) { * This will load the real image source from `imgElement.dataset.lazy` * once the image enters the viewport. */ -class MediaLoader { - constructor(rootMargin = "50px") { - this.observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - const el = entry.target; - const lazySrc = - typeof el.dataset.lazy === "string" - ? el.dataset.lazy.trim() - : ""; - - if (lazySrc) { - const fallbackSrc = - (typeof el.dataset.fallbackSrc === "string" - ? el.dataset.fallbackSrc.trim() - : "") || - el.getAttribute("data-fallback-src") || - ""; - - const applyFallback = () => { - const resolvedFallback = - fallbackSrc && fallbackSrc.trim() - ? fallbackSrc.trim() - : FALLBACK_THUMBNAIL_SRC; - - if (!resolvedFallback) { - return; - } - - if (el.src !== resolvedFallback) { - el.src = resolvedFallback; - } - - if (!el.dataset.fallbackSrc) { - el.dataset.fallbackSrc = resolvedFallback; - } - - if (!el.getAttribute("data-fallback-src")) { - el.setAttribute("data-fallback-src", resolvedFallback); - } - - el.dataset.thumbnailFailed = "true"; - }; - - const cleanupListeners = () => { - el.removeEventListener("error", handleError); - el.removeEventListener("load", handleLoad); - }; - - const handleError = () => { - cleanupListeners(); - applyFallback(); - }; - - const handleLoad = () => { - cleanupListeners(); - if ( - (el.naturalWidth === 0 && el.naturalHeight === 0) || - !el.currentSrc - ) { - applyFallback(); - } else { - delete el.dataset.thumbnailFailed; - } - }; - - el.addEventListener("error", handleError); - el.addEventListener("load", handleLoad); - - if (fallbackSrc && lazySrc === fallbackSrc) { - cleanupListeners(); - delete el.dataset.thumbnailFailed; - } else { - el.src = lazySrc; - - if (el.complete) { - // Handle cached results synchronously - if (el.naturalWidth === 0 && el.naturalHeight === 0) { - handleError(); - } else { - handleLoad(); - } - } - } - } - - delete el.dataset.lazy; - // Stop observing once loaded - this.observer.unobserve(el); - } - } - }, - { rootMargin } - ); - } - - observe(el) { - if (!el || typeof el.dataset === "undefined") { - return; - } - - if (!el.dataset.fallbackSrc) { - const fallbackAttr = - el.getAttribute("data-fallback-src") || el.getAttribute("src") || ""; - if (fallbackAttr) { - el.dataset.fallbackSrc = fallbackAttr; - } else if (el.tagName === "IMG") { - el.dataset.fallbackSrc = FALLBACK_THUMBNAIL_SRC; - el.setAttribute("data-fallback-src", FALLBACK_THUMBNAIL_SRC); - } - } - - if ( - el.tagName === "IMG" && - typeof HTMLImageElement !== "undefined" && - "loading" in HTMLImageElement.prototype - ) { - el.loading = el.loading || "lazy"; - if ("decoding" in HTMLImageElement.prototype) { - el.decoding = el.decoding || "async"; - } - } - - const lazySrc = - typeof el.dataset.lazy === "string" ? el.dataset.lazy.trim() : ""; - if (lazySrc) { - this.observer.observe(el); - } - } - - disconnect() { - this.observer.disconnect(); - } -} - -class bitvidApp { +class Application { get pubkey() { return getStoredPubkey(); } @@ -339,15 +176,23 @@ class bitvidApp { setStoredCurrentVideo(value ?? null); } - constructor() { + constructor({ services = {}, ui = {}, helpers = {}, loadView: viewLoader } = {}) { + this.loadView = typeof viewLoader === "function" ? viewLoader : null; + // Basic auth/display elements this.loginButton = document.getElementById("loginButton") || null; this.logoutButton = document.getElementById("logoutButton") || null; this.userStatus = document.getElementById("userStatus") || null; this.userPubKey = document.getElementById("userPubKey") || null; - // Lazy-loading helper for images - this.mediaLoader = new MediaLoader(); + const mediaLoaderFactory = + typeof helpers.mediaLoaderFactory === "function" + ? helpers.mediaLoaderFactory + : () => new MediaLoader(); + this.mediaLoader = + helpers.mediaLoader instanceof MediaLoader + ? helpers.mediaLoader + : mediaLoaderFactory(); this.loadedThumbnails = new Map(); this.videoDiscussionCountCache = new Map(); this.inFlightDiscussionCounts = new Map(); @@ -373,12 +218,17 @@ class bitvidApp { this.boundVideoListShareListener = null; this.boundVideoListContextListener = null; - this.playbackService = new PlaybackService({ - logger: (message, ...args) => this.log(message, ...args), - torrentClient, - deriveTorrentPlaybackConfig, - isValidMagnetUri, - urlFirstEnabled: URL_FIRST_ENABLED, + this.nostrService = services.nostrService || nostrService; + this.r2Service = services.r2Service || r2Service; + + this.playbackService = + services.playbackService || + new PlaybackService({ + logger: (message, ...args) => this.log(message, ...args), + torrentClient, + deriveTorrentPlaybackConfig, + isValidMagnetUri, + urlFirstEnabled: URL_FIRST_ENABLED, analyticsCallbacks: { "session-start": (detail) => { const urlProvided = detail?.urlProvided ? "true" : "false"; @@ -401,15 +251,17 @@ class bitvidApp { } }, }, - }); + }); this.activePlaybackSession = null; - this.authService = new AuthService({ - nostrClient, - userBlocks, - relayManager, - logger: (message, ...args) => this.log(message, ...args), - }); + this.authService = + services.authService || + new AuthService({ + nostrClient, + userBlocks, + relayManager, + logger: (message, ...args) => this.log(message, ...args), + }); this.authEventUnsubscribes.push( this.authService.on("auth:login", (detail) => this.handleAuthLogin(detail)) ); @@ -487,22 +339,25 @@ class bitvidApp { // Upload modal component this.uploadButton = document.getElementById("uploadButton") || null; - this.r2Service = r2Service; const uploadModalEvents = new EventTarget(); - this.uploadModal = new UploadModal({ - authService: this.authService, - r2Service: this.r2Service, - publishVideoNote: (payload, options) => - this.publishVideoNote(payload, options), - removeTrackingScripts, - setGlobalModalState, - showError: (message) => this.showError(message), - showSuccess: (message) => this.showSuccess(message), - getCurrentPubkey: () => this.pubkey, - safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), - eventTarget: uploadModalEvents, - container: document.getElementById("modalContainer") || null, - }); + this.uploadModal = + (typeof ui.uploadModal === "function" + ? ui.uploadModal({ app: this, eventTarget: uploadModalEvents }) + : ui.uploadModal) || + new UploadModal({ + authService: this.authService, + r2Service: this.r2Service, + publishVideoNote: (payload, options) => + this.publishVideoNote(payload, options), + removeTrackingScripts, + setGlobalModalState, + showError: (message) => this.showError(message), + showSuccess: (message) => this.showSuccess(message), + getCurrentPubkey: () => this.pubkey, + safeEncodeNpub: (pubkey) => this.safeEncodeNpub(pubkey), + eventTarget: uploadModalEvents, + container: document.getElementById("modalContainer") || null, + }); this.boundUploadSubmitHandler = (event) => { this.handleUploadSubmitEvent(event); }; @@ -512,21 +367,25 @@ class bitvidApp { ); const editModalEvents = new EventTarget(); - this.editModal = new EditModal({ - removeTrackingScripts, - setGlobalModalState, - showError: (message) => this.showError(message), - getMode: () => (isDevMode ? "dev" : "live"), - sanitizers: { - text: (value) => (typeof value === "string" ? value.trim() : ""), - url: (value) => (typeof value === "string" ? value.trim() : ""), - magnet: (value) => (typeof value === "string" ? value.trim() : ""), - checkbox: (value) => !!value, - }, - escapeHtml: (value) => escapeHtml(value), - eventTarget: editModalEvents, - container: document.getElementById("modalContainer") || null, - }); + this.editModal = + (typeof ui.editModal === "function" + ? ui.editModal({ app: this, eventTarget: editModalEvents }) + : ui.editModal) || + new EditModal({ + removeTrackingScripts, + setGlobalModalState, + showError: (message) => this.showError(message), + getMode: () => (isDevMode ? "dev" : "live"), + sanitizers: { + text: (value) => (typeof value === "string" ? value.trim() : ""), + url: (value) => (typeof value === "string" ? value.trim() : ""), + magnet: (value) => (typeof value === "string" ? value.trim() : ""), + checkbox: (value) => !!value, + }, + escapeHtml: (value) => escapeHtml(value), + eventTarget: editModalEvents, + container: document.getElementById("modalContainer") || null, + }); this.boundEditModalSubmitHandler = (event) => { this.handleEditModalSubmit(event); @@ -544,17 +403,21 @@ class bitvidApp { this.boundEditModalCancelHandler ); - this.revertModal = new RevertModal({ - removeTrackingScripts, - setGlobalModalState, - formatAbsoluteTimestamp: (timestamp) => - this.formatAbsoluteTimestamp(timestamp), - formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp), - escapeHTML: (value) => this.escapeHTML(value), - truncateMiddle, - fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, - container: document.getElementById("modalContainer") || null, - }); + this.revertModal = + (typeof ui.revertModal === "function" + ? ui.revertModal({ app: this }) + : ui.revertModal) || + new RevertModal({ + removeTrackingScripts, + setGlobalModalState, + formatAbsoluteTimestamp: (timestamp) => + this.formatAbsoluteTimestamp(timestamp), + formatTimeAgo: (timestamp) => this.formatTimeAgo(timestamp), + escapeHTML: (value) => this.escapeHTML(value), + truncateMiddle, + fallbackThumbnailSrc: FALLBACK_THUMBNAIL_SRC, + container: document.getElementById("modalContainer") || null, + }); this.boundRevertConfirmHandler = (event) => { this.handleRevertModalConfirm(event); @@ -574,14 +437,18 @@ class bitvidApp { this.cleanupPromise = null; - this.videoModal = new VideoModal({ - removeTrackingScripts, - setGlobalModalState, - document, - logger: { - log: (message, ...args) => this.log(message, ...args), - }, - }); + this.videoModal = + (typeof ui.videoModal === "function" + ? ui.videoModal({ app: this }) + : ui.videoModal) || + new VideoModal({ + removeTrackingScripts, + setGlobalModalState, + document, + logger: { + log: (message, ...args) => this.log(message, ...args), + }, + }); this.boundVideoModalCloseHandler = () => { this.hideModal(); }; @@ -636,13 +503,13 @@ class bitvidApp { this.currentVideoPointerKey = null; this.playbackTelemetryState = null; this.loggedViewPointerKeys = new Set(); - this.videoSubscription = nostrService.getVideoSubscription() || null; + this.videoSubscription = this.nostrService.getVideoSubscription() || null; this.videoList = null; this.modalViewCountUnsub = null; // Videos stored as a Map (key=event.id) - this.videosMap = nostrService.getVideosMap(); - this.unsubscribeFromNostrService = nostrService.on( + this.videosMap = this.nostrService.getVideosMap(); + this.unsubscribeFromNostrService = this.nostrService.on( "subscription:changed", ({ subscription }) => { this.videoSubscription = subscription || null; @@ -684,7 +551,7 @@ class bitvidApp { }, }); - this.videoListView = new VideoListView({ + const videoListViewConfig = { document, mediaLoader: this.mediaLoader, badgeHelpers: { attachHealthBadges, attachUrlHealthBadges }, @@ -729,7 +596,12 @@ class bitvidApp { renderers: { getLoadingMarkup: (message) => getSidebarLoadingMarkup(message), }, - }); + }; + this.videoListView = + (typeof ui.videoListView === "function" + ? ui.videoListView({ app: this, config: videoListViewConfig }) + : ui.videoListView) || + new VideoListView(videoListViewConfig); this.videoListViewPlaybackHandler = ({ videoId, url, magnet }) => { if (videoId) { @@ -833,7 +705,7 @@ class bitvidApp { } } catch (err) { console.error( - "[bitvidApp] Invalid nevent in blacklist:", + "[Application] Invalid nevent in blacklist:", neventStr, err ); @@ -933,6 +805,11 @@ class bitvidApp { async init() { try { + if (typeof this.loadView !== "function") { + const module = await import("./viewManager.js"); + this.loadView = module?.loadView || null; + } + // Force update of any registered service workers to ensure latest code is used. if ("serviceWorker" in navigator) { navigator.serviceWorker.getRegistrations().then((registrations) => { @@ -1028,7 +905,9 @@ class bitvidApp { console.log( "[app.init()] No #view= in the URL, loading default home view" ); - await loadView("views/most-recent-videos.html"); + if (typeof this.loadView === "function") { + await this.loadView("views/most-recent-videos.html"); + } } else { console.log( "[app.init()] Found hash:", @@ -1636,7 +1515,7 @@ class bitvidApp { ) { return nostrClient.sessionActor.pubkey; } - return window.app?.pubkey || undefined; + return this.pubkey || undefined; }, }); } @@ -3661,7 +3540,7 @@ class bitvidApp { } try { - await nostrService.publishVideoNote(formData, this.pubkey); + await this.nostrService.publishVideoNote(formData, this.pubkey); if (typeof onSuccess === "function") { await onSuccess(); } @@ -3959,8 +3838,8 @@ class bitvidApp { } if (!preserveSubscriptions) { - nostrService.clearVideoSubscription(); - this.videoSubscription = nostrService.getVideoSubscription() || null; + this.nostrService.clearVideoSubscription(); + this.videoSubscription = this.nostrService.getVideoSubscription() || null; } // If there's a small inline player @@ -5028,7 +4907,7 @@ class bitvidApp { container.innerHTML = getSidebarLoadingMarkup("Fetching recent videos…"); } - const videos = await nostrService.loadVideos({ + const videos = await this.nostrService.loadVideos({ forceFetch, blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), @@ -5039,15 +4918,15 @@ class bitvidApp { this.renderVideoList([]); } - this.videoSubscription = nostrService.getVideoSubscription() || null; - this.videosMap = nostrService.getVideosMap(); + this.videoSubscription = this.nostrService.getVideoSubscription() || null; + this.videosMap = this.nostrService.getVideosMap(); if (this.videoListView) { this.videoListView.state.videosMap = this.videosMap; } } async loadOlderVideos(lastTimestamp) { - const olderVideos = await nostrService.loadOlderVideos(lastTimestamp, { + const olderVideos = await this.nostrService.loadOlderVideos(lastTimestamp, { blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), }); @@ -5057,7 +4936,7 @@ class bitvidApp { return; } - const all = nostrService.getFilteredActiveVideos({ + const all = this.nostrService.getFilteredActiveVideos({ blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), }); @@ -6147,7 +6026,7 @@ class bitvidApp { } try { - await nostrService.handleEditVideoSubmit({ + await this.nostrService.handleEditVideoSubmit({ originalEvent, updatedData, pubkey: this.pubkey, @@ -6166,7 +6045,7 @@ class bitvidApp { async handleEditVideo(target) { try { const normalizedTarget = this.normalizeActionTarget(target); - const latestVideos = await nostrService.fetchVideos({ + const latestVideos = await this.nostrService.fetchVideos({ blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), }); @@ -6210,7 +6089,7 @@ class bitvidApp { async handleRevertVideo(target) { try { const normalizedTarget = this.normalizeActionTarget(target); - const activeVideos = await nostrService.fetchVideos({ + const activeVideos = await this.nostrService.fetchVideos({ blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), }); @@ -6306,7 +6185,7 @@ class bitvidApp { async handleFullDeleteVideo(target) { try { const normalizedTarget = this.normalizeActionTarget(target); - const all = nostrService.getFilteredActiveVideos({ + const all = this.nostrService.getFilteredActiveVideos({ blacklistedEventIds: this.blacklistedEventIds, isAuthorBlocked: (pubkey) => this.isAuthorBlocked(pubkey), }); @@ -6337,7 +6216,7 @@ class bitvidApp { // We assume video.videoRootId is not empty, or fallback to video.id if needed const rootId = video.videoRootId || video.id; - await nostrService.handleFullDeleteVideo({ + await this.nostrService.handleFullDeleteVideo({ videoRootId: rootId, pubkey: this.pubkey, confirm: false, @@ -7233,7 +7112,7 @@ class bitvidApp { * this.videosMap or from a bulk fetch. Uses nostrClient.getEventById. */ async getOldEventById(eventId) { - return nostrService.getOldEventById(eventId); + return this.nostrService.getOldEventById(eventId); } /** @@ -7319,7 +7198,7 @@ class bitvidApp { try { this.unsubscribeFromPubkeyState(); } catch (error) { - console.warn("[bitvidApp] Failed to unsubscribe pubkey state:", error); + console.warn("[Application] Failed to unsubscribe pubkey state:", error); } this.unsubscribeFromPubkeyState = null; } @@ -7329,7 +7208,7 @@ class bitvidApp { this.unsubscribeFromCurrentUserState(); } catch (error) { console.warn( - "[bitvidApp] Failed to unsubscribe current user state:", + "[Application] Failed to unsubscribe current user state:", error ); } @@ -7341,7 +7220,7 @@ class bitvidApp { this.watchHistoryPreferenceUnsubscribe(); } catch (error) { console.warn( - "[bitvidApp] Failed to unsubscribe watch history preference:", + "[Application] Failed to unsubscribe watch history preference:", error ); } @@ -7353,7 +7232,7 @@ class bitvidApp { try { unsubscribe(); } catch (error) { - console.warn("[bitvidApp] Auth listener unsubscribe failed:", error); + console.warn("[Application] Auth listener unsubscribe failed:", error); } } }); @@ -7363,7 +7242,7 @@ class bitvidApp { try { this.unsubscribeFromNostrService(); } catch (error) { - console.warn("[bitvidApp] Failed to unsubscribe nostr service:", error); + console.warn("[Application] Failed to unsubscribe nostr service:", error); } this.unsubscribeFromNostrService = null; } @@ -7379,7 +7258,7 @@ class bitvidApp { try { this.uploadModal.destroy(); } catch (error) { - console.warn("[bitvidApp] Failed to destroy upload modal:", error); + console.warn("[Application] Failed to destroy upload modal:", error); } } @@ -7402,7 +7281,7 @@ class bitvidApp { try { this.editModal.destroy(); } catch (error) { - console.warn("[bitvidApp] Failed to destroy edit modal:", error); + console.warn("[Application] Failed to destroy edit modal:", error); } } } @@ -7418,7 +7297,7 @@ class bitvidApp { try { this.revertModal.destroy(); } catch (error) { - console.warn("[bitvidApp] Failed to destroy revert modal:", error); + console.warn("[Application] Failed to destroy revert modal:", error); } } @@ -7462,7 +7341,7 @@ class bitvidApp { try { this.videoModal.destroy(); } catch (error) { - console.warn("[bitvidApp] Failed to destroy video modal:", error); + console.warn("[Application] Failed to destroy video modal:", error); } } } @@ -7495,7 +7374,7 @@ class bitvidApp { try { this.videoListView.destroy(); } catch (error) { - console.warn("[bitvidApp] Failed to destroy VideoListView:", error); + console.warn("[Application] Failed to destroy VideoListView:", error); } } @@ -7552,30 +7431,5 @@ class bitvidApp { * return only the newest (by created_at) for each videoRootId. * If no videoRootId is present, treat the video’s own ID as its root. */ -function dedupeToNewestByRoot(videos) { - const map = new Map(); // key = rootId, value = newest video for that root - - for (const vid of videos) { - // If there's no videoRootId, fall back to vid.id (treat it as its own "root") - const rootId = vid.videoRootId || vid.id; - - const existing = map.get(rootId); - if (!existing || vid.created_at > existing.created_at) { - map.set(rootId, vid); - } - } - - // Return just the newest from each group - return Array.from(map.values()); -} - -export const app = new bitvidApp(); -export const appReady = app.init(); - -if (typeof window !== "undefined") { - window.app = app; - window.appReady = appReady; - window.bitvid = window.bitvid || {}; - window.bitvid.app = app; - window.bitvid.appReady = appReady; -} +export { Application }; +export default Application; diff --git a/js/applicationContext.js b/js/applicationContext.js new file mode 100644 index 00000000..89952d53 --- /dev/null +++ b/js/applicationContext.js @@ -0,0 +1,24 @@ +// js/applicationContext.js + +let applicationInstance = null; +let readyPromise = Promise.resolve(); + +export function setApplication(app) { + applicationInstance = app || null; +} + +export function getApplication() { + return applicationInstance; +} + +export function setApplicationReady(promise) { + if (promise && typeof promise.then === "function") { + readyPromise = promise; + } else { + readyPromise = Promise.resolve(promise); + } +} + +export function getApplicationReady() { + return readyPromise; +} diff --git a/js/bootstrap.js b/js/bootstrap.js new file mode 100644 index 00000000..ad482b6f --- /dev/null +++ b/js/bootstrap.js @@ -0,0 +1,23 @@ +// js/bootstrap.js + +import Application from "./app.js"; +import nostrService from "./services/nostrService.js"; +import r2Service from "./services/r2Service.js"; +import { loadView } from "./viewManager.js"; +import { setApplication, setApplicationReady } from "./applicationContext.js"; + +const app = new Application({ + services: { + nostrService, + r2Service, + }, + loadView, +}); + +setApplication(app); + +const appReady = app.init(); +setApplicationReady(appReady); + +export { app, appReady }; +export default app; diff --git a/js/channelProfile.js b/js/channelProfile.js index 38af27e7..ae5b8ba1 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4,12 +4,14 @@ import { nostrClient, convertEventToVideo as sharedConvertEventToVideo, } from "./nostr.js"; -import { app } from "./app.js"; import { subscriptions } from "./subscriptions.js"; // <-- NEW import import { attachHealthBadges } from "./gridHealth.js"; import { attachUrlHealthBadges } from "./urlHealthObserver.js"; import { accessControl } from "./accessControl.js"; import { escapeHTML } from "./utils/domUtils.js"; +import { getApplication } from "./applicationContext.js"; + +const getApp = () => getApplication(); let currentChannelHex = null; let currentChannelNpub = null; @@ -70,7 +72,9 @@ function buildChannelShareUrl() { return ""; } - const base = typeof app.getShareUrlBase === "function" ? app.getShareUrlBase() : ""; + const app = getApp(); + const base = + typeof app?.getShareUrlBase === "function" ? app.getShareUrlBase() : ""; if (base) { return `${base}#view=channel-profile&npub=${encodeURIComponent(currentChannelNpub)}`; } @@ -109,6 +113,8 @@ function setupChannelShareButton() { return; } + const app = getApp(); + syncChannelShareButtonState(); if (shareBtn.dataset.initialized === "true") { @@ -118,14 +124,14 @@ function setupChannelShareButton() { shareBtn.addEventListener("click", () => { const shareUrl = buildChannelShareUrl(); if (!shareUrl) { - app.showError("Could not generate channel link."); + app?.showError?.("Could not generate channel link."); return; } navigator.clipboard .writeText(shareUrl) - .then(() => app.showSuccess("Channel link copied to clipboard!")) - .catch(() => app.showError("Failed to copy the link.")); + .then(() => app?.showSuccess?.("Channel link copied to clipboard!")) + .catch(() => app?.showError?.("Failed to copy the link.")); }); shareBtn.dataset.initialized = "true"; @@ -136,7 +142,8 @@ function setupChannelMoreMenu() { if (!container) { return; } - app.attachMoreMenuHandlers(container); + const app = getApp(); + app?.attachMoreMenuHandlers?.(container); } async function updateChannelMenuState() { @@ -145,6 +152,8 @@ async function updateChannelMenuState() { return; } + const app = getApp(); + try { await accessControl.ensureReady(); } catch (error) { @@ -152,7 +161,7 @@ async function updateChannelMenuState() { } const canBlacklist = - typeof app.canCurrentUserManageBlacklist === "function" + typeof app?.canCurrentUserManageBlacklist === "function" ? app.canCurrentUserManageBlacklist() : false; @@ -244,12 +253,14 @@ export async function initChannelProfileView() { currentChannelHex = hexPub; currentChannelNpub = npub; + const app = getApp(); + setupChannelShareButton(); setupChannelMoreMenu(); await updateChannelMenuState(); // 3) If user is logged in, load subscriptions and show sub/unsub button - if (app.pubkey) { + if (app?.pubkey) { await subscriptions.loadSubscriptions(app.pubkey); renderSubscribeButton(hexPub); } else { @@ -295,6 +306,8 @@ function renderSubscribeButton(channelHex) { const container = document.getElementById("subscribeBtnArea"); if (!container) return; + const app = getApp(); + container.classList.remove("hidden"); const alreadySubscribed = subscriptions.isSubscribed(channelHex); @@ -319,7 +332,7 @@ function renderSubscribeButton(channelHex) { const toggleBtn = document.getElementById("subscribeToggleBtn"); if (toggleBtn) { toggleBtn.addEventListener("click", async () => { - if (!app.pubkey) { + if (!app?.pubkey) { console.error("Not logged in => cannot subscribe/unsubscribe."); return; } @@ -424,6 +437,7 @@ async function loadUserProfile(pubkey) { * Filters out older overshadowed notes, blacklisted, etc. */ async function loadUserVideos(pubkey) { + const app = getApp(); try { try { await accessControl.ensureReady(); @@ -502,7 +516,7 @@ async function loadUserVideos(pubkey) { // Ensure the global videos map is kept up to date so delegated handlers // have the freshest metadata for this event. - window.app?.videosMap?.set(video.id, video); + app?.videosMap?.set(video.id, video); // Check if user can edit const canEdit = video.pubkey === app.pubkey; @@ -791,17 +805,17 @@ async function loadUserVideos(pubkey) { attachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { - const video = app.videosMap.get(eventId) || { id: eventId }; - app.handleUrlHealthBadge({ video, url, badgeEl }); + const video = app?.videosMap?.get(eventId) || { id: eventId }; + app?.handleUrlHealthBadge?.({ video, url, badgeEl }); }); - window.app.mountVideoListView(); + app?.mountVideoListView?.(); // Lazy-load images const lazyEls = container.querySelectorAll("[data-lazy]"); - lazyEls.forEach((el) => app.mediaLoader.observe(el)); + lazyEls.forEach((el) => app?.mediaLoader?.observe?.(el)); - app.attachMoreMenuHandlers(container); + app?.attachMoreMenuHandlers?.(container); // Gear menu toggles const gearButtons = container.querySelectorAll("[data-settings-dropdown]"); @@ -826,7 +840,7 @@ async function loadUserVideos(pubkey) { const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-edit-event-id") || ""; - app.handleEditVideo({ + app?.handleEditVideo?.({ eventId, index: Number.isNaN(idx) ? null : idx, }); @@ -843,7 +857,7 @@ async function loadUserVideos(pubkey) { const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-revert-event-id") || ""; - app.handleRevertVideo({ + app?.handleRevertVideo?.({ eventId, index: Number.isNaN(idx) ? null : idx, }); @@ -860,7 +874,7 @@ async function loadUserVideos(pubkey) { const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-delete-all-event-id") || ""; - app.handleFullDeleteVideo({ + app?.handleFullDeleteVideo?.({ eventId, index: Number.isNaN(idx) ? null : idx, }); diff --git a/js/historyView.js b/js/historyView.js index b8f25c0c..ac567410 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -12,6 +12,7 @@ import { WATCH_HISTORY_BATCH_RESOLVE, WATCH_HISTORY_BATCH_PAGE_SIZE, } from "./config.js"; +import { getApplication } from "./applicationContext.js"; export const WATCH_HISTORY_EMPTY_COPY = "Your watch history is empty. Watch some videos to populate this list."; @@ -79,16 +80,7 @@ function setTextContent(element, text) { } function getAppInstance() { - if (typeof window === "undefined") { - return null; - } - if (window.app) { - return window.app; - } - if (window.bitvid && window.bitvid.app) { - return window.bitvid.app; - } - return null; + return getApplication(); } function safeLocaleDate(date) { diff --git a/js/index.js b/js/index.js index 7f623e7b..d917942d 100644 --- a/js/index.js +++ b/js/index.js @@ -2,6 +2,7 @@ import "./bufferPolyfill.js"; import { trackPageView } from "./analytics.js"; +import { appReady } from "./bootstrap.js"; const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in"; const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in"; @@ -367,7 +368,7 @@ function handleQueryParams() { } async function waitForAppInitialization() { - const maybePromise = window.appReady; + const maybePromise = appReady; if (!maybePromise || typeof maybePromise.then !== "function") { return; diff --git a/js/subscriptions.js b/js/subscriptions.js index d460e232..1486e8b4 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -13,6 +13,9 @@ import { publishEventToRelays, assertAnyRelayAccepted, } from "./nostrPublish.js"; +import { getApplication } from "./applicationContext.js"; + +const getApp = () => getApplication(); let attachHealthBadgesLoader = null; let attachHealthBadgesErrorLogged = false; @@ -64,8 +67,9 @@ function getAbsoluteShareUrl(nevent) { return ""; } - if (window.app?.buildShareUrlFromNevent) { - const candidate = window.app.buildShareUrlFromNevent(nevent); + const app = getApp(); + if (app?.buildShareUrlFromNevent) { + const candidate = app.buildShareUrlFromNevent(nevent); if (candidate) { return candidate; } @@ -364,9 +368,11 @@ class SubscriptionsManager { const container = document.getElementById(containerId); if (!container) return; + const app = getApp(); + const safeVideos = Array.isArray(videos) ? videos : []; const dedupedVideos = - window.app?.dedupeVideosByRoot?.(safeVideos) ?? + app?.dedupeVideosByRoot?.(safeVideos) ?? this.dedupeToNewestByRoot(safeVideos); const filteredVideos = dedupedVideos.filter((video) => { @@ -374,10 +380,7 @@ class SubscriptionsManager { return false; } - if ( - window.app?.isAuthorBlocked && - window.app.isAuthorBlocked(video.pubkey) - ) { + if (app?.isAuthorBlocked && app.isAuthorBlocked(video.pubkey)) { return false; } @@ -408,26 +411,26 @@ class SubscriptionsManager { // Keep the global videos map up to date so delegated playback handlers // can reuse the already fetched metadata for this event. - window.app?.videosMap?.set(video.id, video); + app?.videosMap?.set(video.id, video); localAuthorSet.add(video.pubkey); const nevent = window.NostrTools.nip19.neventEncode({ id: video.id }); const shareUrl = getAbsoluteShareUrl(nevent); - const canEdit = window.app?.pubkey === video.pubkey; + const canEdit = app?.pubkey === video.pubkey; const highlightClass = video.isPrivate && canEdit ? "border-2 border-yellow-500" : "border-none"; - const timeAgo = window.app?.formatTimeAgo - ? window.app.formatTimeAgo(video.created_at) + const timeAgo = app?.formatTimeAgo + ? app.formatTimeAgo(video.created_at) : new Date(video.created_at * 1000).toLocaleString(); let hasOlder = false; - if (canEdit && video.videoRootId && window.app?.hasOlderVersion) { - hasOlder = window.app.hasOlderVersion(video, fullAllEventsArray); + if (canEdit && video.videoRootId && app?.hasOlderVersion) { + hasOlder = app.hasOlderVersion(video, fullAllEventsArray); } const revertButton = hasOlder @@ -524,8 +527,8 @@ class SubscriptionsManager { `; - const safeTitle = window.app?.escapeHTML(video.title) || "Untitled"; - const safeThumb = window.app?.escapeHTML(video.thumbnail) || ""; + const safeTitle = app?.escapeHTML?.(video.title) || "Untitled"; + const safeThumb = app?.escapeHTML?.(video.thumbnail) || ""; const playbackUrl = typeof video.url === "string" ? video.url : ""; const trimmedUrl = playbackUrl ? playbackUrl.trim() : ""; @@ -537,14 +540,14 @@ class SubscriptionsManager { const playbackMagnet = magnetCandidate; const magnetProvided = magnetCandidate.length > 0; const magnetSupported = - window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false; + app?.isMagnetUriSupported?.(magnetCandidate) ?? false; const urlBadgeHtml = trimmedUrl - ? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ?? - "" + ? app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ?? + "" : ""; const torrentHealthBadgeHtml = magnetProvided && magnetSupported - ? window.app?.getTorrentHealthBadgeMarkup?.({ + ? app?.getTorrentHealthBadgeMarkup?.({ includeMargin: false, }) ?? "" : ""; @@ -696,21 +699,20 @@ class SubscriptionsManager { container.appendChild(fragment); scheduleAttachHealthBadges(container); attachUrlHealthBadges(container, ({ badgeEl, url, eventId }) => { - if (!window.app?.handleUrlHealthBadge) { + if (!app?.handleUrlHealthBadge) { return; } - const video = - window.app.videosMap?.get?.(eventId) || { id: eventId }; - window.app.handleUrlHealthBadge({ video, url, badgeEl }); + const video = app.videosMap?.get?.(eventId) || { id: eventId }; + app.handleUrlHealthBadge({ video, url, badgeEl }); }); - window.app?.mountVideoListView?.(); + app?.mountVideoListView?.(); // Lazy-load const lazyEls = container.querySelectorAll("[data-lazy]"); - lazyEls.forEach((el) => window.app?.mediaLoader.observe(el)); + lazyEls.forEach((el) => app?.mediaLoader?.observe?.(el)); - window.app?.attachMoreMenuHandlers?.(container); + app?.attachMoreMenuHandlers?.(container); // Gear menus const gearButtons = container.querySelectorAll("[data-settings-dropdown]"); @@ -733,7 +735,7 @@ class SubscriptionsManager { const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-edit-event-id") || ""; - window.app?.handleEditVideo({ + app?.handleEditVideo?.({ eventId, index: Number.isNaN(idx) ? null : idx, }); @@ -750,7 +752,7 @@ class SubscriptionsManager { const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`); if (dropdown) dropdown.classList.add("hidden"); const eventId = btn.getAttribute("data-revert-event-id") || ""; - window.app?.handleRevertVideo({ + app?.handleRevertVideo?.({ eventId, index: Number.isNaN(idx) ? null : idx, }); @@ -769,7 +771,7 @@ class SubscriptionsManager { const dd = document.getElementById(`settingsDropdown-${idxAttr}`); if (dd) dd.classList.add("hidden"); const eventId = btn.getAttribute("data-delete-all-event-id") || ""; - window.app?.handleFullDeleteVideo({ + app?.handleFullDeleteVideo?.({ eventId, index: Number.isNaN(idx) ? null : idx, }); @@ -789,8 +791,8 @@ class SubscriptionsManager { localAuthorSet.add(nameEl.getAttribute("data-pubkey")); }); - if (window.app?.batchFetchProfiles && localAuthorSet.size > 0) { - window.app.batchFetchProfiles(localAuthorSet); + if (app?.batchFetchProfiles && localAuthorSet.size > 0) { + app.batchFetchProfiles(localAuthorSet); } // Make author name/pic clickable => open channel @@ -800,7 +802,7 @@ class SubscriptionsManager { ev.preventDefault(); ev.stopPropagation(); const pubkey = pic.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); + app?.goToProfile?.(pubkey); }); }); @@ -810,7 +812,7 @@ class SubscriptionsManager { ev.preventDefault(); ev.stopPropagation(); const pubkey = nameEl.getAttribute("data-pubkey"); - window.app?.goToProfile(pubkey); + app?.goToProfile?.(pubkey); }); }); } diff --git a/js/utils/fakeDecrypt.js b/js/utils/fakeDecrypt.js new file mode 100644 index 00000000..656170c8 --- /dev/null +++ b/js/utils/fakeDecrypt.js @@ -0,0 +1,13 @@ +// js/utils/fakeDecrypt.js + +/** + * Simple "decryption" placeholder for private videos. + */ +export function fakeDecrypt(str) { + if (typeof str !== "string") { + return ""; + } + return str.split("").reverse().join(""); +} + +export default fakeDecrypt; diff --git a/js/utils/magnetValidators.js b/js/utils/magnetValidators.js new file mode 100644 index 00000000..123c5706 --- /dev/null +++ b/js/utils/magnetValidators.js @@ -0,0 +1,59 @@ +// js/utils/magnetValidators.js + +import { safeDecodeMagnet } from "../magnetUtils.js"; + +/** + * Basic validation for BitTorrent magnet URIs. + * + * Returns `true` only when the value looks like a magnet link that WebTorrent + * understands (`magnet:` scheme with at least one `xt=urn:btih:` + * entry, where `` is either a 40-character hex digest or a + * 32-character base32 digest). Magnets that only contain BitTorrent v2 hashes + * (e.g. `btmh`) are treated as unsupported. + */ +export function isValidMagnetUri(magnet) { + const trimmed = typeof magnet === "string" ? magnet.trim() : ""; + if (!trimmed) { + return false; + } + + const decoded = safeDecodeMagnet(trimmed); + const candidate = decoded || trimmed; + + if (/^[0-9a-f]{40}$/i.test(candidate)) { + return true; + } + + try { + const parsed = new URL(candidate); + if (parsed.protocol.toLowerCase() !== "magnet:") { + return false; + } + + const xtValues = parsed.searchParams.getAll("xt"); + if (!xtValues.length) { + return false; + } + + const hexPattern = /^[0-9a-f]{40}$/i; + const base32Pattern = /^[A-Z2-7]{32}$/; + + return xtValues.some((value) => { + if (typeof value !== "string") return false; + const match = value.trim().match(/^urn:btih:([a-z0-9]+)$/i); + if (!match) return false; + + const infoHash = match[1]; + if (hexPattern.test(infoHash)) { + return true; + } + + const upperHash = infoHash.toUpperCase(); + return infoHash.length === 32 && base32Pattern.test(upperHash); + }); + } catch (err) { + return false; + } +} + +export default isValidMagnetUri; diff --git a/js/utils/mediaLoader.js b/js/utils/mediaLoader.js new file mode 100644 index 00000000..b2d65181 --- /dev/null +++ b/js/utils/mediaLoader.js @@ -0,0 +1,82 @@ +// js/utils/mediaLoader.js + +/** + * Simple IntersectionObserver-based lazy loader for images (or videos). + * + * Usage: + * const mediaLoader = new MediaLoader(); + * mediaLoader.observe(imgElement); + * + * This will load the real image source from `imgElement.dataset.lazy` + * once the image enters the viewport. + */ +export class MediaLoader { + constructor(rootMargin = "50px") { + this.observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + const el = entry.target; + const lazySrc = + typeof el.dataset.lazy === "string" ? el.dataset.lazy.trim() : ""; + + if (!lazySrc) { + continue; + } + + const fallbackSrc = + (typeof el.dataset.fallbackSrc === "string" + ? el.dataset.fallbackSrc.trim() + : "") || + el.getAttribute("data-fallback-src") || + ""; + + if (el.tagName === "IMG" || el.tagName === "IMAGE") { + el.src = lazySrc; + if (fallbackSrc) { + el.onerror = () => { + el.src = fallbackSrc; + }; + } + } else if (el.tagName === "VIDEO") { + el.src = lazySrc; + if (fallbackSrc) { + el.poster = fallbackSrc; + } + } else { + el.style.backgroundImage = `url('${lazySrc}')`; + } + + el.dataset.thumbnailLoaded = "true"; + this.observer.unobserve(el); + } + }, { + rootMargin, + threshold: 0.01, + }); + } + + observe(el) { + if (!el || typeof el !== "object") { + return; + } + + if ("decoding" in HTMLImageElement.prototype) { + el.decoding = el.decoding || "async"; + } + + const lazySrc = + typeof el.dataset.lazy === "string" ? el.dataset.lazy.trim() : ""; + if (lazySrc) { + this.observer.observe(el); + } + } + + disconnect() { + this.observer.disconnect(); + } +} + +export default MediaLoader; diff --git a/js/utils/pointer.js b/js/utils/pointer.js new file mode 100644 index 00000000..a0dd1211 --- /dev/null +++ b/js/utils/pointer.js @@ -0,0 +1,27 @@ +// js/utils/pointer.js + +export function pointerArrayToKey(pointer) { + if (!Array.isArray(pointer) || pointer.length < 2) { + return ""; + } + + const type = pointer[0] === "a" ? "a" : pointer[0] === "e" ? "e" : ""; + if (!type) { + return ""; + } + + const value = + typeof pointer[1] === "string" ? pointer[1].trim().toLowerCase() : ""; + if (!value) { + return ""; + } + + const relay = + pointer.length > 2 && typeof pointer[2] === "string" + ? pointer[2].trim() + : ""; + + return relay ? `${type}:${value}:${relay}` : `${type}:${value}`; +} + +export default pointerArrayToKey; diff --git a/js/utils/videoDeduper.js b/js/utils/videoDeduper.js new file mode 100644 index 00000000..76575c42 --- /dev/null +++ b/js/utils/videoDeduper.js @@ -0,0 +1,29 @@ +// js/utils/videoDeduper.js + +/** + * Given an array of video objects, return only the newest (by created_at) for + * each videoRootId. If no videoRootId is present, treat the video’s own ID as + * its root. + */ +export function dedupeToNewestByRoot(videos) { + const map = new Map(); + + for (const vid of videos || []) { + if (!vid) { + continue; + } + const rootId = vid.videoRootId || vid.id; + if (!rootId) { + continue; + } + + const existing = map.get(rootId); + if (!existing || vid.created_at > existing.created_at) { + map.set(rootId, vid); + } + } + + return Array.from(map.values()); +} + +export default dedupeToNewestByRoot; diff --git a/js/viewManager.js b/js/viewManager.js index 01ad725b..2aa3ec89 100644 --- a/js/viewManager.js +++ b/js/viewManager.js @@ -1,6 +1,7 @@ // js/viewManager.js import { initChannelProfileView } from "./channelProfile.js"; import { subscriptions } from "./subscriptions.js"; +import { getApplication } from "./applicationContext.js"; const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; @@ -9,8 +10,9 @@ const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/; */ export async function loadView(viewUrl) { try { - if (window.app && typeof window.app.prepareForViewLoad === "function") { - window.app.prepareForViewLoad(); + const app = getApplication(); + if (app && typeof app.prepareForViewLoad === "function") { + app.prepareForViewLoad(); } const res = await fetch(viewUrl); @@ -52,15 +54,17 @@ export async function loadView(viewUrl) { */ export const viewInitRegistry = { "most-recent-videos": () => { - if (window.app && window.app.loadVideos) { - if (typeof window.app.mountVideoListView === "function") { - window.app.mountVideoListView(); + const app = getApplication(); + if (app && typeof app.loadVideos === "function") { + if (typeof app.mountVideoListView === "function") { + app.mountVideoListView(); } - window.app.loadVideos(); + app.loadVideos(); } // Force profile updates after the new view is in place. - if (window.app && window.app.forceRefreshAllProfiles) { - window.app.forceRefreshAllProfiles(); + const refreshApp = getApplication(); + if (refreshApp && typeof refreshApp.forceRefreshAllProfiles === "function") { + refreshApp.forceRefreshAllProfiles(); } }, explore: () => { @@ -85,7 +89,8 @@ export const viewInitRegistry = { subscriptions: async () => { console.log("Subscriptions view loaded."); - if (!window.app.pubkey) { + const app = getApplication(); + if (!app?.pubkey) { const container = document.getElementById("subscriptionsVideoList"); if (container) { container.innerHTML = @@ -96,7 +101,7 @@ export const viewInitRegistry = { // If user is logged in, let the SubscriptionsManager do everything: await subscriptions.showSubscriptionVideos( - window.app.pubkey, + app.pubkey, "subscriptionsVideoList" ); }, diff --git a/js/watchHistoryService.js b/js/watchHistoryService.js index 94002c05..408a6a83 100644 --- a/js/watchHistoryService.js +++ b/js/watchHistoryService.js @@ -15,6 +15,7 @@ import { getWatchHistoryV2Enabled, } from "./constants.js"; import { isDevMode } from "./config.js"; +import { getApplication } from "./applicationContext.js"; const SESSION_STORAGE_KEY = "bitvid:watch-history:session:v1"; const SESSION_STORAGE_VERSION = 1; @@ -60,7 +61,7 @@ function getLoggedInActorKey() { if (typeof window !== "undefined") { const appCandidate = - (window.bitvid && window.bitvid.app) || window.app || null; + getApplication() || null; if (appCandidate && typeof appCandidate === "object") { if (typeof appCandidate.normalizeHexPubkey === "function") { try { diff --git a/tests/watch-history.test.mjs b/tests/watch-history.test.mjs index b9fae06a..2c537da5 100644 --- a/tests/watch-history.test.mjs +++ b/tests/watch-history.test.mjs @@ -14,6 +14,9 @@ const { chunkWatchHistoryPayloadItems, } = await import("../js/nostr.js"); const { watchHistoryService } = await import("../js/watchHistoryService.js"); +const { getApplication, setApplication } = await import( + "../js/applicationContext.js" +); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -1154,7 +1157,7 @@ async function testWatchHistoryAppLoginFallback() { const actor = "f".repeat(64); const originalPub = nostrClient.pubkey; const originalSession = nostrClient.sessionActor; - const originalApp = typeof window.app === "undefined" ? undefined : window.app; + const originalApp = getApplication(); try { setWatchHistoryV2Enabled(false); @@ -1162,7 +1165,7 @@ async function testWatchHistoryAppLoginFallback() { watchHistoryService.resetProgress(); nostrClient.pubkey = ""; nostrClient.sessionActor = null; - window.app = { + setApplication({ pubkey: actor, normalizeHexPubkey(value) { if (typeof value === "string" && value.trim()) { @@ -1170,7 +1173,7 @@ async function testWatchHistoryAppLoginFallback() { } return null; }, - }; + }); const enabled = typeof watchHistoryService.isEnabled === "function" @@ -1186,11 +1189,7 @@ async function testWatchHistoryAppLoginFallback() { watchHistoryService.resetProgress(); nostrClient.pubkey = originalPub; nostrClient.sessionActor = originalSession; - if (typeof originalApp === "undefined") { - delete window.app; - } else { - window.app = originalApp; - } + setApplication(originalApp || null); } }