From 484e227d305ae76bfaca1be94f2d34a4965ed81d Mon Sep 17 00:00:00 2001 From: Den <2119348+dzianisv@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:12:01 +0000 Subject: [PATCH] fix(android): keep chat composer above the keyboard (#194, #156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of the live-IME-height mechanism from #201 (Keyboard.metrics() + keyboardDidShow/Hide listeners, explicit reset to 0 on hide) plus the bounded multiline composer and scrollable QuestionPrompt from #208. API-35 guard: the native IME-insets listener in MainActivity owns the IME height on Android 15+, so the JS padding applies only below API 35 — exactly one layer applies it on every API level. iOS keeps behavior="padding" with offset 90. #208's static insets.top offset is deliberately not ported: it measured 56 dp short on a Pixel 11 Pro (see #194). --- app/session/[id].tsx | 62 +++++++++++++++++++++++--- src/components/chat/QuestionPrompt.tsx | 16 ++++--- src/lib/keyboard-offset.test.ts | 54 ++++++++++++++++++++++ src/lib/keyboard-offset.ts | 31 +++++++++++++ src/lib/session-layout.test.ts | 36 +++++++++++++++ src/lib/session-layout.ts | 32 +++++++++++++ 6 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 src/lib/keyboard-offset.test.ts create mode 100644 src/lib/keyboard-offset.ts create mode 100644 src/lib/session-layout.test.ts create mode 100644 src/lib/session-layout.ts diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 945b2b477..db6c5e8a2 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -8,9 +8,11 @@ import { StyleSheet, useColorScheme, KeyboardAvoidingView, + Keyboard, Platform, ActivityIndicator, Alert, + useWindowDimensions, } from "react-native" import { useLocalSearchParams, Stack, useRouter, useFocusEffect, useNavigation } from "expo-router" import { Ionicons } from "@expo/vector-icons" @@ -41,6 +43,12 @@ import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" import { messagePartsToSelectableText } from "../../src/lib/message-text" +import { + jsKeyboardPaddingApplies, + keyboardPadding, + keyboardVerticalOffset, +} from "../../src/lib/keyboard-offset" +import { composerMaxHeight } from "../../src/lib/session-layout" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -87,6 +95,35 @@ export default function SessionScreen() { const [input, setInput] = useState("") const [attachments, setAttachments] = useState([]) const [showInfo, setShowInfo] = useState(false) + const { height: windowHeight, fontScale } = useWindowDimensions() + const [keyboardHeight, setKeyboardHeight] = useState(() => Keyboard.metrics()?.height ?? 0) + const [keyboardY, setKeyboardY] = useState(null) + + useEffect(() => { + if (Platform.OS !== "android") return + const show = Keyboard.addListener("keyboardDidShow", (event) => { + setKeyboardHeight(event.endCoordinates.height) + setKeyboardY(event.endCoordinates.screenY) + }) + const hide = Keyboard.addListener("keyboardDidHide", () => { + setKeyboardHeight(0) + setKeyboardY(null) + }) + // Close the render-to-effect race: listeners are attached first, then the + // current native metrics become the source of truth. + setKeyboardHeight(Keyboard.metrics()?.height ?? 0) + return () => { + show.remove() + hide.remove() + } + }, []) + + const inputMaxHeight = composerMaxHeight(Platform.OS, windowHeight, keyboardY, insets.top, fontScale) + // Exactly one layer applies the IME height: JS padding below API 35, the + // native IME-insets listener in MainActivity on API 35+. + const jsKeyboardPadding = jsKeyboardPaddingApplies(Platform.OS, Platform.Version) + ? keyboardPadding(Platform.OS, keyboardHeight) + : 0 // Raw text of the message opened in the full-screen selection sheet const [textSheet, setTextSheet] = useState(null) @@ -626,12 +663,22 @@ export default function SessionScreen() { return ( <> {/* Session info pulldown */} diff --git a/src/components/chat/QuestionPrompt.tsx b/src/components/chat/QuestionPrompt.tsx index d6aaeea43..dd499caff 100644 --- a/src/components/chat/QuestionPrompt.tsx +++ b/src/components/chat/QuestionPrompt.tsx @@ -1,7 +1,8 @@ import { useRef, useState } from "react" -import { View, Text, TextInput, TouchableOpacity, StyleSheet } from "react-native" +import { View, Text, TextInput, TouchableOpacity, ScrollView, StyleSheet } from "react-native" import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" +import { QUESTION_BODY_SCROLL_PROPS } from "../../lib/session-layout" interface QuestionOption { label: string @@ -86,9 +87,8 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { {q.header || t("chat.questionPrompt.headerFallback")} - {q.question} - - + + {q.question} {q.options.map((opt) => { const selected = (answers[current] || []).includes(opt.label) return ( @@ -129,7 +129,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { {t("chat.questionPrompt.customAnswerLabel")} ))} - + @@ -158,6 +158,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) { const s = StyleSheet.create({ card: { + flexShrink: 1, margin: 12, padding: 16, backgroundColor: "#f5f3ff", @@ -169,10 +170,11 @@ const s = StyleSheet.create({ header: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 }, title: { fontSize: 15, fontWeight: "600", color: "#6d28d9" }, textWhite: { color: "#ffffff" }, - question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a", marginBottom: 12 }, + body: { flexShrink: 1 }, + question: { fontSize: 14, lineHeight: 20, color: "#0a0a0a" }, metaDark: { color: "#666666" }, - options: { gap: 8 }, + options: { gap: 8, paddingBottom: 1 }, option: { padding: 12, borderRadius: 8, diff --git a/src/lib/keyboard-offset.test.ts b/src/lib/keyboard-offset.test.ts new file mode 100644 index 000000000..b0fb7fa3a --- /dev/null +++ b/src/lib/keyboard-offset.test.ts @@ -0,0 +1,54 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + IOS_KEYBOARD_VERTICAL_OFFSET, + jsKeyboardPaddingApplies, + keyboardPadding, + keyboardVerticalOffset, +} from "./keyboard-offset.ts" + +test("Android uses the IME-reported height as bottom padding", () => { + assert.equal(keyboardPadding("android", 351.28), 351.28) +}) + +test("Android hide and invalid metrics produce zero padding", () => { + assert.equal(keyboardPadding("android", 0), 0) + assert.equal(keyboardPadding("android", -20), 0) +}) + +test("iOS does not receive Android's explicit keyboard padding", () => { + assert.equal(keyboardPadding("ios", 351.28), 0) +}) + +test("iOS keeps its existing empirical offset", () => { + assert.equal(keyboardVerticalOffset("ios", 0), IOS_KEYBOARD_VERTICAL_OFFSET) + assert.equal(keyboardVerticalOffset("ios", 61.29), IOS_KEYBOARD_VERTICAL_OFFSET) +}) + +test("Android does not use KeyboardAvoidingView's vertical offset", () => { + assert.equal(keyboardVerticalOffset("android", 61.293), 0) +}) + +test("Pixel 11 Pro measurement maps directly to the required padding", () => { + const keyboardHeight = 1168 / (1280 / 393) + assert.equal(keyboardPadding("android", keyboardHeight), keyboardHeight) +}) + +test("JS padding applies on Android below API 35", () => { + assert.equal(jsKeyboardPaddingApplies("android", 33), true) + assert.equal(jsKeyboardPaddingApplies("android", 34), true) +}) + +test("JS padding stays off where the native IME-insets listener runs (API 35+)", () => { + assert.equal(jsKeyboardPaddingApplies("android", 35), false) + assert.equal(jsKeyboardPaddingApplies("android", 36), false) +}) + +test("JS padding never applies on iOS", () => { + assert.equal(jsKeyboardPaddingApplies("ios", 17), false) + assert.equal(jsKeyboardPaddingApplies("ios", "17.0"), false) +}) + +test("JS padding stays off when the API level is not a number", () => { + assert.equal(jsKeyboardPaddingApplies("android", "35"), false) +}) diff --git a/src/lib/keyboard-offset.ts b/src/lib/keyboard-offset.ts new file mode 100644 index 000000000..a77ddd742 --- /dev/null +++ b/src/lib/keyboard-offset.ts @@ -0,0 +1,31 @@ +// iOS continues to use KeyboardAvoidingView's established offset. Android +// applies the IME-reported height directly because RN 0.81's Android hide +// event path can retain keyboardVerticalOffset as stale bottom padding. + +export const IOS_KEYBOARD_VERTICAL_OFFSET = 90 + +// MainActivity installs a native IME-insets listener on Android 15+ +// (SDK_INT >= VANILLA_ICE_CREAM, API 35) that adds the IME height to the +// root view's bottom padding. The JS padding below must stay off there or +// the IME height is applied twice. +export const NATIVE_IME_INSETS_MIN_API_LEVEL = 35 + +export function keyboardPadding(platform: string, keyboardHeight: number): number { + if (platform !== "android") return 0 + return Math.max(0, keyboardHeight) +} + +export function keyboardVerticalOffset(platform: string, _insetTop: number): number { + if (platform === "ios") return IOS_KEYBOARD_VERTICAL_OFFSET + return 0 +} + +// Exactly one layer applies the IME height on every API level: the native +// listener owns API 35+, this JS padding owns API < 35. Platform.Version is +// the API-level number on Android; a non-numeric version cannot prove the +// native listener is absent, so JS padding stays off. +export function jsKeyboardPaddingApplies(platform: string, apiLevel: number | string): boolean { + if (platform !== "android") return false + if (typeof apiLevel !== "number") return false + return apiLevel < NATIVE_IME_INSETS_MIN_API_LEVEL +} diff --git a/src/lib/session-layout.test.ts b/src/lib/session-layout.test.ts new file mode 100644 index 000000000..4db66b780 --- /dev/null +++ b/src/lib/session-layout.test.ts @@ -0,0 +1,36 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { + COMPOSER_MAX_HEIGHT, + COMPOSER_MIN_HEIGHT, + QUESTION_BODY_SCROLL_PROPS, + composerMaxHeight, +} from "./session-layout.ts" + +test("composer keeps its established cap while the Android IME is hidden", () => { + assert.equal(composerMaxHeight("android", 768, null, 39), COMPOSER_MAX_HEIGHT) + assert.equal(composerMaxHeight("ios", 768, 377, 39), COMPOSER_MAX_HEIGHT) +}) + +test("composer keeps multiple lines visible on compact Android IME geometry", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39), COMPOSER_MIN_HEIGHT) + assert.equal(composerMaxHeight("android", 480, 250, 24), COMPOSER_MIN_HEIGHT) +}) + +test("composer preserves multiple draft lines at Android accessibility text scaling", () => { + assert.equal(composerMaxHeight("android", 768, 377, 39, 1.5), COMPOSER_MIN_HEIGHT) + assert.equal(composerMaxHeight("android", 768, 377, 39, 2), 100) +}) + +test("composer responds to keyboard resize without exceeding the established cap", () => { + assert.equal(composerMaxHeight("android", 900, 700, 24), COMPOSER_MAX_HEIGHT) + assert.equal(composerMaxHeight("android", 700, 380, 24), 106) +}) + +test("question body uses the only vertical overflow owner and retains navigation taps", () => { + assert.deepEqual(QUESTION_BODY_SCROLL_PROPS, { + keyboardShouldPersistTaps: "handled", + nestedScrollEnabled: true, + showsVerticalScrollIndicator: true, + }) +}) diff --git a/src/lib/session-layout.ts b/src/lib/session-layout.ts new file mode 100644 index 000000000..72901ea30 --- /dev/null +++ b/src/lib/session-layout.ts @@ -0,0 +1,32 @@ +// Bounded multiline composer + scrollable question body (#208's (b) coverage +// for #194/#156). The keyboard offset itself lives in ./keyboard-offset: +// Android applies the IME-reported height as bottom padding, not the static +// top inset — the insets.top offset alone measured 56 dp short on a +// Pixel 11 Pro (see #194), so no keyboardVerticalOffset is defined here. + +export const COMPOSER_MAX_HEIGHT = 120 +export const COMPOSER_MIN_HEIGHT = 88 +const COMPOSER_LINE_HEIGHT = 20 +const COMPOSER_VERTICAL_PADDING = 20 + +export function composerMaxHeight( + platform: string, + windowHeight: number, + keyboardY: number | null, + insetTop: number, + fontScale = 1, +): number { + if (platform !== "android" || keyboardY === null) return COMPOSER_MAX_HEIGHT + + // Keep multiple scaled draft lines usable on compact IME-resized windows while + // reserving room for the question/transcript surface and composer controls. + const visibleHeight = Math.max(0, Math.min(windowHeight, keyboardY) - Math.max(0, insetTop)) + const scaledMinimum = Math.ceil(COMPOSER_LINE_HEIGHT * Math.max(1, fontScale) * 2 + COMPOSER_VERTICAL_PADDING) + return Math.min(COMPOSER_MAX_HEIGHT, Math.max(COMPOSER_MIN_HEIGHT, scaledMinimum, visibleHeight - 250)) +} + +export const QUESTION_BODY_SCROLL_PROPS = { + keyboardShouldPersistTaps: "handled", + nestedScrollEnabled: true, + showsVerticalScrollIndicator: true, +} as const