diff --git a/apps/mobile/src/features/threads/QuestionAttachments.tsx b/apps/mobile/src/features/threads/QuestionAttachments.tsx index d915166e5aef7..91469aec24e58 100644 --- a/apps/mobile/src/features/threads/QuestionAttachments.tsx +++ b/apps/mobile/src/features/threads/QuestionAttachments.tsx @@ -6,7 +6,7 @@ import { convertPastedImagesToAttachments } from "../../lib/composerImages"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type UserInputQuestion } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { View } from "react-native"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; import { ComposerAttachmentButton } from "./ComposerAttachmentButton"; import { ComposerAttachmentStrip } from "../../components/ComposerAttachmentStrip"; import { pickComposerFiles, pickComposerMedia } from "../../lib/composerImages"; @@ -49,6 +49,14 @@ export function QuestionAttachments(props: { props.requestId, props.question.id, ]); + // A focused input that unmounts never delivers its onBlur, so release focus here or + // the screen would keep treating the keyboard as owned by a removed input. + const focusedRef = useRef(false); + const onInputFocusChange = props.onInputFocusChange; + const releaseFocus = useEffectEvent(() => { + if (focusedRef.current) onInputFocusChange?.(false); + }); + useEffect(() => () => releaseFocus(), []); const pickerScope = useRef<{ key: string; active: boolean } | null>(null); useEffect(() => { const scope = { key: scopeKey, active: true }; @@ -197,8 +205,14 @@ export function QuestionAttachments(props: { submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined} editable={!props.disabled} onChangeText={props.onChangeText} - onFocus={() => props.onInputFocusChange?.(true)} - onBlur={() => props.onInputFocusChange?.(false)} + onFocus={() => { + focusedRef.current = true; + onInputFocusChange?.(true); + }} + onBlur={() => { + focusedRef.current = false; + onInputFocusChange?.(false); + }} placeholder="Or type a custom answer" className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d9da5f506656c..48f69837d6707 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -363,15 +363,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // Android can swallow the IME hide callbacks when the app is backgrounded mid keyboard-hide // (the reported repro: send — which blurs and starts the hide — then Home within a second). The // keyboard library's height AND visibility then stay frozen open, so gating the sticky - // translation on visibility alone still strands the composer after resume. + // translation on visibility alone still strands the composer after resume. An input that kept + // focus through the switch is exempt: Android restores its keyboard unchanged, so no keyboard + // or focus event would ever lift the quarantine. Hiding the keyboard with Back keeps focus, so + // Back then Home mid-hide takes the exemption and can still strand the composer. const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); + const ownedInputFocusedRef = useRef(false); useEffect(() => { if (Platform.OS !== "android") { return; } const subscription = AppState.addEventListener("change", (state) => { if (state === "active") { - setKeyboardStateSuspect(true); + setKeyboardStateSuspect(!ownedInputFocusedRef.current); } }); return () => { @@ -382,6 +386,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread setKeyboardStateSuspect(false); }, [isKeyboardVisible, liveKeyboardHeight]); const handleOwnedInputFocusChange = useCallback((focused: boolean) => { + ownedInputFocusedRef.current = focused; if (focused) { setKeyboardStateSuspect(false); }