545 lines
23 KiB
TypeScript
545 lines
23 KiB
TypeScript
// 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<DragState | null>(null);
|
|
const [avatarMode, setAvatarMode] = useState(false);
|
|
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
const panelRef = useRef<HTMLDivElement>(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<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».
|
|
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 (
|
|
<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
|
|
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}>
|
|
{/* 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. */}
|
|
<div className={css.panelScroll}>
|
|
<div 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={{
|
|
// Collapse the chat header to 0 height as the panel
|
|
// expands — same `1fr → 0fr` row-track trick the legacy
|
|
// layout used. CSS-grid track interpolation is animatable
|
|
// (unlike `height: auto`) and avoids measuring the
|
|
// header's intrinsic height in JS. As the row collapses,
|
|
// the silhouette's bottom edge moves from header-bottom
|
|
// up to panel-bottom; the rounded corners stay anchored
|
|
// there because they belong to the silhouette wrapper.
|
|
gridTemplateRows: `${1 - expandedFraction}fr`,
|
|
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 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>;
|
|
}
|