From 662800351a5ca0e0a42ae9ba459c6b80c1363aef Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Mon, 5 Oct 2026 09:05:35 +0530 Subject: [PATCH 1/8] Raise test tools and the offline banner above required 2FA. Portal only the focused test-tools screen so Cmd/Ctrl+D stays usable on that overlay without losing the stack context that was crashing the modal. --- src/hooks/useRequire2FAOverlayVisibility.ts | 69 ++++ .../Navigation/AppNavigator/AuthScreens.tsx | 11 + .../Navigators/TestToolsModalNavigator.tsx | 100 +++++- .../RequireTwoFactorAuthenticationOverlay.tsx | 34 +- src/styles/index.ts | 8 + .../RequireTwoFactorOverlayStackingTest.tsx | 329 ++++++++++++++++++ .../useRequire2FAOverlayVisibility.test.ts | 118 +++++++ 7 files changed, 639 insertions(+), 30 deletions(-) create mode 100644 src/hooks/useRequire2FAOverlayVisibility.ts create mode 100644 tests/ui/RequireTwoFactorOverlayStackingTest.tsx create mode 100644 tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts diff --git a/src/hooks/useRequire2FAOverlayVisibility.ts b/src/hooks/useRequire2FAOverlayVisibility.ts new file mode 100644 index 000000000000..329f5674303a --- /dev/null +++ b/src/hooks/useRequire2FAOverlayVisibility.ts @@ -0,0 +1,69 @@ +import {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import {useNavigation} from '@react-navigation/core'; + +import useRootNavigationState from './useRootNavigationState'; +import useShouldShowRequire2FAPage from './useShouldShowRequire2FAPage'; + +/** + * Portal host rendered above the required-2FA overlay. + * Test tools render here only while that overlay is covering the root stack. + */ +const REQUIRE_2FA_ABOVE_PORTAL_HOST = 'require2FAAbove'; + +const REQUIRE_2FA_FOCUSED_SCREEN = { + OTHER: 0, + TWO_FACTOR_SETUP: 1, + TEST_TOOLS: 2, +} as const; + +type Require2FAFocusedScreen = (typeof REQUIRE_2FA_FOCUSED_SCREEN)[keyof typeof REQUIRE_2FA_FOCUSED_SCREEN]; + +const TEST_TOOLS_SCREENS = new Set([NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR, SCREENS.TEST_TOOLS_MODAL.ROOT, SCREENS.TEST_TOOLS_MODAL.SERVER]); + +/** + * Classifies the focused screen for the required-2FA overlay. + * Returns a primitive so navigation listeners can skip unchanged values. + */ +function getRequire2FAFocusedScreen(screenName: string | undefined): Require2FAFocusedScreen { + if (isTwoFactorSetupScreen(screenName)) { + return REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP; + } + if (screenName && TEST_TOOLS_SCREENS.has(screenName)) { + return REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS; + } + return REQUIRE_2FA_FOCUSED_SCREEN.OTHER; +} + +type Require2FAOverlayVisibility = { + /** The blocking required-2FA screen is mounted over the app. */ + isRequire2FAOverlayVisible: boolean; + /** Test tools are the focused route, so they must sit above the overlay and take input. */ + isTestToolsRouteFocused: boolean; +}; + +/** + * Shared by the required-2FA overlay and the test-tools navigator. + * Test tools move above the overlay only while the overlay is covering the root stack. + */ +function useRequire2FAOverlayVisibility(): Require2FAOverlayVisibility { + const navigation = useNavigation(); + const shouldShowRequire2FAPage = useShouldShowRequire2FAPage(); + const focusedScreen = useRootNavigationState((state) => { + // When navigation is not ready yet, use the navigation state from the navigation hook. + const screenName = getDeepestFocusedScreen(state ?? navigation.getState())?.name; + return getRequire2FAFocusedScreen(screenName); + }); + + return { + isRequire2FAOverlayVisible: shouldShowRequire2FAPage && focusedScreen !== REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP, + isTestToolsRouteFocused: focusedScreen === REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, + }; +} + +export default useRequire2FAOverlayVisibility; +export {REQUIRE_2FA_ABOVE_PORTAL_HOST, REQUIRE_2FA_FOCUSED_SCREEN, getRequire2FAFocusedScreen}; +export type {Require2FAFocusedScreen}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index f623c2a33423..74bc475d839f 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -24,11 +24,13 @@ import WideRHPContextProvider from '@components/WideRHPContextProvider'; import useGPSInProgressModal from '@hooks/useGPSInProgressModal'; import useGPSTripStateChecker from '@hooks/useGPSTripStateChecker'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; +import {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; import {SidebarOrderedReportsContextProvider} from '@hooks/useSidebarOrderedReports'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; import setFullscreenVisibility from '@libs/actions/setFullscreenVisibility'; import {READ_COMMANDS} from '@libs/API/types'; @@ -55,7 +57,9 @@ import '@src/libs/subscribeToFullReconnect'; import type {RouteProp} from '@react-navigation/native'; import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import {PortalHost} from '@gorhom/portal'; import React, {useEffect} from 'react'; +import {StyleSheet, View} from 'react-native'; import attachmentModalScreenOptions from './attachmentModalScreenOptions'; import AuthScreensInitHandler from './AuthScreensInitHandler'; @@ -131,6 +135,7 @@ const modalScreenListenersWithCancelSearch = { function AuthScreens() { const theme = useTheme(); + const themeStyles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const rootNavigatorScreenOptions = useRootNavigatorScreenOptions(); @@ -403,6 +408,12 @@ function AuthScreens() { /> + + + diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index edaf758e3603..d67eb800001c 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -6,11 +6,13 @@ import TestToolsServerPage from '@components/TestToolsServerPage'; import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useRequire2FAOverlayVisibility, {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {TestToolsModalModalNavigatorParamList} from '@libs/Navigation/types'; import toggleTestToolsModal from '@userActions/TestTool'; @@ -18,16 +20,25 @@ import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; -import type {ComponentRef, MouseEvent} from 'react'; +import type {ComponentRef, ComponentType, MouseEvent, ReactNode} from 'react'; -import React, {useCallback, useRef} from 'react'; +import {Portal} from '@gorhom/portal'; +import {NavigationContext, NavigationRouteContext, useIsFocused} from '@react-navigation/native'; +import {CardAnimationContext} from '@react-navigation/stack'; +import React, {useCallback, useContext, useRef} from 'react'; import {View} from 'react-native'; import Overlay from './Overlay'; const Stack = createPlatformStackNavigator(); -function TestToolsModalNavigator() { +type TestToolsRouteName = keyof TestToolsModalModalNavigatorParamList; + +type TestToolsModalFrameProps = { + children: ReactNode; +}; + +function TestToolsModalFrame({children}: TestToolsModalFrameProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const outerViewRef = useRef>(null); @@ -45,14 +56,13 @@ function TestToolsModalNavigator() { e.stopPropagation(); }, []); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false}); - return ( @@ -62,16 +72,7 @@ function TestToolsModalNavigator() { onClick={handleInnerClick} style={styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated)} > - - - - + {children} @@ -79,4 +80,73 @@ function TestToolsModalNavigator() { ); } +/** + * While the required-2FA overlay covers the root stack, draw this screen in the portal host above it. + * The nested stack stays in the root navigator so the URL, backTo, and goBack keep working. + * The host is outside that stack, so the frame gets the screen contexts it reads. + */ +function withRequire2FAPortal(Screen: ComponentType>) { + function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { + const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); + const navigation = useContext(NavigationContext); + const route = useContext(NavigationRouteContext); + const cardAnimation = useContext(CardAnimationContext); + const isFocused = useIsFocused(); + + if (!isRequire2FAOverlayVisible) { + return ; + } + + // A stack keeps the previous screen mounted. Only the focused one should cover the 2FA screen. + if (!isFocused || !navigation || !route) { + return null; + } + + const screenFrame = ( + + + + ); + const frame = cardAnimation ? {screenFrame} : screenFrame; + + return ( + + + {frame} + + + ); + } + + return Require2FAPortaledTestToolsScreen; +} + +const PortaledTestToolsModalPage = withRequire2FAPortal(TestToolsModalPage); +const PortaledTestToolsServerPage = withRequire2FAPortal(TestToolsServerPage); + +function TestToolsModalNavigator() { + const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); + + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false}); + + const stack = ( + + + + + ); + + if (isRequire2FAOverlayVisible) { + return stack; + } + + return {stack}; +} + export default TestToolsModalNavigator; diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 2b7f7061b380..0c2a76764caf 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -2,18 +2,18 @@ import Button from '@components/Button'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import GpsDraftDetailsRefSync from '@components/GpsDraftDetailsRefSync'; import Icon from '@components/Icon'; +import OfflineIndicator from '@components/OfflineIndicator'; import Text from '@components/Text'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useRootNavigationState from '@hooks/useRootNavigationState'; -import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage'; +import useRequire2FAOverlayVisibility from '@hooks/useRequire2FAOverlayVisibility'; import useSignOut from '@hooks/useSignOut'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; -import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; @@ -29,7 +29,6 @@ import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import type {OnyxCollection} from 'react-native-onyx'; -import {useNavigation} from '@react-navigation/core'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -49,13 +48,7 @@ const is2FARequiredBecauseOfXeroSelector = (email?: string) => { }; function RequireTwoFactorAuthenticationOverlay() { - const navigation = useNavigation(); - const shouldShowRequire2FAPage = useShouldShowRequire2FAPage(); - const isIn2FASetupFlow = useRootNavigationState((state) => { - // When navigation is not ready yet, use the navigation state from the navigation hook. - const focusedScreen = getDeepestFocusedScreen(state ?? navigation.getState()); - return isTwoFactorSetupScreen(focusedScreen?.name); - }); + const {isRequire2FAOverlayVisible, isTestToolsRouteFocused} = useRequire2FAOverlayVisibility(); const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); @@ -90,11 +83,11 @@ function RequireTwoFactorAuthenticationOverlay() { }, [account, onboardingValues, onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath]); useEffect(() => { - if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { + if (!isRequire2FAOverlayVisible) { return; } snapshotOnboardingResumePathIfNeeded(); - }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]); + }, [isRequire2FAOverlayVisible, snapshotOnboardingResumePathIfNeeded]); const onEscapePress = () => { if (isEscapeInFlight) { @@ -112,14 +105,14 @@ function RequireTwoFactorAuthenticationOverlay() { Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true})); }; - if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { + if (!isRequire2FAOverlayVisible) { return null; } return ( <> {isActingAsDelegate && isTrackingGPS && } - + + + + ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index b41ef660d45a..323b6335393d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6055,6 +6055,14 @@ const staticStyles = (theme: ThemeColors) => zIndex: 20, // must be greater than floatingCameraButton.zIndex }, + twoFARequiredOfflineBanner: { + zIndex: 21, // must be greater than twoFARequiredOverlay.zIndex + }, + + twoFARequiredTestToolsHost: { + zIndex: 22, // must be greater than twoFARequiredOfflineBanner.zIndex + }, + twoFARequiredContainer: { maxWidth: 520, margin: 'auto', diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx new file mode 100644 index 000000000000..167a10071699 --- /dev/null +++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx @@ -0,0 +1,329 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ +import {render, screen, within} from '@testing-library/react-native'; + +import {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; + +import TestToolsModalNavigator from '@libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator'; + +import RequireTwoFactorAuthenticationOverlay from '@pages/RequireTwoFactorAuthenticationOverlay'; + +import React from 'react'; + +type Visibility = { + isRequire2FAOverlayVisible: boolean; + isTestToolsRouteFocused: boolean; +}; + +let mockVisibility: Visibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: false, +}; +let mockIsOffline = true; + +jest.mock('@hooks/useRequire2FAOverlayVisibility', () => ({ + __esModule: true, + REQUIRE_2FA_ABOVE_PORTAL_HOST: 'require2FAAbove', + default: () => mockVisibility, +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: mockIsOffline}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [undefined, {status: 'loaded'}], +})); + +jest.mock('@hooks/useSignOut', () => ({ + __esModule: true, + default: () => ({ + signOut: jest.fn(() => Promise.resolve()), + leaveDelegateAccount: jest.fn(() => Promise.resolve()), + isActingAsDelegate: false, + isTrackingGPS: false, + }), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: (key: string) => (key === 'common.youAppearToBeOffline' ? 'You appear to be offline.' : key), + localeCompare: (a: string, b: string) => a.localeCompare(b), + }), +})); + +jest.mock('@hooks/useTheme', () => ({ + __esModule: true, + default: () => ({icon: '#000'}), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => + new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'getTestToolsNavigatorOuterView' || prop === 'getTestToolsNavigatorInnerView') { + return () => ({}); + } + return {}; + }, + }, + ), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: () => ({Encryption: 'encryption'}), + useMemoizedLazyExpensifyIcons: () => ({OfflineCloud: 'offline'}), +})); + +jest.mock('@hooks/useTwoFactorAuthRoute', () => ({ + __esModule: true, + default: () => ({getTwoFactorAuthRoute: jest.fn(() => '/settings/security/two-factor-auth')}), +})); + +jest.mock('@hooks/useKeyboardShortcut', () => ({ + __esModule: true, + default: () => {}, +})); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: false}), +})); + +jest.mock('@hooks/useIsAuthenticated', () => ({ + __esModule: true, + default: () => true, +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + getActiveRoute: () => '/home', + navigate: jest.fn(), + }, +})); + +jest.mock('@userActions/Welcome', () => ({ + updateOnboardingLastVisitedPath: jest.fn(), +})); + +jest.mock('@userActions/Welcome/OnboardingFlow', () => ({ + buildOnboardingFlowParams: jest.fn(() => ({})), + getRequired2FAOnboardingResumePath: jest.fn(() => ''), +})); + +jest.mock('@userActions/TestTool', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@components/FocusTrap/FocusTrapForModal', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + + return { + __esModule: true, + default: ({active, children}: {active?: boolean; children: React.ReactNode}) => + ReactActual.createElement(View, {testID: active ? 'focus-trap-active' : 'focus-trap-inactive'}, children), + }; +}); + +jest.mock('@components/FocusTrap/FocusTrapForScreen', () => { + return { + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, + }; +}); + +jest.mock('@components/Icon', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + + return { + __esModule: true, + default: () => ReactActual.createElement(View), + }; +}); + +let mockFocusedRouteName = 'TestToolsModal_Root'; + +jest.mock('@gorhom/portal', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native'); + const {CardAnimationContext} = require('@react-navigation/stack'); + + // A real portal renders at the host, outside the screen's providers. Drop those contexts so the test fails unless the screen puts them back. + return { + Portal: ({hostName, children}: {hostName: string; children: React.ReactNode}) => + ReactActual.createElement( + View, + {testID: `portal-${hostName}`}, + ReactActual.createElement( + CardAnimationContext.Provider, + {value: undefined}, + ReactActual.createElement(NavigationContext.Provider, {value: undefined}, ReactActual.createElement(NavigationRouteContext.Provider, {value: undefined}, children)), + ), + ), + PortalHost: () => null, + }; +}); + +jest.mock('@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native'); + + function Navigator({children}: {children: React.ReactNode}) { + return ReactActual.createElement(View, {testID: 'test-tools-stack'}, children); + } + + function Screen({name, component: Component}: {name: string; component: React.ComponentType<{route: {key: string; name: string}; navigation: {isFocused: () => boolean}}>}) { + const navigation = { + isFocused: () => name === mockFocusedRouteName, + addListener: () => () => {}, + }; + const route = {key: `${name}-key`, name}; + return ReactActual.createElement( + NavigationContext.Provider, + {value: navigation}, + ReactActual.createElement(NavigationRouteContext.Provider, {value: route}, ReactActual.createElement(Component, {route, navigation})), + ); + } + + return { + __esModule: true, + default: () => ({Navigator, Screen}), + }; +}); + +jest.mock('@components/TestToolsModalPage', () => { + const ReactActual = require('react'); + const {Text} = require('react-native'); + const {useRoute} = require('@react-navigation/native'); + + return function MockTestToolsModalPage() { + const route = useRoute(); + return ReactActual.createElement(Text, {testID: 'test-tools-page'}, route.name); + }; +}); + +jest.mock('@components/TestToolsServerPage', () => { + const ReactActual = require('react'); + const {Text} = require('react-native'); + const {useRoute} = require('@react-navigation/native'); + + return function MockTestToolsServerPage() { + const route = useRoute(); + return ReactActual.createElement(Text, {testID: 'test-tools-server-page'}, route.name); + }; +}); + +describe('RequireTwoFactorAuthenticationOverlay stacking', () => { + beforeEach(() => { + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: false, + }; + mockIsOffline = true; + }); + + it('shows the offline banner outside the covering overlay while required 2FA is visible', () => { + render(); + + const overlay = screen.getByTestId('RequireTwoFactorAuthenticationOverlay'); + + expect(overlay).toBeOnTheScreen(); + expect(screen.getByTestId('RequireTwoFactorOfflineBanner')).toBeOnTheScreen(); + expect(screen.getByText('You appear to be offline.')).toBeOnTheScreen(); + expect(within(overlay).queryByText('You appear to be offline.')).toBeNull(); + expect(screen.getByTestId('focus-trap-active')).toBeOnTheScreen(); + }); + + it('hides the banner when the overlay is not showing', () => { + mockVisibility = { + isRequire2FAOverlayVisible: false, + isTestToolsRouteFocused: false, + }; + + render(); + + expect(screen.queryByText('You appear to be offline.')).toBeNull(); + expect(screen.queryByTestId('RequireTwoFactorAuthenticationOverlay')).toBeNull(); + }); + + it('hides the banner while online', () => { + mockIsOffline = false; + + render(); + + expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); + expect(screen.queryByText('You appear to be offline.')).toBeNull(); + }); + + it('turns the web focus trap off while test tools are focused', () => { + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + render(); + + expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); + expect(screen.getByTestId('focus-trap-inactive')).toBeOnTheScreen(); + expect(screen.queryByTestId('focus-trap-active')).toBeNull(); + }); +}); + +describe('TestToolsModalNavigator portal', () => { + beforeEach(() => { + mockVisibility = { + isRequire2FAOverlayVisible: false, + isTestToolsRouteFocused: false, + }; + mockFocusedRouteName = 'TestToolsModal_Root'; + }); + + it('keeps test tools in place when the required-2FA overlay is hidden', () => { + render(); + + expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); + expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); + expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); + }); + + it('portals the focused screen above the overlay and keeps its route', () => { + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + render(); + + const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); + + expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); + expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); + expect(screen.queryByTestId('test-tools-server-page')).toBeNull(); + }); + + it('renders nothing for an unfocused test tools screen', () => { + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + mockFocusedRouteName = 'TestToolsModal_Server'; + + render(); + + const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); + + expect(within(portal).getByText('TestToolsModal_Server')).toBeOnTheScreen(); + expect(screen.queryByTestId('test-tools-page')).toBeNull(); + }); +}); diff --git a/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts new file mode 100644 index 000000000000..1afb2fff58c3 --- /dev/null +++ b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts @@ -0,0 +1,118 @@ +import {renderHook} from '@testing-library/react-native'; + +import useRequire2FAOverlayVisibility, {getRequire2FAFocusedScreen, REQUIRE_2FA_FOCUSED_SCREEN} from '@hooks/useRequire2FAOverlayVisibility'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +let mockRootState: NavigationState | undefined; +let mockShouldShowRequire2FAPage = true; +const mockGetState = jest.fn(); + +jest.mock('@react-navigation/core', () => { + // jest.requireActual is typed as any. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@react-navigation/core'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + useNavigation: () => ({ + getState: mockGetState, + }), + }; +}); + +jest.mock('@hooks/useShouldShowRequire2FAPage', () => ({ + __esModule: true, + default: () => mockShouldShowRequire2FAPage, +})); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: NavigationState | undefined) => number) => selector(mockRootState), +})); + +function navigationState(screenName: string): NavigationState { + return { + key: 'root', + index: 0, + routeNames: [screenName], + routes: [{key: `${screenName}-key`, name: screenName}], + type: 'stack', + stale: false, + }; +} + +describe('getRequire2FAFocusedScreen', () => { + it('classifies a 2FA setup screen', () => { + expect(getRequire2FAFocusedScreen(SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP); + }); + + it('classifies test tools screens', () => { + expect(getRequire2FAFocusedScreen(SCREENS.TEST_TOOLS_MODAL.ROOT)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS); + expect(getRequire2FAFocusedScreen(SCREENS.TEST_TOOLS_MODAL.SERVER)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS); + expect(getRequire2FAFocusedScreen(NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS); + }); + + it('classifies every other screen as other', () => { + expect(getRequire2FAFocusedScreen(SCREENS.TWO_FACTOR_AUTH.ENABLED)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.OTHER); + expect(getRequire2FAFocusedScreen(undefined)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.OTHER); + }); +}); + +describe('useRequire2FAOverlayVisibility', () => { + beforeEach(() => { + mockRootState = undefined; + mockShouldShowRequire2FAPage = true; + mockGetState.mockReset(); + mockGetState.mockReturnValue(undefined); + }); + + it('shows the overlay when required 2FA is on and the user is not in setup', () => { + mockRootState = navigationState(SCREENS.HOME); + + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + expect(result.current.isRequire2FAOverlayVisible).toBe(true); + expect(result.current.isTestToolsRouteFocused).toBe(false); + }); + + it('hides the overlay during 2FA setup', () => { + mockRootState = navigationState(SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT); + + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + expect(result.current.isRequire2FAOverlayVisible).toBe(false); + expect(result.current.isTestToolsRouteFocused).toBe(false); + }); + + it('hides the overlay when required 2FA is off', () => { + mockShouldShowRequire2FAPage = false; + mockRootState = navigationState(SCREENS.HOME); + + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + expect(result.current.isRequire2FAOverlayVisible).toBe(false); + }); + + it('keeps the overlay visible while test tools are focused', () => { + mockRootState = navigationState(SCREENS.TEST_TOOLS_MODAL.ROOT); + + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + expect(result.current.isRequire2FAOverlayVisible).toBe(true); + expect(result.current.isTestToolsRouteFocused).toBe(true); + }); + + it('falls back to the navigation hook when root state is missing', () => { + mockGetState.mockReturnValue(navigationState(SCREENS.TEST_TOOLS_MODAL.SERVER)); + + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + expect(result.current.isTestToolsRouteFocused).toBe(true); + expect(result.current.isRequire2FAOverlayVisible).toBe(true); + }); +}); From e82b6873557eb44b9a5dd5680fbd6fa2ac604933 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Fri, 9 Oct 2026 10:38:20 +0530 Subject: [PATCH 2/8] Drop the unused Require2FAFocusedScreen export that knip flags. --- src/hooks/useRequire2FAOverlayVisibility.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useRequire2FAOverlayVisibility.ts b/src/hooks/useRequire2FAOverlayVisibility.ts index 329f5674303a..350317bb5c04 100644 --- a/src/hooks/useRequire2FAOverlayVisibility.ts +++ b/src/hooks/useRequire2FAOverlayVisibility.ts @@ -66,4 +66,3 @@ function useRequire2FAOverlayVisibility(): Require2FAOverlayVisibility { export default useRequire2FAOverlayVisibility; export {REQUIRE_2FA_ABOVE_PORTAL_HOST, REQUIRE_2FA_FOCUSED_SCREEN, getRequire2FAFocusedScreen}; -export type {Require2FAFocusedScreen}; From d02b8d9bf00312914e4f09fa516ca55f19cfafaa Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Mon, 12 Oct 2026 09:55:15 +0530 Subject: [PATCH 3/8] Address the required-2FA review notes. Document the overlay banner and test setup, and dim the portaled test-tools frame on native so the backdrop is not left under the overlay. --- .../Navigators/TestToolsModalNavigator.tsx | 21 ++++++++- .../RequireTwoFactorAuthenticationOverlay.tsx | 3 ++ .../RequireTwoFactorOverlayStackingTest.tsx | 30 ++++++++++++- .../useRequire2FAOverlayVisibility.test.ts | 45 ++++++++++++++++--- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index d67eb800001c..67e69ad13f96 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -8,6 +8,8 @@ import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useRequire2FAOverlayVisibility, {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; @@ -15,6 +17,8 @@ import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigati import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {TestToolsModalModalNavigatorParamList} from '@libs/Navigation/types'; +import variables from '@styles/variables'; + import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; @@ -26,7 +30,7 @@ import {Portal} from '@gorhom/portal'; import {NavigationContext, NavigationRouteContext, useIsFocused} from '@react-navigation/native'; import {CardAnimationContext} from '@react-navigation/stack'; import React, {useCallback, useContext, useRef} from 'react'; -import {View} from 'react-native'; +import {Platform, StyleSheet, View} from 'react-native'; import Overlay from './Overlay'; @@ -87,11 +91,16 @@ function TestToolsModalFrame({children}: TestToolsModalFrameProps) { */ function withRequire2FAPortal(Screen: ComponentType>) { function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { + const theme = useTheme(); + const StyleUtils = useStyleUtils(); const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); const navigation = useContext(NavigationContext); const route = useContext(NavigationRouteContext); const cardAnimation = useContext(CardAnimationContext); const isFocused = useIsFocused(); + // Native Overlay renders nothing, and the screen contentStyle stays under the 2FA overlay. + // This fill is the dim that travels with the portal. Web already dims via Overlay inside the frame. + const nativeScrimStyle = Platform.OS === 'web' ? undefined : StyleUtils.getBackgroundColorWithOpacityStyle(theme.overlay, variables.overlayOpacity); if (!isRequire2FAOverlayVisible) { return ; @@ -112,7 +121,15 @@ function withRequire2FAPortal(Screen: Comp return ( - {frame} + + + {frame} + + ); diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 0c2a76764caf..53a712330404 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -155,6 +155,9 @@ function RequireTwoFactorAuthenticationOverlay() { + {/* The overlay covers the screen (and its ScreenWrapper banner) at a higher z-index, so render the + offline banner here, above the overlay, so the user can still see they're offline. box-none lets + taps fall through to the overlay below. */} ({ jest.mock('@hooks/useTheme', () => ({ __esModule: true, - default: () => ({icon: '#000'}), + default: () => ({icon: '#000', overlay: '#000000'}), })); jest.mock('@hooks/useThemeStyles', () => ({ @@ -234,10 +236,13 @@ describe('RequireTwoFactorAuthenticationOverlay stacking', () => { }); it('shows the offline banner outside the covering overlay while required 2FA is visible', () => { + // Given required 2FA is visible and the user is offline, the ScreenWrapper banner is covered by the overlay. + + // When the overlay renders its own banner above that layer. render(); + // Then the user can still see they are offline, and the banner is not trapped inside the overlay. const overlay = screen.getByTestId('RequireTwoFactorAuthenticationOverlay'); - expect(overlay).toBeOnTheScreen(); expect(screen.getByTestId('RequireTwoFactorOfflineBanner')).toBeOnTheScreen(); expect(screen.getByText('You appear to be offline.')).toBeOnTheScreen(); @@ -246,34 +251,43 @@ describe('RequireTwoFactorAuthenticationOverlay stacking', () => { }); it('hides the banner when the overlay is not showing', () => { + // Given required 2FA is not showing, so the ScreenWrapper banner is visible again. mockVisibility = { isRequire2FAOverlayVisible: false, isTestToolsRouteFocused: false, }; + // When the overlay decides whether to render. render(); + // Then there is no second banner, because the two would stack. expect(screen.queryByText('You appear to be offline.')).toBeNull(); expect(screen.queryByTestId('RequireTwoFactorAuthenticationOverlay')).toBeNull(); }); it('hides the banner while online', () => { + // Given required 2FA is visible but the user is online. mockIsOffline = false; + // When the overlay renders. render(); + // Then the extra banner stays hidden, because there is no offline state to show. expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); expect(screen.queryByText('You appear to be offline.')).toBeNull(); }); it('turns the web focus trap off while test tools are focused', () => { + // Given test tools are focused on top of the required-2FA overlay. mockVisibility = { isRequire2FAOverlayVisible: true, isTestToolsRouteFocused: true, }; + // When the overlay renders. render(); + // Then the web focus trap turns off, so keyboard focus can reach the tools. expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); expect(screen.getByTestId('focus-trap-inactive')).toBeOnTheScreen(); expect(screen.queryByTestId('focus-trap-active')).toBeNull(); @@ -290,37 +304,49 @@ describe('TestToolsModalNavigator portal', () => { }); it('keeps test tools in place when the required-2FA overlay is hidden', () => { + // Given the required-2FA overlay is hidden, so the screen contentStyle already dims the tools. + + // When test tools render in the root stack. render(); + // Then they stay in place, and the portaled scrim is absent so the two dims do not stack. expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); + expect(screen.queryByTestId('Require2FATestToolsScrim')).toBeNull(); }); it('portals the focused screen above the overlay and keeps its route', () => { + // Given required 2FA covers the root stack and the root test-tools screen is focused. mockVisibility = { isRequire2FAOverlayVisible: true, isTestToolsRouteFocused: true, }; + // When test tools render above that overlay. render(); + // Then that screen is portaled with its route, and the native scrim travels with it so the 2FA screen is dimmed. const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); + expect(within(portal).getByTestId('Require2FATestToolsScrim')).toBeOnTheScreen(); expect(screen.queryByTestId('test-tools-server-page')).toBeNull(); }); it('renders nothing for an unfocused test tools screen', () => { + // Given the server page is the focused test-tools screen while required 2FA is showing. mockVisibility = { isRequire2FAOverlayVisible: true, isTestToolsRouteFocused: true, }; mockFocusedRouteName = 'TestToolsModal_Server'; + // When test tools render above the overlay. render(); + // Then only the focused page is portaled, so the previous tool screen does not stay on top of 2FA. const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); expect(within(portal).getByText('TestToolsModal_Server')).toBeOnTheScreen(); diff --git a/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts index 1afb2fff58c3..0f2e332bc3e3 100644 --- a/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts +++ b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts @@ -48,18 +48,36 @@ function navigationState(screenName: string): NavigationState { describe('getRequire2FAFocusedScreen', () => { it('classifies a 2FA setup screen', () => { - expect(getRequire2FAFocusedScreen(SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP); + // Given the focused route is the 2FA setup screen the overlay opens. + const screenName = SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT; + + // When the focused screen is classified. + const focusedScreen = getRequire2FAFocusedScreen(screenName); + + // Then it is setup, so the overlay can hide and stop covering the flow it opened. + expect(focusedScreen).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP); }); it('classifies test tools screens', () => { - expect(getRequire2FAFocusedScreen(SCREENS.TEST_TOOLS_MODAL.ROOT)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS); - expect(getRequire2FAFocusedScreen(SCREENS.TEST_TOOLS_MODAL.SERVER)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS); - expect(getRequire2FAFocusedScreen(NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS); + // Given the focused route is a test-tools screen rather than setup. + const screenNames = [SCREENS.TEST_TOOLS_MODAL.ROOT, SCREENS.TEST_TOOLS_MODAL.SERVER, NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR]; + + // When each of those routes is classified. + const focusedScreens = screenNames.map((screenName) => getRequire2FAFocusedScreen(screenName)); + + // Then they stay distinct from setup, so the overlay can remain up while the tools are open. + expect(focusedScreens).toEqual([REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS]); }); it('classifies every other screen as other', () => { - expect(getRequire2FAFocusedScreen(SCREENS.TWO_FACTOR_AUTH.ENABLED)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.OTHER); - expect(getRequire2FAFocusedScreen(undefined)).toBe(REQUIRE_2FA_FOCUSED_SCREEN.OTHER); + // Given a route that is neither setup nor test tools, including a missing route. + const screenNames = [SCREENS.TWO_FACTOR_AUTH.ENABLED, undefined]; + + // When those routes are classified. + const focusedScreens = screenNames.map((screenName) => getRequire2FAFocusedScreen(screenName)); + + // Then they are normal screens, so the overlay follows the required-2FA flag alone. + expect(focusedScreens).toEqual([REQUIRE_2FA_FOCUSED_SCREEN.OTHER, REQUIRE_2FA_FOCUSED_SCREEN.OTHER]); }); }); @@ -72,46 +90,61 @@ describe('useRequire2FAOverlayVisibility', () => { }); it('shows the overlay when required 2FA is on and the user is not in setup', () => { + // Given required 2FA is on and the user is on Home, not in setup. mockRootState = navigationState(SCREENS.HOME); + // When the visibility hook runs. const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + // Then the overlay shows, because the user still has to enable 2FA. expect(result.current.isRequire2FAOverlayVisible).toBe(true); expect(result.current.isTestToolsRouteFocused).toBe(false); }); it('hides the overlay during 2FA setup', () => { + // Given the user is on the 2FA setup screen the overlay opened. mockRootState = navigationState(SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT); + // When the visibility hook runs. const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + // Then the overlay hides, so it does not cover that setup flow. expect(result.current.isRequire2FAOverlayVisible).toBe(false); expect(result.current.isTestToolsRouteFocused).toBe(false); }); it('hides the overlay when required 2FA is off', () => { + // Given required 2FA is turned off. mockShouldShowRequire2FAPage = false; mockRootState = navigationState(SCREENS.HOME); + // When the visibility hook runs. const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + // Then the overlay hides, because there is nothing left to block. expect(result.current.isRequire2FAOverlayVisible).toBe(false); }); it('keeps the overlay visible while test tools are focused', () => { + // Given required 2FA is on and test tools are the focused screen. mockRootState = navigationState(SCREENS.TEST_TOOLS_MODAL.ROOT); + // When the visibility hook runs. const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + // Then the overlay stays visible, so closing the tools returns to the 2FA screen instead of the app. expect(result.current.isRequire2FAOverlayVisible).toBe(true); expect(result.current.isTestToolsRouteFocused).toBe(true); }); it('falls back to the navigation hook when root state is missing', () => { + // Given the root navigation state is missing and the navigation hook still reports test tools. mockGetState.mockReturnValue(navigationState(SCREENS.TEST_TOOLS_MODAL.SERVER)); + // When the visibility hook runs. const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + // Then it uses that hook, so the overlay does not flicker off before the root state is ready. expect(result.current.isTestToolsRouteFocused).toBe(true); expect(result.current.isRequire2FAOverlayVisible).toBe(true); }); From 28b3eb65ac8c89959cd35b21dc708c5645aeaa21 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Tue, 13 Oct 2026 12:12:34 +0530 Subject: [PATCH 4/8] Keep native test tools in the modal and show the full offline label. Portaling the tools out of the native modal left an empty white screen over them, and safe-area padding on the fixed-height offline row clipped the text. --- .../Navigators/TestToolsModalNavigator.tsx | 43 ++++++++--------- .../RequireTwoFactorAuthenticationOverlay.tsx | 19 ++++++-- .../RequireTwoFactorOverlayStackingTest.tsx | 46 +++++++++++++++---- 3 files changed, 70 insertions(+), 38 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index 67e69ad13f96..a9992642fff7 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -8,8 +8,6 @@ import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useRequire2FAOverlayVisibility, {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; @@ -17,8 +15,6 @@ import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigati import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {TestToolsModalModalNavigatorParamList} from '@libs/Navigation/types'; -import variables from '@styles/variables'; - import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; @@ -30,7 +26,7 @@ import {Portal} from '@gorhom/portal'; import {NavigationContext, NavigationRouteContext, useIsFocused} from '@react-navigation/native'; import {CardAnimationContext} from '@react-navigation/stack'; import React, {useCallback, useContext, useRef} from 'react'; -import {Platform, StyleSheet, View} from 'react-native'; +import {Platform, View} from 'react-native'; import Overlay from './Overlay'; @@ -85,24 +81,22 @@ function TestToolsModalFrame({children}: TestToolsModalFrameProps) { } /** - * While the required-2FA overlay covers the root stack, draw this screen in the portal host above it. - * The nested stack stays in the root navigator so the URL, backTo, and goBack keep working. - * The host is outside that stack, so the frame gets the screen contexts it reads. + * On web, the required-2FA overlay is painted after the root stack, so this screen has to move + * into the portal host above it. The nested stack stays in the root navigator so the URL, backTo, + * and goBack keep working. The host is outside that stack, so the frame gets the screen contexts it reads. + * + * Native already presents this navigator as a transparent modal above that overlay. Portaling the + * screen out of the modal leaves an empty opaque screen, which covers the tools once the transition ends. */ function withRequire2FAPortal(Screen: ComponentType>) { function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { - const theme = useTheme(); - const StyleUtils = useStyleUtils(); const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); const navigation = useContext(NavigationContext); const route = useContext(NavigationRouteContext); const cardAnimation = useContext(CardAnimationContext); const isFocused = useIsFocused(); - // Native Overlay renders nothing, and the screen contentStyle stays under the 2FA overlay. - // This fill is the dim that travels with the portal. Web already dims via Overlay inside the frame. - const nativeScrimStyle = Platform.OS === 'web' ? undefined : StyleUtils.getBackgroundColorWithOpacityStyle(theme.overlay, variables.overlayOpacity); - if (!isRequire2FAOverlayVisible) { + if (!isRequire2FAOverlayVisible || Platform.OS !== 'web') { return ; } @@ -121,15 +115,7 @@ function withRequire2FAPortal(Screen: Comp return ( - - - {frame} - - + {frame} ); @@ -147,7 +133,13 @@ function TestToolsModalNavigator() { useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false}); const stack = ( - + ); - if (isRequire2FAOverlayVisible) { + // See withRequire2FAPortal. Only web has to lift the screen out of this stack. + if (isRequire2FAOverlayVisible && Platform.OS === 'web') { return stack; } diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 53a712330404..23f1eb12801c 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -5,8 +5,10 @@ import Icon from '@components/Icon'; import OfflineIndicator from '@components/OfflineIndicator'; import Text from '@components/Text'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useRequire2FAOverlayVisibility from '@hooks/useRequire2FAOverlayVisibility'; import useSignOut from '@hooks/useSignOut'; @@ -53,6 +55,13 @@ function RequireTwoFactorAuthenticationOverlay() { const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + // OfflineIndicator is a fixed-height row. Safe-area padding has to live on the container around it, + // or the home-indicator inset consumes that height and the "You appear to be offline" label clips. + const offlineBannerSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: true, + addOfflineIndicatorBottomSafeAreaPadding: false, + }); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); const {signOut, leaveDelegateAccount, isActingAsDelegate, isTrackingGPS} = useSignOut(); const gpsDraftDetailsRef = useRef(undefined); @@ -163,11 +172,11 @@ function RequireTwoFactorAuthenticationOverlay() { style={[StyleSheet.absoluteFill, styles.twoFARequiredOfflineBanner]} testID="RequireTwoFactorOfflineBanner" > - + {isOffline && ( + + + + )} ); diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx index 9d46e2b652a8..467c6d401b07 100644 --- a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx +++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx @@ -10,6 +10,7 @@ import TestToolsModalNavigator from '@libs/Navigation/AppNavigator/Navigators/Te import RequireTwoFactorAuthenticationOverlay from '@pages/RequireTwoFactorAuthenticationOverlay'; import React from 'react'; +import {Platform} from 'react-native'; type Visibility = { isRequire2FAOverlayVisible: boolean; @@ -33,6 +34,11 @@ jest.mock('@hooks/useNetwork', () => ({ default: () => ({isOffline: mockIsOffline}), })); +jest.mock('@hooks/useBottomSafeSafeAreaPaddingStyle', () => ({ + __esModule: true, + default: () => ({paddingBottom: 34}), +})); + jest.mock('@hooks/useOnyx', () => ({ __esModule: true, default: () => [undefined, {status: 'loaded'}], @@ -295,12 +301,19 @@ describe('RequireTwoFactorAuthenticationOverlay stacking', () => { }); describe('TestToolsModalNavigator portal', () => { + const originalOS = Platform.OS; + beforeEach(() => { mockVisibility = { isRequire2FAOverlayVisible: false, isTestToolsRouteFocused: false, }; mockFocusedRouteName = 'TestToolsModal_Root'; + Platform.OS = originalOS; + }); + + afterEach(() => { + Platform.OS = originalOS; }); it('keeps test tools in place when the required-2FA overlay is hidden', () => { @@ -309,15 +322,15 @@ describe('TestToolsModalNavigator portal', () => { // When test tools render in the root stack. render(); - // Then they stay in place, and the portaled scrim is absent so the two dims do not stack. + // Then they stay in place, because nothing is covering them. expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); - expect(screen.queryByTestId('Require2FATestToolsScrim')).toBeNull(); }); - it('portals the focused screen above the overlay and keeps its route', () => { - // Given required 2FA covers the root stack and the root test-tools screen is focused. + it('portals the focused screen above the overlay on web and keeps its route', () => { + // Given web, where the overlay is painted after the navigator and would cover an in-place screen. + Platform.OS = 'web'; mockVisibility = { isRequire2FAOverlayVisible: true, isTestToolsRouteFocused: true, @@ -326,17 +339,17 @@ describe('TestToolsModalNavigator portal', () => { // When test tools render above that overlay. render(); - // Then that screen is portaled with its route, and the native scrim travels with it so the 2FA screen is dimmed. + // Then that screen is portaled with its route. Web dims via Overlay inside the frame. const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); - expect(within(portal).getByTestId('Require2FATestToolsScrim')).toBeOnTheScreen(); expect(screen.queryByTestId('test-tools-server-page')).toBeNull(); }); - it('renders nothing for an unfocused test tools screen', () => { - // Given the server page is the focused test-tools screen while required 2FA is showing. + it('renders nothing for an unfocused test tools screen on web', () => { + // Given the server page is the focused test-tools screen while required 2FA is showing on web. + Platform.OS = 'web'; mockVisibility = { isRequire2FAOverlayVisible: true, isTestToolsRouteFocused: true, @@ -352,4 +365,21 @@ describe('TestToolsModalNavigator portal', () => { expect(within(portal).getByText('TestToolsModal_Server')).toBeOnTheScreen(); expect(screen.queryByTestId('test-tools-page')).toBeNull(); }); + + it('keeps test tools inside the native modal while required 2FA is showing', () => { + // Given native, where this navigator is already a transparent modal above the overlay. + // Portaling the screen out of that modal leaves an empty opaque screen that covers the tools after the transition. + Platform.OS = 'ios'; + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + // When test tools render. + render(); + + // Then they stay in the navigator, where the modal can show them, and nothing is portaled behind it. + expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); + expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); + }); }); From 4c82172b1cb42502d9e388b17cd99bcd690c1835 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Tue, 13 Oct 2026 12:24:34 +0530 Subject: [PATCH 5/8] Reword the portal comments so spellcheck accepts them. CSpell rejects "Portaling" in the native test-tools comments. --- .../AppNavigator/Navigators/TestToolsModalNavigator.tsx | 4 ++-- tests/ui/RequireTwoFactorOverlayStackingTest.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index a9992642fff7..b326cc28fb83 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -85,8 +85,8 @@ function TestToolsModalFrame({children}: TestToolsModalFrameProps) { * into the portal host above it. The nested stack stays in the root navigator so the URL, backTo, * and goBack keep working. The host is outside that stack, so the frame gets the screen contexts it reads. * - * Native already presents this navigator as a transparent modal above that overlay. Portaling the - * screen out of the modal leaves an empty opaque screen, which covers the tools once the transition ends. + * Native already presents this navigator as a transparent modal above that overlay. Moving the + * screen into the portal host leaves an empty opaque screen, which covers the tools once the transition ends. */ function withRequire2FAPortal(Screen: ComponentType>) { function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx index 467c6d401b07..23a3d8ace720 100644 --- a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx +++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx @@ -368,7 +368,7 @@ describe('TestToolsModalNavigator portal', () => { it('keeps test tools inside the native modal while required 2FA is showing', () => { // Given native, where this navigator is already a transparent modal above the overlay. - // Portaling the screen out of that modal leaves an empty opaque screen that covers the tools after the transition. + // Moving the screen out of that modal into the portal host leaves an empty opaque screen that covers the tools after the transition. Platform.OS = 'ios'; mockVisibility = { isRequire2FAOverlayVisible: true, From 4f36638a80f415128c3303aed9a03f1050f7d405 Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Thu, 15 Oct 2026 18:14:09 +0530 Subject: [PATCH 6/8] Lift Android test tools above required 2FA and mute its Enter shortcut while they are open. --- .../Navigators/TestToolsModalNavigator.tsx | 21 +++++++---- .../RequireTwoFactorAuthenticationOverlay.tsx | 4 +- .../RequireTwoFactorOverlayStackingTest.tsx | 37 +++++++++++++++++-- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index b326cc28fb83..7e47acd3f7cb 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -80,13 +80,18 @@ function TestToolsModalFrame({children}: TestToolsModalFrameProps) { ); } +// iOS already draws this navigator above the overlay. Android's transparent modal stays inside the stack, under it, and web paints the overlay after the stack. +function shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible: boolean): boolean { + return isRequire2FAOverlayVisible && Platform.OS !== 'ios'; +} + /** - * On web, the required-2FA overlay is painted after the root stack, so this screen has to move - * into the portal host above it. The nested stack stays in the root navigator so the URL, backTo, - * and goBack keep working. The host is outside that stack, so the frame gets the screen contexts it reads. + * On web and Android, move the focused screen into the portal host above the required-2FA overlay. + * The nested stack stays in the root navigator so the URL, backTo, and goBack keep working. + * The host is outside that stack, so the frame gets the screen contexts it reads. * - * Native already presents this navigator as a transparent modal above that overlay. Moving the - * screen into the portal host leaves an empty opaque screen, which covers the tools once the transition ends. + * iOS stays in place. Moving that screen into the portal host leaves an empty opaque window, + * which covers the tools once the modal transition ends. */ function withRequire2FAPortal(Screen: ComponentType>) { function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { @@ -96,7 +101,7 @@ function withRequire2FAPortal(Screen: Comp const cardAnimation = useContext(CardAnimationContext); const isFocused = useIsFocused(); - if (!isRequire2FAOverlayVisible || Platform.OS !== 'web') { + if (!shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible)) { return ; } @@ -151,8 +156,8 @@ function TestToolsModalNavigator() { ); - // See withRequire2FAPortal. Only web has to lift the screen out of this stack. - if (isRequire2FAOverlayVisible && Platform.OS === 'web') { + // See shouldLiftTestToolsAboveRequire2FA. iOS keeps the screen in this stack. + if (shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible)) { return stack; } diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 23f1eb12801c..7346fb642f56 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -155,7 +155,9 @@ function RequireTwoFactorAuthenticationOverlay() { variant={CONST.BUTTON_VARIANT.SUCCESS} onPress={enableTwoFactorAuth} > - + {/* The overlay sits outside a screen, so this shortcut always looks focused. + Leave it off while test tools are open, or Enter there starts 2FA setup. */} + {!isTestToolsRouteFocused && } {translate('twoFactorAuth.enable2FA')} diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx index 23a3d8ace720..c1c209ad263d 100644 --- a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx +++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx @@ -158,6 +158,16 @@ jest.mock('@components/Icon', () => { }; }); +jest.mock('@components/Button/primitives/ButtonKeyboardShortcut', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + + return { + __esModule: true, + default: () => ReactActual.createElement(View, {testID: 'enable-2fa-enter-shortcut'}), + }; +}); + let mockFocusedRouteName = 'TestToolsModal_Root'; jest.mock('@gorhom/portal', () => { @@ -254,6 +264,7 @@ describe('RequireTwoFactorAuthenticationOverlay stacking', () => { expect(screen.getByText('You appear to be offline.')).toBeOnTheScreen(); expect(within(overlay).queryByText('You appear to be offline.')).toBeNull(); expect(screen.getByTestId('focus-trap-active')).toBeOnTheScreen(); + expect(screen.getByTestId('enable-2fa-enter-shortcut')).toBeOnTheScreen(); }); it('hides the banner when the overlay is not showing', () => { @@ -293,10 +304,11 @@ describe('RequireTwoFactorAuthenticationOverlay stacking', () => { // When the overlay renders. render(); - // Then the web focus trap turns off, so keyboard focus can reach the tools. + // Then the web focus trap turns off, so keyboard focus can reach the tools, and Enter no longer starts 2FA setup. expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); expect(screen.getByTestId('focus-trap-inactive')).toBeOnTheScreen(); expect(screen.queryByTestId('focus-trap-active')).toBeNull(); + expect(screen.queryByTestId('enable-2fa-enter-shortcut')).toBeNull(); }); }); @@ -366,9 +378,26 @@ describe('TestToolsModalNavigator portal', () => { expect(screen.queryByTestId('test-tools-page')).toBeNull(); }); - it('keeps test tools inside the native modal while required 2FA is showing', () => { - // Given native, where this navigator is already a transparent modal above the overlay. - // Moving the screen out of that modal into the portal host leaves an empty opaque screen that covers the tools after the transition. + it('portals the focused screen above the overlay on Android', () => { + // Given Android, where a transparent modal stays inside the stack and the overlay would cover an in-place screen. + Platform.OS = 'android'; + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + // When test tools render above that overlay. + render(); + + // Then that screen is portaled with its route, the same as on web. + const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); + + expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); + }); + + it('keeps test tools inside the iOS modal while required 2FA is showing', () => { + // Given iOS, where this navigator is already a window above the overlay. + // Moving the screen out of that window into the portal host leaves an empty opaque screen that covers the tools after the transition. Platform.OS = 'ios'; mockVisibility = { isRequire2FAOverlayVisible: true, From a7f5f9f17aa08aa69c6d306aa0ef57ff11edca4e Mon Sep 17 00:00:00 2001 From: Neeraj Bachani Date: Sat, 17 Oct 2026 17:05:41 +0530 Subject: [PATCH 7/8] Drop the overlay comments the reviewer asked to remove. --- src/pages/RequireTwoFactorAuthenticationOverlay.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 7346fb642f56..931334cfb80e 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -155,8 +155,6 @@ function RequireTwoFactorAuthenticationOverlay() { variant={CONST.BUTTON_VARIANT.SUCCESS} onPress={enableTwoFactorAuth} > - {/* The overlay sits outside a screen, so this shortcut always looks focused. - Leave it off while test tools are open, or Enter there starts 2FA setup. */} {!isTestToolsRouteFocused && } {translate('twoFactorAuth.enable2FA')} @@ -166,9 +164,6 @@ function RequireTwoFactorAuthenticationOverlay() { - {/* The overlay covers the screen (and its ScreenWrapper banner) at a higher z-index, so render the - offline banner here, above the overlay, so the user can still see they're offline. box-none lets - taps fall through to the overlay below. */} Date: Sun, 18 Oct 2026 16:34:24 +0530 Subject: [PATCH 8/8] Open test tools over required 2FA from a four-finger tap, and dim the Android sheet without remounting the stack. --- .../ScreenWrapper/ScreenWrapperContainer.tsx | 41 +++--- src/hooks/useTestToolsTapResponder.ts | 21 +++ .../Navigators/TestToolsLiftContext.ts | 19 +++ .../Navigators/TestToolsModalNavigator.tsx | 125 ++++++++++-------- .../TestToolsPortalBackdrop/index.native.tsx | 23 ++++ .../TestToolsPortalBackdrop/index.tsx | 6 + .../index.ios.ts | 10 ++ .../index.ts | 6 + .../RequireTwoFactorAuthenticationOverlay.tsx | 24 ++-- src/styles/index.ts | 6 +- .../RequireTwoFactorOverlayStackingTest.tsx | 80 +++++++++-- 11 files changed, 258 insertions(+), 103 deletions(-) create mode 100644 src/hooks/useTestToolsTapResponder.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts create mode 100644 src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx create mode 100644 src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx create mode 100644 src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts create mode 100644 src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx index 43336e3bc074..9524756158cb 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -6,6 +6,7 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddi import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useTackInputFocus from '@hooks/useTackInputFocus'; +import useTestToolsTapResponder from '@hooks/useTestToolsTapResponder'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -14,14 +15,12 @@ import {isMobile, isMobileWebKit, isSafari} from '@libs/Browser'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import addViewportResizeListener from '@libs/VisualViewport'; -import toggleTestToolsModal from '@userActions/TestTool'; - import CONST from '@src/CONST'; import type {ComponentRef, ForwardedRef, ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useContext, useEffect, useMemo, useState} from 'react'; import {Keyboard, PanResponder, View} from 'react-native'; import {PickerAvoidingView} from 'react-native-picker-select'; @@ -161,24 +160,20 @@ function ScreenWrapperContainer({ [isUsingEdgeToEdgeMode, edgeToEdgeBottomContentStyle, legacyBottomContentStyle, bottomContentStyleProp], ); - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, - onPanResponderRelease: toggleTestToolsModal, - }), - ).current; - - const keyboardDismissPanResponder = useRef( - PanResponder.create({ - onMoveShouldSetPanResponderCapture: (_e, gestureState) => { - const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); - const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && Keyboard.isVisible() && isMobile(); - - return isHorizontalSwipe && shouldDismissKeyboard; - }, - onPanResponderGrant: Keyboard.dismiss, - }), - ).current; + const testToolsTapResponder = useTestToolsTapResponder(); + + const [keyboardDismissPanResponder] = useState( + () => + PanResponder.create({ + onMoveShouldSetPanResponderCapture: (_e, gestureState) => { + const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); + const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && Keyboard.isVisible() && isMobile(); + + return isHorizontalSwipe && shouldDismissKeyboard; + }, + onPanResponderGrant: Keyboard.dismiss, + }).panHandlers, + ); useEffect(() => { /** @@ -206,7 +201,7 @@ function ScreenWrapperContainer({ ref={ref} // This style gives the background for the screens. Stack cards are transparent to make different width screens in RHP possible. style={[styles.flex1, styles.appBG, styles.screenWrapperContainerMinHeight(minHeight)]} - {...panResponder.panHandlers} + {...testToolsTapResponder} testID={testID} fsClass={forwardedFSClass} tabIndex={-1} @@ -215,7 +210,7 @@ function ScreenWrapperContainer({ > + PanResponder.create({ + onStartShouldSetPanResponderCapture: (_event, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, + onPanResponderRelease: toggleTestToolsModal, + }).panHandlers, + ); + + return panHandlers; +} + +export default useTestToolsTapResponder; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts b/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts new file mode 100644 index 000000000000..fff118fd189a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts @@ -0,0 +1,19 @@ +import type {CardAnimationContext} from '@react-navigation/stack'; +import type {ContextType} from 'react'; + +import {createContext} from 'react'; + +type TestToolsLift = { + /** Move the focused screen into the portal host above the required-2FA overlay. */ + shouldLift: boolean; + + /** Card animation of the root test-tools modal, captured before the nested stack replaces it. */ + cardAnimation: ContextType; +}; + +const TestToolsLiftContext = createContext({ + shouldLift: false, + cardAnimation: undefined, +}); + +export default TestToolsLiftContext; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index 7e47acd3f7cb..8ff48bf95eae 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -8,9 +8,11 @@ import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useRequire2FAOverlayVisibility, {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; +import shouldLiftTestToolsAboveRequire2FA from '@libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {TestToolsModalModalNavigatorParamList} from '@libs/Navigation/types'; @@ -25,10 +27,12 @@ import type {ComponentRef, ComponentType, MouseEvent, ReactNode} from 'react'; import {Portal} from '@gorhom/portal'; import {NavigationContext, NavigationRouteContext, useIsFocused} from '@react-navigation/native'; import {CardAnimationContext} from '@react-navigation/stack'; -import React, {useCallback, useContext, useRef} from 'react'; -import {Platform, View} from 'react-native'; +import React, {useCallback, useContext, useMemo, useRef} from 'react'; +import {StyleSheet, View} from 'react-native'; import Overlay from './Overlay'; +import TestToolsLiftContext from './TestToolsLiftContext'; +import TestToolsPortalBackdrop from './TestToolsPortalBackdrop'; const Stack = createPlatformStackNavigator(); @@ -36,13 +40,21 @@ type TestToolsRouteName = keyof TestToolsModalModalNavigatorParamList; type TestToolsModalFrameProps = { children: ReactNode; + + /** Keep the stack in this same slot, without painting the sheet. Used while the focused screen is portaled. */ + isPassThrough?: boolean; + + /** This frame is the copy above the required-2FA overlay, so it needs its own dim and safe-area inset. */ + isPortaled?: boolean; }; -function TestToolsModalFrame({children}: TestToolsModalFrameProps) { +function TestToolsModalFrame({children, isPassThrough = false, isPortaled = false}: TestToolsModalFrameProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {left, right} = useSafeAreaInsets(); const outerViewRef = useRef>(null); const isAuthenticated = useIsAuthenticated(); + const showChrome = !isPassThrough; const handleOuterClick = useCallback(() => { // Release focus from any focused element before closing the modal @@ -56,35 +68,42 @@ function TestToolsModalFrame({children}: TestToolsModalFrameProps) { e.stopPropagation(); }, []); + const portaledInsetStyle = useMemo(() => (isPortaled ? {paddingLeft: left, paddingRight: right} : undefined), [isPortaled, left, right]); + return ( - - - - - true} - onClick={handleInnerClick} - style={styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated)} - > - {children} - - - - + + + + {isPortaled && } + + + + + true : undefined} + onClick={showChrome ? handleInnerClick : undefined} + pointerEvents={showChrome ? 'auto' : 'box-none'} + style={showChrome ? styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated) : styles.flex1} + > + {children} + + + + + ); } -// iOS already draws this navigator above the overlay. Android's transparent modal stays inside the stack, under it, and web paints the overlay after the stack. -function shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible: boolean): boolean { - return isRequire2FAOverlayVisible && Platform.OS !== 'ios'; -} - /** * On web and Android, move the focused screen into the portal host above the required-2FA overlay. * The nested stack stays in the root navigator so the URL, backTo, and goBack keep working. @@ -95,13 +114,12 @@ function shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible: boolean) */ function withRequire2FAPortal(Screen: ComponentType>) { function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { - const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); + const {shouldLift, cardAnimation} = useContext(TestToolsLiftContext); const navigation = useContext(NavigationContext); const route = useContext(NavigationRouteContext); - const cardAnimation = useContext(CardAnimationContext); const isFocused = useIsFocused(); - if (!shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible)) { + if (!shouldLift) { return ; } @@ -111,10 +129,11 @@ function withRequire2FAPortal(Screen: Comp } const screenFrame = ( - + ); + // The nested screen's card is a push, not the modal fade. Keep the root modal's animation on the dim. const frame = cardAnimation ? {screenFrame} : screenFrame; return ( @@ -134,34 +153,28 @@ const PortaledTestToolsServerPage = withRequire2FAPortal(TestToolsServerPage); function TestToolsModalNavigator() { const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); + const shouldLift = shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible); + const cardAnimation = useContext(CardAnimationContext); + const lift = useMemo(() => ({shouldLift, cardAnimation}), [shouldLift, cardAnimation]); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false}); - const stack = ( - - - - + return ( + + + + + + + + ); - - // See shouldLiftTestToolsAboveRequire2FA. iOS keeps the screen in this stack. - if (shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible)) { - return stack; - } - - return {stack}; } export default TestToolsModalNavigator; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx new file mode 100644 index 000000000000..1b8202343beb --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx @@ -0,0 +1,23 @@ +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +/** The native Overlay is empty, and the root modal dim sits under the required-2FA screen once this frame is portaled. */ +function TestToolsPortalBackdrop() { + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + return ( + + ); +} + +export default TestToolsPortalBackdrop; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx new file mode 100644 index 000000000000..91ae1ad3292f --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx @@ -0,0 +1,6 @@ +/** Web already dims through Overlay, which follows the root modal card animation. */ +function TestToolsPortalBackdrop() { + return null; +} + +export default TestToolsPortalBackdrop; diff --git a/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts new file mode 100644 index 000000000000..da785c2b5143 --- /dev/null +++ b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts @@ -0,0 +1,10 @@ +/** iOS already presents this navigator in its own window above the overlay. Moving the screen into the portal host leaves an empty opaque window. */ +function shouldLiftTestToolsAboveRequire2FA( + // The flag is part of the shared signature. iOS never lifts, so the value is unused. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isRequire2FAOverlayVisible: boolean, +): boolean { + return false; +} + +export default shouldLiftTestToolsAboveRequire2FA; diff --git a/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts new file mode 100644 index 000000000000..e9a8bc9e4e27 --- /dev/null +++ b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts @@ -0,0 +1,6 @@ +/** Web and Android paint this navigator under the required-2FA overlay, so the focused screen moves into the portal host above it. */ +function shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible: boolean): boolean { + return isRequire2FAOverlayVisible; +} + +export default shouldLiftTestToolsAboveRequire2FA; diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 931334cfb80e..cde79225d666 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -11,7 +11,9 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useRequire2FAOverlayVisibility from '@hooks/useRequire2FAOverlayVisibility'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSignOut from '@hooks/useSignOut'; +import useTestToolsTapResponder from '@hooks/useTestToolsTapResponder'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; @@ -54,6 +56,8 @@ function RequireTwoFactorAuthenticationOverlay() { const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const testToolsTapResponder = useTestToolsTapResponder(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); // OfflineIndicator is a fixed-height row. Safe-area padding has to live on the container around it, @@ -125,6 +129,7 @@ function RequireTwoFactorAuthenticationOverlay() { @@ -162,19 +167,16 @@ function RequireTwoFactorAuthenticationOverlay() { + {shouldUseNarrowLayout && isOffline && ( + + + + )} - - {isOffline && ( - - - - )} - ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index 2ab7400f19aa..4643b40ed752 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6239,12 +6239,8 @@ const staticStyles = (theme: ThemeColors) => zIndex: 20, // must be greater than floatingCameraButton.zIndex }, - twoFARequiredOfflineBanner: { - zIndex: 21, // must be greater than twoFARequiredOverlay.zIndex - }, - twoFARequiredTestToolsHost: { - zIndex: 22, // must be greater than twoFARequiredOfflineBanner.zIndex + zIndex: 22, // must be greater than twoFARequiredOverlay.zIndex }, twoFARequiredContainer: { diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx index c1c209ad263d..6523f5800aee 100644 --- a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx +++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx @@ -22,6 +22,8 @@ let mockVisibility: Visibility = { isTestToolsRouteFocused: false, }; let mockIsOffline = true; +let mockShouldUseNarrowLayout = true; +let testToolsStackMountCount = 0; jest.mock('@hooks/useRequire2FAOverlayVisibility', () => ({ __esModule: true, @@ -100,9 +102,24 @@ jest.mock('@hooks/useKeyboardShortcut', () => ({ jest.mock('@hooks/useResponsiveLayout', () => ({ __esModule: true, - default: () => ({shouldUseNarrowLayout: false}), + default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout}), })); +jest.mock('@hooks/useSafeAreaInsets', () => ({ + __esModule: true, + default: () => ({top: 0, right: 0, bottom: 0, left: 0}), +})); + +// Jest resolves the iOS helper, which never lifts. This follows Platform.OS at call time so one suite can cover web and Android too. +jest.mock('@libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA', () => { + const {Platform: RNPlatform} = require('react-native'); + + return { + __esModule: true, + default: (isRequire2FAOverlayVisible: boolean) => isRequire2FAOverlayVisible && RNPlatform.OS !== 'ios', + }; +}); + jest.mock('@hooks/useIsAuthenticated', () => ({ __esModule: true, default: () => true, @@ -198,6 +215,9 @@ jest.mock('@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native'); function Navigator({children}: {children: React.ReactNode}) { + ReactActual.useEffect(() => { + testToolsStackMountCount += 1; + }, []); return ReactActual.createElement(View, {testID: 'test-tools-stack'}, children); } @@ -249,24 +269,42 @@ describe('RequireTwoFactorAuthenticationOverlay stacking', () => { isTestToolsRouteFocused: false, }; mockIsOffline = true; + mockShouldUseNarrowLayout = true; }); - it('shows the offline banner outside the covering overlay while required 2FA is visible', () => { - // Given required 2FA is visible and the user is offline, the ScreenWrapper banner is covered by the overlay. + it('shows the offline banner inside the overlay on a narrow screen and opens test tools from a four-finger tap', () => { + // Given required 2FA is visible on a phone and the user is offline. The overlay covers the ScreenWrapper banner and its four-finger handler. - // When the overlay renders its own banner above that layer. + // When the overlay renders its own banner in the column, and the user taps with four fingers. render(); - // Then the user can still see they are offline, and the banner is not trapped inside the overlay. + // Then the banner is visible without covering the buttons, and the overlay itself handles the four-finger tap. const overlay = screen.getByTestId('RequireTwoFactorAuthenticationOverlay'); expect(overlay).toBeOnTheScreen(); - expect(screen.getByTestId('RequireTwoFactorOfflineBanner')).toBeOnTheScreen(); - expect(screen.getByText('You appear to be offline.')).toBeOnTheScreen(); - expect(within(overlay).queryByText('You appear to be offline.')).toBeNull(); + expect(within(overlay).getByTestId('RequireTwoFactorOfflineBanner')).toBeOnTheScreen(); + expect(within(overlay).getByText('You appear to be offline.')).toBeOnTheScreen(); + const fourTouches = {nativeEvent: {touches: [{}, {}, {}, {}]}, touchHistory: {numberActiveTouches: 4}}; + const oneTouch = {nativeEvent: {touches: [{}]}, touchHistory: {numberActiveTouches: 1}}; + expect(overlay.props.onStartShouldSetResponderCapture(fourTouches)).toBe(true); + expect(overlay.props.onStartShouldSetResponderCapture(oneTouch)).toBe(false); + overlay.props.onResponderRelease({touchHistory: {numberActiveTouches: 0}}); + expect(jest.requireMock('@userActions/TestTool').default).toHaveBeenCalledTimes(1); expect(screen.getByTestId('focus-trap-active')).toBeOnTheScreen(); expect(screen.getByTestId('enable-2fa-enter-shortcut')).toBeOnTheScreen(); }); + it('hides the offline banner on a wide layout', () => { + // Given required 2FA is visible on a wide screen, where ScreenWrapper does not show an offline indicator. + mockShouldUseNarrowLayout = false; + + // When the overlay renders. + render(); + + // Then it does not add a full-width banner of its own. + expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); + expect(screen.queryByTestId('RequireTwoFactorOfflineBanner')).toBeNull(); + }); + it('hides the banner when the overlay is not showing', () => { // Given required 2FA is not showing, so the ScreenWrapper banner is visible again. mockVisibility = { @@ -321,6 +359,8 @@ describe('TestToolsModalNavigator portal', () => { isTestToolsRouteFocused: false, }; mockFocusedRouteName = 'TestToolsModal_Root'; + mockShouldUseNarrowLayout = true; + testToolsStackMountCount = 0; Platform.OS = originalOS; }); @@ -393,6 +433,29 @@ describe('TestToolsModalNavigator portal', () => { const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); + expect(within(portal).getByTestId('test-tools-portal-backdrop')).toBeOnTheScreen(); + }); + + it('keeps the stack mounted when required 2FA appears while test tools are open', () => { + // Given test tools are already open on web, before the required-2FA flag arrives. + Platform.OS = 'web'; + mockVisibility = { + isRequire2FAOverlayVisible: false, + isTestToolsRouteFocused: true, + }; + const view = render(); + const mountsAfterOpen = testToolsStackMountCount; + + // When required 2FA becomes visible. + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + view.rerender(); + + // Then the stack stays mounted and the focused screen moves into the portal. + expect(testToolsStackMountCount).toBe(mountsAfterOpen); + expect(screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeOnTheScreen(); }); it('keeps test tools inside the iOS modal while required 2FA is showing', () => { @@ -409,6 +472,7 @@ describe('TestToolsModalNavigator portal', () => { // Then they stay in the navigator, where the modal can show them, and nothing is portaled behind it. expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); + expect(screen.queryByTestId('test-tools-portal-backdrop')).toBeNull(); expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); }); });