Skip to content
Draft
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
19 changes: 17 additions & 2 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import HybridAppModule from '@expensify/react-native-hybrid-app';
import type * as Sentry from '@sentry/react-native';
import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
import type {NativeEventSubscription} from 'react-native';
import {AppState, Platform} from 'react-native';
import type {AppStateStatus, NativeEventSubscription} from 'react-native';
import {AppState, Keyboard, Platform} from 'react-native';
import Onyx from 'react-native-onyx';
import DelegateNoAccessModalProvider from './components/DelegateNoAccessModalProvider';
import EmojiPicker from './components/EmojiPicker/EmojiPicker';
Expand Down Expand Up @@ -57,6 +57,8 @@ Onyx.registerLogger(({level, message, parameters}) => {

function Expensify() {
const appStateChangeListener = useRef<NativeEventSubscription | null>(null);
const keyboardDismissListener = useRef<NativeEventSubscription | null>(null);
const previousAppState = useRef<AppStateStatus>(AppState.currentState);
const [isNavigationReady, setIsNavigationReady] = useState(false);
const [isOnyxMigrated, setIsOnyxMigrated] = useState(false);
const {splashScreenState} = useSplashScreenState();
Expand Down Expand Up @@ -243,6 +245,18 @@ function Expensify() {

appStateChangeListener.current = AppState.addEventListener('change', initializeClient);

// On iOS, dismiss the keyboard when returning from background to prevent it from
// appearing over the splash/transition screen. iOS natively restores first responder
// status on previously focused TextInputs when returning from background.
if (Platform.OS === 'ios') {
keyboardDismissListener.current = AppState.addEventListener('change', (nextAppState) => {
if ((previousAppState.current === 'inactive' || previousAppState.current === 'background') && nextAppState === 'active') {
Keyboard.dismiss();
}
previousAppState.current = nextAppState;
});
}

setIsAuthenticatedAtStartup(isAuthenticated);

startSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK, {
Expand All @@ -257,6 +271,7 @@ function Expensify() {

return () => {
appStateChangeListener.current?.remove();
keyboardDismissListener.current?.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
}, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {isMobileSafari} from '@libs/Browser';
import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils';
import isWindowReadyToFocus from '@libs/isWindowReadyToFocus';
import {isValidValidateCode} from '@libs/ValidationUtils';
import {clearValidateCodeActionError} from '@userActions/User';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -170,14 +171,18 @@ function BaseValidateCodeForm({
clearTimeout(focusTimeoutRef.current);
}

// Keyboard won't show if we focus the input with a delay, so we need to focus immediately.
if (!isMobileSafari()) {
focusTimeoutRef.current = setTimeout(() => {
// Wait until the window is ready to focus (blocks during iOS background-to-foreground transition)
// before triggering keyboard focus, preventing keyboard from appearing over the splash screen.
isWindowReadyToFocus().then(() => {
// Keyboard won't show if we focus the input with a delay, so we need to focus immediately.
if (!isMobileSafari()) {
focusTimeoutRef.current = setTimeout(() => {
inputValidateCodeRef.current?.focusLastSelected();
}, CONST.ANIMATED_TRANSITION);
} else {
inputValidateCodeRef.current?.focusLastSelected();
}, CONST.ANIMATED_TRANSITION);
} else {
inputValidateCodeRef.current?.focusLastSelected();
}
}
});

return () => {
if (!focusTimeoutRef.current) {
Expand Down
33 changes: 33 additions & 0 deletions src/libs/isWindowReadyToFocus/index.ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {AppState} from 'react-native';

let isWindowReadyPromise = Promise.resolve();
let resolveWindowReadyToFocus: (() => void) | undefined;

AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') {
if (resolveWindowReadyToFocus) {
resolveWindowReadyToFocus();
resolveWindowReadyToFocus = undefined;
}
return;
}

// When transitioning to background or inactive, block focus until active again
if (nextAppState === 'background' || nextAppState === 'inactive') {
if (!resolveWindowReadyToFocus) {
isWindowReadyPromise = new Promise((resolve) => {
resolveWindowReadyToFocus = resolve;
});
}
}
});

/**
* On iOS, we need to ensure that the app is fully active before focusing an input.
* When the app transitions from background/inactive to active, iOS may restore the first responder
* and show the keyboard before the app finishes its transition animation.
* This function blocks focus operations until the app is fully active.
*/
const isWindowReadyToFocus = () => isWindowReadyPromise;

export default isWindowReadyToFocus;
Loading