639 lines
28 KiB
TypeScript
639 lines
28 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, 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>;
|
||
}
|