From ecb790eaa1a91c389d0521273ffb33ccbb65aaa5 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Tue, 12 May 2026 02:06:10 +0300 Subject: [PATCH] feat(profile-rail): size mobile user-card rail to measured content height with 85vh cap and inner scroll only on overflow --- .../features/room/RoomViewProfilePanel.css.ts | 28 ++++-- .../features/room/RoomViewProfilePanel.tsx | 95 +++++++++++++++---- 2 files changed, 99 insertions(+), 24 deletions(-) diff --git a/src/app/features/room/RoomViewProfilePanel.css.ts b/src/app/features/room/RoomViewProfilePanel.css.ts index c9401b8f..72de6ab8 100644 --- a/src/app/features/room/RoomViewProfilePanel.css.ts +++ b/src/app/features/room/RoomViewProfilePanel.css.ts @@ -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': { diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index a29dd542..73da4daa 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -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(null); + const panelMeasureRef = useRef(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) }} >
@@ -485,15 +540,21 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) ) : (
- {/* 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. */} -
-
+ {/* 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. */} +
+
{renderUserId && (