fix(composer): re-anchor timeline scrollTop when overlay composer height changes via prop-driven layout effect

This commit is contained in:
v.lagerev 2026-05-11 18:10:56 +03:00
parent 7aaba05e46
commit 2bce6a791d
2 changed files with 47 additions and 20 deletions

View file

@ -222,6 +222,14 @@ type RoomTimelineProps = {
eventId?: string; eventId?: string;
roomInputRef: RefObject<HTMLElement>; roomInputRef: RefObject<HTMLElement>;
editor: Editor; editor: Editor;
// Pixel height of the overlay composer painted by RoomView. The CSS var
// applied on the chat surface paints the bottom padding visually, but
// when this number transitions 0 → N at mount the scroll-content grows
// without a corresponding scrollTop bump — the latest message ends up
// hidden behind the composer. We use this prop in a layout effect to
// re-anchor the scroll to the bottom whenever it changes and the user
// was already at the bottom.
bottomOverlayHeight?: number;
}; };
const PAGINATION_LIMIT = 80; const PAGINATION_LIMIT = 80;
@ -471,7 +479,13 @@ const isChannelsModeHidden = (room: Room, event: MatrixEvent, isBridged: boolean
return false; return false;
}; };
export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { export function RoomTimeline({
room,
eventId,
roomInputRef,
editor,
bottomOverlayHeight = 0,
}: RoomTimelineProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
@ -868,6 +882,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
useCallback(() => roomInputRef.current, [roomInputRef]) useCallback(() => roomInputRef.current, [roomInputRef])
); );
// Re-anchor to bottom when the overlay-composer height changes. At mount
// the composer measures async (RoomView's `useEffect` → `setComposerHeight`
// → CSS var → our paddingBottom grows), which adds N pixels to scrollHeight
// without bumping scrollTop, leaving the latest message hidden behind the
// composer. A layout effect on the prop catches every transition (initial
// 0 → N and subsequent multi-line/reply-preview growth) deterministically,
// unlike a ResizeObserver dispatch which can coalesce the mount and the
// resize into a single skipped first firing.
useLayoutEffect(() => {
if (!atBottomRef.current) return;
const el = getScrollElement();
if (el) scrollToBottom(el);
}, [bottomOverlayHeight, getScrollElement]);
// Stay at bottom when scroll container resizes (e.g. Android keyboard open/close) // Stay at bottom when scroll container resizes (e.g. Android keyboard open/close)
useResizeObserver( useResizeObserver(
useMemo(() => { useMemo(() => {
@ -2059,14 +2087,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
justifyContent="End" justifyContent="End"
style={{ style={{
minHeight: '100%', minHeight: '100%',
// Bottom padding adds the composer's live height (set as the // Bottom padding reserves room for the overlay composer painted
// `--vojo-composer-height` var on the chat surface by RoomView) // by RoomView. Driven by the same number passed in via
// so the latest message sits flush above the overlaid composer // `bottomOverlayHeight` to keep React state and CSS layout in
// instead of disappearing behind it. Falls back to 0 when the // sync — a layout effect (`bottomOverlayHeight` dep, above)
// var is unset (e.g. thread-drawer view doesn't mount the // re-anchors the scroll on every transition so the latest
// overlay composer). // message stays flush above the composer.
paddingTop: config.space.S600, paddingTop: config.space.S600,
paddingBottom: `calc(${config.space.S600} + var(--vojo-composer-height, 0px))`, paddingBottom: `calc(${config.space.S600} + ${bottomOverlayHeight}px)`,
}} }}
> >
{!canPaginateBack && rangeAtStart && getItems().length > 0 && ( {!canPaginateBack && rangeAtStart && getItems().length > 0 && (

View file

@ -1,4 +1,4 @@
import React, { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Text, color, config, toRem } from 'folds'; import { Box, Text, color, config, toRem } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
@ -127,18 +127,16 @@ export function RoomView({ eventId }: { eventId?: string }) {
// Composer renders as a true overlay (absolute, bottom-stuck) above the // Composer renders as a true overlay (absolute, bottom-stuck) above the
// timeline so messages can scroll behind it — WhatsApp / Telegram pattern. // timeline so messages can scroll behind it — WhatsApp / Telegram pattern.
// The CSS variable lets RoomTimeline.tsx pad its scroll-content bottom by // RoomTimeline takes `bottomOverlayHeight` as a prop and both (a) reserves
// the exact composer height, so the latest message sits flush above the // bottom scroll-padding for the overlay so the latest message sits flush
// composer at rest and older messages slide out from underneath as the // above it at rest and (b) re-anchors scrollTop in a layout effect when
// user scrolls up. // the height transitions (e.g. composer measures async after mount, multi-
const pageStyle = { // line typing, reply-preview opens).
backgroundColor: color.SurfaceVariant.Container,
position: 'relative',
'--vojo-composer-height': `${composerHeight}px`,
} as CSSProperties;
return ( return (
<Page ref={roomViewRef} style={pageStyle}> <Page
ref={roomViewRef}
style={{ backgroundColor: color.SurfaceVariant.Container, position: 'relative' }}
>
<Box grow="Yes" direction="Column"> <Box grow="Yes" direction="Column">
<RoomTimeline <RoomTimeline
key={roomId} key={roomId}
@ -146,6 +144,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
eventId={eventId} eventId={eventId}
roomInputRef={roomInputRef} roomInputRef={roomInputRef}
editor={editor} editor={editor}
bottomOverlayHeight={composerHeight}
/> />
<RoomViewTyping room={room} /> <RoomViewTyping room={room} />
</Box> </Box>