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')}
- >
-
- )}
-
-
- );
- }
-}
-
-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')}
+ >
+
+ )}
+
+
+ );
+}
+
+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}
@@ -154,18 +131,13 @@ function ContactMethodsPage(props) {
);
}
-ContactMethodsPage.propTypes = propTypes;
-ContactMethodsPage.defaultProps = defaultProps;
ContactMethodsPage.displayName = 'ContactMethodsPage';
-export default compose(
- withLocalize,
- withOnyx({
- loginList: {
- key: ONYXKEYS.LOGIN_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- }),
-)(ContactMethodsPage);
+export default withOnyx({
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(ContactMethodsPage);
diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
similarity index 56%
rename from src/pages/settings/Profile/Contacts/NewContactMethodPage.js
rename to src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
index b9d5dee8f4be..20e12f71664e 100644
--- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js
+++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx
@@ -1,57 +1,40 @@
+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, useRef} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import type {AnimatedTextInputRef} from '@components/RNTextInput';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
import * as LoginUtils from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
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 INPUT_IDS from '@src/types/form/NewContactMethodForm';
+import type {LoginList} from '@src/types/onyx';
+import type {Errors} from '@src/types/onyx/OnyxCommon';
-const propTypes = {
- /* Onyx Props */
-
+type NewContactMethodPageOnyxProps = {
/** 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)),
- }),
-
- ...withLocalizePropTypes,
-};
-const defaultProps = {
- loginList: {},
+ loginList: OnyxEntry;
};
-const addNewContactMethod = (values) => {
+type NewContactMethodPageProps = NewContactMethodPageOnyxProps & StackScreenProps;
+
+const addNewContactMethod = (values: FormOnyxValues) => {
const phoneLogin = LoginUtils.getPhoneLogin(values.phoneOrEmail);
const validateIfnumber = LoginUtils.validateNumber(phoneLogin);
const submitDetail = (validateIfnumber || values.phoneOrEmail).trim().toLowerCase();
@@ -59,35 +42,36 @@ const addNewContactMethod = (values) => {
User.addNewContactMethodAndNavigate(submitDetail);
};
-function NewContactMethodPage(props) {
+function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) {
const styles = useThemeStyles();
- const loginInputRef = useRef(null);
+ const {translate} = useLocalize();
+ const loginInputRef = useRef(null);
- const navigateBackTo = lodashGet(props.route, 'params.backTo', ROUTES.SETTINGS_PROFILE);
+ const navigateBackTo = route?.params?.backTo ?? ROUTES.SETTINGS_PROFILE;
const validate = React.useCallback(
- (values) => {
+ (values: FormOnyxValues): Errors => {
const phoneLogin = LoginUtils.getPhoneLogin(values.phoneOrEmail);
const validateIfnumber = LoginUtils.validateNumber(phoneLogin);
const errors = {};
- if (_.isEmpty(values.phoneOrEmail)) {
+ if (!values.phoneOrEmail) {
ErrorUtils.addErrorMessage(errors, 'phoneOrEmail', 'contacts.genericFailureMessages.contactMethodRequired');
}
- if (!_.isEmpty(values.phoneOrEmail) && !(validateIfnumber || Str.isValidEmail(values.phoneOrEmail))) {
+ if (!!values.phoneOrEmail && !(validateIfnumber || Str.isValidEmail(values.phoneOrEmail))) {
ErrorUtils.addErrorMessage(errors, 'phoneOrEmail', 'contacts.genericFailureMessages.invalidContactMethod');
}
- if (!_.isEmpty(values.phoneOrEmail) && lodashGet(props.loginList, validateIfnumber || values.phoneOrEmail.toLowerCase())) {
+ if (!!values.phoneOrEmail && loginList?.[validateIfnumber || values.phoneOrEmail.toLowerCase()]) {
ErrorUtils.addErrorMessage(errors, 'phoneOrEmail', 'contacts.genericFailureMessages.enteredMethodIsAlreadySubmited');
}
return errors;
},
- // We don't need `props.loginList` because when submitting this form
- // the props.loginList gets updated, causing this function to run again.
+ // We don't need `loginList` because when submitting this form
+ // the loginList gets updated, causing this function to run again.
// https://github.com/Expensify/App/issues/20610
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@@ -103,38 +87,32 @@ function NewContactMethodPage(props) {
return (
{
- if (!loginInputRef.current) {
- return;
- }
-
- loginInputRef.current.focus();
- }}
+ onEntryTransitionEnd={() => loginInputRef.current?.focus()}
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
testID={NewContactMethodPage.displayName}
>
- {props.translate('common.pleaseEnterEmailOrPhoneNumber')}
-
+ {translate('common.pleaseEnterEmailOrPhoneNumber')}
+
(loginInputRef.current = el)}
+ ref={loginInputRef}
inputID={INPUT_IDS.PHONE_OR_EMAIL}
autoCapitalize="none"
enterKeyHint="done"
@@ -146,13 +124,8 @@ function NewContactMethodPage(props) {
);
}
-NewContactMethodPage.propTypes = propTypes;
-NewContactMethodPage.defaultProps = defaultProps;
NewContactMethodPage.displayName = 'NewContactMethodPage';
-export default compose(
- withLocalize,
- withOnyx({
- loginList: {key: ONYXKEYS.LOGIN_LIST},
- }),
-)(NewContactMethodPage);
+export default withOnyx({
+ loginList: {key: ONYXKEYS.LOGIN_LIST},
+})(NewContactMethodPage);
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.tsx
similarity index 56%
rename from src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
rename to src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.tsx
index 5c1fa30a88f1..adf2680549c7 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.tsx
@@ -1,95 +1,82 @@
import {useFocusEffect} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {ForwardedRef} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {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 DotIndicatorMessage from '@components/DotIndicatorMessage';
import MagicCodeInput from '@components/MagicCodeInput';
+import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import {withNetwork} from '@components/OnyxProvider';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as Session from '@userActions/Session';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {Account, LoginList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
- ...withLocalizePropTypes,
+type ValidateCodeFormHandle = {
+ focus: () => void;
+ focusLastSelected: () => void;
+};
+
+type ValidateCodeFormError = {
+ validateCode?: TranslationPaths;
+};
+
+type BaseValidateCodeFormOnyxProps = {
+ /** The details about the account that the user is signing in with */
+ account: OnyxEntry;
+};
+type ValidateCodeFormProps = {
/** The contact method being valdiated */
- contactMethod: PropTypes.string.isRequired,
+ contactMethod: string;
/** If the magic code has been resent previously */
- hasMagicCodeBeenSent: PropTypes.bool.isRequired,
+ hasMagicCodeBeenSent: boolean;
/** 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,
+ loginList: LoginList;
- /** 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)),
- }).isRequired,
+ /** Specifies autocomplete hints for the system, so it can provide autofill */
+ autoComplete?: AutoCompleteVariant;
/** Forwarded inner ref */
- innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
-
- /* Onyx Props */
-
- /** The details about the account that the user is signing in with */
- account: PropTypes.shape({
- /** Whether or not a sign on form is loading (being submitted) */
- isLoading: PropTypes.bool,
- }),
-
- /** Specifies autocomplete hints for the system, so it can provide autofill */
- autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired,
+ innerRef?: ForwardedRef;
};
-const defaultProps = {
- account: {},
- innerRef: () => {},
-};
+type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps;
-function BaseValidateCodeForm(props) {
+function BaseValidateCodeForm({account = {}, contactMethod, hasMagicCodeBeenSent, loginList, autoComplete = 'one-time-code', innerRef = () => {}}: BaseValidateCodeFormProps) {
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const [formError, setFormError] = useState({});
+ const [formError, setFormError] = useState({});
const [validateCode, setValidateCode] = useState('');
- const loginData = props.loginList[props.contactMethod];
- const inputValidateCodeRef = useRef();
+ const loginData = loginList[contactMethod];
+ const inputValidateCodeRef = useRef(null);
const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin');
- const shouldDisableResendValidateCode = props.network.isOffline || props.account.isLoading;
- const focusTimeoutRef = useRef(null);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case
+ const shouldDisableResendValidateCode = !!isOffline || account?.isLoading;
+ const focusTimeoutRef = useRef(null);
- useImperativeHandle(props.innerRef, () => ({
+ useImperativeHandle(innerRef, () => ({
focus() {
- if (!inputValidateCodeRef.current) {
- return;
- }
- inputValidateCodeRef.current.focus();
+ inputValidateCodeRef.current?.focus();
},
focusLastSelected() {
if (!inputValidateCodeRef.current) {
@@ -98,7 +85,9 @@ function BaseValidateCodeForm(props) {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
- focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION);
+ focusTimeoutRef.current = setTimeout(() => {
+ inputValidateCodeRef.current?.focusLastSelected();
+ }, CONST.ANIMATED_TRANSITION);
},
}));
@@ -110,7 +99,9 @@ function BaseValidateCodeForm(props) {
if (focusTimeoutRef.current) {
clearTimeout(focusTimeoutRef.current);
}
- focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION);
+ focusTimeoutRef.current = setTimeout(() => {
+ inputValidateCodeRef.current?.focusLastSelected();
+ }, CONST.ANIMATED_TRANSITION);
return () => {
if (!focusTimeoutRef.current) {
return;
@@ -125,41 +116,39 @@ function BaseValidateCodeForm(props) {
if (!validateLoginError) {
return;
}
- User.clearContactMethodErrors(props.contactMethod, 'validateLogin');
+ User.clearContactMethodErrors(contactMethod, 'validateLogin');
// contactMethod is not added as a dependency since it does not change between renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
- if (!props.hasMagicCodeBeenSent) {
+ if (!hasMagicCodeBeenSent) {
return;
}
- inputValidateCodeRef.current.clear();
- }, [props.hasMagicCodeBeenSent]);
+ inputValidateCodeRef.current?.clear();
+ }, [hasMagicCodeBeenSent]);
/**
* Request a validate code / magic code be sent to verify this contact method
*/
const resendValidateCode = () => {
- User.requestContactMethodValidateCode(props.contactMethod);
- inputValidateCodeRef.current.clear();
+ User.requestContactMethodValidateCode(contactMethod);
+ inputValidateCodeRef.current?.clear();
};
/**
* Handle text input and clear formError upon text change
- *
- * @param {String} text
*/
const onTextInput = useCallback(
- (text) => {
+ (text: string) => {
setValidateCode(text);
setFormError({});
if (validateLoginError) {
- User.clearContactMethodErrors(props.contactMethod, 'validateLogin');
+ User.clearContactMethodErrors(contactMethod, 'validateLogin');
}
},
- [validateLoginError, props.contactMethod],
+ [validateLoginError, contactMethod],
);
/**
@@ -177,28 +166,27 @@ function BaseValidateCodeForm(props) {
}
setFormError({});
- User.validateSecondaryLogin(props.contactMethod, validateCode);
- }, [validateCode, props.contactMethod]);
+ User.validateSecondaryLogin(contactMethod, validateCode);
+ }, [validateCode, contactMethod]);
return (
<>
User.clearContactMethodErrors(props.contactMethod, 'validateCodeSent')}
+ onClose={() => User.clearContactMethodErrors(contactMethod, 'validateCodeSent')}
>
- {props.translate('validateCodeForm.magicCodeNotReceived')}
+ {translate('validateCodeForm.magicCodeNotReceived')}
- {props.hasMagicCodeBeenSent && (
+ {hasMagicCodeBeenSent && (
)}
User.clearContactMethodErrors(props.contactMethod, 'validateLogin')}
+ onClose={() => User.clearContactMethodErrors(contactMethod, 'validateLogin')}
>
>
);
}
-BaseValidateCodeForm.propTypes = propTypes;
-BaseValidateCodeForm.defaultProps = defaultProps;
BaseValidateCodeForm.displayName = 'BaseValidateCodeForm';
-export default compose(
- withLocalize,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
- withNetwork(),
-)(BaseValidateCodeForm);
+export type {ValidateCodeFormProps, ValidateCodeFormHandle};
+
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+})(BaseValidateCodeForm);
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.tsx
similarity index 61%
rename from src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js
rename to src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.tsx
index a193dc8d2eae..704405f93a2c 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.tsx
@@ -1,7 +1,8 @@
import React, {forwardRef} from 'react';
import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
-const ValidateCodeForm = forwardRef((props, ref) => (
+const ValidateCodeForm = forwardRef((props, ref) => (
(
/>
));
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-
export default ValidateCodeForm;
diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.tsx
similarity index 62%
rename from src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js
rename to src/pages/settings/Profile/Contacts/ValidateCodeForm/index.tsx
index bb4e5ed36b47..453fc9c3f373 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.tsx
@@ -1,7 +1,8 @@
import React, {forwardRef} from 'react';
import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type {ValidateCodeFormHandle, ValidateCodeFormProps} from './BaseValidateCodeForm';
-const ValidateCodeForm = forwardRef((props, ref) => (
+const ValidateCodeForm = forwardRef((props, ref) => (
(
/>
));
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-
export default ValidateCodeForm;
diff --git a/src/pages/settings/Report/VisibilityPage.tsx b/src/pages/settings/Report/VisibilityPage.tsx
index a03068832637..d3b8b2656d50 100644
--- a/src/pages/settings/Report/VisibilityPage.tsx
+++ b/src/pages/settings/Report/VisibilityPage.tsx
@@ -5,6 +5,7 @@ import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
import type {ReportSettingsNavigatorParamList} from '@libs/Navigation/types';
import * as ReportUtils from '@libs/ReportUtils';
@@ -72,6 +73,7 @@ function VisibilityPage({report}: VisibilityProps) {
changeVisibility(option.value);
}}
initiallyFocusedOptionKey={visibilityOptions.find((visibility) => visibility.isSelected)?.keyForList}
+ ListItem={RadioListItem}
/>
phoneOrEmail.replace(/\s+/g, '').toLowerCase();
const validate = (values: FormOnyxValues): FormInputErrors => {
- const userEmailOrPhone = formatPhoneNumber(session?.email);
+ const userEmailOrPhone = session?.email ? formatPhoneNumber(session.email) : null;
const errors = ValidationUtils.getFieldRequiredErrors(values, ['phoneOrEmail']);
- if (values.phoneOrEmail && sanitizePhoneOrEmail(userEmailOrPhone) !== sanitizePhoneOrEmail(values.phoneOrEmail)) {
+ if (values.phoneOrEmail && userEmailOrPhone && sanitizePhoneOrEmail(userEmailOrPhone) !== sanitizePhoneOrEmail(values.phoneOrEmail)) {
errors.phoneOrEmail = 'closeAccountPage.enterYourDefaultContactMethod';
}
return errors;
};
- const userEmailOrPhone = formatPhoneNumber(session?.email);
+ const userEmailOrPhone = session?.email ? formatPhoneNumber(session.email) : null;
return (
!card.isVirtual && !!card.domainName && CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state ?? 0))
- .toSorted((card1, card2) => {
- const isExpensifyCard1 = CardUtils.isExpensifyCard(card1.cardID);
- const isExpensifyCard2 = CardUtils.isExpensifyCard(card2.cardID);
- if (isExpensifyCard1 === isExpensifyCard2) {
- return 0;
- }
-
- return isExpensifyCard1 ? -1 : 1;
- });
+ .filter((card) => !card.isVirtual && !!card.domainName && CONST.EXPENSIFY_CARD.ACTIVE_STATES.includes(card.state ?? 0));
const numberPhysicalExpensifyCards = assignedCards.filter((card) => CardUtils.isExpensifyCard(card.cardID)).length;
- return assignedCards.map((card) => {
+ const assignedCardsSorted = lodashSortBy(assignedCards, (card) => !CardUtils.isExpensifyCard(card.cardID));
+
+ return assignedCardsSorted.map((card) => {
const isExpensifyCard = CardUtils.isExpensifyCard(card.cardID);
const icon = getBankIcon({bankName: card.bank as BankName, isCard: true, styles});
diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js
deleted file mode 100755
index 6ba9b7fcd0f5..000000000000
--- a/src/pages/signin/ChangeExpensifyLoginLink.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import Text from '@components/Text';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-
-const propTypes = {
- /** The credentials of the logged in person */
- credentials: PropTypes.shape({
- /** The email the user logged in with */
- login: PropTypes.string,
- }),
-
- /** Callback to navigate back to email form */
- onPress: PropTypes.func.isRequired,
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- credentials: {
- login: '',
- },
-};
-
-function ChangeExpensifyLoginLink(props) {
- const styles = useThemeStyles();
- return (
-
- {!_.isEmpty(props.credentials.login) && {props.translate('loginForm.notYou', {user: props.formatPhoneNumber(props.credentials.login)})}}
-
-
- {props.translate('common.goBack')}
- {'.'}
-
-
-
- );
-}
-
-ChangeExpensifyLoginLink.propTypes = propTypes;
-ChangeExpensifyLoginLink.defaultProps = defaultProps;
-ChangeExpensifyLoginLink.displayName = 'ChangeExpensifyLoginLink';
-
-export default compose(
- withLocalize,
- withOnyx({
- credentials: {key: ONYXKEYS.CREDENTIALS},
- }),
-)(ChangeExpensifyLoginLink);
diff --git a/src/pages/signin/ChangeExpensifyLoginLink.tsx b/src/pages/signin/ChangeExpensifyLoginLink.tsx
new file mode 100755
index 000000000000..7f6eb05ff663
--- /dev/null
+++ b/src/pages/signin/ChangeExpensifyLoginLink.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Credentials} from '@src/types/onyx';
+
+type ChangeExpensifyLoginLinkOnyxProps = {
+ /** The credentials of the person logging in */
+ credentials: OnyxEntry;
+};
+
+type ChangeExpensifyLoginLinkProps = ChangeExpensifyLoginLinkOnyxProps & {
+ onPress: () => void;
+};
+
+function ChangeExpensifyLoginLink({credentials, onPress}: ChangeExpensifyLoginLinkProps) {
+ const styles = useThemeStyles();
+ const {translate, formatPhoneNumber} = useLocalize();
+
+ return (
+
+ {!!credentials?.login && {translate('loginForm.notYou', {user: formatPhoneNumber(credentials.login)})}}
+
+ {translate('common.goBack')}.
+
+
+ );
+}
+
+ChangeExpensifyLoginLink.displayName = 'ChangeExpensifyLoginLink';
+
+export default withOnyx({
+ credentials: {
+ key: ONYXKEYS.CREDENTIALS,
+ },
+})(ChangeExpensifyLoginLink);
diff --git a/src/pages/signin/ChooseSSOOrMagicCode.tsx b/src/pages/signin/ChooseSSOOrMagicCode.tsx
index d3140da278e8..7a39df332611 100644
--- a/src/pages/signin/ChooseSSOOrMagicCode.tsx
+++ b/src/pages/signin/ChooseSSOOrMagicCode.tsx
@@ -81,10 +81,7 @@ function ChooseSSOOrMagicCode({credentials, account, setIsUsingMagicCode}: Choos
}}
/>
{!!account && !isEmptyObject(account.errors) && }
- Session.clearSignInData()}
- />
+ Session.clearSignInData()} />
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index a033088f7727..6672ccbd0ebc 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -267,7 +267,6 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc
/>
{shouldShowValidateCodeForm && (
;
- /** Information about the network */
- network: networkPropTypes.isRequired,
+ /** The credentials of the person logging in */
+ credentials: OnyxEntry;
- /** Specifies autocomplete hints for the system, so it can provide autofill */
- autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired,
-
- /** Determines if user is switched to using recovery code instead of 2fa code */
- isUsingRecoveryCode: PropTypes.bool.isRequired,
+ /** Session info for the currently logged in user. */
+ session: OnyxEntry;
+};
- /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
- setIsUsingRecoveryCode: PropTypes.func.isRequired,
+type BaseValidateCodeFormProps = WithToggleVisibilityViewProps &
+ ValidateCodeFormProps &
+ BaseValidateCodeFormOnyxProps & {
+ /** Specifies autocomplete hints for the system, so it can provide autofill */
+ autoComplete: 'sms-otp' | 'one-time-code';
+ };
- ...withLocalizePropTypes,
-};
+type ValidateCodeFormVariant = 'validateCode' | 'twoFactorAuthCode' | 'recoveryCode';
-const defaultProps = {
- account: {},
- credentials: {},
- session: {
- authToken: null,
- },
-};
+type FormError = Partial>;
-function BaseValidateCodeForm(props) {
- const theme = useTheme();
+function BaseValidateCodeForm({account, credentials, session, autoComplete, isUsingRecoveryCode, setIsUsingRecoveryCode, isVisible}: BaseValidateCodeFormProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const {translate} = useLocalize();
const isFocused = useIsFocused();
- const [formError, setFormError] = useState({});
- const [validateCode, setValidateCode] = useState(props.credentials.validateCode || '');
+ const {isOffline} = useNetwork();
+ const [formError, setFormError] = useState({});
+ const [validateCode, setValidateCode] = useState(credentials?.validateCode ?? '');
const [twoFactorAuthCode, setTwoFactorAuthCode] = useState('');
const [timeRemaining, setTimeRemaining] = useState(30);
const [recoveryCode, setRecoveryCode] = useState('');
- const [needToClearError, setNeedToClearError] = useState(props.account.errors);
+ const [needToClearError, setNeedToClearError] = useState(!!account?.errors);
- const prevRequiresTwoFactorAuth = usePrevious(props.account.requiresTwoFactorAuth);
- const prevValidateCode = usePrevious(props.credentials.validateCode);
+ const prevRequiresTwoFactorAuth = usePrevious(account?.requiresTwoFactorAuth);
+ const prevValidateCode = usePrevious(credentials?.validateCode);
- const inputValidateCodeRef = useRef();
- const input2FARef = useRef();
- const timerRef = useRef();
+ const inputValidateCodeRef = useRef();
+ const input2FARef = useRef();
+ const timerRef = useRef();
- const hasError = Boolean(props.account) && !_.isEmpty(props.account.errors) && !needToClearError;
- const isLoadingResendValidationForm = props.account.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM;
- const shouldDisableResendValidateCode = props.network.isOffline || props.account.isLoading;
+ const hasError = !!account && !isEmptyObject(account?.errors) && !needToClearError;
+ const isLoadingResendValidationForm = account?.loadingForm === CONST.FORMS.RESEND_VALIDATE_CODE_FORM;
+ const shouldDisableResendValidateCode = isOffline ?? account?.isLoading;
const isValidateCodeFormSubmitting =
- props.account.isLoading && props.account.loadingForm === (props.account.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM);
+ account?.isLoading && account?.loadingForm === (account?.requiresTwoFactorAuth ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM);
useEffect(() => {
- if (!(inputValidateCodeRef.current && hasError && (props.session.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED || props.account.isLoading))) {
+ if (!(inputValidateCodeRef.current && hasError && (session?.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED || account?.isLoading))) {
return;
}
inputValidateCodeRef.current.blur();
- }, [props.account.isLoading, props.session.autoAuthState, hasError]);
+ }, [account?.isLoading, session?.autoAuthState, hasError]);
useEffect(() => {
- if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus() || !props.isVisible || !isFocused) {
+ if (!inputValidateCodeRef.current || !canFocusInputOnScreenFocus() || !isVisible || !isFocused) {
return;
}
inputValidateCodeRef.current.focus();
- }, [props.isVisible, isFocused]);
+ }, [isVisible, isFocused]);
useEffect(() => {
- if (prevValidateCode || !props.credentials.validateCode) {
+ if (!!prevValidateCode || !credentials?.validateCode) {
return;
}
- setValidateCode(props.credentials.validateCode);
- }, [props.credentials.validateCode, prevValidateCode]);
+ setValidateCode(credentials.validateCode);
+ }, [credentials?.validateCode, prevValidateCode]);
useEffect(() => {
- if (!input2FARef.current || prevRequiresTwoFactorAuth || !props.account.requiresTwoFactorAuth) {
+ if (!input2FARef.current || !!prevRequiresTwoFactorAuth || !account?.requiresTwoFactorAuth) {
return;
}
input2FARef.current.focus();
- }, [props.account.requiresTwoFactorAuth, prevRequiresTwoFactorAuth]);
+ }, [account?.requiresTwoFactorAuth, prevRequiresTwoFactorAuth]);
useEffect(() => {
if (!inputValidateCodeRef.current || validateCode.length > 0) {
@@ -163,27 +134,22 @@ function BaseValidateCodeForm(props) {
/**
* Handle text input and clear formError upon text change
- *
- * @param {String} text
- * @param {String} key
*/
- const onTextInput = (text, key) => {
- let setInput;
+ const onTextInput = (text: string, key: ValidateCodeFormVariant) => {
if (key === 'validateCode') {
- setInput = setValidateCode;
+ setValidateCode(text);
}
if (key === 'twoFactorAuthCode') {
- setInput = setTwoFactorAuthCode;
+ setTwoFactorAuthCode(text);
}
if (key === 'recoveryCode') {
- setInput = setRecoveryCode;
+ setRecoveryCode(text);
}
- setInput(text);
- setFormError((prevError) => ({...prevError, [key]: ''}));
+ setFormError((prevError) => ({...prevError, [key]: undefined}));
- if (props.account.errors) {
- Session.clearAccountMessages();
+ if (account?.errors) {
+ SessionActions.clearAccountMessages();
}
};
@@ -191,8 +157,8 @@ function BaseValidateCodeForm(props) {
* Trigger the reset validate code flow and ensure the 2FA input field is reset to avoid it being permanently hidden
*/
const resendValidateCode = () => {
- User.resendValidateCode(props.credentials.login);
- inputValidateCodeRef.current.clear();
+ User.resendValidateCode(credentials?.login ?? '');
+ inputValidateCodeRef.current?.clear();
// Give feedback to the user to let them know the email was sent so that they don't spam the button.
setTimeRemaining(30);
};
@@ -204,7 +170,7 @@ function BaseValidateCodeForm(props) {
setTwoFactorAuthCode('');
setFormError({});
setValidateCode('');
- props.setIsUsingRecoveryCode(false);
+ setIsUsingRecoveryCode(false);
setRecoveryCode('');
};
@@ -213,7 +179,7 @@ function BaseValidateCodeForm(props) {
*/
const clearSignInData = () => {
clearLocalSignInData();
- Session.clearSignInData();
+ SessionActions.clearSignInData();
};
useEffect(() => {
@@ -221,26 +187,26 @@ function BaseValidateCodeForm(props) {
return;
}
- if (props.account.errors) {
- Session.clearAccountMessages();
+ if (account?.errors) {
+ SessionActions.clearAccountMessages();
return;
}
setNeedToClearError(false);
- }, [props.account.errors, needToClearError]);
+ }, [account?.errors, needToClearError]);
/**
* Switches between 2fa and recovery code, clears inputs and errors
*/
const switchBetween2faAndRecoveryCode = () => {
- props.setIsUsingRecoveryCode(!props.isUsingRecoveryCode);
+ setIsUsingRecoveryCode(!isUsingRecoveryCode);
setRecoveryCode('');
setTwoFactorAuthCode('');
- setFormError((prevError) => ({...prevError, recoveryCode: '', twoFactorAuthCode: ''}));
+ setFormError((prevError) => ({...prevError, recoveryCode: undefined, twoFactorAuthCode: undefined}));
- if (props.account.errors) {
- Session.clearAccountMessages();
+ if (account?.errors) {
+ SessionActions.clearAccountMessages();
}
};
@@ -258,10 +224,10 @@ function BaseValidateCodeForm(props) {
* Check that all the form fields are valid, then trigger the submit callback
*/
const validateAndSubmitForm = useCallback(() => {
- if (props.account.isLoading) {
+ if (account?.isLoading) {
return;
}
- const requiresTwoFactorAuth = props.account.requiresTwoFactorAuth;
+ const requiresTwoFactorAuth = account?.requiresTwoFactorAuth;
if (requiresTwoFactorAuth) {
if (input2FARef.current) {
input2FARef.current.blur();
@@ -269,7 +235,7 @@ function BaseValidateCodeForm(props) {
/**
* User could be using either recovery code or 2fa code
*/
- if (!props.isUsingRecoveryCode) {
+ if (!isUsingRecoveryCode) {
if (!twoFactorAuthCode.trim()) {
setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'});
return;
@@ -303,30 +269,30 @@ function BaseValidateCodeForm(props) {
}
setFormError({});
- const recoveryCodeOr2faCode = props.isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode;
+ const recoveryCodeOr2faCode = isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode;
- const accountID = lodashGet(props.credentials, 'accountID');
+ const accountID = credentials?.accountID;
if (accountID) {
- Session.signInWithValidateCode(accountID, validateCode, recoveryCodeOr2faCode);
+ SessionActions.signInWithValidateCode(accountID, validateCode, recoveryCodeOr2faCode);
} else {
- Session.signIn(validateCode, recoveryCodeOr2faCode);
+ SessionActions.signIn(validateCode, recoveryCodeOr2faCode);
}
- }, [props.account, props.credentials, twoFactorAuthCode, validateCode, props.isUsingRecoveryCode, recoveryCode]);
+ }, [account, credentials, twoFactorAuthCode, validateCode, isUsingRecoveryCode, recoveryCode]);
return (
<>
{/* At this point, if we know the account requires 2FA we already successfully authenticated */}
- {props.account.requiresTwoFactorAuth ? (
+ {account?.requiresTwoFactorAuth ? (
- {props.isUsingRecoveryCode ? (
+ {isUsingRecoveryCode ? (
onTextInput(text, 'recoveryCode')}
maxLength={CONST.RECOVERY_CODE_LENGTH}
- label={props.translate('recoveryCodeForm.recoveryCode')}
- errorText={formError.recoveryCode || ''}
+ label={translate('recoveryCodeForm.recoveryCode')}
+ errorText={formError?.recoveryCode ?? ''}
hasError={hasError}
onSubmitEditing={validateAndSubmitForm}
autoFocus
@@ -334,70 +300,76 @@ function BaseValidateCodeForm(props) {
) : (
{
+ if (!magicCodeInput) {
+ return;
+ }
+ input2FARef.current = magicCodeInput;
+ }}
name="twoFactorAuthCode"
value={twoFactorAuthCode}
onChangeText={(text) => onTextInput(text, 'twoFactorAuthCode')}
onFulfill={validateAndSubmitForm}
maxLength={CONST.TFA_CODE_LENGTH}
- errorText={formError.twoFactorAuthCode || ''}
+ errorText={formError?.twoFactorAuthCode ?? ''}
hasError={hasError}
autoFocus
key="twoFactorAuthCode"
/>
)}
- {hasError && }
+ {hasError && }
- {props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')}
+ {isUsingRecoveryCode ? translate('recoveryCodeForm.use2fa') : translate('recoveryCodeForm.useRecoveryCode')}
) : (
{
+ if (!magicCodeInput) {
+ return;
+ }
+ inputValidateCodeRef.current = magicCodeInput;
+ }}
name="validateCode"
value={validateCode}
onChangeText={(text) => onTextInput(text, 'validateCode')}
onFulfill={validateAndSubmitForm}
- errorText={formError.validateCode || ''}
+ errorText={formError?.validateCode ?? ''}
hasError={hasError}
autoFocus
key="validateCode"
testID="validateCode"
/>
- {hasError && }
+ {hasError && }
- {timeRemaining > 0 && !props.network.isOffline ? (
+ {timeRemaining > 0 && !isOffline ? (
- {props.translate('validateCodeForm.requestNewCode')}
+ {translate('validateCodeForm.requestNewCode')}
00:{String(timeRemaining).padStart(2, '0')}
) : (
- {hasError ? props.translate('validateCodeForm.requestNewCodeAfterErrorOccurred') : props.translate('validateCodeForm.magicCodeNotReceived')}
+ {hasError ? translate('validateCodeForm.requestNewCodeAfterErrorOccurred') : translate('validateCodeForm.magicCodeNotReceived')}
)}
@@ -406,10 +378,10 @@ function BaseValidateCodeForm(props) {
)}
@@ -422,17 +394,12 @@ function BaseValidateCodeForm(props) {
);
}
-BaseValidateCodeForm.propTypes = propTypes;
-BaseValidateCodeForm.defaultProps = defaultProps;
BaseValidateCodeForm.displayName = 'BaseValidateCodeForm';
-export default compose(
- withLocalize,
- withOnyx({
+export default withToggleVisibilityView(
+ withOnyx({
account: {key: ONYXKEYS.ACCOUNT},
credentials: {key: ONYXKEYS.CREDENTIALS},
session: {key: ONYXKEYS.SESSION},
- }),
- withNetwork(),
- withToggleVisibilityView,
-)(BaseValidateCodeForm);
+ })(BaseValidateCodeForm),
+);
diff --git a/src/pages/signin/ValidateCodeForm/index.android.js b/src/pages/signin/ValidateCodeForm/index.android.js
deleted file mode 100644
index 9adddf7c92d8..000000000000
--- a/src/pages/signin/ValidateCodeForm/index.android.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import BaseValidateCodeForm from './BaseValidateCodeForm';
-
-const defaultProps = {};
-
-const propTypes = {
- /** Determines if user is switched to using recovery code instead of 2fa code */
- isUsingRecoveryCode: PropTypes.bool.isRequired,
-
- /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
- setIsUsingRecoveryCode: PropTypes.func.isRequired,
-};
-function ValidateCodeForm(props) {
- return (
-
- );
-}
-
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-ValidateCodeForm.propTypes = propTypes;
-ValidateCodeForm.defaultProps = defaultProps;
-
-export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/index.android.tsx b/src/pages/signin/ValidateCodeForm/index.android.tsx
new file mode 100644
index 000000000000..1edd17517539
--- /dev/null
+++ b/src/pages/signin/ValidateCodeForm/index.android.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type ValidateCodeFormProps from './types';
+
+function ValidateCodeForm(props: ValidateCodeFormProps) {
+ return (
+
+ );
+}
+
+ValidateCodeForm.displayName = 'ValidateCodeForm';
+
+export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/index.js b/src/pages/signin/ValidateCodeForm/index.js
deleted file mode 100644
index 35afc283972b..000000000000
--- a/src/pages/signin/ValidateCodeForm/index.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import BaseValidateCodeForm from './BaseValidateCodeForm';
-
-const defaultProps = {};
-
-const propTypes = {
- /** Determines if user is switched to using recovery code instead of 2fa code */
- isUsingRecoveryCode: PropTypes.bool.isRequired,
-
- /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
- setIsUsingRecoveryCode: PropTypes.func.isRequired,
-};
-function ValidateCodeForm(props) {
- return (
-
- );
-}
-
-ValidateCodeForm.displayName = 'ValidateCodeForm';
-ValidateCodeForm.propTypes = propTypes;
-ValidateCodeForm.defaultProps = defaultProps;
-
-export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/index.tsx b/src/pages/signin/ValidateCodeForm/index.tsx
new file mode 100644
index 000000000000..8c1528ae7409
--- /dev/null
+++ b/src/pages/signin/ValidateCodeForm/index.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import BaseValidateCodeForm from './BaseValidateCodeForm';
+import type ValidateCodeFormProps from './types';
+
+function ValidateCodeForm(props: ValidateCodeFormProps) {
+ return (
+
+ );
+}
+
+ValidateCodeForm.displayName = 'ValidateCodeForm';
+
+export default ValidateCodeForm;
diff --git a/src/pages/signin/ValidateCodeForm/types.ts b/src/pages/signin/ValidateCodeForm/types.ts
new file mode 100644
index 000000000000..6edb6eace231
--- /dev/null
+++ b/src/pages/signin/ValidateCodeForm/types.ts
@@ -0,0 +1,11 @@
+type ValidateCodeFormProps = {
+ /** Determines if user is switched to using recovery code instead of 2fa code */
+ isUsingRecoveryCode: boolean;
+
+ /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */
+ setIsUsingRecoveryCode: (value: boolean) => void;
+
+ isVisible: boolean;
+};
+
+export default ValidateCodeFormProps;
diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.tsx
similarity index 57%
rename from src/pages/workspace/WorkspaceMembersPage.js
rename to src/pages/workspace/WorkspaceMembersPage.tsx
index 62b96943453c..5554f6ad282b 100644
--- a/src/pages/workspace/WorkspaceMembersPage.js
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -1,10 +1,11 @@
import {useIsFocused} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {StackScreenProps} from '@react-navigation/stack';
+import lodashIsEqual from 'lodash/isEqual';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {TextInput} from 'react-native';
import {InteractionManager, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import Button from '@components/Button';
import ConfirmModal from '@components/ConfirmModal';
@@ -12,84 +13,75 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import MessagesRow from '@components/MessagesRow';
-import networkPropTypes from '@components/networkPropTypes';
-import {withNetwork} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
+import type {ListItem} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
import Text from '@components/Text';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
+import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
+import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as UserUtils from '@libs/UserUtils';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {PersonalDetailsList, PolicyMember, PolicyMembers, Session} from '@src/types/onyx';
+import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import SearchInputManager from './SearchInputManager';
-import {policyDefaultProps, policyPropTypes} from './withPolicy';
+import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
-const propTypes = {
- /** All personal details asssociated with user */
- personalDetails: PropTypes.objectOf(personalDetailsPropType),
-
- /** URL Route params */
- route: PropTypes.shape({
- /** Params from the URL path */
- params: PropTypes.shape({
- /** policyID passed via route: /workspace/:policyID/members */
- policyID: PropTypes.string,
- }),
- }).isRequired,
-
+type WorkspaceMembersPageOnyxProps = {
+ /** Personal details of all users */
+ personalDetails: OnyxEntry;
/** Session info for the currently logged in user. */
- session: PropTypes.shape({
- /** Currently logged in user accountID */
- accountID: PropTypes.number,
- }),
-
- isLoadingReportData: PropTypes.bool,
- ...policyPropTypes,
- ...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
- ...withCurrentUserPersonalDetailsPropTypes,
- network: networkPropTypes.isRequired,
+ session: OnyxEntry;
};
-const defaultProps = {
- personalDetails: {},
- session: {
- accountID: 0,
- },
- isLoadingReportData: true,
- ...policyDefaultProps,
- ...withCurrentUserPersonalDetailsDefaultProps,
-};
+type WorkspaceMembersPageProps = WithPolicyAndFullscreenLoadingProps &
+ WithCurrentUserPersonalDetailsProps &
+ WorkspaceMembersPageOnyxProps &
+ StackScreenProps;
+
+/**
+ * Inverts an object, equivalent of _.invert
+ */
+function invertObject(object: Record): Record {
+ const invertedEntries = Object.entries(object).map(([key, value]) => [value, key]);
+ const inverted: Record = Object.fromEntries(invertedEntries);
+ return inverted;
+}
+
+type MemberOption = Omit & {accountID: number};
-function WorkspaceMembersPage(props) {
+function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, session, currentUserPersonalDetails, isLoadingReportData = true}: WorkspaceMembersPageProps) {
const styles = useThemeStyles();
- const [selectedEmployees, setSelectedEmployees] = useState([]);
+ const [selectedEmployees, setSelectedEmployees] = useState([]);
const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false);
const [errors, setErrors] = useState({});
const [searchValue, setSearchValue] = useState('');
- const prevIsOffline = usePrevious(props.network.isOffline);
- const accountIDs = useMemo(() => _.map(_.keys(props.policyMembers), (accountID) => Number(accountID)), [props.policyMembers]);
+ const {isOffline} = useNetwork();
+ const prevIsOffline = usePrevious(isOffline);
+ const accountIDs = useMemo(() => Object.keys(policyMembers ?? {}).map((accountID) => Number(accountID)), [policyMembers]);
const prevAccountIDs = usePrevious(accountIDs);
- const textInputRef = useRef(null);
- const isOfflineAndNoMemberDataAvailable = _.isEmpty(props.policyMembers) && props.network.isOffline;
- const prevPersonalDetails = usePrevious(props.personalDetails);
+ const textInputRef = useRef(null);
+ const isOfflineAndNoMemberDataAvailable = isEmptyObject(policyMembers) && isOffline;
+ const prevPersonalDetails = usePrevious(personalDetails);
+ const {translate, formatPhoneNumber, preferredLocale} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
const isFocusedScreen = useIsFocused();
@@ -98,51 +90,49 @@ function WorkspaceMembersPage(props) {
setSearchValue(SearchInputManager.searchInput);
}, [isFocusedScreen]);
- useEffect(() => () => (SearchInputManager.searchInput = ''), []);
+ useEffect(
+ () => () => {
+ SearchInputManager.searchInput = '';
+ },
+ [],
+ );
/**
* Get filtered personalDetails list with current policyMembers
- * @param {Object} policyMembers
- * @param {Object} personalDetails
- * @returns {Object}
*/
- const filterPersonalDetails = (policyMembers, personalDetails) =>
- _.reduce(
- _.keys(policyMembers),
- (result, key) => {
- if (personalDetails[key]) {
- return {
- ...result,
- [key]: personalDetails[key],
- };
- }
- return result;
- },
- {},
- );
+ const filterPersonalDetails = (members: OnyxEntry, details: OnyxEntry): PersonalDetailsList =>
+ Object.keys(members ?? {}).reduce((result, key) => {
+ if (details?.[key]) {
+ return {
+ ...result,
+ [key]: details[key],
+ };
+ }
+ return result;
+ }, {});
/**
* Get members for the current workspace
*/
const getWorkspaceMembers = useCallback(() => {
- Policy.openWorkspaceMembersPage(props.route.params.policyID, _.keys(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)));
- }, [props.route.params.policyID, props.policyMembers, props.personalDetails]);
+ Policy.openWorkspaceMembersPage(route.params.policyID, Object.keys(PolicyUtils.getMemberAccountIDsForWorkspace(policyMembers, personalDetails)));
+ }, [route.params.policyID, policyMembers, personalDetails]);
/**
* Check if the current selection includes members that cannot be removed
*/
const validateSelection = useCallback(() => {
- const newErrors = {};
- const ownerAccountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins(props.policy.owner ? [props.policy.owner] : []));
- _.each(selectedEmployees, (member) => {
- if (member !== ownerAccountID && member !== props.session.accountID) {
+ const newErrors: Errors = {};
+ const ownerAccountID = PersonalDetailsUtils.getAccountIDsByLogins(policy?.owner ? [policy.owner] : [])[0];
+ selectedEmployees.forEach((member) => {
+ if (member !== ownerAccountID && member !== session?.accountID) {
return;
}
- newErrors[member] = props.translate('workspace.people.error.cannotRemove');
+ newErrors[member] = translate('workspace.people.error.cannotRemove');
});
setErrors(newErrors);
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedEmployees, props.policy.owner, props.session.accountID]);
+ }, [selectedEmployees, policy?.owner, session?.accountID]);
useEffect(() => {
getWorkspaceMembers();
@@ -151,40 +141,43 @@ function WorkspaceMembersPage(props) {
useEffect(() => {
validateSelection();
- }, [props.preferredLocale, validateSelection]);
+ }, [preferredLocale, validateSelection]);
useEffect(() => {
- if (removeMembersConfirmModalVisible && !_.isEqual(accountIDs, prevAccountIDs)) {
+ if (removeMembersConfirmModalVisible && !lodashIsEqual(accountIDs, prevAccountIDs)) {
setRemoveMembersConfirmModalVisible(false);
}
setSelectedEmployees((prevSelected) => {
// Filter all personal details in order to use the elements needed for the current workspace
- const currentPersonalDetails = filterPersonalDetails(props.policyMembers, props.personalDetails);
+ const currentPersonalDetails = filterPersonalDetails(policyMembers, personalDetails);
// We need to filter the previous selected employees by the new personal details, since unknown/new user id's change when transitioning from offline to online
- const prevSelectedElements = _.map(prevSelected, (id) => {
- const prevItem = lodashGet(prevPersonalDetails, id);
- const res = _.find(_.values(currentPersonalDetails), (item) => lodashGet(prevItem, 'login') === lodashGet(item, 'login'));
- return lodashGet(res, 'accountID', id);
+ const prevSelectedElements = prevSelected.map((id) => {
+ const prevItem = prevPersonalDetails?.id;
+ const res = Object.values(currentPersonalDetails).find((item) => prevItem?.login === item?.login);
+ return res?.accountID ?? id;
});
- return _.intersection(prevSelectedElements, _.values(PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails)));
+ // This is an equivalent of the lodash intersection function. The reduce method below is used to filter the items that exist in both arrays.
+ return [prevSelectedElements, Object.values(PolicyUtils.getMemberAccountIDsForWorkspace(policyMembers, personalDetails))].reduce((prev, members) =>
+ prev.filter((item) => members.includes(item)),
+ );
});
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.policyMembers]);
+ }, [policyMembers]);
useEffect(() => {
- const isReconnecting = prevIsOffline && !props.network.isOffline;
+ const isReconnecting = prevIsOffline && !isOffline;
if (!isReconnecting) {
return;
}
getWorkspaceMembers();
- }, [props.network.isOffline, prevIsOffline, getWorkspaceMembers]);
+ }, [isOffline, prevIsOffline, getWorkspaceMembers]);
/**
* Open the modal to invite a user
*/
const inviteUser = () => {
setSearchValue('');
- Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(props.route.params.policyID));
+ Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID));
};
/**
@@ -192,14 +185,14 @@ function WorkspaceMembersPage(props) {
* Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details
*/
const removeUsers = () => {
- if (!_.isEmpty(errors)) {
+ if (!isEmptyObject(errors)) {
return;
}
// Remove the admin from the list
- const accountIDsToRemove = _.without(selectedEmployees, props.session.accountID);
+ const accountIDsToRemove = session?.accountID ? selectedEmployees.filter((id) => id !== session.accountID) : selectedEmployees;
- Policy.removeMembers(accountIDsToRemove, props.route.params.policyID);
+ Policy.removeMembers(accountIDsToRemove, route.params.policyID);
setSelectedEmployees([]);
setRemoveMembersConfirmModalVisible(false);
};
@@ -208,7 +201,7 @@ function WorkspaceMembersPage(props) {
* Show the modal to confirm removal of the selected members
*/
const askForConfirmationToRemove = () => {
- if (!_.isEmpty(errors)) {
+ if (!isEmptyObject(errors)) {
return;
}
setRemoveMembersConfirmModalVisible(true);
@@ -216,16 +209,15 @@ function WorkspaceMembersPage(props) {
/**
* Add or remove all users passed from the selectedEmployees list
- * @param {Object} memberList
*/
- const toggleAllUsers = (memberList) => {
- const enabledAccounts = _.filter(memberList, (member) => !member.isDisabled);
- const everyoneSelected = _.every(enabledAccounts, (member) => _.contains(selectedEmployees, member.accountID));
+ const toggleAllUsers = (memberList: MemberOption[]) => {
+ const enabledAccounts = memberList.filter((member) => !member.isDisabled);
+ const everyoneSelected = enabledAccounts.every((member) => selectedEmployees.includes(member.accountID));
if (everyoneSelected) {
setSelectedEmployees([]);
} else {
- const everyAccountId = _.map(enabledAccounts, (member) => member.accountID);
+ const everyAccountId = enabledAccounts.map((member) => member.accountID);
setSelectedEmployees(everyAccountId);
}
@@ -234,11 +226,9 @@ function WorkspaceMembersPage(props) {
/**
* Add user from the selectedEmployees list
- *
- * @param {String} login
*/
const addUser = useCallback(
- (accountID) => {
+ (accountID: number) => {
setSelectedEmployees((prevSelected) => [...prevSelected, accountID]);
validateSelection();
},
@@ -247,12 +237,10 @@ function WorkspaceMembersPage(props) {
/**
* Remove user from the selectedEmployees list
- *
- * @param {String} login
*/
const removeUser = useCallback(
- (accountID) => {
- setSelectedEmployees((prevSelected) => _.without(prevSelected, accountID));
+ (accountID: number) => {
+ setSelectedEmployees((prevSelected) => prevSelected.filter((id) => id !== accountID));
validateSelection();
},
[validateSelection],
@@ -260,19 +248,15 @@ function WorkspaceMembersPage(props) {
/**
* Toggle user from the selectedEmployees list
- *
- * @param {String} accountID
- * @param {String} pendingAction
- *
*/
const toggleUser = useCallback(
- (accountID, pendingAction) => {
+ (accountID: number, pendingAction?: PendingAction) => {
if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
return;
}
// Add or remove the user if the checkbox is enabled
- if (_.contains(selectedEmployees, accountID)) {
+ if (selectedEmployees.includes(accountID)) {
removeUser(accountID);
} else {
addUser(accountID);
@@ -283,42 +267,39 @@ function WorkspaceMembersPage(props) {
/**
* Dismisses the errors on one item
- *
- * @param {Object} item
*/
const dismissError = useCallback(
- (item) => {
+ (item: MemberOption) => {
if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- Policy.clearDeleteMemberError(props.route.params.policyID, item.accountID);
+ Policy.clearDeleteMemberError(route.params.policyID, item.accountID);
} else {
- Policy.clearAddMemberError(props.route.params.policyID, item.accountID);
+ Policy.clearAddMemberError(route.params.policyID, item.accountID);
}
},
- [props.route.params.policyID],
+ [route.params.policyID],
);
/**
* Check if the policy member is deleted from the workspace
- *
- * @param {Object} policyMember
- * @returns {Boolean}
*/
- const isDeletedPolicyMember = (policyMember) => !props.network.isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && _.isEmpty(policyMember.errors);
- const policyOwner = lodashGet(props.policy, 'owner');
- const currentUserLogin = lodashGet(props.currentUserPersonalDetails, 'login');
- const policyID = lodashGet(props.route, 'params.policyID');
- const invitedPrimaryToSecondaryLogins = _.invert(props.policy.primaryLoginsInvited);
+ const isDeletedPolicyMember = (policyMember: PolicyMember): boolean =>
+ !isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyMember.errors);
+ const policyOwner = policy?.owner;
+ const currentUserLogin = currentUserPersonalDetails.login;
+ const policyID = route.params.policyID;
+
+ const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {});
- const getMemberOptions = () => {
- let result = [];
+ const getUsers = (): MemberOption[] => {
+ let result: MemberOption[] = [];
- _.each(props.policyMembers, (policyMember, accountIDKey) => {
+ Object.entries(policyMembers ?? {}).forEach(([accountIDKey, policyMember]) => {
const accountID = Number(accountIDKey);
if (isDeletedPolicyMember(policyMember)) {
return;
}
- const details = props.personalDetails[accountID];
+ const details = personalDetails?.[accountID];
if (!details) {
Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
@@ -352,34 +333,34 @@ function WorkspaceMembersPage(props) {
// If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails
// We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they
// see random people added to their policy, but guides having access to the policies help set them up.
- if (PolicyUtils.isExpensifyTeam(details.login || details.displayName)) {
+ if (PolicyUtils.isExpensifyTeam(details?.login ?? details?.displayName)) {
if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) {
return;
}
}
- const isAdmin = props.session.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
+ const isAdmin = session?.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
result.push({
keyForList: accountIDKey,
accountID,
- isSelected: _.contains(selectedEmployees, accountID),
+ isSelected: selectedEmployees.includes(accountID),
isDisabled:
- accountID === props.session.accountID ||
- details.login === props.policy.owner ||
+ accountID === session?.accountID ||
+ details.login === policy?.owner ||
policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ||
- !_.isEmpty(policyMember.errors),
- text: props.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
- alternateText: props.formatPhoneNumber(details.login),
+ !isEmptyObject(policyMember.errors),
+ text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
+ alternateText: formatPhoneNumber(details?.login ?? ''),
rightElement: isAdmin ? (
- {props.translate('common.admin')}
+ {translate('common.admin')}
- ) : null,
+ ) : undefined,
icons: [
{
source: UserUtils.getAvatar(details.avatar, accountID),
- name: props.formatPhoneNumber(details.login),
+ name: formatPhoneNumber(details?.login ?? ''),
type: CONST.ICON_TYPE_AVATAR,
id: accountID,
},
@@ -388,31 +369,32 @@ function WorkspaceMembersPage(props) {
pendingAction: policyMember.pendingAction,
// Note which secondary login was used to invite this primary login
- invitedSecondaryLogin: invitedPrimaryToSecondaryLogins[details.login] || '',
+ invitedSecondaryLogin: details?.login ? invitedPrimaryToSecondaryLogins[details.login] ?? '' : '',
});
});
- result = _.sortBy(result, (value) => value.text.toLowerCase());
+ result = result.sort((a, b) => a.text.toLowerCase().localeCompare(b.text.toLowerCase()));
return result;
};
- const data = getMemberOptions();
+ const data = getUsers();
const getHeaderMessage = () => {
if (isOfflineAndNoMemberDataAvailable) {
- return props.translate('workspace.common.mustBeOnlineToViewMembers');
+ return translate('workspace.common.mustBeOnlineToViewMembers');
}
- return searchValue.trim() && !data.length ? props.translate('workspace.common.memberNotFound') : '';
+ return searchValue.trim() && !data.length ? translate('workspace.common.memberNotFound') : '';
};
const getHeaderContent = () => {
- if (_.isEmpty(invitedPrimaryToSecondaryLogins)) {
+ if (isEmptyObject(invitedPrimaryToSecondaryLogins)) {
return null;
}
return (
Policy.dismissAddedWithPrimaryLoginMessages(policyID)}
/>
@@ -425,7 +407,7 @@ function WorkspaceMembersPage(props) {
medium
success
onPress={inviteUser}
- text={props.translate('workspace.invite.member')}
+ text={translate('workspace.invite.member')}
icon={Expensicons.Plus}
iconStyles={{transform: [{scale: 0.6}]}}
innerStyles={[isSmallScreenWidth && styles.alignItemsCenter]}
@@ -436,7 +418,7 @@ function WorkspaceMembersPage(props) {
danger
style={[styles.ml2, isSmallScreenWidth && styles.w50]}
isDisabled={selectedEmployees.length === 0}
- text={props.translate('common.remove')}
+ text={translate('common.remove')}
onPress={askForConfirmationToRemove}
/>
@@ -450,12 +432,12 @@ function WorkspaceMembersPage(props) {
shouldShowOfflineIndicatorInWideScreen
>
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
>
{
setSearchValue('');
@@ -469,28 +451,28 @@ function WorkspaceMembersPage(props) {
{isSmallScreenWidth && {getHeaderButtons()}}
setRemoveMembersConfirmModalVisible(false)}
- prompt={props.translate('workspace.people.removeMembersPrompt')}
- confirmText={props.translate('common.remove')}
- cancelText={props.translate('common.cancel')}
- onModalHide={() =>
+ prompt={translate('workspace.people.removeMembersPrompt')}
+ confirmText={translate('common.remove')}
+ cancelText={translate('common.cancel')}
+ onModalHide={() => {
InteractionManager.runAfterInteractions(() => {
if (!textInputRef.current) {
return;
}
textInputRef.current.focus();
- })
- }
+ });
+ }}
/>
{
SearchInputManager.searchInput = value;
@@ -502,10 +484,10 @@ function WorkspaceMembersPage(props) {
onSelectRow={(item) => toggleUser(item.accountID)}
onSelectAll={() => toggleAllUsers(data)}
onDismissError={dismissError}
- showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(props.personalDetails) || _.isEmpty(props.policyMembers))}
+ showLoadingPlaceholder={!isOfflineAndNoMemberDataAvailable && (!OptionsListUtils.isPersonalDetailsReady(personalDetails) || isEmptyObject(policyMembers))}
showScrollIndicator
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
- inputRef={textInputRef}
+ ref={textInputRef}
/>
@@ -513,25 +495,17 @@ function WorkspaceMembersPage(props) {
);
}
-WorkspaceMembersPage.propTypes = propTypes;
-WorkspaceMembersPage.defaultProps = defaultProps;
WorkspaceMembersPage.displayName = 'WorkspaceMembersPage';
-export default compose(
- withLocalize,
- withWindowDimensions,
- withPolicyAndFullscreenLoading,
- withNetwork(),
- withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- isLoadingReportData: {
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- },
- }),
- withCurrentUserPersonalDetails,
-)(WorkspaceMembersPage);
+export default withCurrentUserPersonalDetails(
+ withPolicyAndFullscreenLoading(
+ withOnyx({
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(WorkspaceMembersPage),
+ ),
+);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.tsx
similarity index 71%
rename from src/pages/workspace/WorkspaceNewRoomPage.js
rename to src/pages/workspace/WorkspaceNewRoomPage.tsx
index 36f874e8919d..73107d7e3eba 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx
@@ -1,12 +1,14 @@
-import PropTypes from 'prop-types';
+import {useIsFocused} from '@react-navigation/core';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import BlockingView from '@components/BlockingViews/BlockingView';
import Button from '@components/Button';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
+import type {FormOnyxValues} from '@components/Form/types';
import * as Illustrations from '@components/Icon/Illustrations';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
@@ -14,16 +16,13 @@ import RoomNameInput from '@components/RoomNameInput';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';
import ValuePicker from '@components/ValuePicker';
-import withNavigationFocus from '@components/withNavigationFocus';
import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePrevious from '@hooks/usePrevious';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
-import {translatableTextPropTypes} from '@libs/Localize';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
@@ -33,93 +32,57 @@ import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import type {NewRoomForm} from '@src/types/form/NewRoomForm';
import INPUT_IDS from '@src/types/form/NewRoomForm';
+import type {Account, Policy, Report as ReportType, Session} from '@src/types/onyx';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
-const propTypes = {
- /** All reports shared with the user */
- reports: PropTypes.shape({
- /** The report name */
- reportName: PropTypes.string,
-
- /** The report type */
- type: PropTypes.string,
-
- /** ID of the policy */
- policyID: PropTypes.string,
- }),
-
+type WorkspaceNewRoomPageOnyxProps = {
/** The list of policies the user has access to. */
- policies: PropTypes.objectOf(
- PropTypes.shape({
- /** The policy type */
- type: PropTypes.oneOf(_.values(CONST.POLICY.TYPE)),
+ policies: OnyxCollection;
- /** The name of the policy */
- name: PropTypes.string,
-
- /** The ID of the policy */
- id: PropTypes.string,
- }),
- ),
-
- /** Whether navigation is focused */
- isFocused: PropTypes.bool.isRequired,
+ /** All reports shared with the user */
+ reports: OnyxCollection;
/** Form state for NEW_ROOM_FORM */
- formState: PropTypes.shape({
- /** Loading state for the form */
- isLoading: PropTypes.bool,
-
- /** Field errors in the form */
- errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)),
- }),
+ formState: OnyxEntry;
/** Session details for the user */
- session: PropTypes.shape({
- /** accountID of current user */
- accountID: PropTypes.number,
- }),
+ session: OnyxEntry;
/** policyID for main workspace */
- activePolicyID: PropTypes.string,
-};
-const defaultProps = {
- reports: {},
- policies: {},
- formState: {
- isLoading: false,
- errorFields: {},
- },
- session: {
- accountID: 0,
- },
- activePolicyID: null,
+ activePolicyID: OnyxEntry['activePolicyID']>;
};
-function WorkspaceNewRoomPage(props) {
+type WorkspaceNewRoomPageProps = WorkspaceNewRoomPageOnyxProps;
+
+function WorkspaceNewRoomPage({policies, reports, formState, session, activePolicyID}: WorkspaceNewRoomPageProps) {
const styles = useThemeStyles();
+ const isFocused = useIsFocused();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();
- const [visibility, setVisibility] = useState(CONST.REPORT.VISIBILITY.RESTRICTED);
- const [writeCapability, setWriteCapability] = useState(CONST.REPORT.WRITE_CAPABILITIES.ALL);
- const wasLoading = usePrevious(props.formState.isLoading);
+ const [visibility, setVisibility] = useState>(CONST.REPORT.VISIBILITY.RESTRICTED);
+ const [writeCapability, setWriteCapability] = useState>(CONST.REPORT.WRITE_CAPABILITIES.ALL);
+ const wasLoading = usePrevious(!!formState?.isLoading);
const visibilityDescription = useMemo(() => translate(`newRoomPage.${visibility}Description`), [translate, visibility]);
+ const {isLoading = false, errorFields = {}} = formState ?? {};
const workspaceOptions = useMemo(
() =>
- _.map(
- _.filter(PolicyUtils.getActivePolicies(props.policies), (policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL),
- (policy) => ({
+ PolicyUtils.getActivePolicies(policies)
+ ?.filter((policy) => policy.type !== CONST.POLICY.TYPE.PERSONAL)
+ .map((policy) => ({
label: policy.name,
value: policy.id,
- }),
- ).sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())),
- [props.policies],
+ }))
+ .sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())) ?? [],
+ [policies],
);
- const [policyID, setPolicyID] = useState(() => {
- if (_.some(workspaceOptions, (option) => option.value === props.activePolicyID)) {
- return props.activePolicyID;
+ const [policyID, setPolicyID] = useState(() => {
+ if (!!activePolicyID && workspaceOptions.some((option) => option.value === activePolicyID)) {
+ return activePolicyID;
}
return '';
});
@@ -128,16 +91,16 @@ function WorkspaceNewRoomPage(props) {
return false;
}
- return ReportUtils.isPolicyAdmin(policyID, props.policies);
- }, [policyID, props.policies]);
- const [newRoomReportID, setNewRoomReportID] = useState(undefined);
+ return ReportUtils.isPolicyAdmin(policyID, policies);
+ }, [policyID, policies]);
+ const [newRoomReportID, setNewRoomReportID] = useState();
/**
- * @param {Object} values - form input values passed by the Form component
+ * @param values - form input values passed by the Form component
*/
- const submit = (values) => {
- const participants = [props.session.accountID];
- const parsedDescription = ReportUtils.getParsedComment(values.reportDescription);
+ const submit = (values: FormOnyxValues) => {
+ const participants = [session?.accountID ?? 0];
+ const parsedDescription = ReportUtils.getParsedComment(values.reportDescription ?? '');
const policyReport = ReportUtils.buildOptimisticChatReport(
participants,
values.roomName,
@@ -163,25 +126,25 @@ function WorkspaceNewRoomPage(props) {
useEffect(() => {
if (policyID) {
- if (!_.some(workspaceOptions, (opt) => opt.value === policyID)) {
+ if (!workspaceOptions.some((opt) => opt.value === policyID)) {
setPolicyID('');
}
return;
}
- if (_.some(workspaceOptions, (opt) => opt.value === props.activePolicyID)) {
- setPolicyID(props.activePolicyID);
+ if (!!activePolicyID && workspaceOptions.some((opt) => opt.value === activePolicyID)) {
+ setPolicyID(activePolicyID);
} else {
setPolicyID('');
}
- }, [props.activePolicyID, policyID, workspaceOptions]);
+ }, [activePolicyID, policyID, workspaceOptions]);
useEffect(() => {
- if (!(((wasLoading && !props.formState.isLoading) || (isOffline && props.formState.isLoading)) && _.isEmpty(props.formState.errorFields))) {
+ if (!(((wasLoading && !isLoading) || (isOffline && isLoading)) && isEmptyObject(errorFields))) {
return;
}
Navigation.dismissModal(newRoomReportID);
// eslint-disable-next-line react-hooks/exhaustive-deps -- we just want this to update on changing the form State
- }, [props.formState]);
+ }, [isLoading, errorFields]);
useEffect(() => {
if (isPolicyAdmin) {
@@ -192,12 +155,12 @@ function WorkspaceNewRoomPage(props) {
}, [isPolicyAdmin]);
/**
- * @param {Object} values - form input values passed by the Form component
- * @returns {Boolean}
+ * @param values - form input values passed by the Form component
+ * @returns an object containing validation errors, if any were found during validation
*/
const validate = useCallback(
- (values) => {
- const errors = {};
+ (values: FormOnyxValues): OnyxCommon.Errors => {
+ const errors: {policyID?: string; roomName?: string} = {};
if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) {
// We error if the user doesn't enter a room name or left blank
@@ -208,7 +171,7 @@ function WorkspaceNewRoomPage(props) {
} else if (ValidationUtils.isReservedRoomName(values.roomName)) {
// Certain names are reserved for default rooms and should not be used for policy rooms.
ErrorUtils.addErrorMessage(errors, 'roomName', ['newRoomPage.roomNameReservedError', {reservedName: values.roomName}]);
- } else if (ValidationUtils.isExistingRoomName(values.roomName, props.reports, values.policyID)) {
+ } else if (ValidationUtils.isExistingRoomName(values.roomName, reports, values.policyID ?? '')) {
// Certain names are reserved for default rooms and should not be used for policy rooms.
ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError');
} else if (values.roomName.length > CONST.TITLE_CHARACTER_LIMIT) {
@@ -221,12 +184,12 @@ function WorkspaceNewRoomPage(props) {
return errors;
},
- [props.reports],
+ [reports],
);
const writeCapabilityOptions = useMemo(
() =>
- _.map(CONST.REPORT.WRITE_CAPABILITIES, (value) => ({
+ Object.values(CONST.REPORT.WRITE_CAPABILITIES).map((value) => ({
value,
label: translate(`writeCapabilityPage.writeCapability.${value}`),
})),
@@ -235,14 +198,13 @@ function WorkspaceNewRoomPage(props) {
const visibilityOptions = useMemo(
() =>
- _.map(
- _.filter(_.values(CONST.REPORT.VISIBILITY), (visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE),
- (visibilityOption) => ({
+ Object.values(CONST.REPORT.VISIBILITY)
+ .filter((visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE)
+ .map((visibilityOption) => ({
label: translate(`newRoomPage.visibilityOptions.${visibilityOption}`),
value: visibilityOption,
description: translate(`newRoomPage.${visibilityOption}Description`),
- }),
- ),
+ })),
[translate],
);
@@ -302,7 +264,8 @@ function WorkspaceNewRoomPage(props) {
InputComponent={RoomNameInput}
ref={inputCallbackRef}
inputID={INPUT_IDS.ROOM_NAME}
- isFocused={props.isFocused}
+ isFocused={isFocused}
+ // @ts-expect-error TODO: Remove this once RoomNameInput (https://github.com/Expensify/App/issues/25090) is migrated to TypeScript.
shouldDelayFocus
autoFocus
/>
@@ -313,7 +276,7 @@ function WorkspaceNewRoomPage(props) {
inputID={INPUT_IDS.REPORT_DESCRIPTION}
label={translate('reportDescriptionPage.roomDescriptionOptional')}
accessibilityLabel={translate('reportDescriptionPage.roomDescriptionOptional')}
- role={CONST.ACCESSIBILITY_ROLE.TEXT}
+ role={CONST.ROLE.PRESENTATION}
autoGrowHeight
maxLength={CONST.REPORT_DESCRIPTION.MAX_LENGTH}
autoCapitalize="none"
@@ -327,7 +290,7 @@ function WorkspaceNewRoomPage(props) {
label={translate('workspace.common.workspace')}
items={workspaceOptions}
value={policyID}
- onValueChange={setPolicyID}
+ onValueChange={(value) => setPolicyID(value as typeof policyID)}
/>
{isPolicyAdmin && (
@@ -338,7 +301,7 @@ function WorkspaceNewRoomPage(props) {
label={translate('writeCapabilityPage.label')}
items={writeCapabilityOptions}
value={writeCapability}
- onValueChange={setWriteCapability}
+ onValueChange={(value) => setWriteCapability(value as typeof writeCapability)}
/>
)}
@@ -348,7 +311,7 @@ function WorkspaceNewRoomPage(props) {
inputID={INPUT_IDS.VISIBILITY}
label={translate('newRoomPage.visibility')}
items={visibilityOptions}
- onValueChange={setVisibility}
+ onValueChange={(value) => setVisibility(value as typeof visibility)}
value={visibility}
furtherDetails={visibilityDescription}
shouldShowTooltips={false}
@@ -363,32 +326,24 @@ function WorkspaceNewRoomPage(props) {
);
}
-WorkspaceNewRoomPage.propTypes = propTypes;
-WorkspaceNewRoomPage.defaultProps = defaultProps;
WorkspaceNewRoomPage.displayName = 'WorkspaceNewRoomPage';
-export default compose(
- withNavigationFocus,
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- formState: {
- key: ONYXKEYS.FORMS.NEW_ROOM_FORM,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- activePolicyID: {
- key: ONYXKEYS.ACCOUNT,
- selector: (account) => (account && account.activePolicyID) || null,
- initialValue: null,
- },
- }),
-)(WorkspaceNewRoomPage);
+export default withOnyx({
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ formState: {
+ key: ONYXKEYS.FORMS.NEW_ROOM_FORM,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ activePolicyID: {
+ key: ONYXKEYS.ACCOUNT,
+ selector: (account) => account?.activePolicyID ?? null,
+ initialValue: null,
+ },
+})(WorkspaceNewRoomPage);
diff --git a/src/pages/workspace/WorkspaceProfileCurrencyPage.js b/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
similarity index 53%
rename from src/pages/workspace/WorkspaceProfileCurrencyPage.js
rename to src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
index bd13ce4687f5..e5824ef8a9f9 100644
--- a/src/pages/workspace/WorkspaceProfileCurrencyPage.js
+++ b/src/pages/workspace/WorkspaceProfileCurrencyPage.tsx
@@ -1,65 +1,62 @@
-import PropTypes from 'prop-types';
import React, {useState} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import RadioListItem from '@components/SelectionList/RadioListItem';
import useLocalize from '@hooks/useLocalize';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as Policy from '@userActions/Policy';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import {policyDefaultProps, policyPropTypes} from './withPolicy';
+import type {CurrencyList} from '@src/types/onyx';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading';
import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading';
-const propTypes = {
+type WorkspaceProfileCurrentPageOnyxProps = {
/** Constant, list of available currencies */
- currencyList: PropTypes.objectOf(
- PropTypes.shape({
- /** Symbol of the currency */
- symbol: PropTypes.string.isRequired,
- }),
- ),
- isLoadingReportData: PropTypes.bool,
- ...policyPropTypes,
+ currencyList: OnyxEntry;
};
-const defaultProps = {
- currencyList: {},
- isLoadingReportData: true,
- ...policyDefaultProps,
+type WorkspaceProfileCurrentPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceProfileCurrentPageOnyxProps;
+
+type WorkspaceProfileCurrencyPageSectionItem = {
+ text: string;
+ keyForList: string;
+ isSelected: boolean;
};
-const getDisplayText = (currencyCode, currencySymbol) => `${currencyCode} - ${currencySymbol}`;
+const getDisplayText = (currencyCode: string, currencySymbol: string) => `${currencyCode} - ${currencySymbol}`;
-function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportData}) {
+function WorkspaceProfileCurrencyPage({currencyList = {}, policy, isLoadingReportData = true}: WorkspaceProfileCurrentPageProps) {
const {translate} = useLocalize();
const [searchText, setSearchText] = useState('');
const trimmedText = searchText.trim().toLowerCase();
- const currencyListKeys = _.keys(currencyList);
+ const currencyListKeys = Object.keys(currencyList ?? {});
- const filteredItems = _.filter(currencyListKeys, (currencyCode) => {
- const currency = currencyList[currencyCode];
- return getDisplayText(currencyCode, currency.symbol).toLowerCase().includes(trimmedText);
+ const filteredItems = currencyListKeys.filter((currencyCode: string) => {
+ const currency = currencyList?.[currencyCode];
+ return getDisplayText(currencyCode, currency?.symbol ?? '')
+ .toLowerCase()
+ .includes(trimmedText);
});
let initiallyFocusedOptionKey;
- const currencyItems = _.map(filteredItems, (currencyCode) => {
- const currency = currencyList[currencyCode];
- const isSelected = policy.outputCurrency === currencyCode;
+ const currencyItems: WorkspaceProfileCurrencyPageSectionItem[] = filteredItems.map((currencyCode: string) => {
+ const currency = currencyList?.[currencyCode];
+ const isSelected = policy?.outputCurrency === currencyCode;
if (isSelected) {
initiallyFocusedOptionKey = currencyCode;
}
return {
- text: getDisplayText(currencyCode, currency.symbol),
+ text: getDisplayText(currencyCode, currency?.symbol ?? ''),
keyForList: currencyCode,
isSelected,
};
@@ -69,20 +66,20 @@ function WorkspaceSettingsCurrencyPage({currencyList, policy, isLoadingReportDat
const headerMessage = searchText.trim() && !currencyItems.length ? translate('common.noResultsFound') : '';
- const onSelectCurrency = (item) => {
- Policy.updateGeneralSettings(policy.id, policy.name, item.keyForList);
+ const onSelectCurrency = (item: WorkspaceProfileCurrencyPageSectionItem) => {
+ Policy.updateGeneralSettings(policy?.id ?? '', policy?.name ?? '', item.keyForList);
Navigation.goBack();
};
return (
Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
- shouldShow={(_.isEmpty(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
- subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'}
+ shouldShow={(isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
+ subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'}
>
({
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
- }),
-)(WorkspaceSettingsCurrencyPage);
+ })(WorkspaceProfileCurrencyPage),
+);
diff --git a/src/pages/workspace/WorkspaceProfilePage.js b/src/pages/workspace/WorkspaceProfilePage.tsx
similarity index 67%
rename from src/pages/workspace/WorkspaceProfilePage.js
rename to src/pages/workspace/WorkspaceProfilePage.tsx
index c91f7ed8fb44..4ce5822dfaa0 100644
--- a/src/pages/workspace/WorkspaceProfilePage.js
+++ b/src/pages/workspace/WorkspaceProfilePage.tsx
@@ -1,12 +1,12 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
+import type {ImageStyle, StyleProp} from 'react-native';
import {Image, ScrollView, StyleSheet, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import WorkspaceProfile from '@assets/images/workspace-profile.png';
import Avatar from '@components/Avatar';
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
+import Button from '@components/Button';
import * as Expensicons from '@components/Icon/Expensicons';
import * as Illustrations from '@components/Icon/Illustrations';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
@@ -16,59 +16,47 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import StringUtils from '@libs/StringUtils';
import * as UserUtils from '@libs/UserUtils';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import withPolicy, {policyDefaultProps, policyPropTypes} from './withPolicy';
+import type {CurrencyList} from '@src/types/onyx';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import withPolicy from './withPolicy';
+import type {WithPolicyProps} from './withPolicy';
import WorkspacePageWithSections from './WorkspacePageWithSections';
-const propTypes = {
+type WorkSpaceProfilePageOnyxProps = {
/** Constant, list of available currencies */
- currencyList: PropTypes.objectOf(
- PropTypes.shape({
- /** Symbol of the currency */
- symbol: PropTypes.string.isRequired,
- }),
- ),
-
- /** The route object passed to this page from the navigator */
- route: PropTypes.shape({
- /** Each parameter passed via the URL */
- params: PropTypes.shape({
- /** The policyID that is being configured */
- policyID: PropTypes.string.isRequired,
- }).isRequired,
- }).isRequired,
-
- ...policyPropTypes,
+ currencyList: OnyxEntry;
};
-const defaultProps = {
- currencyList: {},
- ...policyDefaultProps,
-};
+type WorkSpaceProfilePageProps = WithPolicyProps & WorkSpaceProfilePageOnyxProps;
-function WorkspaceProfilePage({policy, currencyList, route}) {
+function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfilePageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
- const formattedCurrency = !_.isEmpty(policy) && !_.isEmpty(currencyList) && !!policy.outputCurrency ? `${policy.outputCurrency} - ${currencyList[policy.outputCurrency].symbol}` : '';
+ const outputCurrency = policy?.outputCurrency ?? '';
+ const currencySymbol = currencyList?.[outputCurrency]?.symbol ?? '';
+ const formattedCurrency = !isEmptyObject(policy) && !isEmptyObject(currencyList) ? `${outputCurrency} - ${currencySymbol}` : '';
- const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy.id)), [policy.id]);
- const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy.id)), [policy.id]);
- const onPressDescription = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy.id)), [policy.id]);
+ const onPressCurrency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_CURRENCY.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressName = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_NAME.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressDescription = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id ?? '')), [policy?.id]);
+ const onPressShare = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_PROFILE_SHARE.getRoute(policy?.id ?? '')), [policy?.id]);
- const policyName = lodashGet(policy, 'name', '');
- const policyDescription = lodashGet(policy, 'description', '');
+ const policyName = policy?.name ?? '';
+ const policyDescription = policy?.description ?? '';
const readOnly = !PolicyUtils.isPolicyAdmin(policy);
- const imageStyle = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
+ const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
return (
- {(hasVBA) => (
+ {(hasVBA?: boolean) => (
-
+
Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy.id))}
- source={lodashGet(policy, 'avatar')}
+ onViewPhotoPress={() => Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy?.id ?? ''))}
+ source={policy?.avatar ?? ''}
size={CONST.AVATAR_SIZE.XLARGE}
avatarStyle={styles.avatarXLarge}
enablePreview
@@ -100,7 +91,7 @@ function WorkspaceProfilePage({policy, currencyList, route}) {
Policy.updateWorkspaceAvatar(lodashGet(policy, 'id', ''), file)}
- onImageRemoved={() => Policy.deleteWorkspaceAvatar(lodashGet(policy, 'id', ''))}
+ isUsingDefaultAvatar={!policy?.avatar ?? null}
+ onImageSelected={(file: File) => Policy.updateWorkspaceAvatar(policy?.id ?? '', file)}
+ onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')}
editorMaskImage={Expensicons.ImageCropSquareMask}
- pendingAction={lodashGet(policy, 'pendingFields.avatar', null)}
- errors={lodashGet(policy, 'errorFields.avatar', null)}
- onErrorClose={() => Policy.clearAvatarErrors(policy.id)}
- previewSource={UserUtils.getFullSizeAvatar(policy.avatar, '')}
+ pendingAction={policy?.pendingFields?.avatar ?? null}
+ errors={policy?.errorFields?.avatar ?? null}
+ onErrorClose={() => Policy.clearAvatarErrors(policy?.id ?? '')}
+ previewSource={UserUtils.getFullSizeAvatar(policy?.avatar ?? '')}
headerTitle={translate('workspace.common.workspaceAvatar')}
- originalFileName={policy.originalFileName}
+ originalFileName={policy?.originalFileName}
disabled={readOnly}
disabledStyle={styles.cursorDefault}
+ errorRowStyles={undefined}
/>
-
+
- {(!_.isEmpty(policy.description) || !readOnly) && (
-
+ {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && (
+
)}
-
+
+ {!readOnly && (
+
+
+
+ )}
@@ -176,13 +179,10 @@ function WorkspaceProfilePage({policy, currencyList, route}) {
);
}
-WorkspaceProfilePage.propTypes = propTypes;
-WorkspaceProfilePage.defaultProps = defaultProps;
WorkspaceProfilePage.displayName = 'WorkspaceProfilePage';
-export default compose(
- withPolicy,
- withOnyx({
+export default withPolicy(
+ withOnyx({
currencyList: {key: ONYXKEYS.CURRENCY_LIST},
- }),
-)(WorkspaceProfilePage);
+ })(WorkspaceProfilePage),
+);
diff --git a/src/pages/workspace/WorkspaceProfileSharePage.tsx b/src/pages/workspace/WorkspaceProfileSharePage.tsx
new file mode 100644
index 000000000000..dd03436042ca
--- /dev/null
+++ b/src/pages/workspace/WorkspaceProfileSharePage.tsx
@@ -0,0 +1,88 @@
+import React, {useRef} from 'react';
+import {ScrollView, View} from 'react-native';
+import type {ImageSourcePropType} from 'react-native';
+import expensifyLogo from '@assets/images/expensify-logo-round-transparent.png';
+import ContextMenuItem from '@components/ContextMenuItem';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItem from '@components/MenuItem';
+import QRShareWithDownload from '@components/QRShare/QRShareWithDownload';
+import type QRShareWithDownloadHandle from '@components/QRShare/QRShareWithDownload/types';
+import ScreenWrapper from '@components/ScreenWrapper';
+import useEnvironment from '@hooks/useEnvironment';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Clipboard from '@libs/Clipboard';
+import Navigation from '@libs/Navigation/Navigation';
+import shouldAllowDownloadQRCode from '@libs/shouldAllowDownloadQRCode';
+import * as Url from '@libs/Url';
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import withPolicy from './withPolicy';
+import type {WithPolicyProps} from './withPolicy';
+
+function WorkspaceProfileSharePage({policy}: WithPolicyProps) {
+ const themeStyles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {environmentURL} = useEnvironment();
+ const qrCodeRef = useRef(null);
+ const {isSmallScreenWidth} = useWindowDimensions();
+
+ const policyName = policy?.name ?? '';
+ const id = policy?.id ?? '';
+ const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL);
+
+ const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_PROFILE.getRoute(id)}`;
+ return (
+
+
+
+
+
+
+
+
+
+ Clipboard.setString(url)}
+ shouldLimitWidth={false}
+ wrapperStyle={themeStyles.sectionMenuItemTopDescription}
+ />
+ {shouldAllowDownloadQRCode && (
+
+
+
+
+ );
+}
+
+WorkspaceProfileSharePage.displayName = 'WorkspaceProfileSharePage';
+
+export default withPolicy(WorkspaceProfileSharePage);
diff --git a/src/setup/index.ts b/src/setup/index.ts
index fe9d80ec5fb1..285b01792cc6 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -34,7 +34,7 @@ export default function () {
// Clear any loading and error messages so they do not appear on app startup
[ONYXKEYS.SESSION]: {loading: false},
[ONYXKEYS.ACCOUNT]: CONST.DEFAULT_ACCOUNT_DATA,
- [ONYXKEYS.NETWORK]: {isOffline: false},
+ [ONYXKEYS.NETWORK]: CONST.DEFAULT_NETWORK_DATA,
[ONYXKEYS.IS_SIDEBAR_LOADED]: false,
[ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT]: true,
[ONYXKEYS.MODAL]: {
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 69e74bb54e63..833907549133 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1227,7 +1227,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
/**
* Returns link styles based on whether the link is disabled or not
*/
- getDisabledLinkStyles: (isDisabled = false): ViewStyle => {
+ getDisabledLinkStyles: (isDisabled = false): TextStyle => {
const disabledLinkStyles = {
color: theme.textSupporting,
...styles.cursorDisabled,
diff --git a/src/types/onyx/Currency.ts b/src/types/onyx/Currency.ts
index a4767403381f..b8d6f8dda88b 100644
--- a/src/types/onyx/Currency.ts
+++ b/src/types/onyx/Currency.ts
@@ -21,4 +21,7 @@ type Currency = {
cacheBurst?: number;
};
+type CurrencyList = Record;
+
export default Currency;
+export type {CurrencyList};
diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts
index 32b084bbf2f7..173ca486b53c 100644
--- a/src/types/onyx/Network.ts
+++ b/src/types/onyx/Network.ts
@@ -1,6 +1,6 @@
type Network = {
/** Is the network currently offline or not */
- isOffline?: boolean;
+ isOffline: boolean;
/** Should the network be forced offline */
shouldForceOffline?: boolean;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 1b2ecdbdce12..c24e0871e0ed 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -9,6 +9,7 @@ import type {CardList} from './Card';
import type Log from './Console';
import type Credentials from './Credentials';
import type Currency from './Currency';
+import type {CurrencyList} from './Currency';
import type CustomStatusDraft from './CustomStatusDraft';
import type Download from './Download';
import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji';
@@ -81,6 +82,7 @@ export type {
CardList,
Credentials,
Currency,
+ CurrencyList,
CustomStatusDraft,
Download,
FrequentlyUsedEmoji,
diff --git a/tests/e2e/compare/math.js b/tests/e2e/compare/math.ts
similarity index 83%
rename from tests/e2e/compare/math.js
rename to tests/e2e/compare/math.ts
index a87c58c4dff3..59a56dd3c842 100644
--- a/tests/e2e/compare/math.js
+++ b/tests/e2e/compare/math.ts
@@ -7,13 +7,8 @@
*
* Based on :: https://github.com/v8/v8/blob/master/test/benchmarks/csuite/compare-baseline.py
*
- * @param {Number} baselineMean
- * @param {Number} baselineStdev
- * @param {Number} currentMean
- * @param {Number} runs
- * @returns {Number}
*/
-const computeZ = (baselineMean, baselineStdev, currentMean, runs) => {
+const computeZ = (baselineMean: number, baselineStdev: number, currentMean: number, runs: number): number => {
if (baselineStdev === 0) {
return 1000;
}
@@ -26,10 +21,8 @@ const computeZ = (baselineMean, baselineStdev, currentMean, runs) => {
*
* Based on :: https://github.com/v8/v8/blob/master/test/benchmarks/csuite/compare-baseline.py
*
- * @param {Number} z
- * @returns {Number}
*/
-const computeProbability = (z) => {
+const computeProbability = (z: number): number => {
// p 0.005: two sided < 0.01
if (z > 2.575829) {
return 0;
diff --git a/tests/e2e/measure/math.js b/tests/e2e/measure/math.ts
similarity index 64%
rename from tests/e2e/measure/math.js
rename to tests/e2e/measure/math.ts
index 14f75a7f980e..e1c0cb981a0c 100644
--- a/tests/e2e/measure/math.js
+++ b/tests/e2e/measure/math.ts
@@ -1,6 +1,13 @@
-import _ from 'underscore';
+type Entries = number[];
-const filterOutliersViaIQR = (data) => {
+type Stats = {
+ mean: number;
+ stdev: number;
+ runs: number;
+ entries: Entries;
+};
+
+const filterOutliersViaIQR = (data: Entries): Entries => {
let q1;
let q3;
@@ -18,22 +25,17 @@ const filterOutliersViaIQR = (data) => {
const maxValue = q3 + iqr * 1.5;
const minValue = q1 - iqr * 1.5;
- return _.filter(values, (x) => x >= minValue && x <= maxValue);
+ return values.filter((x) => x >= minValue && x <= maxValue);
};
-const mean = (arr) => _.reduce(arr, (a, b) => a + b, 0) / arr.length;
+const mean = (arr: Entries): number => arr.reduce((a, b) => a + b, 0) / arr.length;
-const std = (arr) => {
+const std = (arr: Entries): number => {
const avg = mean(arr);
- return Math.sqrt(
- _.reduce(
- _.map(arr, (i) => (i - avg) ** 2),
- (a, b) => a + b,
- ) / arr.length,
- );
+ return Math.sqrt(arr.map((i) => (i - avg) ** 2).reduce((a, b) => a + b) / arr.length);
};
-const getStats = (entries) => {
+const getStats = (entries: Entries): Stats => {
const cleanedEntries = filterOutliersViaIQR(entries);
const meanDuration = mean(cleanedEntries);
const stdevDuration = std(cleanedEntries);
@@ -46,5 +48,4 @@ const getStats = (entries) => {
};
};
-// eslint-disable-next-line import/prefer-default-export
export default getStats;
diff --git a/tests/e2e/measure/writeTestStats.js b/tests/e2e/measure/writeTestStats.js
deleted file mode 100644
index 6de9dcc79db4..000000000000
--- a/tests/e2e/measure/writeTestStats.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import fs from 'fs';
-import config from '../config';
-
-/**
- * Writes the results of `getStats` to the {@link OUTPUT_FILE_CURRENT} file.
- *
- * @param {Object} stats
- * @param {string} stats.name - The name for the test, used in outputs.
- * @param {number} stats.mean - The average time for the test to run.
- * @param {number} stats.stdev - The standard deviation of the test.
- * @param {number} stats.entries - The data points
- * @param {number} stats.runs - The number of times the test was run.
- * @param {string} [path] - The path to write to. Defaults to {@link OUTPUT_FILE_CURRENT}.
- */
-export default (stats, path = config.OUTPUT_FILE_CURRENT) => {
- if (!stats.name || stats.mean == null || stats.stdev == null || !stats.entries || !stats.runs) {
- throw new Error(`Invalid stats object:\n${JSON.stringify(stats, null, 2)}\n\n`);
- }
-
- if (!fs.existsSync(path)) {
- fs.writeFileSync(path, '[]');
- }
-
- try {
- const content = JSON.parse(fs.readFileSync(path, 'utf8'));
- const line = `${JSON.stringify(content.concat([stats]))}\n`;
- fs.writeFileSync(path, line);
- } catch (error) {
- console.error(`Error writing ${path}`, error);
- throw error;
- }
-};
diff --git a/tests/ui/UnreadIndicatorsTest.js b/tests/ui/UnreadIndicatorsTest.js
index 07100d7a5f0f..6051f04f570e 100644
--- a/tests/ui/UnreadIndicatorsTest.js
+++ b/tests/ui/UnreadIndicatorsTest.js
@@ -229,6 +229,7 @@ function signInAndGetAppWithUnreadChat() {
lastVisibleActionCreated: reportAction9CreatedDate,
lastMessageText: 'Test',
participantAccountIDs: [USER_B_ACCOUNT_ID],
+ lastActorAccountID: USER_B_ACCOUNT_ID,
type: CONST.REPORT.TYPE.CHAT,
});
const createdReportActionID = NumberUtils.rand64();
@@ -388,6 +389,7 @@ describe('Unread Indicators', () => {
lastReadTime: '',
lastVisibleActionCreated: DateUtils.getDBTime(utcToZonedTime(NEW_REPORT_FIST_MESSAGE_CREATED_DATE, 'UTC').valueOf()),
lastMessageText: 'Comment 1',
+ lastActorAccountID: USER_C_ACCOUNT_ID,
participantAccountIDs: [USER_C_ACCOUNT_ID],
type: CONST.REPORT.TYPE.CHAT,
},
diff --git a/tests/unit/isStagingDeployLockedTest.js b/tests/unit/isStagingDeployLockedTest.ts
similarity index 100%
rename from tests/unit/isStagingDeployLockedTest.js
rename to tests/unit/isStagingDeployLockedTest.ts