From 0a62ad93975820212d48543c4796c730f178479d Mon Sep 17 00:00:00 2001 From: Carson Loyal Date: Thu, 30 Jul 2026 20:44:49 -0500 Subject: [PATCH] fix(mobile): keep the composer from covering the last message on thread open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a remote thread could leave the last message pinned underneath the floating composer with no way to scroll to it. The feed's bottom inset comes from an async onLayout measurement of the composer overlay, but ThreadFeed remounts its list synchronously when messages arrive and primes it from whatever the shared inset value holds — while the overlay's true height (the pending approval / user-input cards animate in on that same transition) lands a frame or more later, and nothing re-scrolled afterwards. Fix, in two parts: - Correct once the real height is known: mirror the overlay's measured height into plain React state (the reanimated shared value can't trigger effects) and thread it to ThreadFeed, which distinguishes the initial prime for a fresh list from a later correction — re-reporting the inset and re-pinning to the end if the user is still near it. Also tightens the bottom > 0 guard so a zero-inset mount no longer counts as primed. - Over-reserve during the pre-measurement frames: when a pending card is knowable synchronously from props, seed the estimate with its conservative chrome so the window before the first onLayout errs tall rather than short. The cards render in normal flow inside the measured overlay, so the real measurement includes them and always overrides the seed. The "Loading/Syncing messages..." status pill is deliberately NOT part of the inset — in the estimate or the measurement. It stays an absolutely-positioned overlay: sync starts and stops on every fetch, and counting the pill would bounce the feed up and down each time it toggles. It may briefly cover the last message while visible instead. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadComposer.tsx | 2 ++ .../features/threads/ThreadDetailScreen.tsx | 36 ++++++++++++++++--- .../src/features/threads/ThreadFeed.tsx | 29 +++++++++++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index fc45cba4260..f3231f46656 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -82,6 +82,8 @@ export const COMPOSER_COLLAPSED_CHROME = 60; */ export const COMPOSER_EXPANDED_CHROME = 174; +export const COMPOSER_PENDING_CARD_MIN_CHROME = 160; + export interface ThreadComposerProps { readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..8a8f12b3a63 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -16,7 +16,12 @@ import type { } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, View, type GestureResponderEvent } from "react-native"; +import { + Platform, + View, + type GestureResponderEvent, + type LayoutChangeEvent, +} from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -37,6 +42,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, + COMPOSER_PENDING_CARD_MIN_CHROME, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; @@ -180,7 +186,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const composerExpandedRef = useRef(false); + const handleComposerExpandedChange = useCallback((expanded: boolean) => { + composerExpandedRef.current = expanded; + setComposerExpanded(expanded); + }, []); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [composerMeasuredHeight, setComposerMeasuredHeight] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or @@ -201,7 +213,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread })(); const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; - const composerOverlapHeight = composerChrome + composerBottomInset; + const composerExtraChrome = + ((props.activePendingApproval === null ? 0 : 1) + + (props.activePendingUserInput === null ? 0 : 1)) * + COMPOSER_PENDING_CARD_MIN_CHROME; + const composerOverlapHeight = composerChrome + composerExtraChrome + composerBottomInset; const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -218,6 +234,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, ); + const handleComposerOverlayLayout = useCallback( + (event: LayoutChangeEvent) => { + onComposerLayout(event); + // expanded = keyboard transition, which manages its own inset + if (!composerExpandedRef.current) { + setComposerMeasuredHeight(event.nativeEvent.layout.height); + } + }, + [onComposerLayout], + ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; @@ -240,6 +266,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; freeze.set(false); + setComposerMeasuredHeight(null); }, [freeze, selectedThreadKey]); useEffect(() => { @@ -364,6 +391,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread freeze={freeze} anchorMessageId={anchorMessageId} contentInsetEndAdjustment={contentInsetEndAdjustment} + composerMeasuredHeight={composerMeasuredHeight} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} @@ -386,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* No paddingTop here: the overlay's measured height becomes the list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} - + {props.activePendingApproval || props.activePendingUserInput ? ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8ad117c8635..6146f0ee5b5 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -157,6 +157,7 @@ export interface ThreadFeedProps { readonly freeze: SharedValue; readonly anchorMessageId: MessageId | null; readonly contentInsetEndAdjustment: SharedValue; + readonly composerMeasuredHeight?: number | null; readonly contentTopInset?: number; readonly contentBottomInset?: number; readonly contentMaxWidth?: number; @@ -1438,6 +1439,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // only region where layout shifts should animate. Starts true because the // list opens pinned to the end. const nearListEnd = useSharedValue(true); + const userDraggedSinceMountRef = useRef(false); + const handleScrollBeginDrag = useCallback(() => { + userDraggedSinceMountRef.current = true; + }, []); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { @@ -1526,12 +1531,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const lastReprimedMountKeyRef = useRef(null); useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; - if (bottom > 0) { - props.listRef.current?.reportContentInset({ bottom }); + const isInitialMountForKey = lastReprimedMountKeyRef.current !== listMountKey; + lastReprimedMountKeyRef.current = listMountKey; + if (isInitialMountForKey) { + nearListEnd.value = true; + userDraggedSinceMountRef.current = false; + } + if (bottom <= 0) { + return; } - }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + props.listRef.current?.reportContentInset({ bottom }); + if (!isInitialMountForKey && nearListEnd.value && !userDraggedSinceMountRef.current) { + props.listRef.current?.scrollToEnd({ animated: true }); + } + }, [ + listMountKey, + props.composerMeasuredHeight, + props.contentInsetEndAdjustment, + props.listRef, + nearListEnd, + ]); const anchoredEndSpace = useMemo( () => @@ -1891,6 +1913,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} + onScrollBeginDrag={handleScrollBeginDrag} scrollEventThrottle={16} ListHeaderComponent={ usesNativeAutomaticInsets ? null :