diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ce962ff5cf33..47b887891677 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -33,14 +33,22 @@ import { View, type GestureResponderEvent, } from "react-native"; -import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; +import { + KeyboardController, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; +import { + CHAT_CONTENT_MAX_WIDTH, + derivePendingCardsOverlayMaxHeight, + type LayoutVariant, +} from "../../lib/layout"; import { scopedThreadKey } from "../../lib/scopedEntities"; import type { PendingApproval, @@ -218,22 +226,27 @@ 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 windowDimensions = useWindowDimensions(); - // The pending approval/user-input cards live in the bottom-anchored composer - // overlay, so a card with many questions grows upward past the navigation - // header. Cap the card stack to the space between the header and the - // composer and let it scroll instead. Read the context directly - // (useHeaderHeight throws outside a header-providing screen) and fall back - // to the standard iOS bar height. - const navigationHeaderHeight = useContext(HeaderHeightContext); - const pendingCardsMaxHeight = Math.max( - 120, - windowDimensions.height - - (navigationHeaderHeight || insets.top + 44) - - composerOverlapHeight - - 12, - ); const estimatedOverlayHeight = composerOverlapHeight; + // Pending cards live in the bottom-anchored composer overlay, so a request + // with several questions grows the overlay upward until its first questions + // sit under the navigation header, out of reach. Cap the overlay at the band + // between the header and the top of the keyboard — KeyboardStickyView lifts + // the overlay by the keyboard height — and let the cards shrink and scroll + // inside it. The composer is not shrinkable, so it keeps its natural height + // whatever the draft contains and only the cards give way. Header height + // comes from the context directly (useHeaderHeight throws outside a + // header-providing screen) with the fallback ThreadFeed already uses. + const windowHeight = useWindowDimensions().height; + const keyboardHeight = useKeyboardState((state) => state.height); + const navigationHeaderHeight = useContext(HeaderHeightContext); + const hasPendingCards = + props.activePendingApproval !== null || props.activePendingUserInput !== null; + const pendingCardsOverlayMaxHeight = derivePendingCardsOverlayMaxHeight({ + windowHeight, + navigationHeaderHeight: navigationHeaderHeight || insets.top + 44, + keyboardHeight, + composerHeight: composerOverlapHeight, + }); // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes // UIKit add the safe-area bottom to the content inset AGAIN — leaving a @@ -417,16 +430,21 @@ 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 ? ( + + + {hasPendingCards ? ( { }); }); +describe("derivePendingCardsOverlayMaxHeight", () => { + // iPhone 17 Pro portrait: 932pt tall, 96pt large-title-less header, 60pt + // collapsed composer chrome plus a 34pt home-indicator inset. + const iphone = { + windowHeight: 932, + navigationHeaderHeight: 96, + composerHeight: 94, + } as const; + + it("leaves the cards the band between the header and the composer", () => { + expect(derivePendingCardsOverlayMaxHeight({ ...iphone, keyboardHeight: 0 })).toBe(824); + }); + + it("gives the band back to the keyboard while a custom answer is typed", () => { + expect(derivePendingCardsOverlayMaxHeight({ ...iphone, keyboardHeight: 336 })).toBe(488); + }); + + it("collapses the cards rather than clipping the composer in a short viewport", () => { + // iPhone landscape with the keyboard up: nothing is left above the composer. + expect( + derivePendingCardsOverlayMaxHeight({ + windowHeight: 430, + navigationHeaderHeight: 44, + keyboardHeight: 336, + composerHeight: 72, + }), + ).toBe(72); + }); + + it("falls back to the composer height when the window is not measured yet", () => { + expect( + derivePendingCardsOverlayMaxHeight({ + windowHeight: 0, + navigationHeaderHeight: 96, + keyboardHeight: 0, + composerHeight: 94, + }), + ).toBe(94); + }); +}); + describe("deriveStableFormSheetDetent", () => { it.each([ { height: 1_194, expected: 0.62 }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index eb0c45e0607d..202ae8b0f4e1 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -28,6 +28,8 @@ const STABLE_FORM_SHEET_MAX_HEIGHT = 720; const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; const STABLE_FORM_SHEET_MIN_DETENT = 0.62; const STABLE_FORM_SHEET_MAX_DETENT = 0.92; +/** Breathing room kept between the navigation header and the pending cards. */ +const PENDING_CARDS_HEADER_GAP = 12; export type LayoutVariant = "compact" | "split"; @@ -238,6 +240,43 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } +/** + * Bound the bottom-anchored composer overlay to the band between the navigation + * header and the top of the keyboard. + * + * Pending approval/user-input cards sit above the composer inside that overlay, + * so a request carrying several questions grows it upward until its first + * questions are hidden behind the header. The composer itself is never squeezed: + * where the band cannot hold both, the cards collapse and the composer keeps the + * height it measured. + */ +export function derivePendingCardsOverlayMaxHeight(input: { + readonly windowHeight: number; + readonly navigationHeaderHeight: number; + readonly keyboardHeight: number; + readonly composerHeight: number; +}): number { + const composerHeight = Number.isFinite(input.composerHeight) + ? Math.max(0, input.composerHeight) + : 0; + + if (!Number.isFinite(input.windowHeight) || input.windowHeight <= 0) { + return composerHeight; + } + + const navigationHeaderHeight = Number.isFinite(input.navigationHeaderHeight) + ? Math.max(0, input.navigationHeaderHeight) + : 0; + const keyboardHeight = Number.isFinite(input.keyboardHeight) + ? Math.max(0, input.keyboardHeight) + : 0; + + return Math.max( + composerHeight, + input.windowHeight - navigationHeaderHeight - keyboardHeight - PENDING_CARDS_HEADER_GAP, + ); +} + export function deriveStableFormSheetDetent(containerHeight: number): number { if (!Number.isFinite(containerHeight) || containerHeight <= 0) { return STABLE_FORM_SHEET_MAX_DETENT;