Skip to content
Open
54 changes: 52 additions & 2 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,18 @@ import {
type TextDirection,
} from "@t3tools/shared/textDirection";
import { useRef } from "react";
import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
import {
Platform,
Pressable,
ScrollView,
TextInput,
View,
type LayoutChangeEvent,
} from "react-native";
import Animated, {
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
useSharedValue,
type DerivedValue,
type SharedValue,
Expand All @@ -29,6 +38,9 @@ import {
type PendingUserInputDraftAnswer,
} from "@t3tools/client-runtime/state/pending-user-input";
import type { PendingUserInput } from "../../lib/threadActivity";
import { deriveRevealScrollOffset } from "./pendingUserInputLayout";

const VIEWPORT_SETTLE_MS = 120;

export interface PendingUserInputCardProps {
readonly environmentId: EnvironmentId;
Expand Down Expand Up @@ -139,6 +151,42 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
}));
const overlayHeightStyle = useAnimatedStyle(() => ({ height: maxHeight.value }));

// The keyboard shrinks this list under a focused input. Android scrolls it back natively; on
// iOS only UIKit's implicit first-responder scroll does, and not reliably on devices.
const scrollRef = useAnimatedRef<ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const viewportSettle = useRef<ReturnType<typeof setTimeout> | null>(null);
const revealFocusedInput = () => {
const scrollView = scrollRef.current;
const content = scrollView?.getInnerViewRef();
const input = TextInput.State.currentlyFocusedInput();
if (!scrollView || !content || !input) return;
// Fails without calling back when the focused input is outside this list.
input.measureLayout(content, (_x, top, _width, height) => {
// onLayout can trail the keyboard resize applied on the UI thread; measure the list now.
scrollView.measure((_viewportX, _viewportY, _viewportWidth, viewportHeight) => {
const offset = deriveRevealScrollOffset({
top,
height,
viewportHeight,
offset: scrollOffset.get(),
});
if (offset !== null) scrollView.scrollTo({ y: offset, animated: true });
});
});
};
const handleScrollLayout = () => {
if (viewportSettle.current !== null) clearTimeout(viewportSettle.current);
viewportSettle.current = setTimeout(() => {
viewportSettle.current = null;
revealFocusedInput();
}, VIEWPORT_SETTLE_MS);
};
const handleInputFocusChange = (focused: boolean) => {
if (focused) revealFocusedInput();
props.onInputFocusChange?.(focused);
};

const bar = (
<View
onLayout={handleBarLayout}
Expand Down Expand Up @@ -212,6 +260,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
</View>
</Pressable>
<ScrollView
ref={scrollRef}
onLayout={handleScrollLayout}
bounces={false}
className="min-h-0"
contentContainerClassName="gap-2.5 pb-1"
Expand Down Expand Up @@ -299,7 +349,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
onChangeText={(value) =>
props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
}
onInputFocusChange={props.onInputFocusChange}
onInputFocusChange={handleInputFocusChange}
/>
) : null}
</View>
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/features/threads/pendingUserInputLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vite-plus/test";

import { deriveRevealScrollOffset } from "./pendingUserInputLayout";

describe("deriveRevealScrollOffset", () => {
it("keeps the offset when the child is fully visible", () => {
expect(
deriveRevealScrollOffset({ top: 100, height: 54, viewportHeight: 300, offset: 0 }),
).toBeNull();
});

it("scrolls down only until the child's bottom edge is visible", () => {
expect(deriveRevealScrollOffset({ top: 400, height: 54, viewportHeight: 200, offset: 0 })).toBe(
254,
);
});

it("scrolls up to the child's top edge when it is above the viewport", () => {
expect(
deriveRevealScrollOffset({ top: 120, height: 54, viewportHeight: 200, offset: 150 }),
).toBe(120);
});

it("aligns a child taller than the viewport to its top edge when none of it is visible", () => {
expect(
deriveRevealScrollOffset({ top: 400, height: 300, viewportHeight: 200, offset: 0 }),
).toBe(400);
});

it("keeps the offset when a child taller than the viewport already overlaps it", () => {
expect(
deriveRevealScrollOffset({ top: 40, height: 600, viewportHeight: 263, offset: 300 }),
).toBeNull();
expect(
deriveRevealScrollOffset({ top: 0, height: 300, viewportHeight: 263, offset: 37 }),
).toBeNull();
});
});
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/pendingUserInputLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,23 @@ export function derivePendingUserInputMaxHeight(input: {
Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight),
);
}

/** Content offset that brings a child fully into view, or null when it already is. */
export function deriveRevealScrollOffset(input: {
readonly top: number;
readonly height: number;
readonly viewportHeight: number;
readonly offset: number;
}): number | null {
const bottom = input.top + input.height;
const viewportBottom = input.offset + input.viewportHeight;
// A child that cannot fit is left where it overlaps, so its caret stays where the user put it.
if (input.height > input.viewportHeight && input.top < viewportBottom && bottom > input.offset) {
return null;
}
if (input.top < input.offset) return input.top;
if (bottom > viewportBottom) {
return Math.min(input.top, bottom - input.viewportHeight);
}
return null;
}
Loading