Files
bitvid/js/index.js
T

622 lines
19 KiB
JavaScript

// js/index.js
import { validateInstanceConfig } from "../config/validate-config.js";
import { ASSET_VERSION } from "../config/asset-version.js";
import "./bufferPolyfill.js";
import Application from "./app.js";
import { setApplication, setApplicationReady } from "./applicationContext.js";
import nostrService from "./services/nostrService.js";
import r2Service from "./services/r2Service.js";
import { loadView, viewInitRegistry } from "./viewManager.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
import {
initThemeController,
refreshThemeControls,
} from "./themeController.js";
import { devLogger, userLogger } from "./utils/logger.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./ui/components/staticModalAccessibility.js";
validateInstanceConfig();
applyDesignSystemAttributes();
initThemeController();
let application = null;
let applicationReadyPromise = Promise.resolve();
function startApplication() {
if (application) {
return applicationReadyPromise;
}
application = new Application({
services: {
nostrService,
r2Service,
},
loadView,
});
setApplication(application);
const startupPromise = (async () => {
await application.init();
if (typeof application.start === "function") {
await application.start();
}
})();
applicationReadyPromise = startupPromise;
setApplicationReady(startupPromise);
startupPromise.catch((error) => {
userLogger.error("Application failed to initialize:", error);
});
return startupPromise;
}
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
//
// Centralized animation cleanup
// ------------------------------
// We attach a single capture-phase listener for animation events so that any
// chrome element or thumbnail using our fade-in helpers can automatically shed
// the temporary classes/data attributes that trigger the transition. This is
// critical because these DOM nodes frequently persist while the surrounding
// lists re-render; if the classes stick around, future layout shuffles would
// replay the fade and make the UI flicker. Clearing the hooks immediately after
// the first animation keeps the graceful entrance without introducing future
// flashes.
const handleFadeInAnimationComplete = (event) => {
const { animationName, target } = event;
if (!(target instanceof HTMLElement)) {
return;
}
if (animationName === INTERFACE_FADE_IN_ANIMATION) {
if (!target.classList.contains("fade-in")) {
return;
}
// Remove the transient fade-in class so future renders keep the element
// visible. The delays piggyback on `fade-in`, so we strip those too.
target.classList.remove("fade-in");
Array.from(target.classList).forEach((className) => {
if (className.startsWith("fade-in-delay-")) {
target.classList.remove(className);
}
});
return;
}
if (animationName !== VIDEO_THUMBNAIL_FADE_IN_ANIMATION) {
return;
}
if (
target.dataset &&
target.dataset.videoThumbnail === "true" &&
target.dataset.thumbnailLoaded === "true"
) {
// Thumbnails use the same pattern: once the fade finishes we clear the flag
// so a later `load` event (or template reuse) does not restart the
// animation.
delete target.dataset.thumbnailLoaded;
}
};
document.addEventListener("animationend", handleFadeInAnimationComplete, true);
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
// 1) Load modals (login, application, etc.)
const withAssetVersion = (url) => {
if (typeof url !== "string" || url.length === 0) {
return url;
}
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}v=${encodeURIComponent(ASSET_VERSION)}`;
};
const fetchPartial = async (url) => {
const response = await fetch(withAssetVersion(url), { cache: "no-store" });
if (!response.ok) {
throw new Error("Failed to load " + url);
}
return response.text();
};
async function loadModal(url) {
try {
const html = await fetchPartial(url);
document
.getElementById("modalContainer")
.insertAdjacentHTML("beforeend", html);
const modalContainer = document.getElementById("modalContainer");
if (modalContainer) {
applyDesignSystemAttributes(modalContainer);
refreshThemeControls(modalContainer);
}
devLogger.log(url, "loaded");
} catch (err) {
userLogger.error(err);
}
}
// 2) Load sidebar
async function loadSidebar(url, containerId) {
try {
const html = await fetchPartial(url);
const container = document.getElementById(containerId);
if (container) {
container.innerHTML = html;
applyDesignSystemAttributes(container);
refreshThemeControls(container);
}
devLogger.log(url, "loaded into", containerId);
} catch (err) {
userLogger.error(err);
}
}
// 3) Load the disclaimer (now separate)
async function loadDisclaimer(url, containerId) {
try {
const html = await fetchPartial(url);
const container = document.getElementById(containerId);
if (container) {
container.insertAdjacentHTML("beforeend", html);
applyDesignSystemAttributes(container);
refreshThemeControls(container);
}
devLogger.log(url, "disclaimer loaded into", containerId);
} catch (err) {
userLogger.error(err);
}
}
async function bootstrapInterface() {
await Promise.all([
loadModal("components/login-modal.html"),
loadModal("components/application-form.html"),
loadModal("components/content-appeals-form.html"),
loadModal("components/general-feedback-form.html"),
loadModal("components/feature-request-form.html"),
loadModal("components/bug-fix-form.html"),
]);
devLogger.log("Modals loaded.");
[
"loginModal",
"nostrFormModal",
"contentAppealsModal",
"generalFeedbackModal",
"featureRequestModal",
"bugFixModal",
].forEach((id) => {
prepareStaticModal({ id });
});
await loadSidebar("components/sidebar.html", "sidebarContainer");
devLogger.log("Sidebar loaded.");
const sidebar = document.getElementById("sidebar");
const collapseToggle = document.getElementById("sidebarCollapseToggle");
if (sidebar && !sidebar.hasAttribute("data-footer-state")) {
sidebar.setAttribute("data-footer-state", "collapsed");
}
if (!collapseToggle) {
userLogger.warn("Sidebar collapse toggle not found; skipping density controls.");
}
const SIDEBAR_COLLAPSED_STORAGE_KEY = "sidebarCollapsed";
const DEFAULT_SIDEBAR_COLLAPSED = true;
let isSidebarCollapsed = DEFAULT_SIDEBAR_COLLAPSED;
let isFooterDropupExpanded = false;
let syncFooterDropupFn = null;
const readStoredSidebarCollapsed = () => {
try {
const storedValue = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
if (storedValue === null) {
return DEFAULT_SIDEBAR_COLLAPSED;
}
return storedValue === "true";
} catch (error) {
userLogger.warn("Unable to read sidebar collapse state from storage:", error);
return DEFAULT_SIDEBAR_COLLAPSED;
}
};
const persistSidebarCollapsed = (collapsed) => {
try {
localStorage.setItem(
SIDEBAR_COLLAPSED_STORAGE_KEY,
collapsed ? "true" : "false",
);
} catch (error) {
userLogger.warn("Unable to persist sidebar collapse state:", error);
}
};
const applySidebarDensity = (collapsed) => {
const state = collapsed ? "collapsed" : "expanded";
const toggleTargets = new Set(
[document.documentElement, document.body, sidebar].filter(
(element) => element instanceof HTMLElement
),
);
const appShell = document.getElementById("app");
if (appShell instanceof HTMLElement) {
toggleTargets.add(appShell);
appShell.dataset.sidebarState = state;
appShell.classList.toggle("sidebar-collapsed", collapsed);
appShell.classList.toggle("sidebar-expanded", !collapsed);
}
toggleTargets.forEach((element) => {
element.classList.toggle("sidebar-collapsed", collapsed);
element.classList.toggle("sidebar-expanded", !collapsed);
element.setAttribute("data-state", state);
});
if (collapseToggle) {
const actionLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
collapseToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
collapseToggle.setAttribute("data-state", state);
collapseToggle.setAttribute("aria-label", actionLabel);
collapseToggle.setAttribute("title", actionLabel);
}
if (
collapsed &&
isFooterDropupExpanded &&
typeof syncFooterDropupFn === "function"
) {
syncFooterDropupFn(false);
}
};
isSidebarCollapsed = collapseToggle
? readStoredSidebarCollapsed()
: DEFAULT_SIDEBAR_COLLAPSED;
applySidebarDensity(isSidebarCollapsed);
if (collapseToggle) {
collapseToggle.addEventListener("click", (event) => {
event.preventDefault();
const nextCollapsed = !isSidebarCollapsed;
isSidebarCollapsed = nextCollapsed;
applySidebarDensity(nextCollapsed);
persistSidebarCollapsed(nextCollapsed);
});
}
const footerDropdownButton = document.getElementById("footerDropdownButton");
const footerLinksContainer = document.getElementById("footerLinksContainer");
const footerDropdownLabel = document.getElementById("footerDropdownText");
const footerDropdownIcon = document.getElementById("footerDropdownIcon");
if (footerDropdownButton && footerLinksContainer) {
const sidebarFooter = footerDropdownButton.closest(".sidebar-footer");
syncFooterDropupFn = (expanded) => {
const nextState = expanded ? "expanded" : "collapsed";
const actionLabel = expanded ? "Show fewer sidebar links" : "Show more sidebar links";
footerDropdownButton.setAttribute("aria-expanded", expanded ? "true" : "false");
footerDropdownButton.dataset.state = nextState;
footerDropdownButton.setAttribute("aria-label", actionLabel);
footerDropdownButton.setAttribute("title", actionLabel);
if (footerDropdownLabel) {
footerDropdownLabel.textContent = expanded ? "Less" : "More";
}
if (footerDropdownIcon) {
footerDropdownIcon.classList.toggle("is-rotated", expanded);
}
footerLinksContainer.classList.remove("hidden");
footerLinksContainer.setAttribute("aria-hidden", expanded ? "false" : "true");
footerLinksContainer.dataset.state = nextState;
if (sidebar) {
sidebar.setAttribute("data-footer-state", nextState);
}
if (sidebarFooter instanceof HTMLElement) {
sidebarFooter.dataset.footerState = nextState;
}
isFooterDropupExpanded = expanded;
};
footerDropdownButton.addEventListener("click", (event) => {
event.preventDefault();
const expanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
if (isSidebarCollapsed && !expanded) {
const nextCollapsed = false;
isSidebarCollapsed = nextCollapsed;
applySidebarDensity(nextCollapsed);
persistSidebarCollapsed(nextCollapsed);
}
if (typeof syncFooterDropupFn === "function") {
syncFooterDropupFn(!expanded);
}
});
const initialExpanded = footerDropdownButton.getAttribute("aria-expanded") === "true";
if (typeof syncFooterDropupFn === "function") {
syncFooterDropupFn(initialExpanded);
}
}
try {
const sidebarModule = await import("./sidebar.js");
if (typeof sidebarModule.setupSidebarNavigation === "function") {
sidebarModule.setupSidebarNavigation();
}
} catch (error) {
userLogger.error("Failed to set up sidebar navigation:", error);
}
await loadDisclaimer("components/disclaimer.html", "modalContainer");
devLogger.log("Disclaimer loaded.");
prepareStaticModal({ id: "disclaimerModal" });
const loginNavBtn = document.getElementById("loginButton");
if (loginNavBtn) {
loginNavBtn.addEventListener("click", (event) => {
const loginModal =
prepareStaticModal({ id: "loginModal" }) ||
document.getElementById("loginModal");
if (loginModal) {
openStaticModal(loginModal, { triggerElement: event.currentTarget });
}
});
}
const closeLoginBtn = document.getElementById("closeLoginModal");
if (closeLoginBtn) {
closeLoginBtn.addEventListener("click", () => {
closeStaticModal("loginModal");
});
}
const openAppFormBtn = document.getElementById("openApplicationModal");
if (openAppFormBtn) {
openAppFormBtn.addEventListener("click", (event) => {
closeStaticModal("loginModal");
const appModal =
prepareStaticModal({ id: "nostrFormModal" }) ||
document.getElementById("nostrFormModal");
if (appModal) {
openStaticModal(appModal, { triggerElement: event.currentTarget });
}
});
}
const closeNostrFormBtn = document.getElementById("closeNostrFormModal");
if (closeNostrFormBtn) {
closeNostrFormBtn.addEventListener("click", () => {
closeStaticModal("nostrFormModal");
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
openStaticModal(disclaimerModal);
}
}
});
}
handleQueryParams();
window.addEventListener("hashchange", handleHashChange);
await handleHashChange();
const { default: disclaimerModal } = await import("./disclaimer.js");
disclaimerModal.init();
disclaimerModal.show();
}
async function initializeInterface() {
startApplication();
try {
await bootstrapInterface();
} catch (error) {
userLogger.error("Failed to bootstrap bitvid interface:", error);
}
}
function onDomReady() {
initializeInterface().catch((error) => {
userLogger.error("Unhandled error during bitvid initialization:", error);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", onDomReady, { once: true });
} else {
onDomReady();
}
/* -------------------------------------------
HELPER FUNCTIONS FOR QUERY AND HASH
-------------------------------------------- */
/**
* Sets the location.hash to "#view=<viewName>",
* removing any ?modal=... or ?v=... from the query string.
*/
export function setHashView(viewName) {
const url = new URL(window.location.href);
url.searchParams.delete("modal");
url.searchParams.delete("v");
const newUrl = url.pathname + url.search + `#view=${viewName}`;
window.history.replaceState({}, "", newUrl);
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
handleHashChange();
}
/**
* Sets a query param (e.g. ?modal=xxx or ?v=yyy),
* removing any "#view=..." from the hash to avoid collisions.
*/
export function setQueryParam(key, value) {
const url = new URL(window.location.href);
url.hash = "";
url.searchParams.set(key, value);
const newUrl = url.pathname + url.search;
window.history.replaceState({}, "", newUrl);
handleQueryParams();
}
/**
* Check the current URL for ?modal=..., ?v=..., etc.
* Open the correct modals or disclaimers as needed.
*/
function handleQueryParams() {
const urlParams = new URLSearchParams(window.location.search);
const modalParam = urlParams.get("modal");
if (modalParam === "appeals") {
const appealsModal =
prepareStaticModal({ id: "contentAppealsModal" }) ||
document.getElementById("contentAppealsModal");
if (appealsModal) {
openStaticModal(appealsModal);
}
const closeAppealsBtn = document.getElementById("closeContentAppealsModal");
if (closeAppealsBtn) {
closeAppealsBtn.addEventListener("click", () => {
closeStaticModal("contentAppealsModal");
if (!localStorage.getItem("hasSeenDisclaimer")) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
openStaticModal(disclaimerModal);
}
}
});
}
} else if (modalParam === "application") {
const appModal =
prepareStaticModal({ id: "nostrFormModal" }) ||
document.getElementById("nostrFormModal");
if (appModal) {
openStaticModal(appModal);
}
} else {
const hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer");
if (!hasSeenDisclaimer) {
const disclaimerModal = document.getElementById("disclaimerModal");
if (disclaimerModal) {
openStaticModal(disclaimerModal);
}
}
}
if (modalParam === "feedback") {
const feedbackModal =
prepareStaticModal({ id: "generalFeedbackModal" }) ||
document.getElementById("generalFeedbackModal");
if (feedbackModal) {
openStaticModal(feedbackModal);
}
} else if (modalParam === "feature") {
const featureModal =
prepareStaticModal({ id: "featureRequestModal" }) ||
document.getElementById("featureRequestModal");
if (featureModal) {
openStaticModal(featureModal);
}
} else if (modalParam === "bug") {
const bugModal =
prepareStaticModal({ id: "bugFixModal" }) ||
document.getElementById("bugFixModal");
if (bugModal) {
openStaticModal(bugModal);
}
}
const closeFeedbackBtn = document.getElementById("closeGeneralFeedbackModal");
if (closeFeedbackBtn) {
closeFeedbackBtn.addEventListener("click", () => {
closeStaticModal("generalFeedbackModal");
});
}
const closeFeatureBtn = document.getElementById("closeFeatureRequestModal");
if (closeFeatureBtn) {
closeFeatureBtn.addEventListener("click", () => {
closeStaticModal("featureRequestModal");
});
}
const closeBugBtn = document.getElementById("closeBugFixModal");
if (closeBugBtn) {
closeBugBtn.addEventListener("click", () => {
closeStaticModal("bugFixModal");
});
}
}
async function handleHashChange() {
devLogger.log("handleHashChange called, current hash =", window.location.hash);
try {
await applicationReadyPromise;
} catch (error) {
userLogger.warn(
"Proceeding with hash handling despite application initialization failure:",
error
);
}
const hash = window.location.hash || "";
// Use a regex that captures up to the first ampersand or end of string.
// E.g. "#view=channel-profile&npub=..." => viewName = "channel-profile"
const match = hash.match(/^#view=([^&]+)/);
try {
if (!match || !match[1]) {
// No valid "#view=..." => default to "most-recent-videos"
await loadView("views/most-recent-videos.html");
const initFn = viewInitRegistry["most-recent-videos"];
if (typeof initFn === "function") {
await initFn();
}
return;
}
const viewName = match[1]; // only the chunk before any '&'
if (typeof viewName === "string" && viewName.toLowerCase() === "history") {
}
const viewUrl = `views/${viewName}.html`;
// Now dynamically load that partial, then call its init function
await loadView(viewUrl);
const initFn = viewInitRegistry[viewName];
if (typeof initFn === "function") {
await initFn();
}
} catch (error) {
userLogger.error("Failed to handle hash change:", error);
}
}