diff --git a/src/CONST.ts b/src/CONST.ts index 65934582b69c7..be10c49323f82 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5159,11 +5159,13 @@ const CONST = { REPLACE: 'REPLACE', PUSH: 'PUSH', NAVIGATE: 'NAVIGATE', + SET_PARAMS: 'SET_PARAMS', /** These action types are custom for RootNavigator */ SWITCH_POLICY_ID: 'SWITCH_POLICY_ID', DISMISS_MODAL: 'DISMISS_MODAL', OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT', + SET_HISTORY_PARAM: 'SET_HISTORY_PARAM', }, }, TIME_PERIOD: { diff --git a/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts new file mode 100644 index 0000000000000..ca2deafc7493c --- /dev/null +++ b/src/libs/Navigation/AppNavigator/customHistory/addCustomHistoryRouterExtension.ts @@ -0,0 +1,152 @@ +import {CommonActions, findFocusedRoute} from '@react-navigation/native'; +import type {ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native'; +import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SetParamsAction} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import SCREEN_TO_HISTORY_PARAM from '@src/libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM'; +import type {Screen} from '@src/SCREENS'; +import type {HistoryStackNavigatorAction, SetHistoryParamActionType} from './types'; + +const CUSTOM_HISTORY_PREFIX = 'CUSTOM_HISTORY'; + +function isSetParamsAction(action: HistoryStackNavigatorAction): action is SetParamsAction { + return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_PARAMS; +} + +function isSetHistoryParamAction(action: HistoryStackNavigatorAction): action is SetHistoryParamActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM; +} + +// The history can be anything. For now, string is enough but we can extend it to include more data if necessary. +function getCustomHistoryEntry(routeName: string) { + return `${CUSTOM_HISTORY_PREFIX}-${routeName}`; +} + +/** + * Higher-order function that extends the React Navigation stack router with custom history functionality. + * It allows tracking and managing navigation history entries that are not determined by the routes of navigator. + * The extension adds support for custom history entries through route params and maintains a history stack + * that can be manipulated independently of the navigation state. + * + * @param originalStackRouter - The original stack router function to be extended + * @returns Enhanced router with custom history functionality + */ + +function addCustomHistoryRouterExtension( + originalRouter: PlatformStackRouterFactory, +) { + return (options: RouterOptions): Router, HistoryStackNavigatorAction> => { + const router = originalRouter(options); + + const enhanceStateWithHistory = (state: PlatformStackNavigationState) => { + return { + ...state, + history: state.routes.map((route) => route.key), + }; + }; + + // Override methods to enhance state with history + const getInitialState = (configOptions: RouterConfigOptions) => { + const state = router.getInitialState(configOptions); + return enhanceStateWithHistory(state); + }; + + const getRehydratedState = (partialState: PartialState>, configOptions: RouterConfigOptions) => { + const state = router.getRehydratedState(partialState, configOptions); + const stateWithInitialHistory = enhanceStateWithHistory(state); + + const focusedRoute = findFocusedRoute(stateWithInitialHistory); + + // There always be a focused route in the state. It's for type safety. + if (!focusedRoute) { + return stateWithInitialHistory; + } + + // @ts-expect-error focusedRoute.key is always defined because it is a route from a rehydrated state. Find focused route isn't correctly typed in this case. + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const customHistoryEntry = getCustomHistoryEntry(focusedRoute.key); + + const customHistoryParamName = SCREEN_TO_HISTORY_PARAM[focusedRoute.name as Screen]; + + if ( + // The custom history param name should be defined + typeof customHistoryParamName === 'string' && + // Params for the focused route should be defined + focusedRoute.params && + // The custom history param with given name should be defined in the params + customHistoryParamName in focusedRoute.params && + // The custom history param should be set to true + (focusedRoute.params as Record)[customHistoryParamName] && + // The last history entry should not be the custom history entry for the focused route to avoid duplication + stateWithInitialHistory.history.at(-1) !== customHistoryEntry + ) { + // Add the custom history entry to the initial history + stateWithInitialHistory.history = [...stateWithInitialHistory.history, customHistoryEntry]; + } + + return stateWithInitialHistory; + }; + + const getStateForAction = ( + state: PlatformStackNavigationState, + action: CommonActions.Action | StackActionType | HistoryStackNavigatorAction, + configOptions: RouterConfigOptions, + ) => { + // We want to set the right param and then update the history + if (isSetHistoryParamAction(action)) { + const customHistoryEntry = getCustomHistoryEntry(action.payload.key); + + // Start with updating the param. + const setParamsAction = CommonActions.setParams({[action.payload.key]: action.payload.value}); + const stateWithUpdatedParams = router.getStateForAction(state, setParamsAction, configOptions); + + // This shouldn't ever happen as the history should be always defined. It's for type safety. + if (!stateWithUpdatedParams?.history) { + return stateWithUpdatedParams; + } + + // If it's set to true, we need to add the history entry if it's not already there. + if (action.payload.value && stateWithUpdatedParams.history.at(-1) !== customHistoryEntry) { + return {...stateWithUpdatedParams, history: [...stateWithUpdatedParams.history, customHistoryEntry]}; + } + + // If it's set to false, we need to remove the history entry if it's there. + if (!action.payload.value) { + return {...stateWithUpdatedParams, history: stateWithUpdatedParams.history.filter((entry) => entry !== customHistoryEntry)}; + } + + // Else, do not change history. + return stateWithUpdatedParams; + } + + const newState = router.getStateForAction(state, action, configOptions); + + // If the action was not handled, return null. + if (!newState) { + return null; + } + + // If the action was a setParams action, we need to preserve the history. + if (isSetParamsAction(action) && state.history) { + return { + ...newState, + history: [...state.history], + }; + } + + // Handle every other action. + // @ts-expect-error newState can be partial or not. But getRehydratedState will handle it correctly even if the stale === false. + // And we need to update the history if routes have changed. + return getRehydratedState(newState, configOptions); + }; + + return { + ...router, + getInitialState, + getRehydratedState, + getStateForAction, + }; + }; +} + +export default addCustomHistoryRouterExtension; diff --git a/src/libs/Navigation/AppNavigator/customHistory/index.ts b/src/libs/Navigation/AppNavigator/customHistory/index.ts new file mode 100644 index 0000000000000..cfea975fcb72a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/customHistory/index.ts @@ -0,0 +1,2 @@ +export {default as addCustomHistoryRouterExtension} from './addCustomHistoryRouterExtension'; +export {default as useCustomHistoryParam} from './useCustomHistoryParam'; diff --git a/src/libs/Navigation/AppNavigator/customHistory/types.ts b/src/libs/Navigation/AppNavigator/customHistory/types.ts new file mode 100644 index 0000000000000..cd84971f4edf3 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/customHistory/types.ts @@ -0,0 +1,18 @@ +import type {CommonActions, StackActionType} from '@react-navigation/native'; +import type CONST from '@src/CONST'; + +type HistoryStackNavigatorAction = CommonActions.Action | StackActionType | HistoryStackNavigatorActionType; + +type HistoryStackNavigatorActionType = { + type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM; + payload: { + key: string; + value: boolean; + }; +}; + +type SetHistoryParamActionType = HistoryStackNavigatorAction & { + type: typeof CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM; +}; + +export type {HistoryStackNavigatorAction, HistoryStackNavigatorActionType, SetHistoryParamActionType}; diff --git a/src/libs/Navigation/AppNavigator/customHistory/useCustomHistoryParam.ts b/src/libs/Navigation/AppNavigator/customHistory/useCustomHistoryParam.ts new file mode 100644 index 0000000000000..e7f657982e721 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/customHistory/useCustomHistoryParam.ts @@ -0,0 +1,42 @@ +import {useNavigation, useRoute} from '@react-navigation/native'; +import SCREEN_TO_HISTORY_PARAM from '@libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM'; +import CONST from '@src/CONST'; +import type {Screen} from '@src/SCREENS'; + +/** + * Custom hook for managing navigation history entries + * It works as useState but it pushes the history entry when the value is true. + * The value will change to false if the user navigates back with the browser back button. + * It uses screen param to store the information and make it visible in the url so the state persist after refreshing the page. + * + * @returns A tuple containing [historyParam, setHistoryParam] where: + * - historyParam: boolean | undefined - The current state of the history parameter + * - setHistoryParam: (value: boolean) => void - Function to update the history parameter + */ +function useCustomHistoryParam() { + const navigation = useNavigation(); + const route = useRoute(); + const historyParamName = SCREEN_TO_HISTORY_PARAM[route.name as Screen]; + + if (!historyParamName || typeof historyParamName !== 'string') { + throw new Error(`Screen ${route.name} does not have a history param. You can use this hook only on the screens that have one defined in SCREEN_TO_HISTORY_PARAM.`); + } + + const historyParam = route.params && (route.params as Record)[historyParamName] ? ((route.params as Record)[historyParamName] as boolean) : false; + + if (typeof historyParam !== 'boolean') { + throw new Error(`The history param ${historyParamName} is not a boolean. Make sure that you used getHistoryParamParse for this screen in linkingConfig/config.ts`); + } + + return [ + historyParam, + (value: boolean) => { + navigation.dispatch({ + type: CONST.NAVIGATION.ACTION_TYPE.SET_HISTORY_PARAM, + payload: {key: historyParamName, value}, + }); + }, + ] as const; +} + +export default useCustomHistoryParam; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx index 1f3b4a4c04cea..f2b6fcf7eb320 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.native.tsx @@ -3,6 +3,7 @@ import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; import {NativeStackView} from '@react-navigation/native-stack'; import type {NativeStackNavigationEventMap, NativeStackNavigationOptions} from '@react-navigation/native-stack'; import React, {useMemo} from 'react'; +import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory'; import convertToNativeNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToNativeNavigationOptions'; import type { CreatePlatformStackNavigatorComponentOptions, @@ -18,7 +19,7 @@ function createPlatformStackNavigatorComponent, ) { - const createRouter = options?.createRouter ?? StackRouter; + const createRouter = addCustomHistoryRouterExtension(options?.createRouter ?? StackRouter); const defaultScreenOptions = options?.defaultScreenOptions; const useCustomState = options?.useCustomState ?? (() => undefined); const useCustomEffects = options?.useCustomEffects ?? (() => undefined); diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx index fa45e5834aaa6..e98de444ad714 100644 --- a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigatorComponent/index.tsx @@ -3,6 +3,7 @@ import {StackRouter, useNavigationBuilder} from '@react-navigation/native'; import type {StackNavigationEventMap, StackNavigationOptions} from '@react-navigation/stack'; import {StackView} from '@react-navigation/stack'; import React, {useMemo} from 'react'; +import {addCustomHistoryRouterExtension} from '@libs/Navigation/AppNavigator/customHistory'; import convertToWebNavigationOptions from '@libs/Navigation/PlatformStackNavigation/navigationOptions/convertToWebNavigationOptions'; import type { CreatePlatformStackNavigatorComponentOptions, @@ -18,7 +19,7 @@ function createPlatformStackNavigatorComponent, ) { - const createRouter = options?.createRouter ?? StackRouter; + const createRouter = addCustomHistoryRouterExtension(options?.createRouter ?? StackRouter); const useCustomState = options?.useCustomState ?? (() => undefined); const defaultScreenOptions = options?.defaultScreenOptions; const ExtraContent = options?.ExtraContent; diff --git a/src/libs/Navigation/linkingConfig/HISTORY_PARAM.ts b/src/libs/Navigation/linkingConfig/HISTORY_PARAM.ts new file mode 100644 index 0000000000000..9ea2bc1aa1855 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/HISTORY_PARAM.ts @@ -0,0 +1,5 @@ +const HISTORY_PARAM = { + SHOW_VALIDATE_CODE_ACTION_MODAL: 'showValidateActionModal', +} as const; + +export default HISTORY_PARAM; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM.ts new file mode 100644 index 0000000000000..356dc2e49ccd0 --- /dev/null +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SCREEN_TO_HISTORY_PARAM.ts @@ -0,0 +1,22 @@ +import type {NavigatorScreenParams} from '@react-navigation/native'; +import HISTORY_PARAM from '@libs/Navigation/linkingConfig/HISTORY_PARAM'; +import type {RootNavigatorParamList} from '@libs/Navigation/types'; +import type {Screen} from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; + +type DeepPairsOf = DeepPairsForScreen; + +type DeepPairsForScreen = Key extends keyof ParamList ? (ParamList[Key] extends NavigatorScreenParams ? DeepPairsOf : [Key, keyof ParamList[Key]]) : never; + +type DeepPairsOfRoot = DeepPairsOf; + +type ScreenToHistoryParamMap = Partial<{ + [TScreen in Screen]: Extract[1]; +}>; + +// This file maps screens to their history parameters +const SCREEN_TO_HISTORY_PARAM: ScreenToHistoryParamMap = { + [SCREENS.SETTINGS.DELEGATE.DELEGATE_CONFIRM]: HISTORY_PARAM.SHOW_VALIDATE_CODE_ACTION_MODAL, +}; + +export default SCREEN_TO_HISTORY_PARAM; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 1c6bd9d49df21..8af7953cc27e5 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -6,6 +6,8 @@ import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; +import getHistoryParamParse from './helpers/getHistoryParamParse'; +import HISTORY_PARAM from './HISTORY_PARAM'; // Moved to a separate file to avoid cyclic dependencies. const config: LinkingOptions['config'] = { @@ -328,6 +330,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_DELEGATE_CONFIRM.route, parse: { login: (login: string) => decodeURIComponent(login), + ...getHistoryParamParse(HISTORY_PARAM.SHOW_VALIDATE_CODE_ACTION_MODAL), }, }, [SCREENS.SETTINGS.PROFILE.STATUS]: { diff --git a/src/libs/Navigation/linkingConfig/helpers/getHistoryParamParse.ts b/src/libs/Navigation/linkingConfig/helpers/getHistoryParamParse.ts new file mode 100644 index 0000000000000..7bae4da41012d --- /dev/null +++ b/src/libs/Navigation/linkingConfig/helpers/getHistoryParamParse.ts @@ -0,0 +1,5 @@ +const getHistoryParamParse = (historyParamName: string) => ({ + [historyParamName]: (value: string) => value === 'true', +}); + +export default getHistoryParamParse; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 87423aa671ba6..048cecfc0541d 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2137,5 +2137,6 @@ export type { TestDriveModalNavigatorParamList, WorkspaceScreenName, TestDriveDemoNavigatorParamList, + SetParamsAction, WorkspacesTabNavigatorName, }; diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index da6b3dd558ea9..ec4c275a7f0ac 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; @@ -13,6 +13,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; +import {useCustomHistoryParam} from '@libs/Navigation/AppNavigator/customHistory'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -24,21 +25,17 @@ import DelegateMagicCodeModal from './DelegateMagicCodeModal'; type ConfirmDelegatePageProps = PlatformStackScreenProps; -function ConfirmDelegatePage({route, navigation}: ConfirmDelegatePageProps) { +function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const login = route.params.login; const role = route.params.role as ValueOf; - const showValidateActionModal = route.params.showValidateActionModal === 'true'; const {isOffline} = useNetwork(); const [shouldDisableModalAnimation, setShouldDisableModalAnimation] = useState(true); - const [shouldShowLoading, setShouldShowLoading] = useState(showValidateActionModal ?? false); - const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(showValidateActionModal ?? false); - useEffect(() => { - navigation.setParams({showValidateActionModal: String(isValidateCodeActionModalVisible)}); - }, [isValidateCodeActionModalVisible, navigation]); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useCustomHistoryParam(); + const [shouldShowLoading, setShouldShowLoading] = useState(isValidateCodeActionModalVisible ?? false); const personalDetails = getPersonalDetailByEmail(login); const avatarIcon = personalDetails?.avatar ?? FallbackAvatar; @@ -93,7 +90,6 @@ function ConfirmDelegatePage({route, navigation}: ConfirmDelegatePageProps) { // We should disable the animation initially and only enable it when the user manually opens the modal // to ensure it appears immediately when refreshing the page. disableAnimation={shouldDisableModalAnimation} - shouldHandleNavigationBack login={login} role={role} onClose={() => {