// Wrapper around the room's chat column that adds the user-profile // surface on top. // // Mobile (≤ 750): the top «horseshoe» rail. Panel and chat header // live as siblings inside a single `silhouette` wrapper whose own // `overflow:hidden + border-bottom-radius` cuts the bottom of the // combined block. There's no separate header-radius / panel-radius // hand-off mid-drag — the rounding is a property of the silhouette // itself, so it stays visually anchored to «the bottom of whatever's // currently last-visible» throughout the gesture without seams or // blips. Replaces the legacy two-element layout where panel was // `position: absolute` over a chat column that owned the header in // flow; each had its own radius animated by the now-deleted // `horseshoeEmerge` curve. // // Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin // passthrough; the profile renders as a right-side pane via // `RoomViewProfileSidePanel`, which `Room.tsx` mounts as a sibling // of the chat column. import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; import { config } from 'folds'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { userRoomProfileAtom } from '../../state/userRoomProfile'; import { useCloseUserRoomProfile, useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { getMemberAvatarMxc } from '../../utils/room'; import { getMxIdLocalPart, guessDmRoomUserId, mxcUrlToHttp } from '../../utils/matrix'; import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile'; import { stopPropagation } from '../../utils/keyboard'; import colorMXID from '../../../util/colorMXID'; import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import * as css from './RoomViewProfilePanel.css'; // Rail-height policy: the card sizes itself to its actual content // height (hero + info rows + optional alerts + safe-top inset + drag // handle), measured via useLayoutEffect + ResizeObserver below. The // internal scroll has been removed (user request: «запретил бы драгу // внутри карточки») so there is no drag-inside-drag — the rail simply // grows or shrinks with whatever the profile renders. // // `MAX_RAIL_FRACTION` is a defensive ceiling for the rare overflow // case (e.g. 5 stacked moderation alerts) — content beyond it is // clipped by `panelViewport`'s `overflow: hidden`. 0.85 keeps a // couple of chat lines peeking at the bottom so the user still has // context that the chat is behind the card. // // `FALLBACK_RAIL_FRACTION` is the size used for one frame before the // content measurement lands. useLayoutEffect runs synchronously before // browser paint, so in practice the user only ever sees the measured // height — but the constant matters during SSR / before the effect // commits state. const MAX_RAIL_FRACTION = 0.85; const FALLBACK_RAIL_FRACTION = 0.42; // Past this many pixels of drag the gesture commits (open or close). const COMMIT_THRESHOLD_PX = 80; const ANIMATION_MS = 250; // Drag distance over which the horseshoe-radius + chat-gap + // chat-radius ramp from 0 to their full value during finger-drag. // Matched to COMMIT_THRESHOLD_PX so the silhouette is fully formed // exactly when the gesture qualifies to commit — the user sees the // rounding "earn" itself across the whole gesture instead of // snapping in immediately. Combined with the easeInOutCubic curve // below, gives a gentle start (corners barely emerge in first // ~10px) → fast middle (the bulk of the round-in happens around // the visual halfway point) → soft settle near commit. const HORSESHOE_EMERGE_PX = 80; // Vaul / iOS system curve (asymmetric ease-out — steep accelerate, // long decelerate). Reads as "spring-like" without the overshoot of // an actual spring, which is what makes Apple sheet transitions feel // non-mechanical. Used on the CSS release transitions only — the // release phase wants instant feedback + soft settle. Vaul itself // also reserves this curve for release and tracks the finger linearly // during drag; we intentionally diverge from that during drag (see // `easeInOutCubic` below) because the user explicitly asked for a // gentle-start ramp on the rounding, not direct-manipulation. const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)'; // Symmetric cubic in-out S-curve — slow start, fast middle, slow // finish. NOT a Vaul-curve approximation: Vaul's curve is asymmetric // (steep accelerate, long decelerate) so it grows fast from the // first pixel of drag, which would defeat the explicit user ask of // «не сразу скругление». Symmetric in-out gives the rounding a // barely-visible start that only blossoms around the halfway point, // which is the felt-feel the user signed off on. Release animations // keep the asymmetric Vaul curve in CSS where direct manipulation // is over and the auto-animation can take centre stage. const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); type RoomViewProfilePanelProps = { header: ReactNode; children: ReactNode; }; type DragState = { source: 'header' | 'panel'; // Origin tag — `touch` for finger drags, `pointer` for mouse/pen. // Each path filters by this so an in-flight drag started by one // input device can never be advanced or ended by the other. The // failure mode it prevents: hybrid laptop (touchscreen + mouse) // where a touchstart fires but its matching touchend gets dropped // by the OS / browser, leaving `dragRef.current` populated; if the // user then moves the mouse, the pointer-path handlers would // otherwise apply mouse clientY against the touch's startY and // commit a bogus close. Tagging makes each path self-isolating. inputType: 'touch' | 'pointer'; startY: number; deltaY: number; }; // Mobile-only top horseshoe. Slides down from above on drag-down on // the chat header (1:1 DM only); slides up to close on drag-up // anywhere on the panel. Panel and chat header share a single // `silhouette` wrapper whose `overflow:hidden + border-bottom-radius` // owns the visible rounded bottom — see file header for the // architectural rationale. function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) { const { t } = useTranslation(); const profileState = useAtomValue(userRoomProfileAtom); const close = useCloseUserRoomProfile(); const openProfile = useOpenUserRoomProfile(); const room = useRoom(); const space = useSpaceOptionally(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const isOneOnOne = useIsOneOnOne(); const myUserId = mx.getSafeUserId(); const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined; const headerDragPeer = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined; const headerDragEnabled = !!headerDragPeer; const [drag, setDrag] = useState(null); const [avatarMode, setAvatarMode] = useState(false); const headerRef = useRef(null); const panelRef = useRef(null); const headerInnerRef = useRef(null); // Measured natural height of the chat-header inner block (incl. its // `padding-top: var(--vojo-safe-top)`). Drives the explicit `height` // animation on `headerViewport` — replaces the legacy `grid-template- // rows: 1fr → 0fr` trick because Folds `
` enforces // a `min-height` that prevents the grid track from collapsing fully // to 0, leaving a light-blue strip at the bottom of the user card // when the rail is fully open. Measured via `useLayoutEffect` so the // first paint already uses the right value (no flicker on cold // mount) and re-measured by ResizeObserver whenever the chat-header // content reflows (e.g. online tag appears, env-inset changes on // rotation). const [headerNaturalHeight, setHeaderNaturalHeight] = useState(0); // Close profile when the room changes — atom is global state and // would otherwise carry the previous room's userId into this room. useEffect(() => () => close(), [room.roomId, close]); // Reset avatar-zoom mode whenever the rendered user changes. useEffect(() => { setAvatarMode(false); }, [profileState?.userId]); const [viewportHeight, setViewportHeight] = useState(() => { if (typeof window === 'undefined') return 800; return window.innerHeight; }); useEffect(() => { const onResize = () => setViewportHeight(window.innerHeight); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); // Measured natural content height of the user card (S400-padded // wrapper around `UserRoomProfile`) plus the panelContent's safe-top // padding and the panelHandle. Drives `railHeightPx` so the rail // exactly contains the content — no internal scroll in the fit case. // Re-measures when the profile reflows (alerts mount, encryption // detected, presence label changes, etc.). const [contentNaturalHeight, setContentNaturalHeight] = useState(0); const panelContentRef = useRef(null); const panelMeasureRef = useRef(null); useLayoutEffect(() => { const measureEl = panelMeasureRef.current; const contentEl = panelContentRef.current; if (!measureEl || !contentEl) return undefined; const measure = () => { const innerH = measureEl.scrollHeight; if (innerH <= 0) return; const padTop = parseFloat(getComputedStyle(contentEl).paddingTop) || 0; // 20 px must stay in sync with `panelHandle.height` in css.ts. setContentNaturalHeight(innerH + padTop + 20); }; measure(); const ro = new ResizeObserver(measure); ro.observe(measureEl); return () => ro.disconnect(); }, []); const maxRailPx = Math.round(viewportHeight * MAX_RAIL_FRACTION); const railHeightPx = contentNaturalHeight > 0 ? Math.min(maxRailPx, contentNaturalHeight) : Math.round(viewportHeight * FALLBACK_RAIL_FRACTION); // Internal scroll is enabled ONLY when the rail hit the safety cap // (very rare — multiple stacked moderation alerts). In the common // case the rail is exactly content-sized and there is nothing to // scroll — `overflow: hidden` then prevents any drag-inside-drag. const contentOverflows = contentNaturalHeight > 0 && contentNaturalHeight > maxRailPx; // Measure the chat header's natural height (incl. its safe-top padding). // `scrollHeight` returns the content size even while the outer is // constrained by our animated `height` — so we keep getting the right // natural value across reflows. `useLayoutEffect` does the first // measurement synchronously after DOM mutation, before paint, so the // first frame already uses the measured value. useLayoutEffect(() => { const el = headerInnerRef.current; if (!el) return undefined; const measure = () => { const next = el.scrollHeight; if (next > 0) setHeaderNaturalHeight(next); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); const open = !!profileState; const baseExpanded = open ? railHeightPx : 0; 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; const liveUserId = profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined); const lastUserIdRef = useRef(undefined); if (liveUserId) lastUserIdRef.current = liveUserId; const renderUserId = liveUserId ?? lastUserIdRef.current; const renderUserAvatarMxc = renderUserId ? getMemberAvatarMxc(room, renderUserId) : undefined; const renderUserAvatarUrl = (renderUserAvatarMxc && mxcUrlToHttp(mx, renderUserAvatarMxc, useAuthentication, 720, 720, 'scale')) ?? undefined; const dragRef = useRef(null); dragRef.current = drag; const profileStateRef = useRef(profileState); profileStateRef.current = profileState; const openProfileRef = useRef(openProfile); openProfileRef.current = openProfile; const closeRef = useRef(close); closeRef.current = close; const roomIdRef = useRef(room.roomId); roomIdRef.current = room.roomId; const spaceIdRef = useRef(space?.roomId); spaceIdRef.current = space?.roomId; useEffect(() => { const headerEl = headerRef.current; const panelEl = panelRef.current; const dragOpenCords = (): { x: number; y: number; width: number; height: number } => { const rect = headerRef.current?.getBoundingClientRect(); if (rect) return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; return { x: 0, y: 0, width: 0, height: 0 }; }; // Shared commit + cancel logic between touch and pointer paths. const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => { const d = dragRef.current; if (!d) return; const rawDelta = clientY - d.startY; let nextDelta = rawDelta; if (d.source === 'header') { nextDelta = Math.max(0, rawDelta); } else { if (rawDelta > 0) return; nextDelta = rawDelta; } if (e.cancelable) e.preventDefault(); setDrag({ ...d, deltaY: nextDelta }); }; const applyEnd = () => { const d = dragRef.current; if (!d) return; if (d.source === 'header' && d.deltaY > COMMIT_THRESHOLD_PX) { if (headerDragPeer) { openProfileRef.current( roomIdRef.current, spaceIdRef.current, headerDragPeer, dragOpenCords() ); } } else if (d.source === 'panel' && -d.deltaY > COMMIT_THRESHOLD_PX) { closeRef.current(); } setDrag(null); }; // === Touch path === Unchanged from the touch-only implementation. // Touch events have implicit pointer-capture semantics (touchmove // keeps firing on the touchstart element until touchend) so we // don't need to escalate to document-level listeners the way the // mouse path below does. Kept separate from the pointer path so // touch UX is bit-identical to the prior version. const onHeaderTouchStart = (e: TouchEvent) => { if (dragRef.current) return; if (profileStateRef.current) return; if (!headerDragEnabled) return; const touch = e.touches[0]; setDrag({ source: 'header', inputType: 'touch', startY: touch.clientY, deltaY: 0 }); }; const onPanelTouchStart = (e: TouchEvent) => { if (dragRef.current) return; if (!profileStateRef.current) return; const touch = e.touches[0]; setDrag({ source: 'panel', inputType: 'touch', startY: touch.clientY, deltaY: 0 }); }; const onTouchMove = (e: TouchEvent) => { const d = dragRef.current; if (!d || d.inputType !== 'touch') return; applyMove(e.touches[0].clientY, e); }; const onTouchEnd = () => { const d = dragRef.current; if (!d || d.inputType !== 'touch') return; applyEnd(); }; // === Mouse / pen path === Pointer events gated on // `pointerType !== 'touch'` so touch devices stay on the touch // path above and never double-fire. pointermove + pointerup are // attached to `document` (not the trigger element) so the drag // keeps tracking when the mouse cursor wanders past the element // bounds — common when the user yanks the rail up fast and // overshoots the panel. The alternative (setPointerCapture on the // trigger) would steal pointerup from inner interactive children // (kebab popout button inside the user card, mention links) and // break their click handlers — document-level listeners avoid // that hijack entirely. const onHeaderPointerDown = (e: PointerEvent) => { if (e.pointerType === 'touch') return; if (dragRef.current) return; if (profileStateRef.current) return; if (!headerDragEnabled) return; if (e.button !== 0) return; setDrag({ source: 'header', inputType: 'pointer', startY: e.clientY, deltaY: 0 }); }; const onPanelPointerDown = (e: PointerEvent) => { if (e.pointerType === 'touch') return; if (dragRef.current) return; if (!profileStateRef.current) return; if (e.button !== 0) return; setDrag({ source: 'panel', inputType: 'pointer', startY: e.clientY, deltaY: 0 }); }; const onDocumentPointerMove = (e: PointerEvent) => { if (e.pointerType === 'touch') return; const d = dragRef.current; if (!d || d.inputType !== 'pointer') return; applyMove(e.clientY, e); }; const onDocumentPointerEnd = (e: PointerEvent) => { if (e.pointerType === 'touch') return; const d = dragRef.current; if (!d || d.inputType !== 'pointer') return; applyEnd(); }; if (headerEl) { headerEl.addEventListener('touchstart', onHeaderTouchStart, { passive: true }); headerEl.addEventListener('touchmove', onTouchMove, { passive: false }); headerEl.addEventListener('touchend', onTouchEnd, { passive: true }); headerEl.addEventListener('touchcancel', onTouchEnd, { passive: true }); headerEl.addEventListener('pointerdown', onHeaderPointerDown); } if (panelEl) { panelEl.addEventListener('touchstart', onPanelTouchStart, { passive: true }); panelEl.addEventListener('touchmove', onTouchMove, { passive: false }); panelEl.addEventListener('touchend', onTouchEnd, { passive: true }); panelEl.addEventListener('touchcancel', onTouchEnd, { passive: true }); panelEl.addEventListener('pointerdown', onPanelPointerDown); } document.addEventListener('pointermove', onDocumentPointerMove, { passive: false }); document.addEventListener('pointerup', onDocumentPointerEnd, { passive: true }); document.addEventListener('pointercancel', onDocumentPointerEnd, { passive: true }); return () => { if (headerEl) { headerEl.removeEventListener('touchstart', onHeaderTouchStart); headerEl.removeEventListener('touchmove', onTouchMove); headerEl.removeEventListener('touchend', onTouchEnd); headerEl.removeEventListener('touchcancel', onTouchEnd); headerEl.removeEventListener('pointerdown', onHeaderPointerDown); } if (panelEl) { panelEl.removeEventListener('touchstart', onPanelTouchStart); panelEl.removeEventListener('touchmove', onTouchMove); panelEl.removeEventListener('touchend', onTouchEnd); panelEl.removeEventListener('touchcancel', onTouchEnd); panelEl.removeEventListener('pointerdown', onPanelPointerDown); } document.removeEventListener('pointermove', onDocumentPointerMove); document.removeEventListener('pointerup', onDocumentPointerEnd); document.removeEventListener('pointercancel', onDocumentPointerEnd); }; }, [headerDragEnabled, headerDragPeer]); // Horseshoe geometry — single ramp factor drives the silhouette // bottom radius, the chat top radius, AND the chat margin-top gap. // // • Finger-drag: live formula over the first HORSESHOE_EMERGE_PX // (80px = COMMIT_THRESHOLD_PX) of drag, so the rounding and // gap visibly emerge across the whole commit gesture and finish // exactly when the gesture qualifies to commit. // • Release animation: binary on/off, paired with a CSS // transition so the values decay smoothly in lockstep with the // 250ms panel-height / header-collapse animations. // // The same ramp factor feeds all three properties so they always // appear and disappear together — no per-element timing skew that // could create a perceived «blip». let horseshoeRamp: number; if (isDragging) { horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)); } else if (horseshoeActive) { horseshoeRamp = 1; } else { horseshoeRamp = 0; } const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; const chatRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; const chatGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; const panelViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`; const headerViewportTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`; const silhouetteTransition = isDragging ? 'none' : `border-bottom-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-bottom-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; const chatBodyTransition = isDragging ? 'none' : `margin-top ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; const containerStyle: React.CSSProperties = { backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined, }; return (
true, escapeDeactivates: stopPropagation, onDeactivate: () => { if (profileStateRef.current) close(); }, checkCanFocusTrap: () => Promise.resolve(), }} // Don't toggle `active` on drag — flipping it to false makes // focus-trap-react fire `onDeactivate` synthetically, so a // plain panel tap (deltaY=0) closes the rail. Trap stays // active for the entire open lifetime. active={open} >
0 ? 'visible' : 'hidden', }} >
{avatarMode ? ( ) : (
{/* The rail is sized to the measured content height (see `contentNaturalHeight` above), capped at `MAX_RAIL_FRACTION × viewport`. In the common fit case `overflow: hidden` prevents any drag-inside-drag — there's literally nothing to scroll. Only when the cap clips real content (rare — many stacked moderation alerts) do we switch to `auto` so the user can reach the hidden tail. Scrollbar chrome stays suppressed via the css class either way. */}
{renderUserId && ( setAvatarMode(true)} /> )}
)}
` enforces a // `min-height` that prevented the grid track from collapsing // to 0, leaving a light-blue strip at the bottom of the // user card. With explicit `height` + `overflow: hidden` on // the viewport (see css), `height: 0` is forced honest. height: headerNaturalHeight > 0 ? `${(1 - expandedFraction) * headerNaturalHeight}px` : 'auto', transition: headerViewportTransition, // While the rail is mostly open the (collapsed) header // shouldn't intercept drag-back-to-open gestures — // `pan-x` lets horizontal Android-back-edge gestures // still work. touchAction: headerDragEnabled ? 'pan-x' : undefined, }} >
{header}
0 ? 'hidden' : undefined, transition: chatBodyTransition, // Suppress browser pull-to-refresh / scroll-chaining so a // header-drag-down doesn't fire pull-to-refresh briefly // before the touchmove handler intercepts. overscrollBehaviorY: 'contain', }} > {children}
); } // Top-level router. On non-mobile we pass through — the side-pane // surface is rendered by `Room.tsx` as a sibling, so the wrapper just // hands `header` and `children` back to the parent. The mobile branch // owns its own state machine in a sub-component so we don't run // drag/avatar hooks on desktop renders. export function RoomViewProfilePanel({ header, children }: RoomViewProfilePanelProps) { const isMobile = useScreenSizeContext() === ScreenSize.Mobile; if (!isMobile) { return ( <> {header} {children} ); } return {children}; }