diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx
index 2afba267..1a4dafe8 100644
--- a/src/app/components/page/Page.tsx
+++ b/src/app/components/page/Page.tsx
@@ -1,9 +1,23 @@
-import React, { ComponentProps, MutableRefObject, ReactNode } from 'react';
+import React, {
+ ComponentProps,
+ MutableRefObject,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import { Box, Header, Line, Scroll, Text, as } 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';
type PageRootProps = {
nav: ReactNode;
@@ -26,11 +40,20 @@ export function PageRoot({ nav, children }: PageRootProps) {
type ClientDrawerLayoutProps = {
children: ReactNode;
+ resizable?: boolean;
};
-export function PageNav({ size, children }: ClientDrawerLayoutProps & css.PageNavVariants) {
+export function PageNav({
+ size,
+ resizable,
+ children,
+}: ClientDrawerLayoutProps & css.PageNavVariants) {
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
+ if (resizable && !isMobile) {
+ return {children};
+ }
+
return (
(null);
+ const handleRef = useRef(null);
+ 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 && (
+
+ )}
+
+ );
+}
+
export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
({ className, outlined, ...props }, ref) => (
+
+
@@ -50,7 +50,7 @@ export function Channels() {
}, [space.roomId]);
return (
-
+
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 3f2489bc..26e84f01 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -197,7 +197,7 @@ export function Direct() {
});
return (
-
+
{noRoomToDisplay ? (
diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx
index 2a7c6199..44b5a140 100644
--- a/src/app/pages/client/space/Space.tsx
+++ b/src/app/pages/client/space/Space.tsx
@@ -440,7 +440,7 @@ export function Space() {
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
return (
-
+
diff --git a/src/app/state/sidebarWidth.ts b/src/app/state/sidebarWidth.ts
new file mode 100644
index 00000000..5635095c
--- /dev/null
+++ b/src/app/state/sidebarWidth.ts
@@ -0,0 +1,26 @@
+import {
+ atomWithLocalStorage,
+ getLocalStorageItem,
+ setLocalStorageItem,
+} from './utils/atomWithLocalStorage';
+
+export const SIDEBAR_WIDTH_KEY = 'vojo_sidebar_width';
+export const SIDEBAR_WIDTH_MIN = 320;
+export const SIDEBAR_WIDTH_DEFAULT = 416;
+
+const readSidebarWidth = (key: string): number => {
+ const raw = getLocalStorageItem(key, SIDEBAR_WIDTH_DEFAULT);
+ const value = typeof raw === 'number' && Number.isFinite(raw) ? raw : SIDEBAR_WIDTH_DEFAULT;
+ return Math.max(SIDEBAR_WIDTH_MIN, Math.round(value));
+};
+
+export const sidebarWidthAtom = atomWithLocalStorage(
+ SIDEBAR_WIDTH_KEY,
+ readSidebarWidth,
+ setLocalStorageItem
+);
+
+export const clampSidebarWidth = (px: number, viewportWidth: number): number => {
+ const max = Math.max(SIDEBAR_WIDTH_MIN, Math.floor(viewportWidth / 3));
+ return Math.max(SIDEBAR_WIDTH_MIN, Math.min(max, Math.round(px)));
+};