// Sliding horseshoe sheet for the channels workspace switcher. Mirror of // `features/settings/MobileSettingsHorseshoe.tsx` (canonical, shipped in // commit a7d6fc2) with three deliberate adaptations: // // • Single code path on web AND native — no // `useScreenSizeContext()` mobile-only gate. The horseshoe lives // wherever channels render, so a desktop tap on the workspace // footer slides the sheet up inside the PageNav column the same // way a mobile tap does. // • Rail height is a fraction (0.55) of the wrapper container's // height, NOT `window.innerHeight`. The sheet is scoped to its // PageNav slot, so a viewport-relative rail would overshoot on // desktop where the column is short. `ResizeObserver` on the // container drives `containerHeightPx`; the rail recomputes // on column resize. // • Drag-origin data attribute is renamed to // `data-channels-workspace-drag-origin` so it doesn't collide with // the existing settings-sheet selector elsewhere on the page. // // Everything else (clip-path carve, void colour, VAUL easing curve, // entry rAF gate, hasEntered mirror, keepMounted unmount delay, // dialog/aria-label invariants, portal marker for Android back) is // preserved verbatim — those were learned through review cycles on the // canonical horseshoe and are not re-litigated here. import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; import { Room } from 'matrix-js-sdk'; import { channelsWorkspaceSheetAtom } from '../../../state/channelsWorkspaceSheet'; import { useCloseChannelsWorkspaceSheet, useOpenChannelsWorkspaceSheet, } from '../../../state/hooks/channelsWorkspaceSheet'; import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext'; import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader'; import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe'; import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet'; import * as css from './ChannelsWorkspaceHorseshoe.css'; // Data attribute set on `WorkspaceFooter` to mark it as the drag-up / // tap-to-open origin for the sheet. The document-level touchstart / // pointerdown listeners use `target.closest(SELECTOR)` so the footer's // own onClick handler still fires for no-movement taps. Keep this in // sync with the attribute spread on the footer row. export const CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR = 'data-channels-workspace-drag-origin'; const DRAG_ORIGIN_SELECTOR = `[${CHANNELS_WORKSPACE_DRAG_ORIGIN_ATTR}]`; const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; const ANIMATION_MS = 250; // Commit distance (px). 80px matches the canonical horseshoe so the two // gestures (settings open, workspace open) feel identical to muscle // memory. const COMMIT_THRESHOLD_PX = 80; // Sheet rail as a fraction of the PageNav column's measured height. // Workspace switcher content is small (spaces list + one create row), so // 55% reads as a compact tray rather than a half-screen takeover. const RAIL_FRACTION = 0.55; // Drag distance over which radii + void gap ramp from 0 to full during // finger-drag. Matched to COMMIT_THRESHOLD_PX so the silhouette is // fully formed exactly when the gesture qualifies to commit. const HORSESHOE_EMERGE_PX = 80; // Symmetric cubic in-out — linear ramp was too snappy in the canonical // horseshoe (corners jumped in within ~10px of drag); cubic keeps them // subtle until ~40% of the gesture, then blossoms around the midpoint. const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); // Axis dead-zone for horizontal-bail. The finger must travel this far // on either axis before we resolve as vertical (open/close the sheet) // or horizontal (yield to MobileTabsPager on listing surfaces). Note: // the workspace sheet only mounts on /channels/!space/, where the // pager is inactive — the bail here is defensive symmetry with the // settings horseshoe so future layout changes can't surprise us. const AXIS_DEAD_ZONE_PX = 12; type DragSource = 'footer' | 'handle'; type DragState = { source: DragSource; inputType: 'touch' | 'pointer'; startX: number; startY: number; deltaY: number; }; type ChannelsWorkspaceHorseshoeProps = { space: Room; children: ReactNode; }; export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspaceHorseshoeProps) { const { t } = useTranslation(); const open = useAtomValue(channelsWorkspaceSheetAtom); const openSheet = useOpenChannelsWorkspaceSheet(); const closeSheet = useCloseChannelsWorkspaceSheet(); const containerRef = useRef(null); const [containerHeightPx, setContainerHeightPx] = useState(0); const [drag, setDrag] = useState(null); // In pager mode the appBody bg must be transparent so the static // pager header tabs (behind the swipe strip in DOM order) show // through until covered by the rising chats curtain. See // MobileSettingsHorseshoe.tsx for the matching pattern. const inPagerMode = useMobilePagerPane() !== null; // ResizeObserver on the wrapper — rail height is scoped to THIS // column. PageNav width changes via the resizable handle on desktop // and column height changes with viewport / split-screen on Android; // ResizeObserver handles both without a manual `window.resize` // listener which wouldn't catch the column-resize case. useEffect(() => { const el = containerRef.current; if (!el) return undefined; setContainerHeightPx(el.getBoundingClientRect().height); const ro = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; setContainerHeightPx(entry.contentRect.height); }); ro.observe(el); return () => ro.disconnect(); }, []); const railHeightPx = Math.round(containerHeightPx * RAIL_FRACTION); // Entry-animation gate. Kept for gesture-feel parity with the // canonical horseshoe even though the channels sheet has no // deep-link path (atom is only ever flipped by a footer tap/drag on // an already-mounted Channels column, so a "snap-open on mount" // can't actually happen here). The `hasEnteredRef` mirror still // matters because React 18 strict-mode dev runs the unmount // cleanup before the rAF fires; without the guard the effect // would clear the atom mid-rehearsal and break HMR. const [hasEntered, setHasEntered] = useState(false); const hasEnteredRef = useRef(false); useLayoutEffect(() => { const id = requestAnimationFrame(() => { hasEnteredRef.current = true; setHasEntered(true); }); return () => cancelAnimationFrame(id); }, []); // Delay unmount of the sheet content by `ANIMATION_MS` so the slide- // down has something to render. Without this, clearing the atom would // unmount the spaces list instantly and the user would see an empty // panel shrink instead of the menu sliding away with it. const [keepMounted, setKeepMounted] = useState(open); useEffect(() => { if (open) { setKeepMounted(true); return undefined; } const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS); return () => window.clearTimeout(id); }, [open]); const baseExpanded = open && hasEntered ? railHeightPx : 0; // CLAMP via Math.max/min, not early-return — see canonical // `applyMove` for the bug a wrong-direction early-return introduces // (stale deltaY survives a reversed swipe and commits on release). const expandedPx = drag ? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY)) : baseExpanded; const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; const isDragging = drag !== null; const horseshoeActive = expandedPx > 0; // Bridge our local `horseshoeActive` (geometric) signal up to the // pager-shared atom so `MobileTabsPagerHeader` can z-elevate the // static header from the first frame of drag. Mirror of the same // bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in // `state/mobilePagerHeader.ts` for the «no black flash» rationale. const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom); useEffect(() => { setMobileHorseshoeActive(horseshoeActive); return () => setMobileHorseshoeActive(false); }, [horseshoeActive, setMobileHorseshoeActive]); const handleRef = useRef(null); // Refs so the always-installed document listeners see the latest // state without re-subscribing on every render. Same pattern as the // canonical horseshoe. const dragRef = useRef(null); dragRef.current = drag; const openRef = useRef(open); openRef.current = open; const openSheetRef = useRef(openSheet); openSheetRef.current = openSheet; const closeSheetRef = useRef(closeSheet); closeSheetRef.current = closeSheet; // Clear the atom on unmount — switching tabs (Direct, Bots), picking // a channel, or navigating to /explore unmounts Channels.tsx; without // this clear, returning to /channels later would auto-reopen the // sheet. `hasEnteredRef` skips the strict-mode rehearsal cleanup // before the rAF flag flips. useEffect( () => () => { if (!hasEnteredRef.current) return; if (openRef.current) closeSheetRef.current(); }, [] ); // Hardware Escape (web only, rare on mobile) → close. Plain keydown, // no FocusTrap — same rationale as the canonical (focus-trap-react // throws when its container has no tabbable nodes, which is exactly // the case mid-drag before the sheet has rendered any focusable // children). useEffect(() => { if (!open) return undefined; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; const target = e.target as HTMLElement | null; if ( target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) ) { return; } closeSheetRef.current(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [open]); // Drag mechanics — two origin paths: // // 1. Document-level touch/pointer on anything matching // `[data-channels-workspace-drag-origin]` (= WorkspaceFooter). // Passive touchstart / non-passive listeners are split so the // row's own onClick handler still fires for no-movement taps. // 2. Element-level touch/pointer on `handleRef` (the 20px drag // band at the top of the open sheet). Only triggers when the // sheet is open; touches on the spaces list inside the panel // are not drag-sensitive so internal scroll works without // conflict. useEffect(() => { const handleEl = handleRef.current; // Per-drag axis-resolution flag (same idiom as MobileSettings- // Horseshoe). Reset to false in every touchstart / pointerdown // handler, flipped to true in applyMove once the finger leaves the // dead-zone with a vertical-dominant delta. let axisResolved = false; // Sticky bail for the remainder of the current touch series once // we've yielded to the pager (axis horizontal). Prevents a late // vertical reversal in the same touch from re-engaging the sheet. let touchBailed = false; // CLAMP, not early-return — reversal of gesture direction must // drag `deltaY` back toward 0. footer source clamps the upward // drag-open path to negative deltas; handle source clamps the // downward drag-close path to positive deltas. const applyMove = (clientX: number, clientY: number, e: TouchEvent | PointerEvent) => { if (touchBailed) return; const d = dragRef.current; if (!d) return; const rawDeltaX = clientX - d.startX; const rawDeltaY = clientY - d.startY; if (!axisResolved) { const dxAbs = Math.abs(rawDeltaX); const dyAbs = Math.abs(rawDeltaY); if (dxAbs < AXIS_DEAD_ZONE_PX && dyAbs < AXIS_DEAD_ZONE_PX) return; if (dxAbs > dyAbs) { // Horizontal-dominant — yield to MobileTabsPager. Drop drag // state without preventDefault so the pager can take over. // Sticky bail prevents a late vertical reversal from // re-engaging the sheet within the same touch series. touchBailed = true; setDrag(null); return; } axisResolved = true; } const nextDelta = d.source === 'footer' ? Math.min(0, rawDeltaY) : Math.max(0, rawDeltaY); if (e.cancelable) e.preventDefault(); setDrag({ ...d, deltaY: nextDelta }); }; const applyEnd = () => { const d = dragRef.current; if (!d) return; if (d.source === 'footer' && -d.deltaY > COMMIT_THRESHOLD_PX) { openSheetRef.current(); } else if (d.source === 'handle' && d.deltaY > COMMIT_THRESHOLD_PX) { closeSheetRef.current(); } setDrag(null); }; const targetIsDragOrigin = (target: EventTarget | null): boolean => { if (!target || !(target instanceof Element)) return false; return target.closest(DRAG_ORIGIN_SELECTOR) !== null; }; // === Touch path === const onDocTouchStart = (e: TouchEvent) => { if (dragRef.current) return; if (openRef.current) return; // sheet open → handle owns drag if (!targetIsDragOrigin(e.target)) return; const touch = e.touches[0]; axisResolved = false; touchBailed = false; setDrag({ source: 'footer', inputType: 'touch', startX: touch.clientX, startY: touch.clientY, deltaY: 0, }); }; const onHandleTouchStart = (e: TouchEvent) => { if (dragRef.current) return; if (!openRef.current) return; const touch = e.touches[0]; axisResolved = false; touchBailed = false; setDrag({ source: 'handle', inputType: 'touch', startX: touch.clientX, startY: touch.clientY, deltaY: 0, }); }; const onTouchMove = (e: TouchEvent) => { const d = dragRef.current; if (!d || d.inputType !== 'touch') return; applyMove(e.touches[0].clientX, e.touches[0].clientY, e); }; const onTouchEnd = () => { const d = dragRef.current; if (!d || d.inputType !== 'touch') return; applyEnd(); }; // === Mouse / pen path === const onDocPointerDown = (e: PointerEvent) => { if (e.pointerType === 'touch') return; if (dragRef.current) return; if (openRef.current) return; if (e.button !== 0) return; if (!targetIsDragOrigin(e.target)) return; axisResolved = false; touchBailed = false; setDrag({ source: 'footer', inputType: 'pointer', startX: e.clientX, startY: e.clientY, deltaY: 0, }); }; const onHandlePointerDown = (e: PointerEvent) => { if (e.pointerType === 'touch') return; if (dragRef.current) return; if (!openRef.current) return; if (e.button !== 0) return; axisResolved = false; touchBailed = false; setDrag({ source: 'handle', inputType: 'pointer', startX: e.clientX, startY: e.clientY, deltaY: 0, }); }; const onDocPointerMove = (e: PointerEvent) => { if (e.pointerType === 'touch') return; const d = dragRef.current; if (!d || d.inputType !== 'pointer') return; applyMove(e.clientX, e.clientY, e); }; const onDocPointerEnd = (e: PointerEvent) => { if (e.pointerType === 'touch') return; const d = dragRef.current; if (!d || d.inputType !== 'pointer') return; applyEnd(); }; document.addEventListener('touchstart', onDocTouchStart, { passive: true }); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd, { passive: true }); document.addEventListener('touchcancel', onTouchEnd, { passive: true }); document.addEventListener('pointerdown', onDocPointerDown); document.addEventListener('pointermove', onDocPointerMove, { passive: false }); document.addEventListener('pointerup', onDocPointerEnd, { passive: true }); document.addEventListener('pointercancel', onDocPointerEnd, { passive: true }); if (handleEl) { handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true }); handleEl.addEventListener('pointerdown', onHandlePointerDown); } return () => { document.removeEventListener('touchstart', onDocTouchStart); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); document.removeEventListener('touchcancel', onTouchEnd); document.removeEventListener('pointerdown', onDocPointerDown); document.removeEventListener('pointermove', onDocPointerMove); document.removeEventListener('pointerup', onDocPointerEnd); document.removeEventListener('pointercancel', onDocPointerEnd); if (handleEl) { handleEl.removeEventListener('touchstart', onHandleTouchStart); handleEl.removeEventListener('pointerdown', onHandlePointerDown); } }; }, []); // Geometry — radii + void gap ramp via easeInOutCubic during drag; // release jumps to the full value and CSS transition (VAUL_EASING) // carries the visual. See canonical for the visual-curve rationale. let horseshoeRamp: number; if (isDragging) { horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)); } else { horseshoeRamp = expandedFraction > 0 ? 1 : 0; } const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; const appBodyMaskBottomPx = expandedPx + appBodyGapPx; // `inset(top right bottom left round TL TR BR BL)` — only BR/BL carry // the radius so the visible top portion of appBody has rounded // bottom corners at the clip boundary. Always emitted (even at all // zeros) so CSS can transition smoothly between closed and open. const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`; const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`; const containerStyle: React.CSSProperties = { backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined, }; const renderSheet = keepMounted || isDragging; // Portal marker so `useAndroidBackButton` dispatches Escape instead of // `navigate(-1)` while the sheet is open. The keydown handler above // catches the synthetic Escape and closes the sheet — same pattern // the canonical horseshoe uses. Falls back to body for SSR / tests // where `#portalContainer` isn't mounted yet. const portalTarget = typeof document !== 'undefined' ? document.getElementById('portalContainer') ?? document.body : null; return (
{open && portalTarget ? createPortal(