refactor(profile): rebuild mobile horseshoe as single silhouette wrapper with Vaul ease curves replacing the two-radius emerge handoff
This commit is contained in:
parent
4dfd00c289
commit
50c58a1726
2 changed files with 280 additions and 195 deletions
|
|
@ -6,12 +6,11 @@ import { color, toRem } from 'folds';
|
||||||
export const HORSESHOE_RADIUS_PX = 32;
|
export const HORSESHOE_RADIUS_PX = 32;
|
||||||
export const HORSESHOE_GAP_PX = 12;
|
export const HORSESHOE_GAP_PX = 12;
|
||||||
|
|
||||||
// Outer container — establishes the positioning context for the
|
// Outer container — flex column hosting the silhouette (panel +
|
||||||
// absolutely-positioned panel and the under-panel chat column.
|
// header live inside) and the chat body below. `overflow: hidden`
|
||||||
// `overflow: hidden` clips the panel when it slides above the
|
// clips the chat body's rounded top corners against the container's
|
||||||
// visible area (translateY(-railHeight)) so it disappears cleanly,
|
// own edges; `background` paints the dark «void» behind the gap
|
||||||
// and clips the chat's animated rounded top corners against the
|
// between silhouette and chat body when the drawer is open.
|
||||||
// container's own edges.
|
|
||||||
export const container = style({
|
export const container = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -22,31 +21,57 @@ export const container = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Panel === Mobile-only top horseshoe.
|
// === Silhouette === Single-element wrapper that owns the
|
||||||
|
// rounded-bottom geometry shared by the user-card panel and the chat
|
||||||
|
// header. Both live as direct children of this one box, so a single
|
||||||
|
// `border-bottom-{left,right}-radius` cuts the bottom of the
|
||||||
|
// combined block — there's no «hand-off» from header-bottom-radius
|
||||||
|
// to panel-bottom-radius mid-drag because there's only one radius
|
||||||
|
// to begin with. Replaces the legacy `horseshoeEmerge` curve that
|
||||||
|
// ramped two separate radii in the last 15% of drag.
|
||||||
//
|
//
|
||||||
// Full-width sheet with both bottom corners rounded, mirroring the
|
// `overflow: hidden` is what makes the radius actually crop content
|
||||||
// bottom call rail's rounded top corners. `position: absolute` lets
|
// (without it the rounded bottom would just be an SVG-like outline
|
||||||
// it slide in from above (translateY) without disturbing the chat
|
// behind opaque children). The flex column stacks panelViewport (top)
|
||||||
// column's flex layout below.
|
// over headerViewport (bottom), so the silhouette's bottom edge
|
||||||
export const panel = style({
|
// always coincides with whichever child is currently last-visible —
|
||||||
position: 'absolute',
|
// which is exactly the "bottom edge of the silhouette" the rounding
|
||||||
top: 0,
|
// sits on.
|
||||||
left: 0,
|
export const silhouette = style({
|
||||||
zIndex: 2,
|
display: 'flex',
|
||||||
width: '100%',
|
flexDirection: 'column',
|
||||||
// The bottom-corner radius is interpolated inline so the panel
|
flexShrink: 0,
|
||||||
// bottom stays flush against the chat-column top during drag (both
|
|
||||||
// square-cornered, zero gap), and only blossoms into the rounded
|
|
||||||
// horseshoe shape once the gesture commits and CSS transitions
|
|
||||||
// kick in alongside the gap.
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: color.Background.Container,
|
backgroundColor: color.Background.Container,
|
||||||
willChange: 'transform',
|
willChange: 'border-bottom-left-radius, border-bottom-right-radius',
|
||||||
// Allow vertical pan inside the panel (Scroll content); the
|
});
|
||||||
// explicit drag-up handler will preventDefault on close gestures.
|
|
||||||
|
// === Panel viewport === Variable-height clipping window for the
|
||||||
|
// fixed-height panel content (railHeight tall, anchored bottom:0).
|
||||||
|
// As the viewport grows from 0 to railHeight during drag, more of
|
||||||
|
// panelContent is revealed from its bottom edge upward
|
||||||
|
// (handle → scroll content → avatar top) — equivalent visual to the
|
||||||
|
// legacy `transform: translateY(expandedPx - railHeight)` trick that
|
||||||
|
// shifted the whole panel down from above viewport.
|
||||||
|
//
|
||||||
|
// `touchAction: pan-y` keeps the panel-internal scroll responsive;
|
||||||
|
// the drag-up close gesture preventDefault's explicitly inside the
|
||||||
|
// touchmove handler.
|
||||||
|
export const panelViewport = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
willChange: 'height',
|
||||||
touchAction: 'pan-y',
|
touchAction: 'pan-y',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const panelContent = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
});
|
||||||
|
|
||||||
export const panelInner = style({
|
export const panelInner = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|
@ -54,11 +79,11 @@ export const panelInner = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Functional overflow without a visible scrollbar. The card's
|
// Functional overflow without a visible scrollbar. The card's
|
||||||
// content (hero + ~4 info rows + chip row) almost always fits
|
// content (hero + ~4 info rows + chip row) almost always fits inside
|
||||||
// inside the rail height, but moderation alerts can push it past
|
// the rail height, but moderation alerts can push it past — we keep
|
||||||
// — we keep the panel scrollable for that case while suppressing
|
// the panel scrollable for that case while suppressing the
|
||||||
// the scrollbar chrome (it's not a useful affordance on a 42vh
|
// scrollbar chrome (it's not a useful affordance on a 42vh rail and
|
||||||
// rail and the user explicitly asked us to drop it).
|
// the user explicitly asked us to drop it).
|
||||||
export const panelScroll = style({
|
export const panelScroll = style({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
|
|
@ -95,9 +120,9 @@ export const panelHandleBar = style({
|
||||||
backgroundColor: color.Surface.ContainerLine,
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Avatar full-view mode — fills the panel with the user's avatar at
|
// Avatar full-view mode — fills the panel content area with the
|
||||||
// full size when the user taps the avatar inside the open panel.
|
// user's avatar at full size when the user taps the avatar inside
|
||||||
// Click-to-revert switches back to the regular profile content.
|
// the open panel. Click reverts to the regular profile content.
|
||||||
export const avatarFullView = style({
|
export const avatarFullView = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
|
@ -106,6 +131,9 @@ export const avatarFullView = style({
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const avatarFullImage = style({
|
export const avatarFullImage = style({
|
||||||
|
|
@ -126,30 +154,34 @@ export const avatarFullFallback = style({
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Under-panel chat column ===
|
// === Header viewport === Holds the chat header as the silhouette's
|
||||||
//
|
// bottom child. Same CSS-grid `1fr → 0fr` row-track trick the legacy
|
||||||
// `margin-top` is interpolated inline (panel height + 8px gap) so its
|
// `headerWrap` used so the row animates smoothly without measuring
|
||||||
// rounded top edge sits right under the panel's rounded bottom. The
|
// the header's intrinsic height in JS. The inner div clips the
|
||||||
// rounded corners themselves are inline-styled on this same element.
|
// header content as the track collapses.
|
||||||
export const chatColumn = style({
|
export const headerViewport = style({
|
||||||
display: 'flex',
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'column',
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: 0,
|
|
||||||
willChange: 'margin-top',
|
|
||||||
});
|
|
||||||
|
|
||||||
// CSS-grid `1fr → 0fr` is animatable, unlike `height: auto`. The
|
|
||||||
// outer wrap holds the row track; the inner div is the actual
|
|
||||||
// scroll/clip surface.
|
|
||||||
export const headerWrap = style({
|
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
willChange: 'grid-template-rows',
|
willChange: 'grid-template-rows',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headerWrapInner = style({
|
export const headerViewportInner = style({
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === Chat body === Timeline + input below the silhouette. The
|
||||||
|
// rounded top corners and the `margin-top` void gap appear together
|
||||||
|
// with the silhouette's rounded bottom — all three are driven by the
|
||||||
|
// same `horseshoeRamp` (live formula while finger-dragging, binary
|
||||||
|
// with CSS transition during release animation), so the two horseshoes
|
||||||
|
// and the void emerge in lockstep at drag-start without any per-element
|
||||||
|
// «emerge curve» of their own.
|
||||||
|
export const chatBody = style({
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
willChange: 'margin-top, border-top-left-radius, border-top-right-radius',
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
// Wrapper around the room's chat column that adds the user-profile
|
// Wrapper around the room's chat column that adds the user-profile
|
||||||
// surface on top.
|
// surface on top.
|
||||||
//
|
//
|
||||||
// Mobile (≤ 750): the top «horseshoe» rail. The chat header collapses
|
// Mobile (≤ 750): the top «horseshoe» rail. Panel and chat header
|
||||||
// to height 0 while the rail is open so the visible gap between the
|
// live as siblings inside a single `silhouette` wrapper whose own
|
||||||
// rail's rounded bottom and the chat's rounded top is exactly the
|
// `overflow:hidden + border-bottom-radius` cuts the bottom of the
|
||||||
// shared `HORSESHOE_GAP_PX`, matching the bottom call horseshoe.
|
// 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
|
// Tablet + Desktop (> 750): no top horseshoe. The wrapper is a thin
|
||||||
// passthrough; the profile renders as a right-side pane via
|
// passthrough; the profile renders as a right-side pane via
|
||||||
|
|
@ -38,6 +45,39 @@ const RAIL_HEIGHT_FRACTION = 0.42;
|
||||||
// Past this many pixels of drag the gesture commits (open or close).
|
// Past this many pixels of drag the gesture commits (open or close).
|
||||||
const COMMIT_THRESHOLD_PX = 80;
|
const COMMIT_THRESHOLD_PX = 80;
|
||||||
const ANIMATION_MS = 250;
|
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 = {
|
type RoomViewProfilePanelProps = {
|
||||||
header: ReactNode;
|
header: ReactNode;
|
||||||
|
|
@ -52,9 +92,10 @@ type DragState = {
|
||||||
|
|
||||||
// Mobile-only top horseshoe. Slides down from above on drag-down on
|
// 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
|
// the chat header (1:1 DM only); slides up to close on drag-up
|
||||||
// anywhere on the panel. While the rail is open the chat header
|
// anywhere on the panel. Panel and chat header share a single
|
||||||
// collapses to height 0 so there's no transparent ~50px void below
|
// `silhouette` wrapper whose `overflow:hidden + border-bottom-radius`
|
||||||
// the rail — the perceived gap matches the call horseshoe's gap.
|
// owns the visible rounded bottom — see file header for the
|
||||||
|
// architectural rationale.
|
||||||
function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) {
|
function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const profileState = useAtomValue(userRoomProfileAtom);
|
const profileState = useAtomValue(userRoomProfileAtom);
|
||||||
|
|
@ -106,6 +147,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
: baseExpanded;
|
: baseExpanded;
|
||||||
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
||||||
const isDragging = drag !== null;
|
const isDragging = drag !== null;
|
||||||
|
const horseshoeActive = expandedPx > 0;
|
||||||
|
|
||||||
const liveUserId =
|
const liveUserId =
|
||||||
profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined);
|
profileState?.userId ?? (drag?.source === 'header' ? headerDragPeer : undefined);
|
||||||
|
|
@ -220,163 +262,155 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
};
|
};
|
||||||
}, [headerDragEnabled, headerDragPeer]);
|
}, [headerDragEnabled, headerDragPeer]);
|
||||||
|
|
||||||
const panelTransition = isDragging
|
// 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'
|
? 'none'
|
||||||
: `transform ${ANIMATION_MS}ms ease, border-bottom-left-radius ${ANIMATION_MS}ms ease, border-bottom-right-radius ${ANIMATION_MS}ms ease`;
|
: `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
const chatColumnTransition = isDragging
|
const headerViewportTransition = isDragging
|
||||||
? 'none'
|
? 'none'
|
||||||
: `margin-top ${ANIMATION_MS}ms ease, border-top-left-radius ${ANIMATION_MS}ms ease, border-top-right-radius ${ANIMATION_MS}ms ease`;
|
: `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 = {
|
const containerStyle: React.CSSProperties = {
|
||||||
backgroundColor: expandedPx > 0 ? '#090909' : undefined,
|
backgroundColor: horseshoeActive ? '#090909' : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The «two horseshoes» visual (HORSESHOE_GAP_PX gap +
|
|
||||||
// HORSESHOE_RADIUS_PX bottom/top radii) is gated by an emerge
|
|
||||||
// curve that stays at 0 for most of the
|
|
||||||
// open progress and only ramps to 1 in the last 15%. Two reasons:
|
|
||||||
//
|
|
||||||
// 1) Drag-down to open — the chat header is still visible under
|
|
||||||
// the descending rail. A linear gap would show a black `#090909`
|
|
||||||
// strip between rail and header («экстра геп между старой
|
|
||||||
// маленьким хедером чата и карточкой-подковой»). With the
|
|
||||||
// curve the rail sits flush against the (collapsing) header
|
|
||||||
// until the very end, and the gap blossoms when the header
|
|
||||||
// has fully retracted.
|
|
||||||
//
|
|
||||||
// 2) Drag-up to close — touchstart begins at expandedFraction=1,
|
|
||||||
// so the gap is already 8px. As expandedFraction dips even
|
|
||||||
// slightly the curve drops sharply, so the gap closes
|
|
||||||
// smoothly with the user's gesture instead of jolting to 0
|
|
||||||
// at touchstart.
|
|
||||||
const horseshoeEmerge = Math.max(0, (expandedFraction - 0.85) / 0.15);
|
|
||||||
const chatRadius = horseshoeEmerge * css.HORSESHOE_RADIUS_PX;
|
|
||||||
const chatGap = horseshoeEmerge * css.HORSESHOE_GAP_PX;
|
|
||||||
const panelBottomRadius = chatRadius;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.container} style={containerStyle}>
|
<div className={css.container} style={containerStyle}>
|
||||||
<FocusTrap
|
<div
|
||||||
focusTrapOptions={{
|
className={css.silhouette}
|
||||||
initialFocus: false,
|
style={{
|
||||||
// Mobile: only swipe-up dismisses the rail. Click on chat /
|
borderBottomLeftRadius: `${silhouetteRadiusPx}px`,
|
||||||
// scroll on chat must NOT close it AND must remain
|
borderBottomRightRadius: `${silhouetteRadiusPx}px`,
|
||||||
// interactive.
|
transition: silhouetteTransition,
|
||||||
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
|
<FocusTrap
|
||||||
ref={panelRef}
|
focusTrapOptions={{
|
||||||
className={css.panel}
|
initialFocus: false,
|
||||||
style={{
|
// Mobile: only swipe-up dismisses the rail. Click on chat /
|
||||||
height: `${railHeightPx}px`,
|
// scroll on chat must NOT close it AND must remain
|
||||||
transform: `translateY(${expandedPx - railHeightPx}px)`,
|
// interactive.
|
||||||
borderBottomLeftRadius: panelBottomRadius
|
clickOutsideDeactivates: false,
|
||||||
? `${panelBottomRadius}px`
|
allowOutsideClick: () => true,
|
||||||
: 0,
|
escapeDeactivates: stopPropagation,
|
||||||
borderBottomRightRadius: panelBottomRadius
|
onDeactivate: () => {
|
||||||
? `${panelBottomRadius}px`
|
if (profileStateRef.current) close();
|
||||||
: 0,
|
},
|
||||||
transition: panelTransition,
|
checkCanFocusTrap: () => Promise.resolve(),
|
||||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
|
||||||
}}
|
}}
|
||||||
|
// 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}
|
||||||
>
|
>
|
||||||
{avatarMode ? (
|
<div
|
||||||
<button
|
ref={panelRef}
|
||||||
type="button"
|
className={css.panelViewport}
|
||||||
className={css.avatarFullView}
|
style={{
|
||||||
onClick={() => setAvatarMode(false)}
|
height: `${expandedPx}px`,
|
||||||
aria-label={t('Room.collapse_avatar')}
|
transition: panelViewportTransition,
|
||||||
|
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css.panelContent}
|
||||||
|
style={{ height: `${railHeightPx}px` }}
|
||||||
>
|
>
|
||||||
{renderUserAvatarUrl ? (
|
{avatarMode ? (
|
||||||
<img
|
<button
|
||||||
className={css.avatarFullImage}
|
type="button"
|
||||||
src={renderUserAvatarUrl}
|
className={css.avatarFullView}
|
||||||
alt={renderUserId ?? ''}
|
onClick={() => setAvatarMode(false)}
|
||||||
draggable={false}
|
aria-label={t('Room.collapse_avatar')}
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={css.avatarFullFallback}
|
|
||||||
style={{ backgroundColor: colorMXID(renderUserId ?? '') }}
|
|
||||||
>
|
>
|
||||||
{(renderUserId && getMxIdLocalPart(renderUserId)?.[0]) ?? '?'}
|
{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>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
</div>
|
</FocusTrap>
|
||||||
</FocusTrap>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={css.chatColumn}
|
|
||||||
style={{
|
|
||||||
// Push the chat column down by `panel + gap` so its rounded
|
|
||||||
// top edge sits right below the panel's rounded bottom.
|
|
||||||
// The panel is `position: absolute`, so without this margin
|
|
||||||
// the chat would sit behind the panel and the top radius
|
|
||||||
// would be invisible.
|
|
||||||
marginTop:
|
|
||||||
expandedPx > 0 ? `${expandedPx + chatGap}px` : 0,
|
|
||||||
borderTopLeftRadius: chatRadius ? `${chatRadius}px` : undefined,
|
|
||||||
borderTopRightRadius: chatRadius ? `${chatRadius}px` : undefined,
|
|
||||||
// Clip the chat content to the rounded top corners. Folds
|
|
||||||
// popouts use Portals → escape this clip; the timeline
|
|
||||||
// keeps its own scroll container.
|
|
||||||
overflow: chatRadius > 0 ? 'hidden' : undefined,
|
|
||||||
transition: chatColumnTransition,
|
|
||||||
// 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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
className={css.headerWrap}
|
className={css.headerViewport}
|
||||||
style={{
|
style={{
|
||||||
// Collapse the chat header to 0 height while the rail is
|
// Collapse the chat header to 0 height as the panel
|
||||||
// open so the only visible chrome between the rail's
|
// expands — same `1fr → 0fr` row-track trick the legacy
|
||||||
// rounded bottom and the chat's rounded top is the
|
// layout used. CSS-grid track interpolation is animatable
|
||||||
// HORSESHOE_GAP_PX gap. CSS-grid's `1fr → 0fr` is animatable, unlike
|
// (unlike `height: auto`) and avoids measuring the
|
||||||
// `height: auto`, and avoids needing to measure the
|
// header's intrinsic height in JS. As the row collapses,
|
||||||
// header's intrinsic height in JS.
|
// 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`,
|
gridTemplateRows: `${1 - expandedFraction}fr`,
|
||||||
transition: isDragging ? 'none' : `grid-template-rows ${ANIMATION_MS}ms ease`,
|
transition: headerViewportTransition,
|
||||||
// While the rail is mostly open the (collapsed) header
|
// While the rail is mostly open the (collapsed) header
|
||||||
// shouldn't intercept drag-back-to-open gestures —
|
// shouldn't intercept drag-back-to-open gestures —
|
||||||
// `pan-x` lets horizontal Android-back-edge gestures
|
// `pan-x` lets horizontal Android-back-edge gestures
|
||||||
|
|
@ -384,8 +418,27 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
touchAction: headerDragEnabled ? 'pan-x' : undefined,
|
touchAction: headerDragEnabled ? 'pan-x' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className={css.headerWrapInner}>{header}</div>
|
<div className={css.headerViewportInner}>{header}</div>
|
||||||
</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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue