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);
}
}