From 2bce6a791d6edb73cd035cbef38123de0f05815f Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Mon, 11 May 2026 18:10:56 +0300 Subject: [PATCH] fix(composer): re-anchor timeline scrollTop when overlay composer height changes via prop-driven layout effect --- src/app/features/room/RoomTimeline.tsx | 44 +++++++++++++++++++++----- src/app/features/room/RoomView.tsx | 23 +++++++------- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2b0f502f..75486429 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -222,6 +222,14 @@ type RoomTimelineProps = { eventId?: string; roomInputRef: RefObject; 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; @@ -471,7 +479,13 @@ const isChannelsModeHidden = (room: Room, event: MatrixEvent, isBridged: boolean return false; }; -export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimelineProps) { +export function RoomTimeline({ + room, + eventId, + roomInputRef, + editor, + bottomOverlayHeight = 0, +}: RoomTimelineProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -868,6 +882,20 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 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) useResizeObserver( useMemo(() => { @@ -2059,14 +2087,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli justifyContent="End" style={{ minHeight: '100%', - // Bottom padding adds the composer's live height (set as the - // `--vojo-composer-height` var on the chat surface by RoomView) - // so the latest message sits flush above the overlaid composer - // instead of disappearing behind it. Falls back to 0 when the - // var is unset (e.g. thread-drawer view doesn't mount the - // overlay composer). + // Bottom padding reserves room for the overlay composer painted + // by RoomView. Driven by the same number passed in via + // `bottomOverlayHeight` to keep React state and CSS layout in + // sync — a layout effect (`bottomOverlayHeight` dep, above) + // re-anchors the scroll on every transition so the latest + // message stays flush above the composer. 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 && ( diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index f479cfdf..9f34adf6 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -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 { EventType } from 'matrix-js-sdk'; 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 // timeline so messages can scroll behind it — WhatsApp / Telegram pattern. - // The CSS variable lets RoomTimeline.tsx pad its scroll-content bottom by - // the exact composer height, so the latest message sits flush above the - // composer at rest and older messages slide out from underneath as the - // user scrolls up. - const pageStyle = { - backgroundColor: color.SurfaceVariant.Container, - position: 'relative', - '--vojo-composer-height': `${composerHeight}px`, - } as CSSProperties; - + // RoomTimeline takes `bottomOverlayHeight` as a prop and both (a) reserves + // bottom scroll-padding for the overlay so the latest message sits flush + // above it at rest and (b) re-anchors scrollTop in a layout effect when + // the height transitions (e.g. composer measures async after mount, multi- + // line typing, reply-preview opens). return ( - +