feat(profile-rail): size mobile user-card rail to measured content height with 85vh cap and inner scroll only on overflow

This commit is contained in:
heaven 2026-05-12 02:06:10 +03:00
parent 149382299a
commit c6bb66958d
2 changed files with 99 additions and 24 deletions

View file

@ -86,11 +86,19 @@ export const panelViewport = style({
// (`Background.Container`, #0d0e11) through panelContent's transparent
// background, so the status-bar zone reads as part of the dark user
// card when the panel is open.
//
// `box-sizing: border-box` makes the inline `height: ${railHeightPx}`
// include the safe-top padding — so the rail measured in
// `RoomViewProfilePanel.tsx` (content + padTop + handle) exactly
// matches the actual visible height. Without `border-box` the height
// would be content-area only and the rail would visually overshoot by
// `var(--vojo-safe-top)`, leaving an unfilled gap at the bottom.
export const panelContent = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
boxSizing: 'border-box',
paddingTop: 'var(--vojo-safe-top, 0px)',
});
@ -100,16 +108,22 @@ export const panelInner = style({
height: '100%',
});
// Functional overflow without a visible scrollbar. The card's
// content (hero + ~4 info rows + chip row) almost always fits inside
// the rail height, but moderation alerts can push it past — we keep
// the panel scrollable for that case while suppressing the
// scrollbar chrome (it's not a useful affordance on a 42vh rail and
// the user explicitly asked us to drop it).
// Holds the user-card content. `overflow-y` is driven inline from
// `RoomViewProfilePanel.tsx` based on whether the measured content
// height exceeds the safety cap (`MAX_RAIL_FRACTION × viewport`):
//
// • Common fit case (content ≤ cap) → `overflow-y: hidden`. The
// rail is sized to content, so there's literally nothing to
// scroll. This is the user's explicit «запретил бы драгу внутри»
// ask — no drag-inside-drag inside the user card.
// • Rare overflow case (e.g. stacked moderation alerts) → switches
// to `auto`, so the user can still reach the clipped tail.
//
// Scrollbar chrome is suppressed either way; the auto case relies on
// the standard touch / wheel scroll affordances.
export const panelScroll = style({
flex: 1,
minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'none',
selectors: {
'&::-webkit-scrollbar': {

View file

@ -41,8 +41,26 @@ import colorMXID from '../../../util/colorMXID';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import * as css from './RoomViewProfilePanel.css';
// Card height as a fraction of the viewport.
const RAIL_HEIGHT_FRACTION = 0.42;
// 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;
@ -152,18 +170,54 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
setAvatarMode(false);
}, [profileState?.userId]);
const [railHeightPx, setRailHeightPx] = useState(() => {
if (typeof window === 'undefined') return 400;
return Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION);
const [viewportHeight, setViewportHeight] = useState(() => {
if (typeof window === 'undefined') return 800;
return window.innerHeight;
});
useEffect(() => {
const onResize = () => {
setRailHeightPx(Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION));
};
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
@ -457,6 +511,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
}}
>
<div
ref={panelContentRef}
className={css.panelContent}
style={{ height: `${railHeightPx}px` }}
>
@ -485,15 +540,21 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
</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 }}>
{/* 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}