mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Refactor app orchestrator into Application with dependency injection
This commit is contained in:
1 parent
ef6b8bf1cf
commit
cf34e28a47
17 files changed
+525
-375
No files matched your search
@@ -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`.
|
||||
+1
-1
@@ -227,7 +227,7 @@
|
||||
<script type="module" src="js/webtorrent.js"></script>
|
||||
<script type="module" src="js/nostr.js"></script>
|
||||
<script type="module" src="js/viewManager.js"></script>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
<script type="module" src="js/bootstrap.js"></script>
|
||||
<script type="module" src="js/disclaimer.js"></script>
|
||||
<script type="module" src="js/index.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+23
@@ -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;
|
||||
+32
-18
@@ -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,
|
||||
});
|
||||
|
||||
+2
-10
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+35
-33
@@ -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 {
|
||||
</div>
|
||||
`;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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:<info-hash>`
|
||||
* entry, where `<info-hash>` 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+15
-10
@@ -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"
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user