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:
parent
149382299a
commit
c6bb66958d
2 changed files with 99 additions and 24 deletions
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue