Files
bitvid/js/index.js
T
thePR0M3TH3ANandClaude Opus 4.8 7db70330dc playlists(#37): fix playlist grid clobbered by the disclaimer-dismissed feed reload
On a direct #view=playlist load, dismissing the first-visit disclaimer fired
`application.loadVideos(true)` — a forced RECENT-feed load into the shared
#videoList — which raced the playlist view's own fetch and replaced the playlist
with recent videos. It only reproduced when the disclaimer showed, which is why
it was intermittent ("sometimes"), and always after the playlist had already
rendered ("loads then gets dropped").

The disclaimer-dismissed reload is now view-aware: for a playlist view it re-runs
the playlist view's init (viewInitRegistry.playlist) so its grid re-renders,
instead of force-loading the recent feed. Feed views keep the loadVideos reload.

(A fuller fix would give the playlist view its own grid, decoupled from the feed
system's #videoList — noted as a follow-up.)

Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 21:44:54 -04:00

1450 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 { 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() {
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,
});