import { useState, useCallback, useEffect, useRef } from "react"; import { Outlet, Navigate, useLocation } from "react-router-dom"; import { motion } from "framer-motion"; import Box from "@mui/material/Box"; import Drawer from "@mui/material/Drawer"; import IconButton from "@mui/material/IconButton"; import ScopedCssBaseline from "@mui/material/ScopedCssBaseline"; import { useAuth } from "../context/AuthContext"; import ThemeToggle from "./ThemeToggle"; import { setLogoutAlert } from "../utils/api"; import SidebarNav from "./SidebarNav"; import LoadingState from "./LoadingState"; import ShortcutsHelp from "../components/ShortcutsHelp"; const DRAWER_WIDTH = 248; export default function AppShell() { const { isAuthenticated, loading, user, logout } = useAuth(); const [mobileOpen, setMobileOpen] = useState(false); const [loggingOut, setLoggingOut] = useState(false); const location = useLocation(); const logoutTimer = useRef | undefined>( undefined, ); // Chat-style pages own the full mobile viewport: under md the shell header // disappears and main loses its padding — the page brings its own back // button. Desktop keeps the normal shell. const immersiveOnMobile = location.pathname === "/odin"; // Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android // svh/dvh are computed against a viewport that includes system UI the real // layout viewport doesn't have, leaving scroll room no unit can remove // (and Chrome can serve a stale viewport after minimize/restore). Measure // the truth — window.innerHeight — into --app-height and keep it fresh; // immersive layouts size from it with an svh fallback. useEffect(() => { if (!immersiveOnMobile) return; const root = document.documentElement; const set = () => root.style.setProperty("--app-height", `${window.innerHeight}px`); set(); window.addEventListener("resize", set); window.visualViewport?.addEventListener("resize", set); // Kill pull-to-refresh: a PTR reload lands mid-viewport-settle and // re-races the measurement (Chromium settles without a resize event). // The immersive page is fixed-viewport — the gesture has no use here. const prevRootOverscroll = root.style.overscrollBehaviorY; const prevBodyOverscroll = document.body.style.overscrollBehaviorY; root.style.overscrollBehaviorY = "none"; document.body.style.overscrollBehaviorY = "none"; return () => { window.removeEventListener("resize", set); window.visualViewport?.removeEventListener("resize", set); root.style.removeProperty("--app-height"); root.style.overscrollBehaviorY = prevRootOverscroll; document.body.style.overscrollBehaviorY = prevBodyOverscroll; }; }, [immersiveOnMobile]); const handleLogout = useCallback(() => { setLoggingOut(true); setMobileOpen(false); setLogoutAlert(); // Delay so the blur/scale exit animation can play; store the id so an // unmount within the 400ms window cancels it (avoids logout() firing on // an unmounted tree). logoutTimer.current = setTimeout(() => logout(), 400); }, [logout]); useEffect(() => { return () => { if (logoutTimer.current) clearTimeout(logoutTimer.current); }; }, []); if (loading) { return ( ); } if (!isAuthenticated) return ; if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") { return ; } return ( ); }