Skip to content
Open
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
38 changes: 29 additions & 9 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
StyleSheet,
useColorScheme,
KeyboardAvoidingView,
Keyboard,
Platform,
ActivityIndicator,
Alert,
Expand Down Expand Up @@ -39,6 +40,7 @@ import { useConnections } from "../../src/stores/connections"
import { useAuth } from "../../src/stores/auth"
import { useCatalog } from "../../src/stores/catalog"
import { useSpeech } from "../../src/lib/speech"
import { keyboardPadding, keyboardVerticalOffset } from "../../src/lib/keyboard-offset"

// --- Builtin slash commands ---
const BUILTIN_COMMANDS: SlashCommand[] = [
Expand Down Expand Up @@ -85,6 +87,20 @@ export default function SessionScreen() {
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
const [keyboardHeight, setKeyboardHeight] = useState(() => Keyboard.metrics()?.height ?? 0)

useEffect(() => {
if (Platform.OS !== "android") return
const show = Keyboard.addListener("keyboardDidShow", (event) => setKeyboardHeight(event.endCoordinates.height))
const hide = Keyboard.addListener("keyboardDidHide", () => setKeyboardHeight(0))
// 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 {
currentSession,
Expand Down Expand Up @@ -592,21 +608,25 @@ export default function SessionScreen() {
/>

<KeyboardAvoidingView
style={[s.container, isDark && s.containerDark]}
// Both platforms use "padding" so the composer/toolbar is pushed up
// above the keyboard via JS-measured keyboard height.
//
style={[
s.container,
isDark && s.containerDark,
Platform.OS === "android" && { paddingBottom: keyboardPadding(Platform.OS, keyboardHeight) },
]}
// Android previously relied on the native android:windowSoftInputMode
// (adjustResize, see AndroidManifest.xml) with behavior={undefined}
// to let the OS resize the window (see #70/#53). Since adopting
// Expo's mandatory edge-to-edge display, Android no longer resizes
// the window when the keyboard opens — the system assumes insets are
// handled dynamically — so adjustResize became a no-op and the
// bottom toolbar + input were left completely hidden behind the
// keyboard (#147). "padding" restores avoidance without depending
// on native resize.
behavior="padding"
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
// bottom toolbar + input were left hidden behind the keyboard (#147).
//
// RN 0.81 handles Android keyboardDidHide as another frame-change
// event, so a non-zero vertical offset survives as stale padding.
// Android therefore uses the IME's reported height directly in style;
// the hide listener above explicitly resets it to zero.
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={keyboardVerticalOffset(Platform.OS, insets.top)}
>
{/* Session info pulldown */}
<SessionInfo
Expand Down
34 changes: 34 additions & 0 deletions src/lib/keyboard-offset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import {
IOS_KEYBOARD_VERTICAL_OFFSET,
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)
})
15 changes: 15 additions & 0 deletions src/lib/keyboard-offset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// 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

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
}