diff --git a/android/app/build.gradle b/android/app/build.gradle index fc376ad08862..a5b0b1e0332f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001044309 - versionName "1.4.43-9" + versionCode 1001044312 + versionName "1.4.43-12" } flavorDimensions "default" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 93faff6ab427..2da4a4505f00 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.43.9 + 1.4.43.12 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 85d5f45e4184..455d55020ee2 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.43.9 + 1.4.43.12 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 6b0cc0c08d14..0726ea76b176 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.43 CFBundleVersion - 1.4.43.9 + 1.4.43.12 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index c114de61408f..7c1019963c5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.43-9", + "version": "1.4.43-12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.43-9", + "version": "1.4.43-12", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -38,6 +38,7 @@ "@react-native-picker/picker": "2.5.1", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.8", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.1.11", @@ -10258,6 +10259,17 @@ "react": "*" } }, + "node_modules/@react-navigation/elements": { + "version": "1.3.21", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.21.tgz", + "integrity": "sha512-eyS2C6McNR8ihUoYfc166O1D8VYVh9KIl0UQPI8/ZJVsStlfSTgeEEh+WXge6+7SFPnZ4ewzEJdSAHH+jzcEfg==", + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0" + } + }, "node_modules/@react-navigation/material-top-tabs": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz", @@ -10289,6 +10301,22 @@ "react-native": "*" } }, + "node_modules/@react-navigation/native-stack": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.9.17.tgz", + "integrity": "sha512-X8p8aS7JptQq7uZZNFEvfEcPf6tlK4PyVwYDdryRbG98B4bh2wFQYMThxvqa+FGEN7USEuHdv2mF0GhFKfX0ew==", + "dependencies": { + "@react-navigation/elements": "^1.3.21", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0", + "react-native-screens": ">= 3.0.0" + } + }, "node_modules/@react-navigation/routers": { "version": "6.1.9", "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz", @@ -10315,17 +10343,6 @@ "react-native-screens": ">= 3.0.0" } }, - "node_modules/@react-navigation/stack/node_modules/@react-navigation/elements": { - "version": "1.3.17", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz", - "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==", - "peerDependencies": { - "@react-navigation/native": "^6.0.0", - "react": "*", - "react-native": "*", - "react-native-safe-area-context": ">= 3.0.0" - } - }, "node_modules/@react-ng/bounds-observer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz", diff --git a/package.json b/package.json index 66d60bcd87cd..a160f9ec067c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.43-9", + "version": "1.4.43-12", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -86,6 +86,7 @@ "@react-native-picker/picker": "2.5.1", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.8", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.1.11", diff --git a/patches/@react-navigation+native-stack+6.9.17.patch b/patches/@react-navigation+native-stack+6.9.17.patch new file mode 100644 index 000000000000..933ca6ce792e --- /dev/null +++ b/patches/@react-navigation+native-stack+6.9.17.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx +index 206fb0b..7a34a8e 100644 +--- a/node_modules/@react-navigation/native-stack/src/types.tsx ++++ b/node_modules/@react-navigation/native-stack/src/types.tsx +@@ -490,6 +490,14 @@ export type NativeStackNavigationOptions = { + * Only supported on iOS and Android. + */ + freezeOnBlur?: boolean; ++ // partial changes from https://github.com/react-navigation/react-navigation/commit/90cfbf23bcc5259f3262691a9eec6c5b906e5262 ++ // patch can be removed when new version of `native-stack` will be released ++ /** ++ * Whether the keyboard should hide when swiping to the previous screen. Defaults to `false`. ++ * ++ * Only supported on iOS ++ */ ++ keyboardHandlingEnabled?: boolean; + }; + + export type NativeStackNavigatorProps = DefaultNavigatorOptions< +diff --git a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +index a005c43..03d8b50 100644 +--- a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx ++++ b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +@@ -161,6 +161,7 @@ const SceneView = ({ + statusBarTranslucent, + statusBarColor, + freezeOnBlur, ++ keyboardHandlingEnabled, + } = options; + + let { +@@ -289,6 +290,7 @@ const SceneView = ({ + onNativeDismissCancelled={onNativeDismissCancelled} + // this prop is available since rn-screens 3.16 + freezeOnBlur={freezeOnBlur} ++ hideKeyboardOnSwipe={keyboardHandlingEnabled} + > + + diff --git a/patches/expo-av+13.10.4.patch b/patches/expo-av+13.10.4.patch new file mode 100644 index 000000000000..c7b1626e233a --- /dev/null +++ b/patches/expo-av+13.10.4.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/expo-av/android/build.gradle b/node_modules/expo-av/android/build.gradle +index 2d68ca6..c3fa3c5 100644 +--- a/node_modules/expo-av/android/build.gradle ++++ b/node_modules/expo-av/android/build.gradle +@@ -7,10 +7,11 @@ apply plugin: 'maven-publish' + group = 'host.exp.exponent' + version = '13.10.4' + ++def REACT_NATIVE_PATH = this.hasProperty('reactNativeProject') ? this.reactNativeProject + '/node_modules/react-native/package.json' : 'react-native/package.json' + def REACT_NATIVE_BUILD_FROM_SOURCE = findProject(":ReactAndroid") != null + def REACT_NATIVE_DIR = REACT_NATIVE_BUILD_FROM_SOURCE + ? findProject(":ReactAndroid").getProjectDir().parent +- : new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parent ++ : new File(["node", "--print", "require.resolve('${REACT_NATIVE_PATH}')"].execute(null, rootDir).text.trim()).parent + + def reactNativeArchitectures() { + def value = project.getProperties().get("reactNativeArchitectures") diff --git a/src/CONST.ts b/src/CONST.ts index 6a57738d06ec..008002a71078 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -895,6 +895,7 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, + DEFAULT_NETWORK_DATA: {isOffline: false}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -1555,6 +1556,7 @@ const CONST = { WORKSPACE_TRAVEL: 'WorkspaceBookTravel', WORKSPACE_MEMBERS: 'WorkspaceManageMembers', WORKSPACE_BANK_ACCOUNT: 'WorkspaceBankAccount', + WORKSPACE_SETTINGS: 'WorkspaceSettings', }, get EXPENSIFY_EMAILS() { return [ diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9d35994875e1..ee8bceeab44a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -416,7 +416,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm; - [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm; [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form; @@ -491,7 +491,7 @@ type OnyxValuesMapping = { [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; - [ONYXKEYS.CURRENCY_LIST]: Record; + [ONYXKEYS.CURRENCY_LIST]: OnyxTypes.CurrencyList; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; [ONYXKEYS.COUNTRY_CODE]: number; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c41ef521661c..66d8b72c93c7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -463,6 +463,10 @@ const ROUTES = { route: 'workspace/:policyID/profile/description', getRoute: (policyID: string) => `workspace/${policyID}/profile/description` as const, }, + WORKSPACE_PROFILE_SHARE: { + route: 'workspace/:policyID/profile/share', + getRoute: (policyID: string) => `workspace/${policyID}/profile/share` as const, + }, WORKSPACE_AVATAR: { route: 'workspace/:policyID/avatar', getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 18754a3513c1..416c278cceb6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -210,6 +210,7 @@ const SCREENS = { INVITE_MESSAGE: 'Workspace_Invite_Message', CURRENCY: 'Workspace_Profile_Currency', DESCRIPTION: 'Workspace_Profile_Description', + SHARE: 'Workspace_Profile_Share', NAME: 'Workspace_Profile_Name', }, diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index 0e57bcf4db03..2374fc9e5d0c 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -3,6 +3,7 @@ import React, {useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -67,6 +68,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC textInputLabel={shouldShowTextInput && translate('common.search')} onChangeText={setSearchValue} onSelectRow={onSubmit} + ListItem={RadioListItem} initiallyFocusedOptionKey={selectedOptionKey} /> ); diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 48e9aa49d0de..7313bb4aa7bb 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -45,7 +45,7 @@ type LocaleContextProps = { /** Returns a locally converted phone number for numbers from the same region * and an internationally converted phone number with the country code for numbers from other regions */ - formatPhoneNumber: (phoneNumber: string | undefined) => string; + formatPhoneNumber: (phoneNumber: string) => string; /** Gets the locale digit corresponding to a standard digit */ toLocaleDigit: (digit: string) => string; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 46c96fd706a9..93febc4fd3c0 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -430,3 +430,4 @@ function MagicCodeInput( MagicCodeInput.displayName = 'MagicCodeInput'; export default forwardRef(MagicCodeInput); +export type {AutoCompleteVariant, MagicCodeInputHandle}; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 69728d7be126..b8d4efbd916d 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -21,14 +21,15 @@ function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = useState(false); const activePopoverRef = useRef(null); - const closePopover = useCallback((anchorRef?: RefObject) => { + const closePopover = useCallback((anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { - return; + return false; } activePopoverRef.current.close(); activePopoverRef.current = null; setIsOpen(false); + return true; }, []); useEffect(() => { @@ -63,11 +64,13 @@ function PopoverContextProvider(props: PopoverContextProps) { if (e.key !== 'Escape') { return; } - closePopover(); + if (closePopover()) { + e.stopImmediatePropagation(); + } }; - document.addEventListener('keydown', listener, true); + document.addEventListener('keyup', listener, true); return () => { - document.removeEventListener('keydown', listener, true); + document.removeEventListener('keyup', listener, true); }; }, [closePopover]); diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx index b717c4890a2d..74ea4596046e 100644 --- a/src/components/Pressable/PressableWithFeedback.tsx +++ b/src/components/Pressable/PressableWithFeedback.tsx @@ -2,6 +2,7 @@ import React, {forwardRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import OpacityView from '@components/OpacityView'; +import type {Color} from '@styles/theme/types'; import variables from '@styles/variables'; import GenericPressable from './GenericPressable'; import type {PressableRef} from './GenericPressable/types'; @@ -27,6 +28,9 @@ type PressableWithFeedbackProps = PressableProps & { /** Whether the view needs to be rendered offscreen (for Android only) */ needsOffscreenAlphaCompositing?: boolean; + + /** The color of the underlay that will show through when the Pressable is active. */ + underlayColor?: Color; }; function PressableWithFeedback( diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 8b6a894cdd51..198b47cb4259 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -25,7 +25,7 @@ import SafeAreaConsumer from './SafeAreaConsumer'; import TestToolsModal from './TestToolsModal'; type ChildrenProps = { - insets?: EdgeInsets; + insets: EdgeInsets; safeAreaPaddingBottomStyle?: { paddingBottom?: DimensionValue; }; @@ -201,7 +201,17 @@ function ScreenWrapper( return ( - {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => { + {({ + insets = { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + paddingTop, + paddingBottom, + safeAreaPaddingBottomStyle, + }) => { const paddingStyle: StyleProp = {}; if (includePaddingTop) { diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index b0996a08895a..edaa48f2cf7a 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -287,7 +287,7 @@ function BaseSelectionList( showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} onSelectRow={() => selectRow(item)} - onDismissError={onDismissError} + onDismissError={() => onDismissError?.(item)} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} keyForList={item.keyForList} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 403ccd91a26b..868e47309921 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -155,7 +155,7 @@ type BaseSelectionListProps = Partial & { onSelectAll?: () => void; /** Callback to fire when an error is dismissed */ - onDismissError?: () => void; + onDismissError?: (item: TItem) => void; /** Label for the text input */ textInputLabel?: string; diff --git a/src/components/VideoPlayer/BaseVideoPlayer.js b/src/components/VideoPlayer/BaseVideoPlayer.js index 73dbf8407c0c..df79c7ef18da 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.js +++ b/src/components/VideoPlayer/BaseVideoPlayer.js @@ -13,6 +13,7 @@ import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import * as Browser from '@libs/Browser'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import {videoPlayerDefaultProps, videoPlayerPropTypes} from './propTypes'; +import shouldReplayVideo from './shouldReplayVideo'; import VideoPlayerControls from './VideoPlayerControls'; const isMobileSafari = Browser.isMobileSafari(); @@ -95,6 +96,9 @@ function BaseVideoPlayer({ const handlePlaybackStatusUpdate = useCallback( (e) => { + if (shouldReplayVideo(e, isPlaying, duration, position)) { + videoPlayerRef.current.setStatusAsync({positionMillis: 0, shouldPlay: true}); + } const isVideoPlaying = e.isPlaying || false; preventPausingWhenExitingFullscreen(isVideoPlaying); setIsPlaying(isVideoPlaying); @@ -105,7 +109,7 @@ function BaseVideoPlayer({ onPlaybackStatusUpdate(e); }, - [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration], + [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration, isPlaying, duration, position], ); const handleFullscreenUpdate = useCallback( diff --git a/src/components/VideoPlayer/shouldReplayVideo.android.ts b/src/components/VideoPlayer/shouldReplayVideo.android.ts new file mode 100644 index 000000000000..c1c3de034aac --- /dev/null +++ b/src/components/VideoPlayer/shouldReplayVideo.android.ts @@ -0,0 +1,11 @@ +import type {AVPlaybackStatusSuccess} from 'expo-av'; + +/** + * Whether to replay the video when users press play button + */ +export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean { + if (!isPlaying && !e.didJustFinish && duration === position) { + return true; + } + return false; +} diff --git a/src/components/VideoPlayer/shouldReplayVideo.ios.ts b/src/components/VideoPlayer/shouldReplayVideo.ios.ts new file mode 100644 index 000000000000..0a923d430699 --- /dev/null +++ b/src/components/VideoPlayer/shouldReplayVideo.ios.ts @@ -0,0 +1,11 @@ +import type {AVPlaybackStatusSuccess} from 'expo-av'; + +/** + * Whether to replay the video when users press play button + */ +export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean { + if (!isPlaying && e.isPlaying && duration === position) { + return true; + } + return false; +} diff --git a/src/components/VideoPlayer/shouldReplayVideo.ts b/src/components/VideoPlayer/shouldReplayVideo.ts new file mode 100644 index 000000000000..3a55562d4bd2 --- /dev/null +++ b/src/components/VideoPlayer/shouldReplayVideo.ts @@ -0,0 +1,9 @@ +import type {AVPlaybackStatusSuccess} from 'expo-av'; + +/** + * Whether to replay the video when users press play button + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean { + return false; +} diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index 17fda7fd5e30..9da862ecdebe 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -7,7 +7,7 @@ import getComponentDisplayName from '@libs/getComponentDisplayName'; type WithToggleVisibilityViewProps = { /** Whether the content is visible. */ - isVisible?: boolean; + isVisible: boolean; }; export default function withToggleVisibilityView( diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index f9e1a627c5f5..1e4a6d4cf2ca 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -1,17 +1,18 @@ import {useContext, useEffect, useRef} from 'react'; import {NetworkContext} from '@components/OnyxProvider'; +import CONST from '@src/CONST'; type UseNetworkProps = { onReconnect?: () => void; }; -type UseNetwork = {isOffline?: boolean}; +type UseNetwork = {isOffline: boolean}; export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork { const callback = useRef(onReconnect); callback.current = onReconnect; - const {isOffline} = useContext(NetworkContext) ?? {}; + const {isOffline} = useContext(NetworkContext) ?? CONST.DEFAULT_NETWORK_DATA; const prevOfflineStatusRef = useRef(isOffline); useEffect(() => { // If we were offline before and now we are not offline then we just reconnected diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts index 9aacc6968e1e..933aa7937560 100644 --- a/src/libs/LocalePhoneNumber.ts +++ b/src/libs/LocalePhoneNumber.ts @@ -13,7 +13,7 @@ Onyx.connect({ * Returns a locally converted phone number for numbers from the same region * and an internationally converted phone number with the country code for numbers from other regions */ -function formatPhoneNumber(number: string | undefined): string { +function formatPhoneNumber(number: string): string { if (!number) { return ''; } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 3af123a74910..bb655efea4cb 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -1,8 +1,8 @@ import type {ParamListBase} from '@react-navigation/routers'; import type {StackNavigationOptions} from '@react-navigation/stack'; -import {CardStyleInterpolators, createStackNavigator} from '@react-navigation/stack'; import React, {useMemo} from 'react'; import useThemeStyles from '@hooks/useThemeStyles'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type { AddPersonalBankAccountNavigatorParamList, DetailsNavigatorParamList, @@ -35,6 +35,7 @@ import type { import type {ThemeStyles} from '@styles/index'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; +import subRouteOptions from './modalStackNavigatorOptions'; type Screens = Partial React.ComponentType>>; @@ -45,16 +46,15 @@ type Screens = Partial React.ComponentType>>; * @param getScreenOptions optional function that returns the screen options, override the default options */ function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType { - const ModalStackNavigator = createStackNavigator(); + const ModalStackNavigator = createPlatformStackNavigator(); function ModalStack() { const styles = useThemeStyles(); const defaultSubRouteOptions = useMemo( (): StackNavigationOptions => ({ + ...subRouteOptions, cardStyle: styles.navigationScreenCardStyle, - headerShown: false, - cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, }), [styles], ); @@ -247,6 +247,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, [SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, + [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index 087e963b3892..14aa6de27116 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -1,12 +1,12 @@ -import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; import useThemeStyles from '@hooks/useThemeStyles'; import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; import getCurrentUrl from '@libs/Navigation/currentUrl'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; import SCREENS from '@src/SCREENS'; -const Stack = createStackNavigator(); +const Stack = createPlatformStackNavigator(); const url = getCurrentUrl(); const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx new file mode 100644 index 000000000000..30651e32cbd6 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx @@ -0,0 +1,7 @@ +function Overlay() { + return null; +} + +Overlay.displayName = 'Overlay'; + +export default Overlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx similarity index 100% rename from src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx rename to src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c421bdc82028..550fb947a4e6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,5 +1,4 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import {createStackNavigator} from '@react-navigation/stack'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; @@ -7,6 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types'; import type NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -14,7 +14,7 @@ import Overlay from './Overlay'; type RightModalNavigatorProps = StackScreenProps; -const Stack = createStackNavigator(); +const Stack = createPlatformStackNavigator(); function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx index 6b1557994627..792a538cfd39 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx +++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx @@ -1,5 +1,5 @@ -import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {PublicScreensParamList} from '@navigation/types'; import LogInWithShortLivedAuthTokenPage from '@pages/LogInWithShortLivedAuthTokenPage'; import AppleSignInDesktopPage from '@pages/signin/AppleSignInDesktopPage'; @@ -12,7 +12,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import defaultScreenOptions from './defaultScreenOptions'; -const RootStack = createStackNavigator(); +const RootStack = createPlatformStackNavigator(); function PublicScreens() { return ( diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts new file mode 100644 index 000000000000..17100bc71bda --- /dev/null +++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts @@ -0,0 +1,11 @@ +const defaultScreenOptions = { + contentStyle: { + overflow: 'visible', + flex: 1, + }, + headerShown: false, + animationTypeForReplace: 'push', + animation: 'slide_from_right', +}; + +export default defaultScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts new file mode 100644 index 000000000000..4015c43c679e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts @@ -0,0 +1,12 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; + +const defaultScreenOptions: StackNavigationOptions = { + cardStyle: { + overflow: 'visible', + flex: 1, + }, + headerShown: false, + animationTypeForReplace: 'push', +}; + +export default defaultScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts new file mode 100644 index 000000000000..2b062fd2f2be --- /dev/null +++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts @@ -0,0 +1,8 @@ +import type {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +const rightModalNavigatorOptions = (): NativeStackNavigationOptions => ({ + presentation: 'card', + animation: 'slide_from_right', +}); + +export default rightModalNavigatorOptions; diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts new file mode 100644 index 000000000000..935c0041b794 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts @@ -0,0 +1,20 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +// eslint-disable-next-line no-restricted-imports +import getNavigationModalCardStyle from '@styles/utils/getNavigationModalCardStyles'; + +const rightModalNavigatorOptions = (isSmallScreenWidth: boolean): StackNavigationOptions => ({ + presentation: 'transparentModal', + + // We want pop in RHP since there are some flows that would work weird otherwise + animationTypeForReplace: 'pop', + cardStyle: { + ...getNavigationModalCardStyle(), + + // This is necessary to cover translated sidebar with overlay. + width: isSmallScreenWidth ? '100%' : '200%', + // Excess space should be on the left so we need to position from right. + right: 0, + }, +}); + +export default rightModalNavigatorOptions; diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts index c3a69bbd7ccf..5685afec5459 100644 --- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts @@ -4,6 +4,7 @@ import type {StyleUtilsType} from '@styles/utils'; import variables from '@styles/variables'; import CONFIG from '@src/CONFIG'; import createModalCardStyleInterpolator from './createModalCardStyleInterpolator'; +import getRightModalNavigatorOptions from './getRightModalNavigatorOptions'; type ScreenOptions = Record; @@ -25,23 +26,12 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr return { rightModalNavigator: { ...commonScreenOptions, + ...getRightModalNavigatorOptions(isSmallScreenWidth), cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - presentation: 'transparentModal', - - // We want pop in RHP since there are some flows that would work weird otherwise - animationTypeForReplace: 'pop', - cardStyle: { - ...StyleUtils.getNavigationModalCardStyle(), - - // This is necessary to cover translated sidebar with overlay. - width: isSmallScreenWidth ? '100%' : '200%', - // Excess space should be on the left so we need to position from right. - right: 0, - }, }, leftModalNavigator: { ...commonScreenOptions, - cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), presentation: 'transparentModal', // We want pop in LHP since there are some flows that would work weird otherwise @@ -59,8 +49,8 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr homeScreen: { title: CONFIG.SITE_TITLE, ...commonScreenOptions, + // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults. cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), width: isSmallScreenWidth ? '100%' : variables.sideBarWidth, @@ -73,6 +63,7 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr fullScreen: { ...commonScreenOptions, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), @@ -87,7 +78,9 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr ...commonScreenOptions, animationEnabled: isSmallScreenWidth, cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), - + // temporary solution - better to hide a keyboard than see keyboard flickering + // see https://github.com/software-mansion/react-native-screens/issues/2021 for more details + keyboardHandlingEnabled: true, cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth, diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts new file mode 100644 index 000000000000..ca9769fa9972 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts @@ -0,0 +1,8 @@ +import type {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +const defaultSubRouteOptions: NativeStackNavigationOptions = { + headerShown: false, + animation: 'slide_from_right', +}; + +export default defaultSubRouteOptions; diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts new file mode 100644 index 000000000000..280a38b263b7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts @@ -0,0 +1,9 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +import {CardStyleInterpolators} from '@react-navigation/stack'; + +const defaultSubRouteOptions: StackNavigationOptions = { + headerShown: false, + cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, +}; + +export default defaultSubRouteOptions; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts new file mode 100644 index 000000000000..ef44cefc13c9 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts @@ -0,0 +1,7 @@ +import {createNativeStackNavigator} from '@react-navigation/native-stack'; + +function createPlatformStackNavigator() { + return createNativeStackNavigator(); +} + +export default createPlatformStackNavigator; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts new file mode 100644 index 000000000000..51228295572f --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts @@ -0,0 +1,5 @@ +import {createStackNavigator} from '@react-navigation/stack'; + +const createPlatformStackNavigator: typeof createStackNavigator = () => createStackNavigator(); + +export default createPlatformStackNavigator; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 3a4abe225120..371ea89df2e2 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -215,7 +215,7 @@ export default function linkTo(navigation: NavigationContainerRef> = { - [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION], + [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 2640025efa09..e9f86323368e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -235,6 +235,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, + [SCREENS.WORKSPACE.SHARE]: { + path: ROUTES.WORKSPACE_PROFILE_SHARE.route, + }, [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts index 55ccca73a389..02ad78a4c044 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts @@ -1,9 +1,11 @@ import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; +import type {CentralPaneName, CentralPaneNavigatorParamList, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; +const WORKSPACES_SCREENS = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL].concat(TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.ALL_SETTINGS]); + /** * @param state - react-navigation state */ @@ -31,8 +33,47 @@ const getTopMostReportIDFromRHP = (state: State): string => { return ''; }; +// Check if the given route has a policyID equal to the id provided in the function params +function hasRouteMatchingPolicyID(route: NavigationPartialRoute, policyID?: string) { + if (!route.params) { + return false; + } + + const params = `params` in route?.params ? (route.params.params as Record) : undefined; + + // If params are not defined, then we need to check if the policyID exists + if (!params) { + return !policyID; + } + + return 'policyID' in params && params.policyID === policyID; +} + +// Get already opened settings screen within the policy +function getAlreadyOpenedSettingsScreen(rootState?: State, policyID?: string): keyof CentralPaneNavigatorParamList | undefined { + if (!rootState) { + return undefined; + } + + // If one of the screen from WORKSPACES_SCREENS is now in the navigation state, we can decide which screen we should display. + // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace. + // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab. + const alreadyOpenedSettingsTab = rootState.routes + .filter((item) => item.params && 'screen' in item.params && WORKSPACES_SCREENS.includes(item.params.screen as keyof CentralPaneNavigatorParamList)) + .at(-1); + + if (!hasRouteMatchingPolicyID(alreadyOpenedSettingsTab as NavigationPartialRoute, policyID)) { + return undefined; + } + + const settingsScreen = + alreadyOpenedSettingsTab?.params && 'screen' in alreadyOpenedSettingsTab?.params ? (alreadyOpenedSettingsTab?.params?.screen as keyof CentralPaneNavigatorParamList) : undefined; + + return settingsScreen; +} + // Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT -function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined { +function getMatchingCentralPaneRouteForState(state: State, rootState?: State): NavigationPartialRoute | undefined { const topmostBottomTabRoute = getTopmostBottomTabRoute(state); if (!topmostBottomTabRoute) { @@ -42,7 +83,10 @@ function getMatchingCentralPaneRouteForState(state: State): const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0]; if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) { - return {name: centralPaneName, params: topmostBottomTabRoute.params}; + // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen + const policyID = topmostBottomTabRoute?.params && 'policyID' in topmostBottomTabRoute?.params ? (topmostBottomTabRoute.params.policyID as string) : undefined; + const screen = getAlreadyOpenedSettingsScreen(rootState, policyID) ?? centralPaneName; + return {name: screen, params: topmostBottomTabRoute.params}; } if (topmostBottomTabRoute.name === SCREENS.HOME) { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 81229f353e52..23221e3539f2 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -92,9 +92,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined; - [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { + backTo: Routes; + }; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: { + contactMethod: string; + }; + [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: { + backTo: Routes; + }; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; @@ -146,6 +152,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; [SCREENS.WORKSPACE.DESCRIPTION]: undefined; + [SCREENS.WORKSPACE.SHARE]: undefined; [SCREENS.WORKSPACE.RATE_AND_UNIT]: { policyID: string; }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index a85e97a4cf05..974ce88a03ec 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -93,7 +93,7 @@ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolea ); } -function isExpensifyTeam(email: string): boolean { +function isExpensifyTeam(email: string | undefined): boolean { const emailDomain = Str.extractEmailDomain(email ?? ''); return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d9e7fb8e7e6b..ae6e02e70d29 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2370,7 +2370,9 @@ function getReportPreviewMessage( if (isSettled(report.reportID) || (report.isWaitingOnBankAccount && isPreviewMessageForParentChatReport)) { // A settled report preview message can come in three formats "paid ... elsewhere" or "paid ... with Expensify" let translatePhraseKey: TranslationPaths = 'iou.paidElsewhereWithAmount'; - if ( + if (isPreviewMessageForParentChatReport) { + translatePhraseKey = 'iou.payerPaidAmount'; + } else if ( [CONST.IOU.PAYMENT_TYPE.VBBA, CONST.IOU.PAYMENT_TYPE.EXPENSIFY].some((paymentType) => paymentType === originalMessage?.paymentType) || !!reportActionMessage.match(/ (with Expensify|using Expensify)$/) || report.isWaitingOnBankAccount @@ -4711,7 +4713,6 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) // property. If it does, it indicates that this is a 'Send money' action. const {amount, currency} = originalMessage.IOUDetails ?? originalMessage; const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency) ?? ''; - const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport?.managerID, true); switch (originalMessage.paymentType) { case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: @@ -4725,7 +4726,7 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) translationKey = 'iou.payerPaidAmount'; break; } - return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: payerName ?? ''}); + return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: ''}); } const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID ?? ''); diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 49436576295c..d9298817f6b7 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -242,7 +242,9 @@ function getOptionData({ result.policyID = report.policyID; result.stateNum = report.stateNum; result.statusNum = report.statusNum; - result.isUnread = ReportUtils.isUnread(report); + // When the only message of a report is deleted lastVisibileActionCreated is not reset leading to wrongly + // setting it Unread so we add additional condition here to avoid empty chat LHN from being bold. + result.isUnread = ReportUtils.isUnread(report) && !!report.lastActorAccountID; result.isUnreadWithMention = ReportUtils.isUnreadWithMention(report); result.hasDraftComment = report.hasDraft; result.isPinned = report.isPinned; diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index e3a667fd5a44..12b52524f113 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -173,7 +173,7 @@ function getAvatarUrl(avatarSource: AvatarSource | undefined, accountID: number) * Avatars uploaded by users will have a _128 appended so that the asset server returns a small version. * This removes that part of the URL so the full version of the image can load. */ -function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID: number): AvatarSource { +function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID?: number): AvatarSource { const source = getAvatar(avatarSource, accountID); if (typeof source !== 'string') { return source; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 39ce9dd6d2bb..f39728e7d31c 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1010,7 +1010,7 @@ function calculateDiffAmount(iouReport: OnyxEntry, updatedTran // Subtract the diff from the total if we change the currency from the currency of IOU report to a different currency return -updatedAmount; } - if (updatedCurrency === iouReport?.currency && updatedTransaction?.modifiedAmount) { + if (updatedCurrency === iouReport?.currency && updatedAmount !== currentAmount) { // Calculate the diff between the updated amount and the current amount if we change the amount and the currency of the transaction is the currency of the report return updatedAmount - currentAmount; } @@ -1134,32 +1134,32 @@ function getUpdateMoneyRequestParams( }, }, }); + } - // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - let updatedMoneyRequestReport = {...iouReport}; - const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); - - if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { - // For expense report, the amount is negative so we should subtract total from diff - updatedMoneyRequestReport.total -= diff; - } else { - updatedMoneyRequestReport = iouReport - ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true) - : {}; - } - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + let updatedMoneyRequestReport = {...iouReport}; + const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, - }); + if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { + // For expense report, the amount is negative so we should subtract total from diff + updatedMoneyRequestReport.total -= diff; + } else { + updatedMoneyRequestReport = iouReport + ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true) + : {}; } + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }); // Optimistically modify the transaction and the transaction thread optimisticData.push({ diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 6efe0860e9b5..1276207e37c3 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -86,12 +86,14 @@ type ActionSubscriber = { callback: SubscriberCallback; }; +let conciergeChatReportID: string | undefined; let currentUserAccountID = -1; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { // When signed out, val is undefined if (!value?.accountID) { + conciergeChatReportID = undefined; return; } @@ -168,7 +170,6 @@ Onyx.connect({ }); const allReports: OnyxCollection = {}; -let conciergeChatReportID: string | undefined; const typingWatchTimers: Record = {}; let reportIDDeeplinkedFromOldDot: string | undefined; @@ -1716,24 +1717,29 @@ function updateWriteCapabilityAndNavigate(report: Report, newValue: WriteCapabil /** * Navigates to the 1:1 report with Concierge - * - * @param ignoreConciergeReportID - Flag to ignore conciergeChatReportID during navigation. The default behavior is to not ignore. */ -function navigateToConciergeChat(ignoreConciergeReportID = false, shouldDismissModal = false) { +function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScreen = false, checkIfCurrentPageActive = () => true) { // If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. // Otherwise, we would find the concierge chat and navigate to it. - // Now, when user performs sign-out and a sign-in again, conciergeChatReportID may contain a stale value. - // In order to prevent navigation to a stale value, we use ignoreConciergeReportID to forcefully find and navigate to concierge chat. - if (!conciergeChatReportID || ignoreConciergeReportID) { + if (!conciergeChatReportID) { // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled Welcome.serverDataIsReadyPromise().then(() => { // If we don't have a chat with Concierge then create it + if (!checkIfCurrentPageActive()) { + return; + } + if (shouldPopCurrentScreen && !shouldDismissModal) { + Navigation.goBack(); + } navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal); }); } else if (shouldDismissModal) { Navigation.dismissModal(conciergeChatReportID); } else { + if (shouldPopCurrentScreen) { + Navigation.goBack(); + } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID)); } } @@ -2213,10 +2219,7 @@ function openReportFromDeepLink(url: string, isAuthenticated: boolean) { Session.waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { const route = ReportUtils.getRouteFromLink(url); - if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(true); - return; - } + if (route && Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { Session.signOutAndRedirectToSignIn(true); return; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7416b4f07e5e..f384e38f6d55 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -890,7 +890,7 @@ const canAccessRouteByAnonymousUser = (route: string) => { if (route.startsWith('/')) { routeRemovedReportId = routeRemovedReportId.slice(1); } - const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route]; + const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route, ROUTES.CONCIERGE]; if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) { return true; diff --git a/src/libs/shouldAllowDownloadQRCode/index.native.ts b/src/libs/shouldAllowDownloadQRCode/index.native.ts new file mode 100644 index 000000000000..ea9b2b9c8aa1 --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/index.native.ts @@ -0,0 +1,5 @@ +import type ShouldAllowDownloadQRCode from './types'; + +const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = true; + +export default shouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowDownloadQRCode/index.ts b/src/libs/shouldAllowDownloadQRCode/index.ts new file mode 100644 index 000000000000..8331f7d4821f --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/index.ts @@ -0,0 +1,5 @@ +import type ShouldAllowDownloadQRCode from './types'; + +const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = false; + +export default shouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowDownloadQRCode/types.ts b/src/libs/shouldAllowDownloadQRCode/types.ts new file mode 100644 index 000000000000..3bd6c5dc4dd7 --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/types.ts @@ -0,0 +1,3 @@ +type ShouldAllowDownloadQRCode = boolean; + +export default ShouldAllowDownloadQRCode; diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 251728866a54..4abf8f0d2033 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -1,11 +1,16 @@ import {useFocusEffect} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; -import React from 'react'; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as App from '@userActions/App'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -24,19 +29,39 @@ type ConciergePageProps = ConciergePageOnyxProps & StackScreenProps { if (session && 'authToken' in session) { + App.confirmReadyToOpenApp(); // Pop the concierge loading page before opening the concierge report. Navigation.isNavigationReady().then(() => { - Navigation.goBack(); - Report.navigateToConciergeChat(); + if (isUnmounted.current) { + return; + } + Report.navigateToConciergeChat(undefined, true, () => !isUnmounted.current); }); } else { Navigation.navigate(); } }); - return ; + useEffect( + () => () => { + isUnmounted.current = true; + }, + [], + ); + + return ( + + + + + + + ); } ConciergePage.displayName = 'ConciergePage'; diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index a19a815664ce..7593857536a6 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -189,7 +189,7 @@ function RoomMembersPage({report, session, policies}: RoomMembersPageProps) { isSelected: selectedMembers.includes(accountID), isDisabled: accountID === session?.accountID, text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)), - alternateText: formatPhoneNumber(details.login), + alternateText: details?.login ? formatPhoneNumber(details.login) : '', icons: [ { source: UserUtils.getAvatar(details.avatar, accountID), diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 8ec0bce9d1a7..4bbf3d393213 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -446,7 +446,12 @@ function ReportActionCompose({ onBlur={onBlur} measureParentContainer={measureContainer} listHeight={listHeight} - onValueChange={validateCommentMaxLength} + onValueChange={(value) => { + if (value.length === 0 && isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + validateCommentMaxLength(value); + }} /> { diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js deleted file mode 100644 index a9acf37ae556..000000000000 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ /dev/null @@ -1,393 +0,0 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {Component} from 'react'; -import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withTheme, {withThemePropTypes} from '@components/withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import {translatableTextPropTypes} from '@libs/Localize'; -import Navigation from '@libs/Navigation/Navigation'; -import * as Session from '@userActions/Session'; -import * as User from '@userActions/User'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import ValidateCodeForm from './ValidateCodeForm'; - -const propTypes = { - /* Onyx Props */ - - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Value of partner name */ - partnerName: PropTypes.string, - - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - - /** Date when login was validated */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), - - /** Field-specific pending states for offline UI status */ - pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), - - /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - /** User's security group IDs by domain */ - myDomainSecurityGroups: PropTypes.objectOf(PropTypes.string), - - /** All of the user's security groups and their settings */ - securityGroups: PropTypes.shape({ - hasRestrictedPrimaryLogin: PropTypes.bool, - }), - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Passed via route /settings/profile/contact-methods/:contactMethod/details */ - contactMethod: PropTypes.string, - }), - }), - - /** Indicated whether the report data is loading */ - isLoadingReportData: PropTypes.bool, - - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, -}; - -const defaultProps = { - loginList: {}, - session: { - email: null, - }, - myDomainSecurityGroups: {}, - securityGroups: {}, - route: { - params: { - contactMethod: '', - }, - }, - isLoadingReportData: true, -}; - -class ContactMethodDetailsPage extends Component { - constructor(props) { - super(props); - - this.deleteContactMethod = this.deleteContactMethod.bind(this); - this.toggleDeleteModal = this.toggleDeleteModal.bind(this); - this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.getContactMethod = this.getContactMethod.bind(this); - this.setAsDefault = this.setAsDefault.bind(this); - - this.state = { - isDeleteModalOpen: false, - }; - - this.validateCodeFormRef = React.createRef(); - } - - componentDidMount() { - const contactMethod = this.getContactMethod(); - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - if (_.isEmpty(loginData)) { - return; - } - User.resetContactMethodValidateCodeSentState(this.getContactMethod()); - } - - componentDidUpdate(prevProps) { - const contactMethod = this.getContactMethod(); - const validatedDate = lodashGet(this.props.loginList, [contactMethod, 'validatedDate']); - const prevValidatedDate = lodashGet(prevProps.loginList, [contactMethod, 'validatedDate']); - - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - // Navigate to methods page on successful magic code verification - // validatedDate property is responsible to decide the status of the magic code verification - if (!prevValidatedDate && validatedDate) { - // If the selected contactMethod is the current session['login'] and the account is unvalidated, - // the current authToken is invalid after the successful magic code verification. - // So we need to sign out the user and redirect to the sign in page. - if (isDefaultContactMethod) { - Session.signOutAndRedirectToSignIn(); - return; - } - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); - } - } - - /** - * Gets the current contact method from the route params - * @returns {string} - */ - getContactMethod() { - const contactMethod = lodashGet(this.props.route, 'params.contactMethod'); - - // We find the number of times the url is encoded based on the last % sign and remove them. - const lastPercentIndex = contactMethod.lastIndexOf('%'); - const encodePercents = contactMethod.substring(lastPercentIndex).match(new RegExp('25', 'g')); - let numberEncodePercents = encodePercents ? encodePercents.length : 0; - const beforeAtSign = contactMethod.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { - if (numberEncodePercents > 0) { - numberEncodePercents--; - return '%'; - } - return match; - }); - const afterAtSign = contactMethod.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); - - return decodeURIComponent(beforeAtSign + afterAtSign); - } - - /** - * Attempt to set this contact method as user's "Default contact method" - */ - setAsDefault() { - User.setContactMethodAsDefault(this.getContactMethod()); - } - - /** - * Checks if the user is allowed to change their default contact method. This should only be allowed if: - * 1. The viewed contact method is not already their default contact method - * 2. The viewed contact method is validated - * 3. If the user is on a private domain, their security group must allow primary login switching - * - * @returns {Boolean} - */ - canChangeDefaultContactMethod() { - const contactMethod = this.getContactMethod(); - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - - // Cannot set this contact method as default if: - // 1. This contact method is already their default - // 2. This contact method is not validated - if (isDefaultContactMethod || !loginData.validatedDate) { - return false; - } - - const domainName = Str.extractEmailDomain(this.props.session.email); - const primaryDomainSecurityGroupID = lodashGet(this.props.myDomainSecurityGroups, domainName); - - // If there's no security group associated with the user for the primary domain, - // default to allowing the user to change their default contact method. - if (!primaryDomainSecurityGroupID) { - return true; - } - - // Allow user to change their default contact method if they don't have a security group OR if their security group - // does NOT restrict primary login switching. - return !lodashGet(this.props.securityGroups, [`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`, 'hasRestrictedPrimaryLogin'], false); - } - - /** - * Deletes the contact method if it has errors. Otherwise, it shows the confirmation alert and deletes it only if the user confirms. - */ - deleteContactMethod() { - if (!_.isEmpty(lodashGet(this.props.loginList, [this.getContactMethod(), 'errorFields'], {}))) { - User.deleteContactMethod(this.getContactMethod(), this.props.loginList); - return; - } - this.toggleDeleteModal(true); - } - - /** - * Toggle delete confirm modal visibility - * @param {Boolean} isOpen - */ - toggleDeleteModal(isOpen) { - if (canUseTouchScreen() && isOpen) { - InteractionManager.runAfterInteractions(() => { - this.setState({isDeleteModalOpen: isOpen}); - }); - Keyboard.dismiss(); - } else { - this.setState({isDeleteModalOpen: isOpen}); - } - } - - /** - * Delete the contact method and hide the modal - */ - confirmDeleteAndHideModal() { - this.toggleDeleteModal(false); - User.deleteContactMethod(this.getContactMethod(), this.props.loginList); - } - - render() { - const contactMethod = this.getContactMethod(); - - // Replacing spaces with "hard spaces" to prevent breaking the number - const formattedContactMethod = Str.isSMSLogin(contactMethod) ? this.props.formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod; - - if (this.props.isLoadingReportData && _.isEmpty(this.props.loginList)) { - return ; - } - - const loginData = this.props.loginList[contactMethod]; - if (!contactMethod || !loginData) { - return ( - - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - /> - - ); - } - - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false); - const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin')); - const isFailedRemovedContactMethod = Boolean(lodashGet(loginData, 'errorFields.deletedLogin')); - - return ( - this.validateCodeFormRef.current && this.validateCodeFormRef.current.focus()} - testID={ContactMethodDetailsPage.displayName} - > - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - /> - - this.toggleDeleteModal(false)} - onModalHide={() => { - InteractionManager.runAfterInteractions(() => { - if (!this.validateCodeFormRef.current) { - return; - } - this.validateCodeFormRef.current.focusLastSelected(); - }); - }} - prompt={this.props.translate('contacts.removeAreYouSure')} - confirmText={this.props.translate('common.yesContinue')} - cancelText={this.props.translate('common.cancel')} - isVisible={this.state.isDeleteModalOpen && !isDefaultContactMethod} - danger - /> - - {isFailedAddContactMethod && ( - - )} - - {!loginData.validatedDate && !isFailedAddContactMethod && ( - - - - - - )} - {this.canChangeDefaultContactMethod() ? ( - User.clearContactMethodErrors(contactMethod, 'defaultLogin')} - > - - - ) : null} - {isDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} - > - {this.props.translate('contacts.yourDefaultContactMethod')} - - ) : ( - User.clearContactMethodErrors(contactMethod, 'deletedLogin')} - > - this.toggleDeleteModal(true)} - /> - - )} - - - ); - } -} - -ContactMethodDetailsPage.propTypes = propTypes; -ContactMethodDetailsPage.defaultProps = defaultProps; -ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage'; - -export default compose( - withLocalize, - withOnyx({ - loginList: { - key: ONYXKEYS.LOGIN_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - myDomainSecurityGroups: { - key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, - }, - securityGroups: { - key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`, - }, - isLoadingReportData: { - key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`, - }, - }), - withThemeStyles, - withTheme, -)(ContactMethodDetailsPage); diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx new file mode 100644 index 000000000000..7de22da728dd --- /dev/null +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -0,0 +1,305 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import Str from 'expensify-common/lib/str'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import ConfirmModal from '@components/ConfirmModal'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as Session from '@userActions/Session'; +import * as User from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {LoginList, SecurityGroup, Session as TSession} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ValidateCodeForm from './ValidateCodeForm'; +import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; + +type ContactMethodDetailsPageOnyxProps = { + /** Login list for the user that is signed in */ + loginList: OnyxEntry; + + /** Current user session */ + session: OnyxEntry; + + /** User's security group IDs by domain */ + myDomainSecurityGroups: OnyxEntry>; + + /** All of the user's security groups and their settings */ + securityGroups: OnyxCollection; + + /** Indicated whether the report data is loading */ + isLoadingReportData: OnyxEntry; +}; + +type ContactMethodDetailsPageProps = ContactMethodDetailsPageOnyxProps & StackScreenProps; + +function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, securityGroups, isLoadingReportData = true, route}: ContactMethodDetailsPageProps) { + const {formatPhoneNumber, translate} = useLocalize(); + const theme = useTheme(); + const themeStyles = useThemeStyles(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const validateCodeFormRef = useRef(null); + + /** + * Gets the current contact method from the route params + */ + const contactMethod: string = useMemo(() => { + const contactMethodParam = route.params.contactMethod; + + // We find the number of times the url is encoded based on the last % sign and remove them. + const lastPercentIndex = contactMethodParam.lastIndexOf('%'); + const encodePercents = contactMethodParam.substring(lastPercentIndex).match(new RegExp('25', 'g')); + let numberEncodePercents = encodePercents?.length ?? 0; + const beforeAtSign = contactMethodParam.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { + if (numberEncodePercents > 0) { + numberEncodePercents--; + return '%'; + } + return match; + }); + const afterAtSign = contactMethodParam.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); + + return decodeURIComponent(beforeAtSign + afterAtSign); + }, [route.params.contactMethod]); + const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); + const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]); + + /** + * Attempt to set this contact method as user's "Default contact method" + */ + const setAsDefault = useCallback(() => { + User.setContactMethodAsDefault(contactMethod); + }, [contactMethod]); + + /** + * Checks if the user is allowed to change their default contact method. This should only be allowed if: + * 1. The viewed contact method is not already their default contact method + * 2. The viewed contact method is validated + * 3. If the user is on a private domain, their security group must allow primary login switching + */ + const canChangeDefaultContactMethod = useMemo(() => { + // Cannot set this contact method as default if: + // 1. This contact method is already their default + // 2. This contact method is not validated + if (isDefaultContactMethod || !loginData?.validatedDate) { + return false; + } + + const domainName = Str.extractEmailDomain(session?.email ?? ''); + const primaryDomainSecurityGroupID = myDomainSecurityGroups?.[domainName]; + + // If there's no security group associated with the user for the primary domain, + // default to allowing the user to change their default contact method. + if (!primaryDomainSecurityGroupID) { + return true; + } + + // Allow user to change their default contact method if they don't have a security group OR if their security group + // does NOT restrict primary login switching. + return !securityGroups?.[`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`]?.hasRestrictedPrimaryLogin; + }, [isDefaultContactMethod, loginData?.validatedDate, session?.email, myDomainSecurityGroups, securityGroups]); + + /** + * Toggle delete confirm modal visibility + */ + const toggleDeleteModal = useCallback((isOpen: boolean) => { + if (canUseTouchScreen() && isOpen) { + InteractionManager.runAfterInteractions(() => { + setIsDeleteModalOpen(isOpen); + }); + Keyboard.dismiss(); + } else { + setIsDeleteModalOpen(isOpen); + } + }, []); + + /** + * Delete the contact method and hide the modal + */ + const confirmDeleteAndHideModal = useCallback(() => { + toggleDeleteModal(false); + User.deleteContactMethod(contactMethod, loginList ?? {}); + }, [contactMethod, loginList, toggleDeleteModal]); + + useEffect(() => { + if (isEmptyObject(loginData)) { + return; + } + User.resetContactMethodValidateCodeSentState(contactMethod); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const prevValidatedDate = usePrevious(loginData?.validatedDate); + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (prevValidatedDate || !loginData?.validatedDate) { + return; + } + + // If the selected contactMethod is the current session['login'] and the account is unvalidated, + // the current authToken is invalid after the successful magic code verification. + // So we need to sign out the user and redirect to the sign in page. + if (isDefaultContactMethod) { + Session.signOutAndRedirectToSignIn(); + return; + } + // Navigate to methods page on successful magic code verification + // validatedDate property is responsible to decide the status of the magic code verification + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); + }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod]); + + if (isLoadingReportData && isEmptyObject(loginList)) { + return ; + } + + if (!contactMethod || !loginData) { + return ( + + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + /> + + ); + } + + // Replacing spaces with "hard spaces" to prevent breaking the number + const formattedContactMethod = Str.isSMSLogin(contactMethod) ? formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod; + const hasMagicCodeBeenSent = !!loginData.validateCodeSent; + const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin; + const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin; + + return ( + validateCodeFormRef.current?.focus?.()} + testID={ContactMethodDetailsPage.displayName} + > + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + /> + + toggleDeleteModal(false)} + onModalHide={() => { + InteractionManager.runAfterInteractions(() => { + validateCodeFormRef.current?.focusLastSelected?.(); + }); + }} + prompt={translate('contacts.removeAreYouSure')} + confirmText={translate('common.yesContinue')} + cancelText={translate('common.cancel')} + isVisible={isDeleteModalOpen && !isDefaultContactMethod} + danger + /> + + {isFailedAddContactMethod && ( + + )} + + {!loginData.validatedDate && !isFailedAddContactMethod && ( + + + + + + )} + {canChangeDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, 'defaultLogin')} + > + + + ) : null} + {isDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} + > + {translate('contacts.yourDefaultContactMethod')} + + ) : ( + User.clearContactMethodErrors(contactMethod, 'deletedLogin')} + > + toggleDeleteModal(true)} + /> + + )} + + + ); +} + +ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage'; + +export default withOnyx({ + loginList: { + key: ONYXKEYS.LOGIN_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + myDomainSecurityGroups: { + key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, + }, + securityGroups: { + key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`, + }, + isLoadingReportData: { + key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`, + }, +})(ContactMethodDetailsPage); diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx similarity index 53% rename from src/pages/settings/Profile/Contacts/ContactMethodsPage.js rename to src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index c85d123ad3fd..5d150e782c44 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -1,10 +1,9 @@ +import type {StackScreenProps} from '@react-navigation/stack'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import Button from '@components/Button'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import FixedFooter from '@components/FixedFooter'; @@ -13,86 +12,64 @@ import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {LoginList, Session} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -const propTypes = { - /* Onyx Props */ - +type ContactMethodsPageOnyxProps = { /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */ - partnerName: PropTypes.string, - - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - - /** The date when the login was validated, used to show the brickroad status */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), - - /** Field-specific pending states for offline UI status */ - pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), + loginList: OnyxEntry; /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - ...withLocalizePropTypes, + session: OnyxEntry; }; -const defaultProps = { - loginList: {}, - session: { - email: null, - }, -}; +type ContactMethodsPageProps = ContactMethodsPageOnyxProps & StackScreenProps; -function ContactMethodsPage(props) { +function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps) { const styles = useThemeStyles(); - const loginNames = _.keys(props.loginList); - const navigateBackTo = lodashGet(props.route, 'params.backTo', ''); + const {formatPhoneNumber, translate} = useLocalize(); + const loginNames = Object.keys(loginList ?? {}); + const navigateBackTo = route?.params?.backTo || ROUTES.SETTINGS_PROFILE; // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods. // The default contact method is determined by checking against the session email (the current login). - const sortedLoginNames = _.sortBy(loginNames, (loginName) => (props.loginList[loginName].partnerUserID === props.session.email ? 0 : 1)); + const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1)); - const loginMenuItems = _.map(sortedLoginNames, (loginName) => { - const login = props.loginList[loginName]; - const pendingAction = lodashGet(login, 'pendingFields.deletedLogin') || lodashGet(login, 'pendingFields.addedLogin'); - if (!login.partnerUserID && _.isEmpty(pendingAction)) { + const loginMenuItems = sortedLoginNames.map((loginName) => { + const login = loginList?.[loginName]; + const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; + if (!login?.partnerUserID && !pendingAction) { return null; } let description = ''; - if (props.session.email === login.partnerUserID) { - description = props.translate('contacts.getInTouch'); - } else if (lodashGet(login, 'errorFields.addedLogin')) { - description = props.translate('contacts.failedNewContact'); - } else if (!login.validatedDate) { - description = props.translate('contacts.pleaseVerify'); + if (session?.email === login?.partnerUserID) { + description = translate('contacts.getInTouch'); + } else if (login?.errorFields?.addedLogin) { + description = translate('contacts.failedNewContact'); + } else if (!login?.validatedDate) { + description = translate('contacts.pleaseVerify'); } - let indicator = null; - if (_.some(lodashGet(login, 'errorFields', {}), (errorField) => !_.isEmpty(errorField))) { + let indicator; + if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } else if (!login.validatedDate) { + } else if (!login?.validatedDate) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } // Default to using login key if we deleted login.partnerUserID optimistically // but still need to show the pending login being deleted while offline. - const partnerUserID = login.partnerUserID || loginName; - const menuItemTitle = Str.isSMSLogin(partnerUserID) ? props.formatPhoneNumber(partnerUserID) : partnerUserID; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const partnerUserID = login?.partnerUserID || loginName; + const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID; return ( ); @@ -126,25 +103,25 @@ function ContactMethodsPage(props) { testID={ContactMethodsPage.displayName} > Navigation.goBack(navigateBackTo)} /> - {props.translate('contacts.helpTextBeforeEmail')} + {translate('contacts.helpTextBeforeEmail')} - {props.translate('contacts.helpTextAfterEmail')} + {translate('contacts.helpTextAfterEmail')} {loginMenuItems}