// 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, 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 * as css from './RoomViewProfilePanel.css'; // Card height as a fraction of the viewport. const RAIL_HEIGHT_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 - Math.pow(-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); // 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 [railHeightPx, setRailHeightPx] = useState(() => { if (typeof window === 'undefined') return 400; return Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION); }); useEffect(() => { const onResize = () => { setRailHeightPx(Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION)); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); 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». const horseshoeRamp = isDragging ? easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)) : horseshoeActive ? 1 : 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' : `grid-template-rows ${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 ? '#090909' : 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 ? ( ) : (
{/* No visible scrollbar — the card content (hero + ~4 info rows + a chip row) almost always fits, and on the rare overflow case (lots of moderation alerts) we keep functional scrolling via the `panelScroll` class which sets `overflow: auto` + `scrollbar-width: none` / `::-webkit-scrollbar` hidden. */}
{renderUserId && ( setAvatarMode(true)} /> )}
)}
{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}; }