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.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': {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue