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.Container`, #0d0e11) through panelContent's transparent
// background, so the status-bar zone reads as part of the dark user // background, so the status-bar zone reads as part of the dark user
// card when the panel is open. // 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({ export const panelContent = style({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
boxSizing: 'border-box',
paddingTop: 'var(--vojo-safe-top, 0px)', paddingTop: 'var(--vojo-safe-top, 0px)',
}); });
@ -100,16 +108,22 @@ export const panelInner = style({
height: '100%', height: '100%',
}); });
// Functional overflow without a visible scrollbar. The card's // Holds the user-card content. `overflow-y` is driven inline from
// content (hero + ~4 info rows + chip row) almost always fits inside // `RoomViewProfilePanel.tsx` based on whether the measured content
// the rail height, but moderation alerts can push it past — we keep // height exceeds the safety cap (`MAX_RAIL_FRACTION × viewport`):
// the panel scrollable for that case while suppressing the //
// scrollbar chrome (it's not a useful affordance on a 42vh rail and // • Common fit case (content ≤ cap) → `overflow-y: hidden`. The
// the user explicitly asked us to drop it). // 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({ export const panelScroll = style({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'none', scrollbarWidth: 'none',
selectors: { selectors: {
'&::-webkit-scrollbar': { '&::-webkit-scrollbar': {

View file

@ -41,8 +41,26 @@ import colorMXID from '../../../util/colorMXID';
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import * as css from './RoomViewProfilePanel.css'; import * as css from './RoomViewProfilePanel.css';
// Card height as a fraction of the viewport. // Rail-height policy: the card sizes itself to its actual content
const RAIL_HEIGHT_FRACTION = 0.42; // 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). // 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;
@ -152,18 +170,54 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
setAvatarMode(false); setAvatarMode(false);
}, [profileState?.userId]); }, [profileState?.userId]);
const [railHeightPx, setRailHeightPx] = useState(() => { const [viewportHeight, setViewportHeight] = useState(() => {
if (typeof window === 'undefined') return 400; if (typeof window === 'undefined') return 800;
return Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION); return window.innerHeight;
}); });
useEffect(() => { useEffect(() => {
const onResize = () => { const onResize = () => setViewportHeight(window.innerHeight);
setRailHeightPx(Math.round(window.innerHeight * RAIL_HEIGHT_FRACTION));
};
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('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). // Measure the chat header's natural height (incl. its safe-top padding).
// `scrollHeight` returns the content size even while the outer is // `scrollHeight` returns the content size even while the outer is
// constrained by our animated `height` — so we keep getting the right // constrained by our animated `height` — so we keep getting the right
@ -457,6 +511,7 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
}} }}
> >
<div <div
ref={panelContentRef}
className={css.panelContent} className={css.panelContent}
style={{ height: `${railHeightPx}px` }} style={{ height: `${railHeightPx}px` }}
> >
@ -485,15 +540,21 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
</button> </button>
) : ( ) : (
<div className={css.panelInner}> <div className={css.panelInner}>
{/* No visible scrollbar the card content (hero + {/* The rail is sized to the measured content height
~4 info rows + a chip row) almost always fits, (see `contentNaturalHeight` above), capped at
and on the rare overflow case (lots of `MAX_RAIL_FRACTION × viewport`. In the common
moderation alerts) we keep functional scrolling fit case `overflow: hidden` prevents any
via the `panelScroll` class which sets drag-inside-drag there's literally nothing to
`overflow: auto` + `scrollbar-width: none` / scroll. Only when the cap clips real content
`::-webkit-scrollbar` hidden. */} (rare many stacked moderation alerts) do we
<div className={css.panelScroll}> switch to `auto` so the user can reach the
<div style={{ padding: config.space.S400 }}> 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 && ( {renderUserId && (
<UserRoomProfile <UserRoomProfile
userId={renderUserId} userId={renderUserId}