import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Box, Button, Input, Spinner, Text, color } from 'folds'; import { Outlet, generatePath, matchPath, useLocation, useNavigate, useParams, } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AuthFooter } from './AuthFooter'; import { AuthMascot } from './AuthMascot'; import { authLayoutRootVars } from './layoutConfig'; import * as css from './styles.css'; import { clientAllowedServer, clientDefaultServer, useClientConfig, } from '../../hooks/useClientConfig'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsProvider } from '../../hooks/useSpecVersions'; import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; import { AuthFlowsLoader } from '../../components/AuthFlowsLoader'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthServerProvider } from '../../hooks/useAuthServer'; import { tryDecodeURIComponent } from '../../utils/dom'; const currentAuthPath = (pathname: string): string => { if (matchPath(LOGIN_PATH, pathname)) return LOGIN_PATH; if (matchPath(RESET_PASSWORD_PATH, pathname)) return RESET_PASSWORD_PATH; if (matchPath(REGISTER_PATH, pathname)) return REGISTER_PATH; return LOGIN_PATH; }; function AuthLayoutLoading({ message }: { message: string }) { return ( {message} ); } function AuthLayoutError({ message }: { message: string }) { return ( {message} ); } /* ── Server edit dialog ── */ function ServerEditDialog({ currentServer, onConfirm, onCancel, }: { currentServer: string; onConfirm: (server: string) => void; onCancel: () => void; }) { const { t } = useTranslation(); const [value, setValue] = useState(currentServer); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); useEffect(() => { const handleKeyDown = (evt: KeyboardEvent) => { if (evt.key === 'Escape' && !evt.defaultPrevented && !evt.isComposing) { onCancel(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [onCancel]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = value.trim(); if (trimmed) onConfirm(trimmed); }; return (
); } /* ── Layout helpers (from element-web) ── */ function readPx(s: CSSStyleDeclaration, name: string, fallback = 0): number { const v = Number.parseFloat(s.getPropertyValue(name)); return Number.isFinite(v) ? v : fallback; } function readNum(s: CSSStyleDeclaration, name: string, fallback: number): number { const v = Number.parseFloat(s.getPropertyValue(name)); return Number.isFinite(v) ? v : fallback; } function calculateModalLayout(input: { pageHeight: number; mascotTopOffset: number; mascotHeight: number; modalHeight: number; footerHeight: number; anchorRatio: number; minTop: number; bottomGap: number; }) { const desiredTop = input.mascotTopOffset + input.mascotHeight * input.anchorRatio; const canFitAboveFooter = input.modalHeight <= input.pageHeight - input.minTop - input.footerHeight - input.bottomGap; const reservedFooterHeight = canFitAboveFooter ? input.footerHeight : 0; const reservedBottomGap = canFitAboveFooter ? input.bottomGap : 0; const maxTop = input.pageHeight - reservedFooterHeight - reservedBottomGap - input.modalHeight; const top = Math.max(input.minTop, Math.min(desiredTop, maxTop)); const maxHeight = input.pageHeight - reservedFooterHeight - reservedBottomGap - top; return { top: Math.round(top), maxHeight: Math.max(0, Math.floor(maxHeight)), constrained: input.modalHeight > maxHeight + 1, }; } export function AuthLayout() { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const { server: urlEncodedServer } = useParams(); const clientConfig = useClientConfig(); const defaultServer = clientDefaultServer(clientConfig); let server: string = urlEncodedServer ? tryDecodeURIComponent(urlEncodedServer) : defaultServer; if (!clientAllowedServer(clientConfig, server)) { server = defaultServer; } const [showServerDialog, setShowServerDialog] = useState(false); const [discoveryState, discoverServer] = useAsyncCallback( useCallback(async (serverName: string) => { const response = await autoDiscovery(fetch, serverName); return { serverName, response }; }, []) ); useEffect(() => { if (server) discoverServer(server); }, [discoverServer, server]); useEffect(() => { if (!urlEncodedServer || tryDecodeURIComponent(urlEncodedServer) !== server) { navigate( generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(server), }), { replace: true } ); } }, [urlEncodedServer, navigate, location, server]); const selectServer = useCallback( (newServer: string) => { if (newServer === server) { if (discoveryState.status === AsyncStatus.Loading) return; discoverServer(server); return; } navigate( generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer), }) ); }, [navigate, location, discoveryState, server, discoverServer] ); const [autoDiscoveryError, autoDiscoveryInfo] = discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : []; /* ── Page title based on route ── */ const getPageTitle = () => { if (matchPath(LOGIN_PATH, location.pathname)) return t('Auth.title_login'); if (matchPath(REGISTER_PATH, location.pathname)) return t('Auth.title_register'); return t('Auth.title_reset_password'); }; const pageTitle = getPageTitle(); /* ── Refs for JS-driven layout ── */ const pageRef = useRef(null); const mascotRef = useRef(null); const cardContentRef = useRef(null); const cardBodyRef = useRef(null); const footerRef = useRef(null); useLayoutEffect(() => { const page = pageRef.current; const mascot = mascotRef.current; const cardContent = cardContentRef.current; const cardBody = cardBodyRef.current; const footer = footerRef.current; if (!page || !mascot || !cardContent || !cardBody || !footer) return undefined; const rootEl = document.getElementById('root'); let frameId = 0; let padTop = 0; let padBottom = 0; // env(safe-area-inset-*) only changes on rotate / fullscreen toggle; both // fire window.resize. Recompute lazily, not on every RAF. const recomputePadding = (): void => { if (!rootEl) return; const s = getComputedStyle(rootEl); padTop = parseFloat(s.paddingTop) || 0; padBottom = parseFloat(s.paddingBottom) || 0; }; recomputePadding(); const update = (): void => { cancelAnimationFrame(frameId); frameId = requestAnimationFrame(() => { // Capacitor WebView can publish env(safe-area-inset-*) one frame after // mount; cheaper to refresh per RAF than to miss the first cold paint. recomputePadding(); const s = getComputedStyle(page); const footerHeight = footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight); const bottomGap = readPx(s, '--vojo-modal-gap'); // AuthLayout is height: 100dvh inside #root which has env(safe-area-inset-*) // padding. AuthLayout overflows #root's content area by the safe-area // amounts and gets clipped by body { overflow: hidden }. Subtract #root's // vertical padding from pageHeight so the form fits the visible band. const rawPageHeight = window.visualViewport?.height ?? page.clientHeight; const pageHeight = Math.max(0, rawPageHeight - padTop - padBottom); const anchorRatio = readNum(s, '--vojo-anchor-ratio', 0.58); // Compact mode: form + footer don't fit in the viewport at all. // When they do fit, the form naturally docks at the mascot anchor and // the mascot peeks above through the glassmorphism — that's the design, // not a bug. We only drop the mascot when the math has nowhere to put // both. Triggers on keyboard up, very small windows, landscape, etc. const compact = cardBody.offsetHeight + footerHeight + bottomGap > pageHeight; const mascotTopOffset = compact ? 0 : readPx(s, '--vojo-mascot-top'); const mascotHeight = compact ? 0 : mascot.offsetHeight; const minTop = compact ? 0 : readPx(s, '--vojo-modal-min-top'); const layout = calculateModalLayout({ pageHeight, mascotTopOffset, mascotHeight, modalHeight: cardBody.offsetHeight, footerHeight, anchorRatio, minTop, bottomGap, }); page.style.setProperty('--vojo-modal-top', `${layout.top}px`); page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`); cardContent.classList.toggle(css.AuthCardContentScrollable, layout.constrained); mascot.classList.toggle(css.AuthMascotHidden, compact); }); }; update(); const ro = new ResizeObserver(update); ro.observe(page); ro.observe(mascot); ro.observe(cardBody); ro.observe(footer); const onWindowResize = (): void => { recomputePadding(); update(); }; window.addEventListener('resize', onWindowResize); window.visualViewport?.addEventListener('resize', update); return () => { cancelAnimationFrame(frameId); ro.disconnect(); window.removeEventListener('resize', onWindowResize); window.visualViewport?.removeEventListener('resize', update); }; }, []); return (
{/* Title */} {pageTitle} {/* Server info row with separator */}
{t('Auth.homeserver')} {server} {clientConfig.allowCustomHomeservers && ( )}
{/* Loading / error / form */} {discoveryState.status === AsyncStatus.Loading && ( )} {discoveryState.status === AsyncStatus.Error && ( )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && ( )} {autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && ( )} {discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && ( ( )} error={() => ( )} > {(specVersions) => ( ( )} error={() => } > {(authFlows) => ( )} )} )}
{/* Server edit dialog */} {showServerDialog && ( { setShowServerDialog(false); selectServer(newServer); }} onCancel={() => setShowServerDialog(false)} /> )}
); }