Files
bitvid/js/index.js
T

1452 lines
42 KiB
JavaScript

// js/index.js
import { validateInstanceConfig } from "../config/validate-config.js";
import "./bufferPolyfill.js";
import "./devReqMonitor.js"; // dev-only relay REQ-rate monitor (localhost only)
import {
getApplication,
setApplication,
setApplicationReady,
} from "./applicationContext.js";
import nostrService from "./services/nostrService.js";
import r2Service from "./services/r2Service.js";
import s3UploadService from "./services/s3UploadService.js";
import { loadView, viewInitRegistry } from "./viewManager.js";
import { applyDesignSystemAttributes } from "./designSystem.js";
import {
initThemeController,
refreshThemeControls,
} from "./themeController.js";
import { setHashView } from "./hashView.js";
import { devLogger, userLogger } from "./utils/logger.js";
import { parseFilterQuery } from "./search/searchFilters.js";
import {
buildSearchHashFromState,
getSearchFilterState,
resetSearchFilters,
setSearchFilterState,
} from "./search/searchFilterState.js";
import {
prepareStaticModal,
openStaticModal,
closeStaticModal,
} from "./ui/components/staticModalAccessibility.js";
import {
BLOG_URL,
COMMUNITY_URL,
NOSTR_URL,
GITHUB_URL,
BETA_URL,
DNS_URL,
TIP_JAR_URL,
isLockdownMode,
LOCKDOWN_TITLE,
LOCKDOWN_MESSAGE,
} from "./config.js";
import { createHashChangeHandler } from "./hashChangeHandler.js";
import AuthService from "./services/authService.js";
import getAuthProvider, {
providers as authProviders,
} from "./services/authProviders/index.js";
import { accessControl } from "./accessControl.js";
import { nostrClient } from "./nostrClientFacade.js";
import { userBlocks } from "./userBlocks.js";
import { relayManager } from "./relayManager.js";
import createApplication from "./bootstrap.js";
import SearchFilterModal from "./ui/components/SearchFilterModal.js";
import { initializeSiteUpdateNotice } from "./ui/siteUpdateNotice.js";
import { installTestHarness } from "./testHarness.js";
validateInstanceConfig();
// Install test harness early so relay overrides apply before connections.
// Only activates when ?__test__=1 or localStorage __bitvidTestMode__ is set.
installTestHarness();
applyDesignSystemAttributes();
initThemeController();
let application = null;
let applicationReadyPromise = Promise.resolve();
const LOCKDOWN_MODAL_ID = "lockdownModal";
const LOCKDOWN_LOGIN_BUTTON_ID = "lockdownLoginButton";
const LOCKDOWN_STATUS_ID = "lockdownStatusMessage";
const LOCKDOWN_ERROR_ID = "lockdownErrorMessage";
let lockdownAuthService = null;
let lockdownAccessControlPromise = null;
let lockdownResumePromise = null;
const bindOptionalExternalLink = ({ selector, url, label }) => {
const element = document.querySelector(selector);
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
if (!(element instanceof HTMLAnchorElement)) {
if (sanitizedUrl) {
userLogger.warn(
`${label} not found; skipping external link binding.`,
);
}
return;
}
if (!sanitizedUrl) {
element.remove();
return;
}
element.href = sanitizedUrl;
element.target = "_blank";
element.rel = "noopener noreferrer";
};
function ensureLockdownAccessControlReady() {
if (lockdownAccessControlPromise) {
return lockdownAccessControlPromise;
}
lockdownAccessControlPromise = accessControl
.refresh()
.then(
() => true,
(error) => {
devLogger.error(
"[Lockdown] Failed to refresh admin lists before login attempt:",
error,
);
return false;
},
);
return lockdownAccessControlPromise;
}
function getLockdownAuthService() {
if (lockdownAuthService) {
return lockdownAuthService;
}
const service = new AuthService({
nostrClient,
userBlocks,
relayManager,
logger: devLogger,
accessControl,
authProviders,
getAuthProvider,
});
try {
service.hydrateFromStorage();
} catch (error) {
devLogger.warn("[Lockdown] Failed to hydrate auth state from storage:", error);
}
lockdownAuthService = service;
return service;
}
function setLockdownStatusMessage(message) {
const statusNode = document.getElementById(LOCKDOWN_STATUS_ID);
if (!(statusNode instanceof HTMLElement)) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
if (text) {
statusNode.textContent = text;
statusNode.classList.remove("hidden");
} else {
statusNode.textContent = "";
statusNode.classList.add("hidden");
}
}
function setLockdownErrorMessage(message) {
const errorNode = document.getElementById(LOCKDOWN_ERROR_ID);
if (!(errorNode instanceof HTMLElement)) {
return;
}
const text = typeof message === "string" ? message.trim() : "";
if (text) {
errorNode.textContent = text;
errorNode.classList.remove("hidden");
} else {
errorNode.textContent = "";
errorNode.classList.add("hidden");
}
}
function resetLockdownError() {
setLockdownErrorMessage("");
}
function setLockdownLoginBusy(isBusy) {
const button = document.getElementById(LOCKDOWN_LOGIN_BUTTON_ID);
if (!(button instanceof HTMLButtonElement)) {
return;
}
const busy = Boolean(isBusy);
button.disabled = busy;
if (busy) {
button.setAttribute("aria-busy", "true");
} else {
button.removeAttribute("aria-busy");
}
}
async function tryAutoResumeLockdownSession() {
const authService = getLockdownAuthService();
if (!authService || typeof authService.getActivePubkey !== "function") {
return false;
}
const savedPubkey = authService.getActivePubkey();
if (!savedPubkey) {
return false;
}
resetLockdownError();
setLockdownLoginBusy(true);
setLockdownStatusMessage("Restoring your previous session…");
try {
await ensureLockdownAccessControlReady();
await authService.login(savedPubkey, { persistActive: false });
setLockdownStatusMessage("Administrator session restored. Loading bitvid…");
await resumeLockdownStartup();
return true;
} catch (error) {
devLogger.warn("[Lockdown] Stored session failed to resume:", error);
if (error && typeof error === "object") {
if (error.code === "site-lockdown") {
setLockdownErrorMessage(
"Stored credentials are no longer permitted during lockdown. Please sign in with an authorized administrator account.",
);
} else if (typeof error.message === "string" && error.message.trim()) {
setLockdownErrorMessage(error.message.trim());
}
}
return false;
} finally {
if (!lockdownResumePromise) {
setLockdownLoginBusy(false);
setLockdownStatusMessage("Administrator access is required to proceed.");
}
}
}
function resumeLockdownStartup() {
if (lockdownResumePromise) {
return lockdownResumePromise;
}
const modal = document.getElementById(LOCKDOWN_MODAL_ID);
const loginButton = document.getElementById(LOCKDOWN_LOGIN_BUTTON_ID);
if (loginButton instanceof HTMLButtonElement) {
loginButton.removeEventListener("click", handleLockdownLoginClick);
}
if (modal) {
closeStaticModal(modal);
}
lockdownResumePromise = Promise.resolve()
.then(() => initializeInterface())
.catch((error) => {
lockdownResumePromise = null;
userLogger.error("Failed to start bitvid after lockdown login:", error);
if (modal) {
openStaticModal(modal);
}
setLockdownLoginBusy(false);
setLockdownStatusMessage("Administrator access is required to proceed.");
setLockdownErrorMessage(
"We couldn't start the application. Please try again.",
);
throw error;
});
return lockdownResumePromise;
}
async function handleLockdownLoginClick(event) {
event.preventDefault();
resetLockdownError();
setLockdownStatusMessage("Requesting authorization from your Nostr extension…");
setLockdownLoginBusy(true);
let shouldReleaseButton = true;
try {
await ensureLockdownAccessControlReady();
const authService = getLockdownAuthService();
const result = await authService.requestLogin({ allowAccountSelection: true });
const pubkey =
result && typeof result === "object" && typeof result.pubkey === "string"
? result.pubkey
: null;
if (!pubkey) {
setLockdownStatusMessage(
"No account was selected. Choose an authorized administrator profile to continue.",
);
return;
}
setLockdownStatusMessage("Login successful. Loading bitvid…");
shouldReleaseButton = false;
await resumeLockdownStartup();
return;
} catch (error) {
userLogger.error("Lockdown login failed:", error);
let message = "Failed to login. Please try again.";
if (error && typeof error === "object") {
if (error.code === "site-lockdown") {
message =
"Only administrators and moderators may sign in during lockdown. Try another account.";
} else if (error.code === "extension-permission-denied") {
message =
"The NIP-07 extension denied the permission request required to finish logging in.";
} else if (typeof error.message === "string" && error.message.trim()) {
message = error.message.trim();
}
}
setLockdownErrorMessage(message);
setLockdownStatusMessage("Administrator access is required to proceed.");
} finally {
if (shouldReleaseButton) {
setLockdownLoginBusy(false);
}
}
}
async function prepareLockdownInterface() {
ensureLockdownAccessControlReady();
try {
await loadModal("components/lockdown-modal.html");
} catch (error) {
userLogger.error("Failed to load lockdown modal markup:", error);
throw error;
}
const modal =
prepareStaticModal({ id: LOCKDOWN_MODAL_ID }) ||
document.getElementById(LOCKDOWN_MODAL_ID);
if (!(modal instanceof HTMLElement)) {
throw new Error("Lockdown modal markup is missing after load.");
}
const titleEl = document.getElementById("lockdownModalTitle");
if (titleEl) {
titleEl.textContent = LOCKDOWN_TITLE;
}
const messageEl = document.getElementById("lockdownModalMessage");
if (messageEl) {
messageEl.textContent = LOCKDOWN_MESSAGE;
}
const loginButton = modal.querySelector("[data-lockdown-login]");
if (loginButton instanceof HTMLButtonElement) {
loginButton.addEventListener("click", handleLockdownLoginClick);
} else {
devLogger.warn(
"[Lockdown] Login button not found inside lockdown modal. Users cannot sign in.",
);
}
openStaticModal(modal);
resetLockdownError();
setLockdownStatusMessage("Administrator access is required to proceed.");
setLockdownLoginBusy(false);
const autoResumed = await tryAutoResumeLockdownSession();
if (autoResumed) {
return;
}
if (loginButton instanceof HTMLButtonElement && typeof window !== "undefined") {
window.requestAnimationFrame(() => {
try {
loginButton.focus();
} catch (error) {
devLogger.warn("[Lockdown] Failed to focus lockdown login button:", error);
}
});
}
}
function startApplication() {
if (application) {
return applicationReadyPromise;
}
const startupPromise = (async () => {
application = await createApplication({
services: {
nostrService,
r2Service,
s3Service: s3UploadService,
},
loadView,
});
setApplication(application);
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;
}
function wasBackForwardNavigation(event) {
if (event?.persisted) {
return true;
}
if (typeof window === "undefined" || !window.performance) {
return false;
}
if (typeof window.performance.getEntriesByType === "function") {
try {
const navigationEntries = window.performance.getEntriesByType("navigation");
if (Array.isArray(navigationEntries) && navigationEntries.length) {
const latestEntry = navigationEntries[navigationEntries.length - 1];
if (latestEntry && latestEntry.type === "back_forward") {
return true;
}
}
} catch (error) {
devLogger.warn("[pageshow] Failed to inspect navigation entries:", error);
}
}
const navigation = window.performance.navigation;
if (!navigation || typeof navigation.type !== "number") {
return false;
}
const backForwardType =
typeof navigation.TYPE_BACK_FORWARD === "number"
? navigation.TYPE_BACK_FORWARD
: 2;
return navigation.type === backForwardType;
}
function handlePageShow(event) {
if (!wasBackForwardNavigation(event)) {
return;
}
if (!application) {
return;
}
Promise.resolve(applicationReadyPromise)
.catch(() => {})
.then(() => {
if (!application) {
return null;
}
try {
application.refreshVisibleModerationUi?.({ reason: "pageshow" });
} catch (error) {
devLogger.warn("[pageshow] Failed to refresh moderation UI after resume:", error);
}
if (typeof application.refreshAllVideoGrids !== "function") {
return null;
}
return application
.refreshAllVideoGrids({ reason: "pageshow", forceMainReload: true })
.catch((error) => {
devLogger.warn("[pageshow] Failed to refresh video grids after resume:", error);
});
})
.catch((error) => {
devLogger.warn("[pageshow] Failed to process resume refresh:", error);
});
}
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("pageshow", handlePageShow);
}
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 fetchPartial = async (url) => {
const response = await fetch(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"),
loadModal("components/search-filter-modal.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;
const DESKTOP_VIEWPORT_MIN_WIDTH = 1024;
const DESKTOP_VIEWPORT_QUERY = "(min-width: 1024px)";
let desktopViewportQuery = null;
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);
}
};
const isDesktopViewportActive = () => {
if (typeof window === "undefined") {
return false;
}
if (desktopViewportQuery === null && typeof window.matchMedia === "function") {
try {
desktopViewportQuery = window.matchMedia(DESKTOP_VIEWPORT_QUERY);
} catch (error) {
desktopViewportQuery = null;
}
}
if (desktopViewportQuery) {
return desktopViewportQuery.matches === true;
}
if (typeof window.innerWidth === "number") {
return window.innerWidth >= DESKTOP_VIEWPORT_MIN_WIDTH;
}
return false;
};
const shouldAutoCollapseSidebarOnAction = () => !isDesktopViewportActive();
const collapseSidebarForMobile = () => {
if (!shouldAutoCollapseSidebarOnAction()) {
return;
}
if (!isSidebarCollapsed) {
const nextCollapsed = true;
isSidebarCollapsed = nextCollapsed;
applySidebarDensity(nextCollapsed);
persistSidebarCollapsed(nextCollapsed);
return;
}
if (isFooterDropupExpanded && typeof syncFooterDropupFn === "function") {
syncFooterDropupFn(false);
}
persistSidebarCollapsed(true);
};
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);
});
}
if (sidebar) {
sidebar.addEventListener("click", (event) => {
if (!shouldAutoCollapseSidebarOnAction()) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const navLink = target.closest(".sidebar-nav-link");
if (!(navLink instanceof HTMLElement)) {
return;
}
collapseSidebarForMobile();
});
}
const footerDropdownButton = document.getElementById("footerDropdownButton");
const footerLinksContainer = document.getElementById("footerLinksContainer");
const footerDropdownLabel = document.getElementById("footerDropdownText");
const footerDropdownIcon = document.getElementById("footerDropdownIcon");
const debounce = (fn, delay) => {
let timeoutId = null;
return (...args) => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
timeoutId = null;
fn(...args);
}, delay);
};
};
const sidebarDropupStyleState = (() => {
let styleNode = null;
let rootWidthRule = "";
const measureRules = new Map();
const ensureStyleNode = () => {
if (styleNode && styleNode.isConnected) {
return styleNode;
}
styleNode = document.getElementById("sidebarDropupStyles");
if (!(styleNode instanceof HTMLStyleElement)) {
styleNode = document.createElement("style");
styleNode.id = "sidebarDropupStyles";
document.head.appendChild(styleNode);
}
return styleNode;
};
const updateStyles = () => {
const node = ensureStyleNode();
const rules = [rootWidthRule, ...measureRules.values()].filter(Boolean);
node.textContent = rules.join("\n");
};
return {
setRootWidth: (value) => {
rootWidthRule = `:root { --sidebar-dropup-content-width: ${value}; }`;
updateStyles();
},
setMeasureWidth: (id, value) => {
measureRules.set(
id,
`.sidebar-dropup-measure[data-measure-id="${id}"] { width: ${value}; }`,
);
updateStyles();
},
clearMeasure: (id) => {
measureRules.delete(id);
updateStyles();
},
};
})();
let sidebarDropupMeasureId = 0;
const resolveCssLengthToPixels = (value, container) => {
if (!(container instanceof HTMLElement) || typeof value !== "string") {
return 0;
}
const measurementNode = container.ownerDocument.createElement("div");
sidebarDropupMeasureId += 1;
const measureId = `sidebar-dropup-measure-${sidebarDropupMeasureId}`;
measurementNode.classList.add("sidebar-dropup-measure");
measurementNode.dataset.measureId = measureId;
sidebarDropupStyleState.setMeasureWidth(measureId, value);
container.appendChild(measurementNode);
const pixels = measurementNode.getBoundingClientRect().width;
container.removeChild(measurementNode);
sidebarDropupStyleState.clearMeasure(measureId);
return Number.isFinite(pixels) ? pixels : 0;
};
const updateSidebarDropupContentWidth = () => {
const panelInner = document.querySelector(".sidebar-dropup-panel__inner");
const panel = panelInner?.closest(".sidebar-dropup-panel");
if (!(panelInner instanceof HTMLElement) || !(panel instanceof HTMLElement)) {
sidebarDropupStyleState.setRootWidth("0px");
return;
}
panelInner.classList.add("is-measuring");
const measuredInnerWidth = Math.ceil(panelInner.scrollWidth);
panelInner.classList.remove("is-measuring");
const panelStyles = window.getComputedStyle(panel);
const paddingInlineStart = Number.parseFloat(panelStyles.paddingInlineStart) || 0;
const paddingInlineEnd = Number.parseFloat(panelStyles.paddingInlineEnd) || 0;
const scrollReserveValue = window
.getComputedStyle(panel)
.getPropertyValue("--space-sidebar-dropup-scroll-reserve");
const scrollReserve = resolveCssLengthToPixels(scrollReserveValue, panel);
const totalWidth = Math.max(
0,
measuredInnerWidth + paddingInlineStart + paddingInlineEnd + scrollReserve,
);
sidebarDropupStyleState.setRootWidth(`${Math.ceil(totalWidth)}px`);
};
const debouncedSidebarDropupResize = debounce(updateSidebarDropupContentWidth, 150);
window.addEventListener("resize", debouncedSidebarDropupResize);
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;
updateSidebarDropupContentWidth();
};
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);
}
} else {
updateSidebarDropupContentWidth();
}
bindOptionalExternalLink({
selector: "[data-blog-link]",
url: BLOG_URL,
label: "Sidebar blog link",
});
bindOptionalExternalLink({
selector: "[data-nostr-link]",
url: NOSTR_URL,
label: "Sidebar Nostr link",
});
bindOptionalExternalLink({
selector: "[data-community-link]",
url: COMMUNITY_URL,
label: "Sidebar community link",
});
bindOptionalExternalLink({
selector: "[data-github-link]",
url: GITHUB_URL,
label: "Sidebar GitHub link",
});
bindOptionalExternalLink({
selector: "[data-beta-link]",
url: BETA_URL,
label: "Sidebar Beta link",
});
bindOptionalExternalLink({
selector: "[data-dns-link]",
url: DNS_URL,
label: "Sidebar DNS link",
});
bindOptionalExternalLink({
selector: "[data-tip-jar-link]",
url: TIP_JAR_URL,
label: "Sidebar Tip Jar link",
});
const headerSearchForm = document.getElementById("headerSearchForm");
if (headerSearchForm) {
headerSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const input = document.getElementById("headerSearchInput");
const query = input && input.value ? input.value.trim() : "";
if (!query) {
return;
}
// Check if it's an npub
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
const parsedFilters = parseFilterQuery(query);
if (parsedFilters.errors.length > 0) {
devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors);
}
const nextState = {
text: parsedFilters.text || "",
filters: parsedFilters.filters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
});
}
// Mobile Search Logic
const mobileSearchFab = document.getElementById("mobileSearchFab");
const mobileSearchContainer = document.getElementById(
"mobileSearchContainer",
);
const mobileSearchForm = document.getElementById("mobileSearchForm");
const mobileSearchInput = document.getElementById("mobileSearchInput");
if (mobileSearchFab && mobileSearchContainer) {
const openMobileSearch = () => {
mobileSearchFab.classList.add("hidden");
mobileSearchContainer.classList.remove("hidden");
if (mobileSearchInput) {
mobileSearchInput.focus();
}
};
const closeMobileSearch = () => {
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
};
mobileSearchFab.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent document click from immediately closing it
openMobileSearch();
});
// Close when clicking outside
document.addEventListener("click", (event) => {
// If mobile search is not visible, do nothing
if (mobileSearchContainer.classList.contains("hidden")) {
return;
}
// If click is on the FAB, do nothing (handled by FAB listener)
if (mobileSearchFab.contains(event.target)) {
return;
}
// If click is inside the search container, do nothing
if (mobileSearchContainer.contains(event.target)) {
return;
}
// Otherwise, close it
closeMobileSearch();
});
// Prevent clicks inside the container from closing it (redundant with above check but safe)
mobileSearchContainer.addEventListener("click", (e) => {
e.stopPropagation();
});
}
if (mobileSearchForm) {
mobileSearchForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await applicationReadyPromise;
} catch (error) {
// Continue even if app fails full initialization, if possible.
}
const query =
mobileSearchInput && mobileSearchInput.value
? mobileSearchInput.value.trim()
: "";
if (!query) {
return;
}
// Check if it's an npub (Copy logic from headerSearchForm)
try {
if (
window.NostrTools &&
window.NostrTools.nip19 &&
query.startsWith("npub1")
) {
const decoded = window.NostrTools.nip19.decode(query);
if (decoded && decoded.type === "npub") {
setHashView(`channel-profile&npub=${encodeURIComponent(query)}`);
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
return;
}
}
} catch (error) {
// Not a valid npub, proceed to search
}
const parsedFilters = parseFilterQuery(query);
if (parsedFilters.errors.length > 0) {
devLogger.warn("[Search] Filter parsing errors", parsedFilters.errors);
}
const nextState = {
text: parsedFilters.text || "",
filters: parsedFilters.filters,
};
setSearchFilterState(nextState);
setHashView(buildSearchHashFromState(nextState));
// Close search on submit
mobileSearchContainer.classList.add("hidden");
mobileSearchFab.classList.remove("hidden");
});
}
SearchFilterModal.init();
const searchFilterButtons = document.querySelectorAll(".header-search__filter");
searchFilterButtons.forEach((button) => {
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
SearchFilterModal.open();
});
});
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 { default: disclaimerModal } = await import("./disclaimer.js");
disclaimerModal.init();
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);
}
}
});
}
try {
await applicationReadyPromise;
} catch (error) {
// fall through
}
if (application) {
if (typeof application.initializeLoginModalController === "function") {
try {
application.initializeLoginModalController();
} catch (error) {
devLogger.error(
"[Interface] Failed to initialize login modal controller:",
error
);
}
}
if (typeof application.hydrateSidebarNavigation === "function") {
try {
application.hydrateSidebarNavigation();
} catch (error) {
devLogger.warn(
"[Interface] Failed to hydrate sidebar navigation:",
error
);
}
}
}
handleQueryParams();
window.addEventListener("hashchange", handleHashChange);
// The feed view loads here while the disclaimer (opened above for first-time
// visitors) holds the background inert, so its initial grid render is dropped.
// When the user dismisses the disclaimer the background becomes interactive
// again — re-load the feed so the grid populates without a manual refresh
// (KNOWN_BUGS #1). Fires only when a shown disclaimer is dismissed; returning
// visitors never see it, so there's no redundant reload for them.
document.addEventListener(
"bitvid:disclaimer-dismissed",
() => {
// The playlist view (#37) reuses the shared #videoList but is NOT the
// recent feed — force-loading loadVideos() here would race the playlist's
// own fetch and replace it with recent videos. Re-run the current view's
// init for a playlist so its grid re-renders; only the feed grids want the
// recent reload.
const viewName =
((window.location?.hash || "").match(/^#view=([^&]+)/) || [])[1] || "";
if (viewName === "playlist") {
const initPlaylist = viewInitRegistry?.playlist;
if (typeof initPlaylist === "function") {
Promise.resolve(initPlaylist()).catch((error) => {
devLogger.warn(
"[Interface] Failed to re-render playlist after disclaimer dismissed:",
error,
);
});
}
return;
}
if (application && typeof application.loadVideos === "function") {
Promise.resolve(application.loadVideos(true)).catch((error) => {
devLogger.warn(
"[Interface] Failed to reload feed after disclaimer dismissed:",
error,
);
});
}
},
{ once: true },
);
await handleHashChange();
disclaimerModal.show();
}
async function initializeInterface() {
initializeSiteUpdateNotice();
startApplication();
try {
await bootstrapInterface();
} catch (error) {
userLogger.error("Failed to bootstrap bitvid interface:", error);
}
}
function onDomReady() {
if (isLockdownMode) {
prepareLockdownInterface().catch((error) => {
userLogger.error("Failed to initialize lockdown interface:", error);
});
return;
}
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
-------------------------------------------- */
/**
* 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");
});
}
}
const handleHashChange = createHashChangeHandler({
getApplication,
getApplicationReady: () => applicationReadyPromise,
loadView,
viewInitRegistry,
devLogger,
userLogger,
});