Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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[] = [
Expand Down Expand Up @@ -87,6 +95,35 @@ export default function SessionScreen() {
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
const { height: windowHeight, fontScale } = useWindowDimensions()
const [keyboardHeight, setKeyboardHeight] = useState(() => Keyboard.metrics()?.height ?? 0)
const [keyboardY, setKeyboardY] = useState<number | null>(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<string | null>(null)

Expand Down Expand Up @@ -626,12 +663,22 @@ export default function SessionScreen() {
return (
<>
<KeyboardAvoidingView
style={[s.container, isDark && s.containerDark]}
// "padding" pushes the composer/toolbar above the keyboard. On Android
// the padding is driven by the native WindowInsets from MainActivity
// (see #216); on iOS by the keyboard frame events.
behavior="padding"
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
style={[
s.container,
isDark && s.containerDark,
Platform.OS === "android" && { paddingBottom: jsKeyboardPadding },
]}
// iOS keeps behavior="padding" with its established offset (90).
// Android applies the IME-reported height directly as bottom
// padding (see keyboard-offset.ts): RN 0.81 retains a non-zero
// vertical offset as stale padding after keyboardDidHide, so
// Android uses behavior={undefined} and the hide listener above
// explicitly resets the padding to zero. On Android 15+ the
// native IME-insets listener in MainActivity owns the IME height
// instead, so the JS padding stays off there — exactly one layer
// applies it on every API level.
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={keyboardVerticalOffset(Platform.OS, insets.top)}
>
{/* Session info pulldown */}
<SessionInfo
Expand Down Expand Up @@ -839,7 +886,7 @@ export default function SessionScreen() {
</TouchableOpacity>

<TextInput
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening, { maxHeight: inputMaxHeight }]}
placeholder={
speech.listening
? t("session.input.placeholderListening")
Expand All @@ -852,6 +899,7 @@ export default function SessionScreen() {
onChangeText={speech.listening ? undefined : setInput}
editable={!speech.listening}
multiline
scrollEnabled
maxLength={10000}
testID="chat-message-input"
/>
Expand Down
16 changes: 9 additions & 7 deletions src/components/chat/QuestionPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -86,9 +87,8 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
<Ionicons name="chatbubble-ellipses-outline" size={18} color="#8b5cf6" />
<Text style={[s.title, isDark && s.textWhite]}>{q.header || t("chat.questionPrompt.headerFallback")}</Text>
</View>
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>

<View style={s.options}>
<ScrollView {...QUESTION_BODY_SCROLL_PROPS} style={s.body} contentContainerStyle={s.options} testID="question-options-scroll">
<Text style={[s.question, isDark && s.textWhite]}>{q.question}</Text>
{q.options.map((opt) => {
const selected = (answers[current] || []).includes(opt.label)
return (
Expand Down Expand Up @@ -129,7 +129,7 @@ export function QuestionPrompt({ request, isDark, onReply, onReject }: Props) {
<Text style={[s.optionLabel, { color: "#8b5cf6" }]}>{t("chat.questionPrompt.customAnswerLabel")}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>

<View style={s.footer}>
<TouchableOpacity onPress={reject}>
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions src/lib/keyboard-offset.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
31 changes: 31 additions & 0 deletions src/lib/keyboard-offset.ts
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions src/lib/session-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
32 changes: 32 additions & 0 deletions src/lib/session-layout.ts
Original file line number Diff line number Diff line change
@@ -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
Loading