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 :