vojo/src/app/features/room/RoomViewProfilePanel.tsx

639 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<DragState | null>(null);
const [avatarMode, setAvatarMode] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const headerInnerRef = useRef<HTMLDivElement>(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 `<Header size="600">` 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<HTMLDivElement>(null);
const panelMeasureRef = useRef<HTMLDivElement>(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<string | undefined>(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<DragState | null>(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 (
<div className={css.container} style={containerStyle}>
<div
className={css.silhouette}
style={{
borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
borderBottomRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
}}
>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// Mobile: only swipe-up dismisses the rail. Click on chat /
// scroll on chat must NOT close it AND must remain
// interactive.
clickOutsideDeactivates: false,
allowOutsideClick: () => 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}
>
<div
ref={panelRef}
className={css.panelViewport}
style={{
height: `${expandedPx}px`,
transition: panelViewportTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
}}
>
<div
ref={panelContentRef}
className={css.panelContent}
style={{ height: `${railHeightPx}px` }}
>
{avatarMode ? (
<button
type="button"
className={css.avatarFullView}
onClick={() => setAvatarMode(false)}
aria-label={t('Room.collapse_avatar')}
>
{renderUserAvatarUrl ? (
<img
className={css.avatarFullImage}
src={renderUserAvatarUrl}
alt={renderUserId ?? ''}
draggable={false}
/>
) : (
<div
className={css.avatarFullFallback}
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
>
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
</div>
)}
</button>
) : (
<div className={css.panelInner}>
{/* 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. */}
<div
className={css.panelScroll}
style={{ overflowY: contentOverflows ? 'auto' : 'hidden' }}
>
<div ref={panelMeasureRef} style={{ padding: config.space.S400 }}>
{renderUserId && (
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => setAvatarMode(true)}
/>
)}
</div>
</div>
<div className={css.panelHandle} aria-label={t('Room.drag_to_close')}>
<div className={css.panelHandleBar} />
</div>
</div>
)}
</div>
</div>
</FocusTrap>
<div
ref={headerRef}
className={css.headerViewport}
style={{
// Animate the chat header's height from its measured natural
// size down to 0 as the user-card panel expands. Linear scale
// by `(1 - expandedFraction)` so the panel and the header
// grow / shrink in lockstep. Falls back to `auto` until the
// first measurement lands (cold mount before useLayoutEffect
// runs) so the closed-state header isn't briefly squashed to
// 0. Replaces the legacy `grid-template-rows: 1fr → 0fr`
// trick because Folds `<Header size="600">` 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,
}}
>
<div ref={headerInnerRef} className={css.headerViewportInner}>
{header}
</div>
</div>
</div>
<div
className={css.chatBody}
style={{
marginTop: `${chatGapPx}px`,
borderTopLeftRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
borderTopRightRadius: chatRadiusPx ? `${chatRadiusPx}px` : undefined,
// Clip chat content to the rounded top corners. Folds
// popouts use Portals → escape this clip; the timeline
// keeps its own scroll container.
overflow: chatRadiusPx > 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}
</div>
</div>
);
}
// 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 <MobileProfileHorseshoe header={header}>{children}</MobileProfileHorseshoe>;
}