import React, { ComponentProps, MutableRefObject, ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import { Box, Header, Line, Scroll, Text, as, toRem } from 'folds'; import { useAtom } from 'jotai'; import classNames from 'classnames'; import { ContainerColor } from '../../styles/ContainerColor.css'; import * as css from './style.css'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { SIDEBAR_WIDTH_MIN, clampSidebarWidth, sidebarWidthAtom, } from '../../state/sidebarWidth'; import { VOJO_HORSESHOE_VOID_COLOR, VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX, } from '../../styles/horseshoe'; import { isNativePlatform } from '../../utils/capacitor'; // Off-switch for the web horseshoe layout. Default `true` (horseshoe // enabled on web non-mobile). Set to `false` by any container that // embeds `PageRoot` outside the page level — currently `Modal500` for // the settings dialogs (Settings / SpaceSettings / RoomSettings), so // the modal's nav rail doesn't end up with a 12px void column and // rounded corners inside a fixed 500px shell. export const HorseshoeEnabledContext = createContext(true); const useHorseshoeEnabled = (): boolean => { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const contextEnabled = useContext(HorseshoeEnabledContext); return contextEnabled && !isMobile && !isNativePlatform(); }; type PageRootProps = { nav: ReactNode; children: ReactNode; }; export function PageRoot({ nav, children }: PageRootProps) { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; // Horseshoe (gap + rounded chat wrapper) is web-only AND non-mobile- // only AND not embedded (the `HorseshoeEnabledContext` lets parents // like `Modal500` switch it off — settings dialogs render `PageRoot` // inside a fixed-width modal where the void column doesn't belong). // Native keeps the original single `` separator; mobile shows // one panel at a time so no separator is needed at all. const horseshoe = useHorseshoeEnabled(); const horseshoeRadius = toRem(VOJO_HORSESHOE_RADIUS_PX); if (horseshoe) { return ( {/* Page-nav slot — its right edge stays square (the original TR / BR rounding was reverted). The 12px void gap below still creates a visible seam between page-nav and the chat panel, and the resize handle inside `ResizablePageNav` is shifted into that void via the inline style below. */} {nav} {/* Right-side panel: two layers, both filling the same flex slot. Outer paints the void colour; inner re-paints the standard Background and clips its content with `overflow:hidden + border-radius`, so the inner's rounded TL/BL carves expose the outer's void. The explicit Background bg on the inner is what keeps the panel's apparent colour unchanged for routes whose content has no opaque bg of its own (e.g. ChannelsLanding) — without it the outer void would bleed through. */} {children} ); } return ( {nav} {!isMobile && } {children} ); } type ClientDrawerLayoutProps = { children: ReactNode; resizable?: boolean; }; export function PageNav({ size, resizable, children, }: ClientDrawerLayoutProps & css.PageNavVariants) { const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; const horseshoe = useHorseshoeEnabled(); if (resizable && !isMobile) { return {children}; } return ( {children} ); } function ResizablePageNav({ children }: { children: ReactNode }) { const navRef = useRef(null); const handleRef = useRef(null); const horseshoe = useHorseshoeEnabled(); const [savedWidth, setSavedWidth] = useAtom(sidebarWidthAtom); const [vw, setVw] = useState( typeof window !== 'undefined' ? window.innerWidth : 1280 ); const [dragging, setDragging] = useState(false); // Live width during a drag — kept in component state so we don't write to // the localStorage-backed atom on every pointermove (hundreds of sync disk // writes per drag). Atom is flushed once on pointerup. const [liveWidth, setLiveWidth] = useState(null); useEffect(() => { const onResize = () => setVw(window.innerWidth); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); const maxW = Math.max(SIDEBAR_WIDTH_MIN, Math.floor(vw / 3)); const baseWidth = dragging && liveWidth !== null ? liveWidth : savedWidth; const width = clampSidebarWidth(baseWidth, vw); // On viewports too narrow for any meaningful range (max == min) we hide the // handle entirely: leaving it pointer-active but un-draggable reads as a // broken UI. Falls back to the static MIN width. const canResize = maxW > SIDEBAR_WIDTH_MIN; // During a drag, signal that the user has pushed past a clamp — min // squishes the indicator, max stretches it, so the limit feels tactile. const atMin = dragging && liveWidth !== null && liveWidth <= SIDEBAR_WIDTH_MIN; const atMax = dragging && liveWidth !== null && liveWidth >= maxW; // Cleanup body styles when the drag ends OR the component unmounts mid-drag // (e.g. mobile breakpoint flip, route change, Alt-Tab without pointerup). // Without this the page can get stuck with col-resize cursor and // user-select: none on body until the next mutation. useEffect(() => { if (!dragging) return undefined; return () => { document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [dragging]); const beginDrag = useCallback((pointerId: number) => { setDragging(true); setLiveWidth(null); try { handleRef.current?.setPointerCapture(pointerId); } catch { /* setPointerCapture is best-effort */ } document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, []); const endDrag = useCallback(() => { setDragging(false); document.body.style.cursor = ''; document.body.style.userSelect = ''; setLiveWidth((current) => { if (current !== null) { setSavedWidth(clampSidebarWidth(current, window.innerWidth)); } return null; }); }, [setSavedWidth]); const onPointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0 || !canResize) return; e.preventDefault(); beginDrag(e.pointerId); }, [beginDrag, canResize] ); const onPointerMove = useCallback( (e: React.PointerEvent) => { if (!dragging || !navRef.current) return; const rect = navRef.current.getBoundingClientRect(); setLiveWidth(clampSidebarWidth(e.clientX - rect.left, window.innerWidth)); }, [dragging] ); const onStopPointer = useCallback( (e: React.PointerEvent) => { try { handleRef.current?.releasePointerCapture(e.pointerId); } catch { /* releasePointerCapture is best-effort */ } endDrag(); }, [endDrag] ); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { if (!canResize) return; const step = e.shiftKey ? 64 : 16; let next: number | null = null; if (e.key === 'ArrowLeft') next = savedWidth - step; else if (e.key === 'ArrowRight') next = savedWidth + step; else if (e.key === 'Home') next = SIDEBAR_WIDTH_MIN; else if (e.key === 'End') next = maxW; if (next === null) return; e.preventDefault(); setSavedWidth(clampSidebarWidth(next, vw)); }, [savedWidth, vw, maxW, canResize, setSavedWidth] ); return ( {children} {canResize && (
` separator — shift the // handle so its hit-area fills exactly that void instead of // bleeding onto the chat surface. Both dimensions are in // `rem` to match the gap Box width (also `toRem(...)`), so // they stay aligned across non-default root font-sizes. // Native (or any context that disables the horseshoe, e.g. // inside `Modal500`) keeps the CSS default which lines up // with the Line. style={ horseshoe ? { right: `-${toRem(VOJO_HORSESHOE_GAP_PX)}`, width: toRem(VOJO_HORSESHOE_GAP_PX), } : undefined } data-dragging={dragging || undefined} data-at-min={atMin || undefined} data-at-max={atMax || undefined} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onStopPointer} onPointerCancel={onStopPointer} onLostPointerCapture={onStopPointer} onKeyDown={onKeyDown} /> )} ); } export const PageNavHeader = as<'header', css.PageNavHeaderVariants>( ({ className, outlined, ...props }, ref) => (
) ); export function PageNavContent({ scrollRef, children, }: { children: ReactNode; scrollRef?: MutableRefObject; }) { return (
{children}
); } export const Page = as<'div'>(({ className, ...props }, ref) => ( )); export const PageHeader = as<'div', css.PageHeaderVariants>( ({ className, outlined, balance, ...props }, ref) => (
) ); export const PageContent = as<'div'>(({ className, ...props }, ref) => (
)); export function PageHeroEmpty({ children }: { children: ReactNode }) { return ( {children} ); } export const PageHeroSection = as<'div', ComponentProps>( ({ className, ...props }, ref) => ( ) ); export function PageHero({ icon, title, subTitle, children, }: { icon?: ReactNode; title: ReactNode; subTitle?: ReactNode; children?: ReactNode; }) { return ( {icon && ( {icon} )} {title} {subTitle && ( {subTitle} )} {children} ); } export const PageContentCenter = as<'div'>(({ className, ...props }, ref) => (
));