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

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>;
}