diff --git a/android/app/build.gradle b/android/app/build.gradle
index 52e983a1ce82..1c7e0fccd618 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -106,8 +106,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001033505
- versionName "1.3.35-5"
+ versionCode 1001033603
+ versionName "1.3.36-3"
}
splits {
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 7ae3b26627ee..1ceaea0004b8 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.35
+ 1.3.36
CFBundleSignature
????
CFBundleURLTypes
@@ -32,7 +32,7 @@
CFBundleVersion
- 1.3.35.5
+ 1.3.36.3
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 035f8ebde8d6..3827a7228527 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.3.35
+ 1.3.36
CFBundleSignature
????
CFBundleVersion
- 1.3.35.5
+ 1.3.36.3
diff --git a/package-lock.json b/package-lock.json
index 67d2cd51360d..03d6d2ddeeb2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.35-5",
+ "version": "1.3.36-3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.35-5",
+ "version": "1.3.36-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -69,7 +69,7 @@
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.9.0",
- "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5",
+ "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0",
"react-native-haptic-feedback": "^1.13.0",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^5.1.0",
@@ -144,7 +144,7 @@
"css-loader": "^6.7.2",
"diff-so-fancy": "^1.3.0",
"dotenv": "^16.0.3",
- "electron": "^22.3.14",
+ "electron": "22.3.14",
"electron-builder": "24.5.0",
"eslint": "^7.6.0",
"eslint-config-expensify": "^2.0.38",
@@ -36665,8 +36665,8 @@
},
"node_modules/react-native-google-places-autocomplete": {
"version": "2.5.1",
- "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5",
- "integrity": "sha512-7NiBK83VggJ2HQaHGfJoaPyxtiLu1chwP1VqH9te+PZtf0L9p50IuBQciW+4s173cBamt4U2+mvnCt7zfMFeDg==",
+ "resolved": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0",
+ "integrity": "sha512-OJWCz4Epj1p8tyNImWNykAqpd/X1MkNCFPY0dSbgiTJGbW4J5T4bC0PIUQ+ExjxWpWjcFaielTLdoSz0HfeIpw==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8",
@@ -68460,9 +68460,9 @@
}
},
"react-native-google-places-autocomplete": {
- "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5",
- "integrity": "sha512-7NiBK83VggJ2HQaHGfJoaPyxtiLu1chwP1VqH9te+PZtf0L9p50IuBQciW+4s173cBamt4U2+mvnCt7zfMFeDg==",
- "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5",
+ "version": "git+ssh://git@github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0",
+ "integrity": "sha512-OJWCz4Epj1p8tyNImWNykAqpd/X1MkNCFPY0dSbgiTJGbW4J5T4bC0PIUQ+ExjxWpWjcFaielTLdoSz0HfeIpw==",
+ "from": "react-native-google-places-autocomplete@git+https://github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0",
"requires": {
"lodash.debounce": "^4.0.8",
"prop-types": "^15.7.2",
diff --git a/package.json b/package.json
index 5188fbc1e20b..9b4a7acc4de9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.35-5",
+ "version": "1.3.36-3",
"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.",
@@ -106,7 +106,7 @@
"react-native-fast-image": "^8.6.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.9.0",
- "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5",
+ "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#ee87343c3e827ff7818abc71b6bb04fcc1f120e0",
"react-native-haptic-feedback": "^1.13.0",
"react-native-image-pan-zoom": "^2.1.12",
"react-native-image-picker": "^5.1.0",
diff --git a/src/CONST.js b/src/CONST.js
index b5e3d30516bd..2e61e3f1c876 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -652,6 +652,7 @@ const CONST = {
},
},
TIMING: {
+ CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION: 'calc_most_recent_last_modified_action',
SEARCH_RENDER: 'search_render',
HOMEPAGE_INITIAL_RENDER: 'homepage_initial_render',
REPORT_INITIAL_RENDER: 'report_initial_render',
@@ -724,9 +725,6 @@ const CONST = {
MAX_RETRY_WAIT_TIME_MS: 10 * 1000,
PROCESS_REQUEST_DELAY_MS: 1000,
MAX_PENDING_TIME_MS: 10 * 1000,
- COMMAND: {
- RECONNECT_APP: 'ReconnectApp',
- },
},
DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'},
DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 9699eb9aab94..795e45c6f892 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -120,7 +120,7 @@ function AddressSearch(props) {
postal_code: zipCode,
administrative_area_level_1: state,
administrative_area_level_2: stateFallback,
- country,
+ country: countryPrimary,
} = GooglePlacesUtils.getAddressComponents(addressComponents, {
street_number: 'long_name',
route: 'long_name',
@@ -142,7 +142,15 @@ function AddressSearch(props) {
// Make sure that the order of keys remains such that the country is always set above the state.
// Refer to https://github.com/Expensify/App/issues/15633 for more information.
- const {state: stateAutoCompleteFallback = '', city: cityAutocompleteFallback = ''} = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData.terms);
+ const {
+ country: countryFallbackLongName = '',
+ state: stateAutoCompleteFallback = '',
+ city: cityAutocompleteFallback = '',
+ } = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData.terms);
+
+ const countryFallback = _.findKey(CONST.ALL_COUNTRIES, (country) => country === countryFallbackLongName);
+
+ const country = countryPrimary || countryFallback;
const values = {
street: `${streetNumber} ${streetName}`.trim(),
diff --git a/src/components/Avatar.js b/src/components/Avatar.js
index 66a1b60c3cef..b59a8902eb13 100644
--- a/src/components/Avatar.js
+++ b/src/components/Avatar.js
@@ -73,9 +73,12 @@ function Avatar(props) {
const isWorkspace = props.type === CONST.ICON_TYPE_WORKSPACE;
const iconSize = StyleUtils.getAvatarSize(props.size);
- const imageStyle = props.imageStyles ? [StyleUtils.getAvatarStyle(props.size), ...props.imageStyles, StyleUtils.getAvatarBorderRadius(props.size, props.type)] : undefined;
+ const imageStyle =
+ props.imageStyles && props.imageStyles.length
+ ? [StyleUtils.getAvatarStyle(props.size), ...props.imageStyles, StyleUtils.getAvatarBorderRadius(props.size, props.type)]
+ : [StyleUtils.getAvatarStyle(props.size), styles.noBorderRadius];
- const iconStyle = props.imageStyles ? [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles] : undefined;
+ const iconStyle = props.imageStyles && props.imageStyles.length ? [StyleUtils.getAvatarStyle(props.size), styles.bgTransparent, ...props.imageStyles] : undefined;
const iconFillColor = isWorkspace ? StyleUtils.getDefaultWorkspaceAvatarColor(props.name).fill : props.fill;
const fallbackAvatar = isWorkspace ? ReportUtils.getDefaultWorkspaceAvatar(props.name) : props.fallbackIcon;
@@ -101,11 +104,13 @@ function Avatar(props) {
/>
) : (
- setImageError(true)}
- />
+
+ setImageError(true)}
+ />
+
)}
);
diff --git a/src/components/AvatarWithDisplayName.js b/src/components/AvatarWithDisplayName.js
index 7889dcb0b703..152eaadcf709 100644
--- a/src/components/AvatarWithDisplayName.js
+++ b/src/components/AvatarWithDisplayName.js
@@ -17,6 +17,9 @@ import compose from '../libs/compose';
import * as OptionsListUtils from '../libs/OptionsListUtils';
import Text from './Text';
import * as StyleUtils from '../styles/StyleUtils';
+import Navigation from '../libs/Navigation/Navigation';
+import ROUTES from '../ROUTES';
+import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
const propTypes = {
/** The report currently being looked at */
@@ -52,6 +55,7 @@ const defaultProps = {
function AvatarWithDisplayName(props) {
const title = props.isAnonymous ? props.report.displayName : ReportUtils.getDisplayNameForParticipant(props.report.ownerAccountID, true);
const subtitle = ReportUtils.getChatRoomSubtitle(props.report);
+ const parentNavigationSubtitle = ReportUtils.getParentNavigationSubtitle(props.report);
const isExpenseReport = ReportUtils.isExpenseReport(props.report);
const icons = ReportUtils.getIcons(props.report, props.personalDetails, props.policies);
const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs([props.report.ownerAccountID], props.personalDetails);
@@ -88,6 +92,22 @@ function AvatarWithDisplayName(props) {
textStyles={[props.isAnonymous ? styles.headerAnonymousFooter : styles.headerText, styles.pre]}
shouldUseFullTitle={isExpenseReport || props.isAnonymous}
/>
+ {!_.isEmpty(parentNavigationSubtitle) && (
+ {
+ Navigation.navigate(ROUTES.getReportRoute(props.report.parentReportID));
+ }}
+ accessibilityLabel={subtitle}
+ accessibilityRole="link"
+ >
+
+ {parentNavigationSubtitle}
+
+
+ )}
{!_.isEmpty(subtitle) && (
diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js
index 956e1fd425c9..1d5f0f4fe06e 100644
--- a/src/components/KYCWall/BaseKYCWall.js
+++ b/src/components/KYCWall/BaseKYCWall.js
@@ -69,7 +69,7 @@ class KYCWall extends React.Component {
}
return {
- anchorPositionVertical: domRect.top - 150,
+ anchorPositionVertical: domRect.top - 8,
anchorPositionHorizontal: domRect.left,
};
}
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 07538b7ec658..a59dd6d7b971 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -79,7 +79,7 @@ function MenuItem(props) {
props.icon && !_.isArray(props.icon) ? styles.ml3 : undefined,
props.shouldShowBasicTitle ? undefined : styles.textStrong,
props.shouldShowHeaderTitle ? styles.textHeadlineH1 : undefined,
- props.interactive && props.disabled ? {...styles.disabledText, ...styles.userSelectNone} : undefined,
+ props.interactive && props.disabled ? {...styles.userSelectNone} : undefined,
styles.pre,
styles.ltr,
isDeleted ? styles.offlineFeedback.deleted : undefined,
@@ -119,6 +119,7 @@ function MenuItem(props) {
StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || hovered, pressed, props.success, props.disabled, props.interactive), true),
(hovered || pressed) && props.hoverAndPressStyle,
...(_.isArray(props.wrapperStyle) ? props.wrapperStyle : [props.wrapperStyle]),
+ props.disabled && styles.buttonOpacityDisabled,
]}
disabled={props.disabled}
ref={props.forwardedRef}
diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js
index a0e0fc3b70ad..3ecc854951e0 100644
--- a/src/components/MoneyRequestHeader.js
+++ b/src/components/MoneyRequestHeader.js
@@ -90,6 +90,11 @@ function MoneyRequestHeader(props) {
const shouldShowSettlementButton = !isSettled && !props.isSingleTransactionView && isPayer;
const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport);
const shouldShowPaypal = Boolean(lodashGet(props.personalDetails, [moneyRequestReport.managerID, 'payPalMeAddress']));
+ const report = props.report;
+ if (props.isSingleTransactionView) {
+ report.ownerAccountID = lodashGet(props, ['parentReport', 'ownerAccountID'], null);
+ report.ownerEmail = lodashGet(props, ['parentReport', 'ownerEmail'], '');
+ }
return (
0 || Boolean(props.prefixCharacter);
-
- this.state = {
- isFocused: false,
- labelTranslateY: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y),
- labelScale: new Animated.Value(activeLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE),
- passwordHidden: props.secureTextEntry,
- textInputWidth: 0,
- textInputHeight: 0,
- prefixWidth: 0,
- selection: props.selection,
- height: variables.componentSizeLarge,
-
- // Value should be kept in state for the autoGrow feature to work - https://github.com/Expensify/App/pull/8232#issuecomment-1077282006
- value,
- };
-
- this.input = null;
- this.isLabelActive = activeLabel;
- this.onPress = this.onPress.bind(this);
- this.onFocus = this.onFocus.bind(this);
- this.onBlur = this.onBlur.bind(this);
- this.setValue = this.setValue.bind(this);
- this.togglePasswordVisibility = this.togglePasswordVisibility.bind(this);
- this.dismissKeyboardWhenBackgrounded = this.dismissKeyboardWhenBackgrounded.bind(this);
- this.storePrefixLayoutDimensions = this.storePrefixLayoutDimensions.bind(this);
- }
-
- componentDidMount() {
- if (this.props.disableKeyboard) {
- this.appStateSubscription = AppState.addEventListener('change', this.dismissKeyboardWhenBackgrounded);
+function BaseTextInput(props) {
+ const inputValue = props.value || props.defaultValue || '';
+ const initialActiveLabel = props.forceActiveLabel || inputValue.length > 0 || Boolean(props.prefixCharacter);
+
+ const [isFocused, setIsFocused] = useState(false);
+ const [passwordHidden, setPasswordHidden] = useState(props.secureTextEntry);
+ const [textInputWidth, setTextInputWidth] = useState(0);
+ const [textInputHeight, setTextInputHeight] = useState(0);
+ const [prefixWidth, setPrefixWidth] = useState(0);
+ const [height, setHeight] = useState(variables.componentSizeLarge);
+ const [width, setWidth] = useState();
+ const labelScale = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_SCALE : styleConst.INACTIVE_LABEL_SCALE)).current;
+ const labelTranslateY = useRef(new Animated.Value(initialActiveLabel ? styleConst.ACTIVE_LABEL_TRANSLATE_Y : styleConst.INACTIVE_LABEL_TRANSLATE_Y)).current;
+
+ const input = useRef(null);
+ const isLabelActive = useRef(initialActiveLabel);
+
+ useEffect(() => {
+ if (!props.disableKeyboard) {
+ return;
}
+ const appStateSubscription = AppState.addEventListener('change', (nextAppState) => {
+ if (!nextAppState.match(/inactive|background/)) {
+ return;
+ }
+
+ Keyboard.dismiss();
+ });
+
+ return () => {
+ appStateSubscription.remove();
+ };
+ }, [props.disableKeyboard]);
+
+ // AutoFocus which only works on mount:
+ useEffect(() => {
// We are manually managing focus to prevent this issue: https://github.com/Expensify/App/issues/4514
- if (!this.props.autoFocus || !this.input) {
+ if (!props.autoFocus || !input.current) {
return;
}
- if (this.props.shouldDelayFocus) {
- this.focusTimeout = setTimeout(() => this.input.focus(), CONST.ANIMATED_TRANSITION);
+ let focusTimeout;
+ if (props.shouldDelayFocus) {
+ focusTimeout = setTimeout(() => input.current.focus(), CONST.ANIMATED_TRANSITION);
return;
}
- this.input.focus();
- }
+ input.current.focus();
- componentDidUpdate(prevProps) {
- // Activate or deactivate the label when value is changed programmatically from outside
- const inputValue = _.isUndefined(this.props.value) ? this.input.value : this.props.value;
- if ((_.isUndefined(inputValue) || this.state.value === inputValue) && _.isEqual(prevProps.selection, this.props.selection)) {
+ return () => {
+ if (!focusTimeout) {
+ return;
+ }
+ clearTimeout(focusTimeout);
+ };
+ // We only want this to run on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const animateLabel = useCallback(
+ (translateY, scale) => {
+ Animated.parallel([
+ Animated.spring(labelTranslateY, {
+ toValue: translateY,
+ duration: styleConst.LABEL_ANIMATION_DURATION,
+ useNativeDriver: true,
+ }),
+ Animated.spring(labelScale, {
+ toValue: scale,
+ duration: styleConst.LABEL_ANIMATION_DURATION,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ },
+ [labelScale, labelTranslateY],
+ );
+
+ const activateLabel = useCallback(() => {
+ const value = props.value || '';
+
+ if (value.length < 0 || isLabelActive.current) {
return;
}
- // eslint-disable-next-line react/no-did-update-set-state
- this.setState({value: inputValue, selection: this.props.selection}, () => {
- if (this.state.value) {
- this.activateLabel();
- } else if (!this.state.isFocused) {
- this.deactivateLabel();
- }
- });
+ animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE);
+ isLabelActive.current = true;
+ }, [animateLabel, props.value]);
- // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput.
- if (inputValue === '') {
- this.input.clear();
+ const deactivateLabel = useCallback(() => {
+ const value = props.value || '';
+
+ if (props.forceActiveLabel || value.length !== 0 || props.prefixCharacter) {
+ return;
}
- }
- componentWillUnmount() {
- if (this.focusTimeout) {
- clearTimeout(this.focusTimeout);
+ animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE);
+ isLabelActive.current = false;
+ }, [animateLabel, props.forceActiveLabel, props.prefixCharacter, props.value]);
+
+ const onFocus = (event) => {
+ if (props.onFocus) {
+ props.onFocus(event);
}
+ setIsFocused(true);
+ };
- if (!this.props.disableKeyboard || !this.appStateSubscription) {
- return;
+ const onBlur = (event) => {
+ if (props.onBlur) {
+ props.onBlur(event);
}
+ setIsFocused(false);
- this.appStateSubscription.remove();
- }
+ // If the text has been supplied by Chrome autofill, the value state is not synced with the value
+ // as Chrome doesn't trigger a change event. When there is autofill text, don't deactivate label.
+ if (!isInputAutoFilled(input.current)) {
+ deactivateLabel();
+ }
+ };
- onPress(event) {
- if (this.props.disabled) {
+ const onPress = (event) => {
+ if (props.disabled) {
return;
}
- if (this.props.onPress) {
- this.props.onPress(event);
+ if (props.onPress) {
+ props.onPress(event);
}
if (!event.isDefaultPrevented()) {
- this.input.focus();
+ input.current.focus();
}
- }
+ };
- onFocus(event) {
- if (this.props.onFocus) {
- this.props.onFocus(event);
- }
- this.setState({isFocused: true});
- this.activateLabel();
- }
+ const onLayout = useCallback(
+ (event) => {
+ if (!props.autoGrowHeight && props.multiline) {
+ return;
+ }
+
+ const layout = event.nativeEvent.layout;
+
+ setWidth((prevWidth) => (props.autoGrowHeight ? layout.width : prevWidth));
+ setHeight((prevHeight) => (!props.multiline ? layout.height : prevHeight));
+ },
+ [props.autoGrowHeight, props.multiline],
+ );
+
+ useEffect(() => {
+ // Handle side effects when the value gets changed programatically from the outside
- onBlur(event) {
- if (this.props.onBlur) {
- this.props.onBlur(event);
+ // In some cases, When the value prop is empty, it is not properly updated on the TextInput due to its uncontrolled nature, thus manually clearing the TextInput.
+ if (inputValue === '') {
+ input.current.clear();
}
- this.setState({isFocused: false});
- // If the text has been supplied by Chrome autofill, the value state is not synced with the value
- // as Chrome doesn't trigger a change event. When there is autofill text, don't deactivate label.
- if (!isInputAutoFilled(this.input)) {
- this.deactivateLabel();
+ if (inputValue) {
+ activateLabel();
}
- }
+ }, [activateLabel, inputValue]);
+
+ // We capture whether the input has a value or not in a ref.
+ // It gets updated when the text gets changed.
+ const hasValueRef = useRef(inputValue.length > 0);
+
+ // Activate or deactivate the label when the focus changes:
+ useEffect(() => {
+ // We can't use inputValue here directly, as it might contain
+ // the defaultValue, which doesn't get updated when the text changes.
+ // We can't use props.value either, as it might be undefined.
+ if (hasValueRef.current || isFocused) {
+ activateLabel();
+ } else if (!hasValueRef.current && !isFocused) {
+ deactivateLabel();
+ }
+ }, [activateLabel, deactivateLabel, inputValue, isFocused]);
/**
* Set Value & activateLabel
@@ -147,258 +203,202 @@ class BaseTextInput extends Component {
* @param {String} value
* @memberof BaseTextInput
*/
- setValue(value) {
- if (this.props.onInputChange) {
- this.props.onInputChange(value);
- }
- this.setState({value});
- Str.result(this.props.onChangeText, value);
- this.activateLabel();
- }
-
- activateLabel() {
- if (this.state.value.length < 0 || this.isLabelActive) {
- return;
+ const setValue = (value) => {
+ if (props.onInputChange) {
+ props.onInputChange(value);
}
- this.animateLabel(styleConst.ACTIVE_LABEL_TRANSLATE_Y, styleConst.ACTIVE_LABEL_SCALE);
- this.isLabelActive = true;
- }
-
- deactivateLabel() {
- if (this.props.forceActiveLabel || this.state.value.length !== 0 || this.props.prefixCharacter) {
- return;
- }
-
- this.animateLabel(styleConst.INACTIVE_LABEL_TRANSLATE_Y, styleConst.INACTIVE_LABEL_SCALE);
- this.isLabelActive = false;
- }
-
- dismissKeyboardWhenBackgrounded(nextAppState) {
- if (!nextAppState.match(/inactive|background/)) {
- return;
+ Str.result(props.onChangeText, value);
+ if (value && value.length > 0) {
+ hasValueRef.current = true;
+ activateLabel();
+ } else {
+ hasValueRef.current = false;
}
-
- Keyboard.dismiss();
- }
-
- animateLabel(translateY, scale) {
- Animated.parallel([
- Animated.spring(this.state.labelTranslateY, {
- toValue: translateY,
- duration: styleConst.LABEL_ANIMATION_DURATION,
- useNativeDriver: true,
- }),
- Animated.spring(this.state.labelScale, {
- toValue: scale,
- duration: styleConst.LABEL_ANIMATION_DURATION,
- useNativeDriver: true,
- }),
- ]).start();
- }
-
- togglePasswordVisibility() {
- this.setState((prevState) => ({passwordHidden: !prevState.passwordHidden}));
- }
-
- storePrefixLayoutDimensions(event) {
- this.setState({prefixWidth: Math.abs(event.nativeEvent.layout.width)});
- }
-
- render() {
- // eslint-disable-next-line react/forbid-foreign-prop-types
- const inputProps = _.omit(this.props, _.keys(baseTextInputPropTypes.propTypes));
- const hasLabel = Boolean(this.props.label.length);
- const isEditable = _.isUndefined(this.props.editable) ? !this.props.disabled : this.props.editable;
- const inputHelpText = this.props.errorText || this.props.hint;
- const placeholder = this.props.prefixCharacter || this.state.isFocused || !hasLabel || (hasLabel && this.props.forceActiveLabel) ? this.props.placeholder : null;
- const maxHeight = StyleSheet.flatten(this.props.containerStyles).maxHeight;
- const textInputContainerStyles = _.reduce(
- [
- styles.textInputContainer,
- ...this.props.textInputContainerStyles,
- this.props.autoGrow && StyleUtils.getWidthStyle(this.state.textInputWidth),
- !this.props.hideFocusedState && this.state.isFocused && styles.borderColorFocus,
- (this.props.hasError || this.props.errorText) && styles.borderColorDanger,
- this.props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight},
- ],
- (finalStyles, s) => ({...finalStyles, ...s}),
- {},
- );
- const isMultiline = this.props.multiline || this.props.autoGrowHeight;
-
- return (
- <>
-
- {
+ setPasswordHidden((prevState) => !prevState.passwordHidden);
+ }, []);
+
+ const storePrefixLayoutDimensions = useCallback((event) => {
+ setPrefixWidth(Math.abs(event.nativeEvent.layout.width));
+ }, []);
+
+ // eslint-disable-next-line react/forbid-foreign-prop-types
+ const inputProps = _.omit(props, _.keys(baseTextInputPropTypes.propTypes));
+ const hasLabel = Boolean(props.label.length);
+ const isEditable = _.isUndefined(props.editable) ? !props.disabled : props.editable;
+ const inputHelpText = props.errorText || props.hint;
+ const placeholder = props.prefixCharacter || isFocused || !hasLabel || (hasLabel && props.forceActiveLabel) ? props.placeholder : null;
+ const maxHeight = StyleSheet.flatten(props.containerStyles).maxHeight;
+ const textInputContainerStyles = StyleSheet.flatten([
+ styles.textInputContainer,
+ ...props.textInputContainerStyles,
+ props.autoGrow && StyleUtils.getWidthStyle(textInputWidth),
+ !props.hideFocusedState && isFocused && styles.borderColorFocus,
+ (props.hasError || props.errorText) && styles.borderColorDanger,
+ props.autoGrowHeight && {scrollPaddingTop: 2 * maxHeight},
+ ]);
+ const isMultiline = props.multiline || props.autoGrowHeight;
+
+ return (
+ <>
+
+
+
- {
- if (!this.props.autoGrowHeight && this.props.multiline) {
- return;
- }
-
- const layout = event.nativeEvent.layout;
-
- this.setState((prevState) => ({
- width: this.props.autoGrowHeight ? layout.width : prevState.width,
- height: !isMultiline ? layout.height : prevState.height,
- }));
- }}
- style={[
- textInputContainerStyles,
-
- // When autoGrow is on and minWidth is not supplied, add a minWidth to allow the input to be focusable.
- this.props.autoGrow && !textInputContainerStyles.minWidth && styles.mnw2,
- ]}
- >
- {hasLabel ? (
- <>
- {/* Adding this background to the label only for multiline text input,
- to prevent text overlapping with label when scrolling */}
- {isMultiline && (
-
- )}
-
+ {/* Adding this background to the label only for multiline text input,
+ to prevent text overlapping with label when scrolling */}
+ {isMultiline && (
+
- >
- ) : null}
-
- {Boolean(this.props.prefixCharacter) && (
-
-
- {this.props.prefixCharacter}
-
-
)}
- {
- if (typeof this.props.innerRef === 'function') {
- this.props.innerRef(ref);
- } else if (this.props.innerRef && _.has(this.props.innerRef, 'current')) {
- this.props.innerRef.current = ref;
- }
- this.input = ref;
- }}
- // eslint-disable-next-line
- {...inputProps}
- autoCorrect={this.props.secureTextEntry ? false : this.props.autoCorrect}
- placeholder={placeholder}
- placeholderTextColor={themeColors.placeholderText}
- underlineColorAndroid="transparent"
- style={[
- styles.flex1,
- styles.w100,
- this.props.inputStyle,
- (!hasLabel || isMultiline) && styles.pv0,
- this.props.prefixCharacter && StyleUtils.getPaddingLeft(this.state.prefixWidth + styles.pl1.paddingLeft),
- this.props.secureTextEntry && styles.secureInput,
-
- // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear
- // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802)
- !isMultiline && {height: this.state.height, lineHeight: undefined},
-
- // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
- this.props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(this.state.textInputHeight, maxHeight),
- ]}
- multiline={isMultiline}
- maxLength={this.props.maxLength}
- onFocus={this.onFocus}
- onBlur={this.onBlur}
- onChangeText={this.setValue}
- secureTextEntry={this.state.passwordHidden}
- onPressOut={this.props.onPress}
- showSoftInputOnFocus={!this.props.disableKeyboard}
- keyboardType={getSecureEntryKeyboardType(this.props.keyboardType, this.props.secureTextEntry, this.state.passwordHidden)}
- value={this.state.value}
- selection={this.state.selection}
- editable={isEditable}
- // FormSubmit Enter key handler does not have access to direct props.
- // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback.
- dataSet={{submitOnEnter: isMultiline && this.props.submitOnEnter}}
+
- {Boolean(this.props.secureTextEntry) && (
- e.preventDefault()}
- accessibilityLabel={this.props.translate('common.visible')}
+ >
+ ) : null}
+
+ {Boolean(props.prefixCharacter) && (
+
+
-
-
- )}
- {!this.props.secureTextEntry && Boolean(this.props.icon) && (
-
-
-
- )}
-
+ {props.prefixCharacter}
+
+
+ )}
+ {
+ if (typeof props.innerRef === 'function') {
+ props.innerRef(ref);
+ } else if (props.innerRef && _.has(props.innerRef, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ props.innerRef.current = ref;
+ }
+ input.current = ref;
+ }}
+ // eslint-disable-next-line
+ {...inputProps}
+ autoCorrect={props.secureTextEntry ? false : props.autoCorrect}
+ placeholder={placeholder}
+ placeholderTextColor={themeColors.placeholderText}
+ underlineColorAndroid="transparent"
+ style={[
+ styles.flex1,
+ styles.w100,
+ props.inputStyle,
+ (!hasLabel || isMultiline) && styles.pv0,
+ props.prefixCharacter && StyleUtils.getPaddingLeft(prefixWidth + styles.pl1.paddingLeft),
+ props.secureTextEntry && styles.secureInput,
+
+ // Explicitly remove `lineHeight` from single line inputs so that long text doesn't disappear
+ // once it exceeds the input space (See https://github.com/Expensify/App/issues/13802)
+ !isMultiline && {height, lineHeight: undefined},
+
+ // Stop scrollbar flashing when breaking lines with autoGrowHeight enabled.
+ props.autoGrowHeight && StyleUtils.getAutoGrowHeightInputStyle(textInputHeight, maxHeight),
+ ]}
+ multiline={isMultiline}
+ maxLength={props.maxLength}
+ onFocus={onFocus}
+ onBlur={onBlur}
+ onChangeText={setValue}
+ secureTextEntry={passwordHidden}
+ onPressOut={props.onPress}
+ showSoftInputOnFocus={!props.disableKeyboard}
+ keyboardType={getSecureEntryKeyboardType(props.keyboardType, props.secureTextEntry, passwordHidden)}
+ value={props.value}
+ selection={props.selection}
+ editable={isEditable}
+ defaultValue={props.defaultValue}
+ // FormSubmit Enter key handler does not have access to direct props.
+ // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback.
+ dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}}
+ />
+ {Boolean(props.secureTextEntry) && (
+ e.preventDefault()}
+ accessibilityLabel={props.translate('common.visible')}
+ >
+
+
+ )}
+ {!props.secureTextEntry && Boolean(props.icon) && (
+
+
+
+ )}
-
- {!_.isEmpty(inputHelpText) && (
-
- )}
-
- {/*
- Text input component doesn't support auto grow by default.
- We're using a hidden text input to achieve that.
- This text view is used to calculate width or height of the input value given textStyle in this component.
- This Text component is intentionally positioned out of the screen.
- */}
- {(this.props.autoGrow || this.props.autoGrowHeight) && (
- // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158.
- this.setState({textInputWidth: e.nativeEvent.layout.width + 2, textInputHeight: e.nativeEvent.layout.height})}
- >
- {this.state.value || this.props.placeholder}
-
+
+
+ {!_.isEmpty(inputHelpText) && (
+
)}
- >
- );
- }
+
+ {/*
+ Text input component doesn't support auto grow by default.
+ We're using a hidden text input to achieve that.
+ This text view is used to calculate width or height of the input value given textStyle in this component.
+ This Text component is intentionally positioned out of the screen.
+ */}
+ {(props.autoGrow || props.autoGrowHeight) && (
+ // Add +2 to width so that the first digit of amount do not cut off on mWeb - https://github.com/Expensify/App/issues/8158.
+ {
+ setTextInputWidth(e.nativeEvent.layout.width + 2);
+ setTextInputHeight(e.nativeEvent.layout.height);
+ }}
+ >
+ {props.value || props.placeholder}
+
+ )}
+ >
+ );
}
+BaseTextInput.displayName = 'BaseTextInput';
BaseTextInput.propTypes = baseTextInputPropTypes.propTypes;
BaseTextInput.defaultProps = baseTextInputPropTypes.defaultProps;
diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js
index 2e278bab5d69..8a1b05a628c2 100644
--- a/src/components/TextInput/baseTextInputPropTypes.js
+++ b/src/components/TextInput/baseTextInputPropTypes.js
@@ -40,10 +40,18 @@ const propTypes = {
/** Disable the virtual keyboard */
disableKeyboard: PropTypes.bool,
- /** Autogrow input container length based on the entered text */
+ /**
+ * Autogrow input container length based on the entered text.
+ * Note: If you use this prop, the text input has to be controlled
+ * by a value prop.
+ */
autoGrow: PropTypes.bool,
- /** Autogrow input container height based on the entered text */
+ /**
+ * Autogrow input container height based on the entered text
+ * Note: If you use this prop, the text input has to be controlled
+ * by a value prop.
+ */
autoGrowHeight: PropTypes.bool,
/** Hide the focus styles on TextInput */
diff --git a/src/components/UserDetailsTooltip/index.js b/src/components/UserDetailsTooltip/index.js
index 16fdacd8964a..18069bc8fde8 100644
--- a/src/components/UserDetailsTooltip/index.js
+++ b/src/components/UserDetailsTooltip/index.js
@@ -12,6 +12,7 @@ import ONYXKEYS from '../../ONYXKEYS';
import withLocalize from '../withLocalize';
import compose from '../../libs/compose';
import * as UserUtils from '../../libs/UserUtils';
+import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber';
function UserDetailsTooltip(props) {
const userDetails = lodashGet(props.personalDetailsList, props.accountID, props.fallbackUserDetails);
@@ -44,7 +45,7 @@ function UserDetailsTooltip(props) {
{userDisplayName}
- {(userLogin || '').trim() && !_.isEqual(userLogin, userDisplayName) ? Str.removeSMSDomain(userLogin) : ''}
+ {(userLogin || '').trim() && !_.isEqual(LocalePhoneNumber.formatPhoneNumber(userLogin || ''), userDisplayName) ? Str.removeSMSDomain(userLogin) : ''}
),
diff --git a/src/hooks/usePermissions.js b/src/hooks/usePermissions.js
new file mode 100644
index 000000000000..1c31ffc8bb64
--- /dev/null
+++ b/src/hooks/usePermissions.js
@@ -0,0 +1,15 @@
+import _ from 'underscore';
+import {useContext, useMemo} from 'react';
+import Permissions from '../libs/Permissions';
+import {BetasContext} from '../components/OnyxProvider';
+
+export default function usePermissions() {
+ const betas = useContext(BetasContext);
+ return useMemo(() => {
+ const permissions = {};
+ _.each(Permissions, (checkerFunction, beta) => {
+ permissions[beta] = checkerFunction(betas);
+ });
+ return permissions;
+ }, [betas]);
+}
diff --git a/src/languages/en.js b/src/languages/en.js
index db5d25ef1675..f6989d32f35a 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -1424,7 +1424,7 @@ export default {
lastReply: 'Last reply',
replies: 'Replies',
reply: 'Reply',
- from: 'From',
+ parentNavigationSummary: ({rootReportName, workspaceName}) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`,
},
qrCodes: {
copyUrlToClipboard: 'Copy URL to clipboard',
diff --git a/src/languages/es.js b/src/languages/es.js
index 27b51cd73492..f765ce0270df 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -1893,7 +1893,7 @@ export default {
lastReply: 'Ăšltima respuesta',
replies: 'Respuestas',
reply: 'Respuesta',
- from: 'De',
+ parentNavigationSummary: ({rootReportName, workspaceName}) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`,
},
qrCodes: {
copyUrlToClipboard: 'Copiar URL al portapapeles',
diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js
index e76517e2059f..5a8185a03038 100644
--- a/src/libs/HttpUtils.js
+++ b/src/libs/HttpUtils.js
@@ -22,9 +22,6 @@ Onyx.connect({
// We use the AbortController API to terminate pending request in `cancelPendingRequests`
let cancellationController = new AbortController();
-// To terminate pending ReconnectApp requests https://github.com/Expensify/App/issues/15627
-let reconnectAppCancellationController = new AbortController();
-
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
@@ -33,18 +30,12 @@ let reconnectAppCancellationController = new AbortController();
* @param {String} [method]
* @param {Object} [body]
* @param {Boolean} [canCancel]
- * @param {String} [command]
* @returns {Promise}
*/
-function processHTTPRequest(url, method = 'get', body = null, canCancel = true, command = '') {
- let signal;
- if (canCancel) {
- signal = command === CONST.NETWORK.COMMAND.RECONNECT_APP ? reconnectAppCancellationController.signal : cancellationController.signal;
- }
-
+function processHTTPRequest(url, method = 'get', body = null, canCancel = true) {
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
- signal,
+ signal: canCancel ? cancellationController.signal : undefined,
method,
body,
})
@@ -136,12 +127,7 @@ function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure =
});
const url = ApiUtils.getCommandURL({shouldUseSecure, command});
- return processHTTPRequest(url, type, formData, data.canCancel, command);
-}
-
-function cancelPendingReconnectAppRequest() {
- reconnectAppCancellationController.abort();
- reconnectAppCancellationController = new AbortController();
+ return processHTTPRequest(url, type, formData, data.canCancel);
}
function cancelPendingRequests() {
@@ -150,11 +136,9 @@ function cancelPendingRequests() {
// We create a new instance because once `abort()` is called any future requests using the same controller would
// automatically get rejected: https://dom.spec.whatwg.org/#abortcontroller-api-integration
cancellationController = new AbortController();
- cancelPendingReconnectAppRequest();
}
export default {
xhr,
cancelPendingRequests,
- cancelPendingReconnectAppRequest,
};
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index e943d529d86b..c2fe7d3475e0 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -32,6 +32,7 @@ import CentralPaneNavigator from './Navigators/CentralPaneNavigator';
import NAVIGATORS from '../../../NAVIGATORS';
import FullScreenNavigator from './Navigators/FullScreenNavigator';
import styles from '../../../styles/styles';
+import * as SessionUtils from '../../SessionUtils';
let currentUserEmail;
Onyx.connect({
@@ -119,7 +120,14 @@ class AuthScreens extends React.Component {
User.subscribeToUserEvents();
});
- App.openApp();
+ // If we are on this screen then we are "logged in", but the user might not have "just logged in". They could be reopening the app
+ // or returning from background. If so, we'll assume they have some app data already and we can call reconnectApp() instead of openApp().
+ if (SessionUtils.didUserLogInDuringSession()) {
+ App.openApp();
+ } else {
+ App.reconnectApp();
+ }
+
App.setUpPoliciesAndNavigate(this.props.session);
if (this.props.lastOpenedPublicRoomID) {
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 72f8f297344e..aa3f600cb9d7 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -461,7 +461,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, {
result.isArchivedRoom = ReportUtils.isArchivedRoom(report);
result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report);
- result.isThread = ReportUtils.isThread(report);
+ result.isThread = ReportUtils.isChatThread(report);
result.isTaskReport = ReportUtils.isTaskReport(report);
result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report);
result.allReportErrors = getAllReportErrors(report, reportActions);
@@ -644,7 +644,7 @@ function getOptions(
return;
}
- const isThread = ReportUtils.isThread(report);
+ const isThread = ReportUtils.isChatThread(report);
const isChatRoom = ReportUtils.isChatRoom(report);
const isTaskReport = ReportUtils.isTaskReport(report);
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js
index d3e407260e20..cce0fc984dab 100644
--- a/src/libs/Permissions.js
+++ b/src/libs/Permissions.js
@@ -1,5 +1,4 @@
import _ from 'underscore';
-import * as Environment from './Environment/Environment';
import CONST from '../CONST';
/**
@@ -8,7 +7,7 @@ import CONST from '../CONST';
* @returns {Boolean}
*/
function canUseAllBetas(betas) {
- return Environment.isDevelopment() || _.contains(betas, CONST.BETAS.ALL);
+ return _.contains(betas, CONST.BETAS.ALL);
}
/**
@@ -75,7 +74,7 @@ function canUseCommentLinking(betas) {
* @returns {Boolean}
*/
function canUsePolicyRooms(betas) {
- return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || _.contains(betas, CONST.BETAS.ALL);
+ return _.contains(betas, CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas);
}
/**
@@ -91,7 +90,7 @@ function canUsePolicyExpenseChat(betas) {
* @returns {Boolean}
*/
function canUsePasswordlessLogins(betas) {
- return _.contains(betas, CONST.BETAS.PASSWORDLESS) || _.contains(betas, CONST.BETAS.ALL);
+ return _.contains(betas, CONST.BETAS.PASSWORDLESS) || canUseAllBetas(betas);
}
/**
@@ -99,7 +98,7 @@ function canUsePasswordlessLogins(betas) {
* @returns {Boolean}
*/
function canUseTasks(betas) {
- return _.contains(betas, CONST.BETAS.TASKS) || _.contains(betas, CONST.BETAS.ALL);
+ return _.contains(betas, CONST.BETAS.TASKS) || canUseAllBetas(betas);
}
export default {
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
index a18664b32ab4..9a280711e947 100644
--- a/src/libs/ReportActionsUtils.js
+++ b/src/libs/ReportActionsUtils.js
@@ -11,6 +11,19 @@ import Log from './Log';
import * as CurrencyUtils from './CurrencyUtils';
import isReportMessageAttachment from './isReportMessageAttachment';
+const allReports = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ callback: (report, key) => {
+ if (!key || !report) {
+ return;
+ }
+
+ const reportID = CollectionUtils.extractCollectionItemID(key);
+ allReports[reportID] = report;
+ },
+});
+
const allReportActions = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
@@ -430,6 +443,55 @@ function getLinkedTransactionID(reportID, reportActionID) {
return reportAction.originalMessage.IOUTransactionID;
}
+/**
+ *
+ * @param {String} reportID
+ * @param {String} reportActionID
+ * @returns {Object}
+ */
+function getReportAction(reportID, reportActionID) {
+ return lodashGet(allReportActions, [reportID, reportActionID], {});
+}
+
+/**
+ * @returns {string}
+ */
+function getMostRecentReportActionLastModified() {
+ // Start with the oldest date possible
+ let mostRecentReportActionLastModified = new Date(0).toISOString();
+
+ // Flatten all the actions
+ // Loop over them all to find the one that is the most recent
+ const flatReportActions = _.flatten(_.map(allReportActions, (actions) => _.values(actions)));
+ _.each(flatReportActions, (action) => {
+ // Pending actions should not be counted here as a user could create a comment or some other action while offline and the server might know about
+ // messages they have not seen yet.
+ if (!_.isEmpty(action.pendingAction)) {
+ return;
+ }
+
+ const lastModified = action.lastModified || action.created;
+ if (lastModified < mostRecentReportActionLastModified) {
+ return;
+ }
+
+ mostRecentReportActionLastModified = lastModified;
+ });
+
+ // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get
+ // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these
+ _.each(allReports, (report) => {
+ const reportLastVisibleActionLastModified = report.lastVisibleActionLastModified || report.lastVisibleActionCreated;
+ if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) {
+ return;
+ }
+
+ mostRecentReportActionLastModified = reportLastVisibleActionLastModified;
+ });
+
+ return mostRecentReportActionLastModified;
+}
+
/**
* @param {*} chatReportID
* @param {*} iouReportID
@@ -486,6 +548,7 @@ export {
isMoneyRequestAction,
hasCommentThread,
getLinkedTransactionID,
+ getMostRecentReportActionLastModified,
getReportPreviewAction,
isCreatedTaskReportAction,
getParentReportAction,
@@ -497,4 +560,5 @@ export {
isMessageDeleted,
isWhisperAction,
isPendingRemove,
+ getReportAction,
};
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index b669dd117c56..8647893f1fdf 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -205,24 +205,6 @@ function canEditReportAction(reportAction) {
);
}
-/**
- * Can only flag if:
- *
- * - It was written by someone else
- * - It's an ADDCOMMENT that is not an attachment
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function canFlagReportAction(reportAction) {
- return (
- reportAction.actorAccountID !== currentUserAccountID &&
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT &&
- !ReportActionsUtils.isDeletedAction(reportAction) &&
- !ReportActionsUtils.isCreatedTaskReportAction(reportAction)
- );
-}
-
/**
* Whether the Money Request report is settled
*
@@ -372,13 +354,23 @@ function getBankAccountRoute(report) {
}
/**
- * Returns true if report has a parent and is therefore a Thread.
+ * Returns true if report has a parent
*
* @param {Object} report
* @returns {Boolean}
*/
function isThread(report) {
- return Boolean(report && report.parentReportID && report.parentReportActionID && report.type === CONST.REPORT.TYPE.CHAT);
+ return Boolean(report && report.parentReportID && report.parentReportActionID);
+}
+
+/**
+ * Returns true if report is of type chat and has a parent and is therefore a Thread.
+ *
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function isChatThread(report) {
+ return isThread(report) && report.type === CONST.REPORT.TYPE.CHAT;
}
/**
@@ -388,7 +380,7 @@ function isThread(report) {
* @returns {Boolean}
*/
function isConciergeChatReport(report) {
- return lodashGet(report, 'participantAccountIDs', []).length === 1 && Number(report.participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isThread(report);
+ return lodashGet(report, 'participantAccountIDs', []).length === 1 && Number(report.participantAccountIDs[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report);
}
/**
@@ -474,9 +466,15 @@ function isArchivedRoom(report) {
* @param {String} report.policyID
* @param {String} report.oldPolicyName
* @param {String} report.policyName
+ * @param {Boolean} [returnEmptyIfNotFound]
* @returns {String}
*/
-function getPolicyName(report) {
+function getPolicyName(report, returnEmptyIfNotFound = false) {
+ const noPolicyFound = returnEmptyIfNotFound ? '' : Localize.translateLocal('workspace.common.unavailable');
+ if (report === undefined) {
+ return noPolicyFound;
+ }
+
if ((!allPolicies || _.size(allPolicies) === 0) && !report.policyName) {
return Localize.translateLocal('workspace.common.unavailable');
}
@@ -485,7 +483,7 @@ function getPolicyName(report) {
// // Public rooms send back the policy name with the reportSummary,
// // since they can also be accessed by people who aren't in the workspace
- return lodashGet(policy, 'name') || report.policyName || report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable');
+ return lodashGet(policy, 'name') || report.policyName || report.oldPolicyName || noPolicyFound;
}
/**
@@ -728,7 +726,7 @@ function getIcons(report, personalDetails, defaultIcon = null, isPayer = false,
result.source = Expensicons.DeletedRoomAvatar;
return [result];
}
- if (isThread(report)) {
+ if (isChatThread(report)) {
const parentReportAction = ReportActionsUtils.getParentReportAction(report);
const actorAccountID = lodashGet(parentReportAction, 'actorAccountID', 0);
@@ -1024,7 +1022,7 @@ function getReportPreviewMessage(report, reportAction) {
*/
function getReportName(report) {
let formattedName;
- if (isThread(report)) {
+ if (isChatThread(report)) {
const parentReportAction = ReportActionsUtils.getParentReportAction(report);
if (ReportActionsUtils.isTransactionThread(parentReportAction)) {
return getTransactionReportName(parentReportAction);
@@ -1066,17 +1064,33 @@ function getReportName(report) {
}
/**
- * Recursively navigates through parent to get the root reports name only for DM reports.
+ * Recursively navigates through thread parents to get the root report and workspace name.
+ * The recursion stops when we find a non thread or money request report, whichever comes first.
* @param {Object} report
- * @returns {String|*}
+ * @returns {Object}
*/
-function getDMRootReportName(report) {
- if (isThread(report) && !getChatType(report)) {
+function getRootReportAndWorkspaceName(report) {
+ if (isThread(report) && !isMoneyRequestReport(report)) {
const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]);
- return getDMRootReportName(parentReport);
+ return getRootReportAndWorkspaceName(parentReport);
+ }
+
+ if (isIOUReport(report)) {
+ return {
+ rootReportName: lodashGet(report, 'displayName', ''),
+ };
+ }
+ if (isMoneyRequestReport(report)) {
+ return {
+ rootReportName: lodashGet(report, 'displayName', ''),
+ workspaceName: isIOUReport(report) ? CONST.POLICY.OWNER_EMAIL_FAKE : getPolicyName(report, true),
+ };
}
- return getReportName(report);
+ return {
+ rootReportName: getReportName(report),
+ workspaceName: getPolicyName(report, true),
+ };
}
/**
@@ -1085,23 +1099,8 @@ function getDMRootReportName(report) {
* @returns {String}
*/
function getChatRoomSubtitle(report) {
- if (isThread(report)) {
- if (!getChatType(report)) {
- return `${Localize.translateLocal('threads.from')} ${getDMRootReportName(report)}`;
- }
-
- let roomName = '';
- if (isChatRoom(report)) {
- const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]);
- if (parentReport) {
- roomName = lodashGet(parentReport, 'displayName', '');
- } else {
- roomName = lodashGet(report, 'displayName', '');
- }
- }
-
- const workspaceName = getPolicyName(report);
- return `${Localize.translateLocal('threads.from')} ${roomName ? [roomName, workspaceName].join(' in ') : workspaceName}`;
+ if (isChatThread(report)) {
+ return '';
}
if (!isDefaultRoom(report) && !isUserCreatedPolicyRoom(report) && !isPolicyExpenseChat(report)) {
return '';
@@ -1119,6 +1118,24 @@ function getChatRoomSubtitle(report) {
return getPolicyName(report);
}
+/**
+ * Gets the parent navigation subtitle for the report
+ * @param {Object} report
+ * @returns {String}
+ */
+function getParentNavigationSubtitle(report) {
+ if (isThread(report)) {
+ const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]);
+ const {rootReportName, workspaceName} = getRootReportAndWorkspaceName(parentReport);
+ if (_.isEmpty(rootReportName)) {
+ return '';
+ }
+
+ return Localize.translateLocal('threads.parentNavigationSummary', {rootReportName, workspaceName});
+ }
+ return '';
+}
+
/**
* Get the report for a reportID
*
@@ -1137,7 +1154,7 @@ function getReport(reportID) {
function navigateToDetailsPage(report) {
const participantAccountIDs = lodashGet(report, 'participantAccountIDs', []);
- if (isChatRoom(report) || isPolicyExpenseChat(report) || isThread(report)) {
+ if (isChatRoom(report) || isPolicyExpenseChat(report) || isChatThread(report)) {
Navigation.navigate(ROUTES.getReportDetailsRoute(report.reportID));
return;
}
@@ -1948,7 +1965,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, iouRep
if (
!report ||
!report.reportID ||
- (_.isEmpty(report.participantAccountIDs) && !isThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report))
+ (_.isEmpty(report.participantAccountIDs) && !isChatThread(report) && !isPublicRoom(report) && !isArchivedRoom(report) && !isMoneyRequestReport(report) && !isTaskReport(report))
) {
return false;
}
@@ -2008,7 +2025,7 @@ function getChatByParticipants(newParticipantList) {
newParticipantList.sort();
return _.find(allReports, (report) => {
// If the report has been deleted, or there are no participants (like an empty #admins room) then skip it
- if (!report || !report.participantAccountIDs || isThread(report)) {
+ if (!report || !report.participantAccountIDs || isChatThread(report)) {
return false;
}
@@ -2075,6 +2092,26 @@ function chatIncludesChronos(report) {
return report.participantAccountIDs && _.contains(report.participantAccountIDs, CONST.ACCOUNT_ID.CHRONOS);
}
+/**
+ * Can only flag if:
+ *
+ * - It was written by someone else
+ * - It's an ADDCOMMENT that is not an attachment
+ *
+ * @param {Object} reportAction
+ * @param {number} reportID
+ * @returns {Boolean}
+ */
+function canFlagReportAction(reportAction, reportID) {
+ return (
+ !loginList.includes(reportAction.actorEmail) &&
+ reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT &&
+ !ReportActionsUtils.isDeletedAction(reportAction) &&
+ !ReportActionsUtils.isCreatedTaskReportAction(reportAction) &&
+ isAllowedToComment(getReport(reportID))
+ );
+}
+
/**
* Whether flag comment page should show
*
@@ -2085,7 +2122,7 @@ function chatIncludesChronos(report) {
function shouldShowFlagComment(reportAction, report) {
return (
- canFlagReportAction(reportAction) &&
+ canFlagReportAction(reportAction, report.reportID) &&
!isArchivedRoom(report) &&
!chatIncludesChronos(report) &&
!isConciergeChatReport(report.reportID) &&
@@ -2188,7 +2225,7 @@ function canRequestMoney(report) {
*/
function getMoneyRequestOptions(report, reportParticipants, betas) {
// In any thread, we do not allow any new money requests yet
- if (isThread(report)) {
+ if (isChatThread(report)) {
return [];
}
@@ -2294,7 +2331,7 @@ function shouldReportShowSubscript(report) {
return false;
}
- if (isPolicyExpenseChat(report) && !isThread(report) && !isTaskReport(report) && !report.isOwnPolicyExpenseChat) {
+ if (isPolicyExpenseChat(report) && !isChatThread(report) && !isTaskReport(report) && !report.isOwnPolicyExpenseChat) {
return true;
}
@@ -2359,6 +2396,7 @@ export {
isUserCreatedPolicyRoom,
isChatRoom,
getChatRoomSubtitle,
+ getParentNavigationSubtitle,
getPolicyName,
getPolicyType,
isArchivedRoom,
@@ -2427,6 +2465,7 @@ export {
getWhisperDisplayNames,
getWorkspaceAvatar,
isThread,
+ isChatThread,
isThreadParent,
isThreadFirstChat,
shouldReportShowSubscript,
diff --git a/src/libs/SessionUtils.js b/src/libs/SessionUtils.js
index 875b540e5599..7b1fc9f42d25 100644
--- a/src/libs/SessionUtils.js
+++ b/src/libs/SessionUtils.js
@@ -1,4 +1,7 @@
+import Onyx from 'react-native-onyx';
+import _ from 'underscore';
import lodashGet from 'lodash/get';
+import ONYXKEYS from '../ONYXKEYS';
/**
* Determine if the transitioning user is logging in as a new user.
@@ -28,7 +31,34 @@ function isLoggingInAsNewUser(transitionURL, sessionEmail) {
return linkedEmail !== sessionEmail;
}
-export {
- // eslint-disable-next-line import/prefer-default-export
- isLoggingInAsNewUser,
-};
+let loggedInDuringSession;
+
+// To tell if the user logged in during this session we will check the value of session.authToken once when the app's JS inits. When the user logs out
+// we can reset this flag so that it can be updated again.
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (session) => {
+ if (!_.isUndefined(loggedInDuringSession)) {
+ return;
+ }
+
+ if (session && session.authToken) {
+ loggedInDuringSession = false;
+ } else {
+ loggedInDuringSession = true;
+ }
+ },
+});
+
+function resetDidUserLogInDuringSession() {
+ loggedInDuringSession = undefined;
+}
+
+/**
+ * @returns {boolean}
+ */
+function didUserLogInDuringSession() {
+ return Boolean(loggedInDuringSession);
+}
+
+export {isLoggingInAsNewUser, didUserLogInDuringSession, resetDidUserLogInDuringSession};
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index ee53cd9df69a..f722f0cd1572 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -263,7 +263,7 @@ function getOptionData(reportID) {
const participantPersonalDetailList = _.values(OptionsListUtils.getPersonalDetailsForAccountIDs(report.participantAccountIDs, personalDetails));
const personalDetail = participantPersonalDetailList[0] || {};
- result.isThread = ReportUtils.isThread(report);
+ result.isThread = ReportUtils.isChatThread(report);
result.isChatRoom = ReportUtils.isChatRoom(report);
result.isTaskReport = ReportUtils.isTaskReport(report);
if (result.isTaskReport) {
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index 38638e22a464..8fd0ed6ce1ff 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -15,6 +15,8 @@ import ROUTES from '../../ROUTES';
import * as SessionUtils from '../SessionUtils';
import getCurrentUrl from '../Navigation/currentUrl';
import * as Session from './Session';
+import * as ReportActionsUtils from '../ReportActionsUtils';
+import Timing from './Timing';
let currentUserAccountID;
Onyx.connect({
@@ -31,25 +33,6 @@ Onyx.connect({
initWithStoredValues: false,
});
-let myPersonalDetails;
-Onyx.connect({
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- callback: (val) => {
- if (!val || !currentUserAccountID) {
- return;
- }
-
- myPersonalDetails = val[currentUserAccountID];
- },
-});
-
-let allPolicies = [];
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.POLICY,
- waitForCollectionCallback: true,
- callback: (policies) => (allPolicies = policies),
-});
-
let preferredLocale;
Onyx.connect({
key: ONYXKEYS.NVP_PREFERRED_LOCALE,
@@ -137,42 +120,52 @@ AppState.addEventListener('change', (nextAppState) => {
/**
* Fetches data needed for app initialization
+ * @param {boolean} [isReconnecting]
*/
-function openApp() {
+function openApp(isReconnecting = false) {
isReadyToOpenApp.then(() => {
- // We need a fresh connection/callback here to make sure that the list of policyIDs that is sent to OpenApp is the most updated list from Onyx
const connectionID = Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY,
waitForCollectionCallback: true,
callback: (policies) => {
+ // When the app reconnects we do a fast "sync" of the LHN and only return chats that have new messages. We achieve this by sending the most recent reportActionID.
+ // we have locally. And then only update the user about chats with messages that have occurred after that reportActionID.
+ //
+ // - Look through the local report actions and reports to find the most recently modified report action or report.
+ // - We send this to the server so that it can compute which new chats the user needs to see and return only those as an optimization.
+ const params = {policyIDList: getNonOptimisticPolicyIDs(policies)};
+ if (isReconnecting) {
+ Timing.start(CONST.TIMING.CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION);
+ params.mostRecentReportActionLastModified = ReportActionsUtils.getMostRecentReportActionLastModified();
+ Timing.end(CONST.TIMING.CALCULATE_MOST_RECENT_LAST_MODIFIED_ACTION, '', 500);
+ }
Onyx.disconnect(connectionID);
- API.read(
- 'OpenApp',
- {policyIDList: getNonOptimisticPolicyIDs(policies)},
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- value: true,
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- value: false,
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- value: false,
- },
- ],
- },
- );
+
+ // eslint-disable-next-line rulesdir/no-multiple-api-calls
+ const apiMethod = isReconnecting ? API.write : API.read;
+ apiMethod(isReconnecting ? 'ReconnectApp' : 'OpenApp', params, {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ value: true,
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ value: false,
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_REPORT_DATA,
+ value: false,
+ },
+ ],
+ });
},
});
});
@@ -182,33 +175,7 @@ function openApp() {
* Refreshes data when the app reconnects
*/
function reconnectApp() {
- API.write(
- CONST.NETWORK.COMMAND.RECONNECT_APP,
- {policyIDList: getNonOptimisticPolicyIDs(allPolicies)},
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- value: true,
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- value: false,
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_REPORT_DATA,
- value: false,
- },
- ],
- },
- );
+ openApp(true);
}
/**
@@ -267,8 +234,8 @@ function setUpPoliciesAndNavigate(session) {
}
}
-function openProfile() {
- const oldTimezoneData = myPersonalDetails.timezone || {};
+function openProfile(personalDetails) {
+ const oldTimezoneData = personalDetails.timezone || {};
let newTimezoneData = oldTimezoneData;
if (lodashGet(oldTimezoneData, 'automatic', true)) {
@@ -308,8 +275,6 @@ function openProfile() {
],
},
);
-
- Navigation.navigate(ROUTES.SETTINGS_PROFILE);
}
export {setLocale, setLocaleAndNavigate, setSidebarLoaded, setUpPoliciesAndNavigate, openProfile, openApp, reconnectApp, confirmReadyToOpenApp};
diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js
index 09c040ab47bc..b920cb1c7ee6 100644
--- a/src/libs/actions/Link.js
+++ b/src/libs/actions/Link.js
@@ -66,18 +66,13 @@ function openOldDotLink(url) {
}
// If shortLivedAuthToken is not accessible, fallback to opening the link without the token.
- // eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects('OpenOldDotLink', {}, {})
- .then((response) => {
- buildOldDotURL(url, response.shortLivedAuthToken).then((oldDotUrl) => {
- Linking.openURL(oldDotUrl);
- });
- })
- .catch(() => {
- buildOldDotURL(url).then((oldDotUrl) => {
- Linking.openURL(oldDotUrl);
- });
- });
+ asyncOpenURL(
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects('OpenOldDotLink', {}, {})
+ .then((response) => buildOldDotURL(url, response.shortLivedAuthToken))
+ .catch(() => buildOldDotURL(url)),
+ (oldDotURL) => oldDotURL,
+ );
}
/**
diff --git a/src/libs/actions/PersistedRequests.js b/src/libs/actions/PersistedRequests.js
index e3aafd18c35d..d893ee255287 100644
--- a/src/libs/actions/PersistedRequests.js
+++ b/src/libs/actions/PersistedRequests.js
@@ -1,8 +1,6 @@
import Onyx from 'react-native-onyx';
import _ from 'underscore';
-import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
-import HttpUtils from '../HttpUtils';
let persistedRequests = [];
@@ -19,12 +17,7 @@ function clear() {
* @param {Array} requestsToPersist
*/
function save(requestsToPersist) {
- HttpUtils.cancelPendingReconnectAppRequest();
- persistedRequests = _.chain(persistedRequests)
- .concat(requestsToPersist)
- .partition((request) => request.command !== CONST.NETWORK.COMMAND.RECONNECT_APP)
- .flatten()
- .value();
+ persistedRequests = persistedRequests.concat(requestsToPersist);
Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
}
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 2f6f812bdbe3..9027b8fb2f3c 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1316,6 +1316,8 @@ function deleteReport(reportID) {
* @param {String} reportID The reportID of the policy report (workspace room)
*/
function navigateToConciergeChatAndDeleteReport(reportID) {
+ // Dismiss the current report screen and replace it with Concierge Chat
+ Navigation.goBack();
navigateToConciergeChat();
deleteReport(reportID);
}
@@ -1633,12 +1635,18 @@ function removeEmojiReaction(reportID, originalReportAction, emoji) {
/**
* Calls either addEmojiReaction or removeEmojiReaction depending on if the current user has reacted to the report action.
* @param {String} reportID
- * @param {Object} reportAction
+ * @param {String} reportActionID
* @param {Object} emoji
* @param {number} paramSkinTone
* @returns {Promise}
*/
-function toggleEmojiReaction(reportID, reportAction, emoji, paramSkinTone = preferredSkinTone) {
+function toggleEmojiReaction(reportID, reportActionID, emoji, paramSkinTone = preferredSkinTone) {
+ const reportAction = ReportActionsUtils.getReportAction(reportID, reportActionID);
+
+ if (_.isEmpty(reportAction)) {
+ return;
+ }
+
const message = reportAction.message[0];
const reactionObject = message.reactions && _.find(message.reactions, (reaction) => reaction.emoji === emoji.name);
const skinTone = emoji.types === undefined ? null : paramSkinTone; // only use skin tone if emoji supports it
diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.js
index a500635222d6..a010621c4eea 100644
--- a/src/libs/actions/SignInRedirect.js
+++ b/src/libs/actions/SignInRedirect.js
@@ -10,6 +10,7 @@ import navigationRef from '../Navigation/navigationRef';
import SCREENS from '../../SCREENS';
import Navigation from '../Navigation/Navigation';
import * as ErrorUtils from '../ErrorUtils';
+import * as SessionUtils from '../SessionUtils';
let currentIsOffline;
let currentShouldForceOffline;
@@ -87,6 +88,7 @@ function redirectToSignIn(errorMessage) {
NetworkConnection.clearReconnectionCallbacks();
clearStorageAndRedirect(errorMessage);
resetHomeRouteParams();
+ SessionUtils.resetDidUserLogInDuringSession();
}
export default redirectToSignIn;
diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js
index 38072ea6669d..610315b63e84 100644
--- a/src/libs/actions/Task.js
+++ b/src/libs/actions/Task.js
@@ -65,6 +65,7 @@ function createTaskAndNavigate(parentReportID, title, description, assignee, ass
// Create the CreatedReportAction on the task
const optimisticTaskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(optimisticTaskReport.reportID);
const optimisticAddCommentReport = ReportUtils.buildOptimisticTaskCommentReportAction(taskReportID, title, assignee, assigneeAccountID, `Created a task: ${title}`, parentReportID);
+ optimisticTaskReport.parentReportActionID = optimisticAddCommentReport.reportAction.reportActionID;
const currentTime = DateUtils.getDBTime();
diff --git a/src/libs/actions/Timing.js b/src/libs/actions/Timing.js
index baaf666948ff..2be2cdc6fa63 100644
--- a/src/libs/actions/Timing.js
+++ b/src/libs/actions/Timing.js
@@ -2,6 +2,7 @@ import getPlatform from '../getPlatform';
import * as Environment from '../Environment/Environment';
import Firebase from '../Firebase';
import * as API from '../API';
+import Log from '../Log';
let timestampData = {};
@@ -26,8 +27,9 @@ function start(eventName, shouldUseFirebase = false) {
*
* @param {String} eventName - event name used as timestamp key
* @param {String} [secondaryName] - optional secondary event name, passed to grafana
+ * @param {number} [maxExecutionTime] - optional amount of time (ms) to wait before logging a warn
*/
-function end(eventName, secondaryName = '') {
+function end(eventName, secondaryName = '', maxExecutionTime = 0) {
if (!timestampData[eventName]) {
return;
}
@@ -51,6 +53,10 @@ function end(eventName, secondaryName = '') {
return;
}
+ if (maxExecutionTime && eventTime > maxExecutionTime) {
+ Log.warn(`${eventName} exceeded max execution time of ${maxExecutionTime}.`, {eventTime, eventName});
+ }
+
API.read(
'SendPerformanceTiming',
{
diff --git a/src/libs/migrations/PersonalDetailsByAccountID.js b/src/libs/migrations/PersonalDetailsByAccountID.js
index 2872e0ed1afe..34b8ebabc150 100644
--- a/src/libs/migrations/PersonalDetailsByAccountID.js
+++ b/src/libs/migrations/PersonalDetailsByAccountID.js
@@ -225,6 +225,13 @@ export default function () {
}
});
+ // The personalDetails object has been replaced by personalDetailsList
+ // So if we find an instance of personalDetails we will clear it out
+ if (oldPersonalDetails) {
+ Log.info('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails');
+ onyxData[DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS] = null;
+ }
+
return Onyx.multiSet(onyxData);
},
);
diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js
index 76a817acc334..8927088c3f67 100755
--- a/src/pages/NewChatPage.js
+++ b/src/pages/NewChatPage.js
@@ -1,5 +1,5 @@
import _ from 'underscore';
-import React, {Component} from 'react';
+import React, {useState, useEffect, useMemo} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -44,80 +44,58 @@ const defaultProps = {
reports: {},
};
-class NewChatPage extends Component {
- constructor(props) {
- super(props);
-
- this.toggleOption = this.toggleOption.bind(this);
- this.createChat = this.createChat.bind(this);
- this.createGroup = this.createGroup.bind(this);
- this.updateOptionsWithSearchTerm = this.updateOptionsWithSearchTerm.bind(this);
- this.excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE);
-
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
- props.reports,
- props.personalDetails,
- props.betas,
- '',
- [],
- this.props.isGroupChat ? this.excludedGroupEmails : [],
- );
- this.state = {
- searchTerm: '',
- recentReports,
- personalDetails,
- selectedOptions: [],
- userToInvite,
- };
- }
-
- componentDidUpdate(prevProps) {
- if (_.isEqual(prevProps.reports, this.props.reports) && _.isEqual(prevProps.personalDetails, this.props.personalDetails)) {
- return;
- }
- this.updateOptionsWithSearchTerm(this.state.searchTerm);
- }
-
- /**
- * Returns the sections needed for the OptionsSelector
- *
- * @param {Boolean} maxParticipantsReached
- * @returns {Array}
- */
- getSections(maxParticipantsReached) {
- const sections = [];
+const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE);
+
+function NewChatPage(props) {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [filteredRecentReports, setFilteredRecentReports] = useState([]);
+ const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]);
+ const [filteredUserToInvite, setFilteredUserToInvite] = useState();
+ const [selectedOptions, setSelectedOptions] = useState([]);
+
+ const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
+ const headerMessage = OptionsListUtils.getHeaderMessage(
+ filteredPersonalDetails.length + filteredRecentReports.length !== 0,
+ Boolean(filteredUserToInvite),
+ searchTerm,
+ maxParticipantsReached,
+ );
+ const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(props.personalDetails);
+
+ const sections = useMemo(() => {
+ const sectionsList = [];
let indexOffset = 0;
- if (this.props.isGroupChat) {
- sections.push({
+ if (props.isGroupChat) {
+ sectionsList.push({
title: undefined,
- data: this.state.selectedOptions,
- shouldShow: !_.isEmpty(this.state.selectedOptions),
+ data: selectedOptions,
+ shouldShow: !_.isEmpty(selectedOptions),
indexOffset,
});
- indexOffset += this.state.selectedOptions.length;
+ indexOffset += selectedOptions.length;
if (maxParticipantsReached) {
- return sections;
+ return sectionsList;
}
}
// Filtering out selected users from the search results
- const filterText = _.reduce(this.state.selectedOptions, (str, {login}) => `${str} ${login}`, '');
- const recentReportsWithoutSelected = _.filter(this.state.recentReports, ({login}) => !filterText.includes(login));
- const personalDetailsWithoutSelected = _.filter(this.state.personalDetails, ({login}) => !filterText.includes(login));
- const hasUnselectedUserToInvite = this.state.userToInvite && !filterText.includes(this.state.userToInvite.login);
+ const filterText = _.reduce(selectedOptions, (str, {login}) => `${str} ${login}`, '');
+ const recentReportsWithoutSelected = _.filter(filteredRecentReports, ({login}) => !filterText.includes(login));
+ const personalDetailsWithoutSelected = _.filter(filteredPersonalDetails, ({login}) => !filterText.includes(login));
+ const hasUnselectedUserToInvite = filteredUserToInvite && !filterText.includes(filteredUserToInvite.login);
- sections.push({
- title: this.props.translate('common.recents'),
+ sectionsList.push({
+ title: props.translate('common.recents'),
data: recentReportsWithoutSelected,
shouldShow: !_.isEmpty(recentReportsWithoutSelected),
indexOffset,
});
indexOffset += recentReportsWithoutSelected.length;
- sections.push({
- title: this.props.translate('common.contacts'),
+ sectionsList.push({
+ title: props.translate('common.contacts'),
data: personalDetailsWithoutSelected,
shouldShow: !_.isEmpty(personalDetailsWithoutSelected),
indexOffset,
@@ -125,67 +103,39 @@ class NewChatPage extends Component {
indexOffset += personalDetailsWithoutSelected.length;
if (hasUnselectedUserToInvite) {
- sections.push({
+ sectionsList.push({
title: undefined,
- data: [this.state.userToInvite],
+ data: [filteredUserToInvite],
shouldShow: true,
indexOffset,
});
}
- return sections;
- }
-
- updateOptionsWithSearchTerm(searchTerm = '') {
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
- this.props.reports,
- this.props.personalDetails,
- this.props.betas,
- searchTerm,
- [],
- this.props.isGroupChat ? this.excludedGroupEmails : [],
- );
- this.setState({
- searchTerm,
- userToInvite,
- recentReports,
- personalDetails,
- });
- }
+ return sectionsList;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, maxParticipantsReached, props.isGroupChat, selectedOptions]);
/**
* Removes a selected option from list if already selected. If not already selected add this option to the list.
* @param {Object} option
*/
- toggleOption(option) {
- this.setState((prevState) => {
- const isOptionInList = _.some(prevState.selectedOptions, (selectedOption) => selectedOption.login === option.login);
+ function toggleOption(option) {
+ const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login);
- let newSelectedOptions;
+ let newSelectedOptions;
- if (isOptionInList) {
- newSelectedOptions = _.reject(prevState.selectedOptions, (selectedOption) => selectedOption.login === option.login);
- } else {
- newSelectedOptions = [...prevState.selectedOptions, option];
- }
+ if (isOptionInList) {
+ newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
+ } else {
+ newSelectedOptions = [...selectedOptions, option];
+ }
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
- this.props.reports,
- this.props.personalDetails,
- this.props.betas,
- prevState.searchTerm,
- [],
- this.excludedGroupEmails,
- );
+ const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(props.reports, props.personalDetails, props.betas, searchTerm, [], excludedGroupEmails);
- return {
- selectedOptions: newSelectedOptions,
- recentReports,
- personalDetails,
- userToInvite,
- searchTerm: prevState.searchTerm,
- };
- });
+ setSelectedOptions(newSelectedOptions);
+ setFilteredRecentReports(recentReports);
+ setFilteredPersonalDetails(personalDetails);
+ setFilteredUserToInvite(userToInvite);
}
/**
@@ -194,7 +144,7 @@ class NewChatPage extends Component {
*
* @param {Object} option
*/
- createChat(option) {
+ function createChat(option) {
Report.navigateToAndOpenReport([option.login]);
}
@@ -202,65 +152,69 @@ class NewChatPage extends Component {
* Creates a new group chat with all the selected options and the current user,
* or navigates to the existing chat if one with those participants already exists.
*/
- createGroup() {
- if (!this.props.isGroupChat) {
+ const createGroup = () => {
+ if (!props.isGroupChat) {
return;
}
- const logins = _.pluck(this.state.selectedOptions, 'login');
+ const logins = _.pluck(selectedOptions, 'login');
if (logins.length < 1) {
return;
}
Report.navigateToAndOpenReport(logins);
- }
+ };
- render() {
- const maxParticipantsReached = this.state.selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
- const sections = this.getSections(maxParticipantsReached);
- const headerMessage = OptionsListUtils.getHeaderMessage(
- this.state.personalDetails.length + this.state.recentReports.length !== 0,
- Boolean(this.state.userToInvite),
- this.state.searchTerm,
- maxParticipantsReached,
- );
- const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(this.props.personalDetails);
-
- return (
-
- {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
- <>
-
-
- 0 ? safeAreaPaddingBottomStyle : {}]}>
- (this.props.isGroupChat ? this.toggleOption(option) : this.createChat(option))}
- onChangeText={this.updateOptionsWithSearchTerm}
- headerMessage={headerMessage}
- boldStyle
- shouldFocusOnSelectRow={this.props.isGroupChat && !Browser.isMobile()}
- shouldShowConfirmButton={this.props.isGroupChat}
- shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady}
- confirmButtonText={this.props.translate('newChatPage.createGroup')}
- onConfirmSelection={this.createGroup}
- textInputLabel={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')}
- safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
- />
-
- >
- )}
-
+ useEffect(() => {
+ const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
+ props.reports,
+ props.personalDetails,
+ props.betas,
+ searchTerm,
+ [],
+ props.isGroupChat ? excludedGroupEmails : [],
);
- }
+ setFilteredRecentReports(recentReports);
+ setFilteredPersonalDetails(personalDetails);
+ setFilteredUserToInvite(userToInvite);
+ // props.betas and props.isGroupChat are not added as dependencies since they don't change during the component lifecycle
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.reports, props.personalDetails, searchTerm]);
+
+ return (
+
+ {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
+ <>
+
+ 0 ? safeAreaPaddingBottomStyle : {}]}>
+ (props.isGroupChat ? toggleOption(option) : createChat(option))}
+ onChangeText={setSearchTerm}
+ headerMessage={headerMessage}
+ boldStyle
+ shouldFocusOnSelectRow={props.isGroupChat && !Browser.isMobile()}
+ shouldShowConfirmButton={props.isGroupChat}
+ shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady}
+ confirmButtonText={props.translate('newChatPage.createGroup')}
+ onConfirmSelection={createGroup}
+ textInputLabel={props.translate('optionsSelector.nameEmailOrPhoneNumber')}
+ safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
+ />
+
+ >
+ )}
+
+ );
}
NewChatPage.propTypes = propTypes;
NewChatPage.defaultProps = defaultProps;
+NewChatPage.displayName = 'NewChatPage';
export default compose(
withLocalize,
diff --git a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js
index 1d0c3032d5b9..053cd3432473 100644
--- a/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js
+++ b/src/pages/ReimbursementAccount/ContinueBankAccountSetup.js
@@ -51,7 +51,6 @@ function ContinueBankAccountSetup(props) {
icon={Illustrations.BankArrow}
>
Link.buildOldDotURL(secureYourAccountUrl),
},
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index 26588481f53a..cb727d9e3eae 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -63,7 +63,7 @@ function ReportDetailsPage(props) {
const isPolicyAdmin = useMemo(() => PolicyUtils.isPolicyAdmin(policy), [policy]);
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(props.report), [props.report]);
const isChatRoom = useMemo(() => ReportUtils.isChatRoom(props.report), [props.report]);
- const isThread = useMemo(() => ReportUtils.isThread(props.report), [props.report]);
+ const isThread = useMemo(() => ReportUtils.isChatThread(props.report), [props.report]);
const isUserCreatedPolicyRoom = useMemo(() => ReportUtils.isUserCreatedPolicyRoom(props.report), [props.report]);
const isArchivedRoom = useMemo(() => ReportUtils.isArchivedRoom(props.report), [props.report]);
diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js
index cdcaabcfe34d..6d9bdbffba54 100755
--- a/src/pages/ReportParticipantsPage.js
+++ b/src/pages/ReportParticipantsPage.js
@@ -93,7 +93,9 @@ function ReportParticipantsPage(props) {
1;
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant);
- const isThread = ReportUtils.isThread(props.report);
+ const isChatThread = ReportUtils.isChatThread(props.report);
const isChatRoom = ReportUtils.isChatRoom(props.report);
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(props.report);
const isTaskReport = ReportUtils.isTaskReport(props.report);
- const reportHeaderData = !isTaskReport && !isThread && props.report.parentReportID ? props.parentReport : props.report;
+ const reportHeaderData = !isTaskReport && !isChatThread && props.report.parentReportID ? props.parentReport : props.report;
const title = ReportUtils.getReportName(reportHeaderData);
const subtitle = ReportUtils.getChatRoomSubtitle(reportHeaderData);
+ const parentNavigationSubtitle = ReportUtils.getParentNavigationSubtitle(reportHeaderData);
const isConcierge = participants.length === 1 && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE);
const isAutomatedExpensifyAccount = participants.length === 1 && ReportUtils.hasAutomatedExpensifyAccountIDs(participants);
const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink');
@@ -174,7 +175,7 @@ function HeaderView(props) {
) : (
)}
@@ -184,35 +185,31 @@ function HeaderView(props) {
tooltipEnabled
numberOfLines={1}
textStyles={[styles.headerText, styles.pre]}
- shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isThread || isTaskReport}
+ shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isChatThread || isTaskReport}
/>
- {(isChatRoom || isPolicyExpenseChat || isThread) && !_.isEmpty(subtitle) && (
- <>
- {isThread ? (
- {
- Navigation.navigate(ROUTES.getReportRoute(props.report.parentReportID));
- }}
- style={[styles.alignSelfStart, styles.mw100]}
- accessibilityLabel={subtitle}
- accessibilityRole="link"
- >
-
- {subtitle}
-
-
- ) : (
-
- {subtitle}
-
- )}
- >
+ {!_.isEmpty(parentNavigationSubtitle) && (
+ {
+ Navigation.navigate(ROUTES.getReportRoute(props.report.parentReportID));
+ }}
+ accessibilityLabel={parentNavigationSubtitle}
+ accessibilityRole="link"
+ >
+
+ {parentNavigationSubtitle}
+
+
+ )}
+ {!_.isEmpty(subtitle) && (
+
+ {subtitle}
+
)}
{brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && (
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 7f36eb9ac8b3..4b7ddaec1f99 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -58,7 +58,7 @@ export default [
};
const onEmojiSelected = (emoji) => {
- Report.toggleEmojiReaction(reportID, reportAction, emoji);
+ Report.toggleEmojiReaction(reportID, reportAction.reportActionID, emoji);
closeContextMenu();
};
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index dad7d1f41b48..2afd28f934fe 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -199,7 +199,7 @@ function ReportActionItem(props) {
const toggleReaction = useCallback(
(emoji) => {
- Report.toggleEmojiReaction(props.report.reportID, props.action, emoji);
+ Report.toggleEmojiReaction(props.report.reportID, props.action.reportActionID, emoji);
},
[props.report, props.action],
);
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 8e48f06c3528..b0f108ed7d39 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -116,7 +116,7 @@ function ReportActionsList(props) {
({item: reportAction, index}) => {
// When the new indicator should not be displayed we explicitly set it to null
const shouldDisplayNewMarker = reportAction.reportActionID === newMarkerReportActionID;
- const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isThread(report);
+ const shouldDisplayParentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isChatThread(report);
const shouldHideThreadDividerLine =
shouldDisplayParentAction && sortedReportActions.length > 1 && sortedReportActions[sortedReportActions.length - 2].reportActionID === newMarkerReportActionID;
return shouldDisplayParentAction ? (
diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js
index ef486dff123f..7aef507ac12d 100755
--- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js
+++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js
@@ -1,4 +1,4 @@
-import React, {Component} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
@@ -55,37 +55,15 @@ const defaultProps = {
safeAreaPaddingBottomStyle: {},
};
-class MoneyRequestParticipantsSplitSelector extends Component {
- constructor(props) {
- super(props);
-
- this.toggleOption = this.toggleOption.bind(this);
- this.finalizeParticipants = this.finalizeParticipants.bind(this);
- this.updateOptionsWithSearchTerm = this.updateOptionsWithSearchTerm.bind(this);
-
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
- props.reports,
- props.personalDetails,
- props.betas,
- '',
- props.participants,
- CONST.EXPENSIFY_EMAILS,
- );
-
- this.state = {
- searchTerm: '',
- recentReports,
- personalDetails,
- userToInvite,
- };
- }
-
- componentDidUpdate(prevProps) {
- if (_.isEqual(prevProps.reports, this.props.reports) && _.isEqual(prevProps.personalDetails, this.props.personalDetails)) {
- return;
- }
- this.updateOptionsWithSearchTerm(this.state.searchTerm);
- }
+function MoneyRequestParticipantsSplitSelector({betas, participants, personalDetails, reports, translate, onAddParticipants, onStepComplete, safeAreaPaddingBottomStyle}) {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [newChatOptions, setNewChatOptions] = useState({
+ recentReports: [],
+ personalDetails: [],
+ userToInvite: null,
+ });
+
+ const maxParticipantsReached = participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
/**
* Returns the sections needed for the OptionsSelector
@@ -93,145 +71,124 @@ class MoneyRequestParticipantsSplitSelector extends Component {
* @param {Boolean} maxParticipantsReached
* @returns {Array}
*/
- getSections(maxParticipantsReached) {
- const sections = [];
+ const sections = useMemo(() => {
+ const newSections = [];
let indexOffset = 0;
- sections.push({
+ newSections.push({
title: undefined,
- data: OptionsListUtils.getParticipantsOptions(this.props.participants, this.props.personalDetails),
+ data: OptionsListUtils.getParticipantsOptions(participants, personalDetails),
shouldShow: true,
indexOffset,
});
- indexOffset += this.props.participants.length;
+ indexOffset += participants.length;
if (maxParticipantsReached) {
- return sections;
+ return newSections;
}
- sections.push({
- title: this.props.translate('common.recents'),
- data: this.state.recentReports,
- shouldShow: !_.isEmpty(this.state.recentReports),
+ newSections.push({
+ title: translate('common.recents'),
+ data: newChatOptions.recentReports,
+ shouldShow: !_.isEmpty(newChatOptions.recentReports),
indexOffset,
});
- indexOffset += this.state.recentReports.length;
+ indexOffset += newChatOptions.recentReports.length;
- sections.push({
- title: this.props.translate('common.contacts'),
- data: this.state.personalDetails,
- shouldShow: !_.isEmpty(this.state.personalDetails),
+ newSections.push({
+ title: translate('common.contacts'),
+ data: newChatOptions.personalDetails,
+ shouldShow: !_.isEmpty(newChatOptions.personalDetails),
indexOffset,
});
- indexOffset += this.state.personalDetails.length;
+ indexOffset += newChatOptions.personalDetails.length;
- if (this.state.userToInvite && !OptionsListUtils.isCurrentUser(this.state.userToInvite)) {
- sections.push({
+ if (newChatOptions.userToInvite && !OptionsListUtils.isCurrentUser(newChatOptions.userToInvite)) {
+ newSections.push({
undefined,
- data: [this.state.userToInvite],
+ data: [newChatOptions.userToInvite],
shouldShow: true,
indexOffset,
});
}
- return sections;
- }
-
- updateOptionsWithSearchTerm(searchTerm = '') {
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
- this.props.reports,
- this.props.personalDetails,
- this.props.betas,
- searchTerm,
- this.props.participants,
- CONST.EXPENSIFY_EMAILS,
- );
- this.setState({
- searchTerm,
- userToInvite,
- recentReports,
- personalDetails,
- });
- }
-
- /**
- * Once a single or more users are selected, navigates to next step
- */
- finalizeParticipants() {
- this.props.onStepComplete();
- }
+ return newSections;
+ }, [maxParticipantsReached, newChatOptions, participants, personalDetails, translate]);
/**
* Removes a selected option from list if already selected. If not already selected add this option to the list.
* @param {Object} option
*/
- toggleOption(option) {
- const isOptionInList = _.some(this.props.participants, (selectedOption) => selectedOption.accountID === option.accountID);
+ const toggleOption = useCallback(
+ (option) => {
+ const isOptionInList = _.some(participants, (selectedOption) => selectedOption.accountID === option.accountID);
- let newSelectedOptions;
+ let newSelectedOptions;
- if (isOptionInList) {
- newSelectedOptions = _.reject(this.props.participants, (selectedOption) => selectedOption.accountID === option.accountID);
- } else {
- newSelectedOptions = [...this.props.participants, {accountID: option.accountID, login: option.login, selected: true}];
- }
+ if (isOptionInList) {
+ newSelectedOptions = _.reject(participants, (selectedOption) => selectedOption.accountID === option.accountID);
+ } else {
+ newSelectedOptions = [...participants, {accountID: option.accountID, login: option.login, selected: true}];
+ }
+
+ onAddParticipants(newSelectedOptions);
+
+ const chatOptions = OptionsListUtils.getNewChatOptions(reports, personalDetails, betas, isOptionInList ? searchTerm : '', newSelectedOptions, CONST.EXPENSIFY_EMAILS);
- this.props.onAddParticipants(newSelectedOptions);
-
- this.setState((prevState) => {
- const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
- this.props.reports,
- this.props.personalDetails,
- this.props.betas,
- isOptionInList ? prevState.searchTerm : '',
- newSelectedOptions,
- CONST.EXPENSIFY_EMAILS,
- );
- return {
- recentReports,
- personalDetails,
- userToInvite,
- searchTerm: isOptionInList ? prevState.searchTerm : '',
- };
+ setNewChatOptions({
+ recentReports: chatOptions.recentReports,
+ personalDetails: chatOptions.personalDetails,
+ userToInvite: chatOptions.userToInvite,
+ });
+ if (!isOptionInList) {
+ setSearchTerm('');
+ }
+ },
+ [searchTerm, participants, onAddParticipants, reports, personalDetails, betas, setNewChatOptions, setSearchTerm],
+ );
+
+ const headerMessage = OptionsListUtils.getHeaderMessage(
+ newChatOptions.personalDetails.length + newChatOptions.recentReports.length !== 0,
+ Boolean(newChatOptions.userToInvite),
+ searchTerm,
+ maxParticipantsReached,
+ );
+ const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails);
+
+ useEffect(() => {
+ const chatOptions = OptionsListUtils.getNewChatOptions(reports, personalDetails, betas, searchTerm, participants, CONST.EXPENSIFY_EMAILS);
+ setNewChatOptions({
+ recentReports: chatOptions.recentReports,
+ personalDetails: chatOptions.personalDetails,
+ userToInvite: chatOptions.userToInvite,
});
- }
-
- render() {
- const maxParticipantsReached = this.props.participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
- const sections = this.getSections(maxParticipantsReached);
- const headerMessage = OptionsListUtils.getHeaderMessage(
- this.state.personalDetails.length + this.state.recentReports.length !== 0,
- Boolean(this.state.userToInvite),
- this.state.searchTerm,
- maxParticipantsReached,
- );
- const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(this.props.personalDetails);
-
- return (
- 0 ? this.props.safeAreaPaddingBottomStyle : {}]}>
-
-
- );
- }
+ }, [betas, reports, participants, personalDetails, translate, searchTerm, setNewChatOptions]);
+
+ return (
+ 0 ? safeAreaPaddingBottomStyle : {}]}>
+
+
+ );
}
MoneyRequestParticipantsSplitSelector.propTypes = propTypes;
MoneyRequestParticipantsSplitSelector.defaultProps = defaultProps;
+MoneyRequestParticipantsSplitSelector.displayName = 'MoneyRequestParticipantsSplitSelector';
export default compose(
withLocalize,
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index b7364ce623fa..97d013f31084 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -21,7 +21,6 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize
import compose from '../../libs/compose';
import CONST from '../../CONST';
import Permissions from '../../libs/Permissions';
-import * as App from '../../libs/actions/App';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../../components/withCurrentUserPersonalDetails';
import * as PaymentMethods from '../../libs/actions/PaymentMethods';
import bankAccountPropTypes from '../../components/bankAccountPropTypes';
@@ -200,7 +199,7 @@ class InitialSettingsPage extends React.Component {
translationKey: 'common.profile',
icon: Expensicons.Profile,
action: () => {
- App.openProfile();
+ Navigation.navigate(ROUTES.SETTINGS_PROFILE);
},
brickRoadIndicator: profileBrickRoadIndicator,
},
diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js
index 1fd1e585648d..07db3d0cdffb 100755
--- a/src/pages/settings/Profile/ProfilePage.js
+++ b/src/pages/settings/Profile/ProfilePage.js
@@ -1,5 +1,5 @@
import lodashGet from 'lodash/get';
-import React from 'react';
+import React, {useEffect} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
@@ -23,6 +23,7 @@ import * as Expensicons from '../../../components/Icon/Expensicons';
import ONYXKEYS from '../../../ONYXKEYS';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
import userPropTypes from '../userPropTypes';
+import * as App from '../../../libs/actions/App';
const propTypes = {
/* Onyx Props */
@@ -84,6 +85,10 @@ function ProfilePage(props) {
},
];
+ useEffect(() => {
+ App.openProfile(props.currentUserPersonalDetails);
+ }, [props.currentUserPersonalDetails]);
+
return (
policy && policy.id === this.props.report.policyID);
- const shouldDisableRename = this.shouldDisableRename(linkedWorkspace) || ReportUtils.isThread(this.props.report);
+ const shouldDisableRename = this.shouldDisableRename(linkedWorkspace) || ReportUtils.isChatThread(this.props.report);
const notificationPreference = this.props.translate(`notificationPreferencesPage.notificationPreferences.${this.props.report.notificationPreference}`);
const shouldDisableWelcomeMessage = this.shouldDisableWelcomeMessage(linkedWorkspace);
const writeCapability = this.props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL;
@@ -119,6 +119,7 @@ class ReportSettingsPage extends Component {
Report.clearPolicyRoomNameErrors(this.props.report.reportID)}
>
{shouldDisableRename ? (
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index a875c25359b0..8c3d513f5303 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -1,30 +1,27 @@
-import React, {Component} from 'react';
+import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
-import lodashGet from 'lodash/get';
import Str from 'expensify-common/lib/str';
-import {withSafeAreaInsets} from 'react-native-safe-area-context';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
import ONYXKEYS from '../../ONYXKEYS';
import styles from '../../styles/styles';
-import compose from '../../libs/compose';
import SignInPageLayout from './SignInPageLayout';
import LoginForm from './LoginForm';
import PasswordForm from './PasswordForm';
import ValidateCodeForm from './ValidateCodeForm';
import ResendValidationForm from './ResendValidationForm';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
import Performance from '../../libs/Performance';
import * as App from '../../libs/actions/App';
-import Permissions from '../../libs/Permissions';
import UnlinkLoginForm from './UnlinkLoginForm';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions';
import * as Localize from '../../libs/Localize';
import * as StyleUtils from '../../styles/StyleUtils';
+import useLocalize from '../../hooks/useLocalize';
+import usePermissions from '../../hooks/usePermissions';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import Log from '../../libs/Log';
const propTypes = {
- /* Onyx Props */
-
/** The details about the account that the user is signing in with */
account: PropTypes.shape({
/** Error to display when there is an account error returned */
@@ -35,153 +32,149 @@ const propTypes = {
/** The primaryLogin associated with the account */
primaryLogin: PropTypes.string,
- }),
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
+ /** Has the user pressed the forgot password button? */
+ forgotPassword: PropTypes.bool,
+
+ /** Does this account require 2FA? */
+ requiresTwoFactorAuth: PropTypes.bool,
+ }),
/** The credentials of the person signing in */
credentials: PropTypes.shape({
login: PropTypes.string,
password: PropTypes.string,
twoFactorAuthCode: PropTypes.string,
+ validateCode: PropTypes.string,
}),
-
- ...withLocalizePropTypes,
-
- ...windowDimensionsPropTypes,
};
const defaultProps = {
account: {},
- betas: [],
credentials: {},
};
-class SignInPage extends Component {
- componentDidMount() {
- Performance.measureTTI();
+/**
+ * @param {Boolean} hasLogin
+ * @param {Boolean} hasPassword
+ * @param {Boolean} hasValidateCode
+ * @param {Boolean} isPrimaryLogin
+ * @param {Boolean} isAccountValidated
+ * @param {Boolean} didForgetPassword
+ * @param {Boolean} canUsePasswordlessLogins
+ * @returns {Object}
+ */
+function getRenderOptions({hasLogin, hasPassword, hasValidateCode, isPrimaryLogin, isAccountValidated, didForgetPassword, canUsePasswordlessLogins}) {
+ const shouldShowLoginForm = !hasLogin && !hasValidateCode;
+ const isUnvalidatedSecondaryLogin = hasLogin && !isPrimaryLogin && !isAccountValidated;
+ const shouldShowPasswordForm = hasLogin && isAccountValidated && !hasPassword && !didForgetPassword && !isUnvalidatedSecondaryLogin && !canUsePasswordlessLogins;
+ const shouldShowValidateCodeForm = (hasLogin || hasValidateCode) && !isUnvalidatedSecondaryLogin && canUsePasswordlessLogins;
+ const shouldShowResendValidationForm = hasLogin && (!isAccountValidated || didForgetPassword) && !isUnvalidatedSecondaryLogin && !canUsePasswordlessLogins;
+ const shouldShowWelcomeHeader = shouldShowLoginForm || shouldShowPasswordForm || shouldShowValidateCodeForm || isUnvalidatedSecondaryLogin;
+ const shouldShowWelcomeText = shouldShowLoginForm || shouldShowPasswordForm || shouldShowValidateCodeForm;
+ return {
+ shouldShowLoginForm,
+ shouldShowUnlinkLoginForm: isUnvalidatedSecondaryLogin,
+ shouldShowPasswordForm,
+ shouldShowValidateCodeForm,
+ shouldShowResendValidationForm,
+ shouldShowWelcomeHeader,
+ shouldShowWelcomeText,
+ };
+}
- App.setLocale(Localize.getDevicePreferredLocale());
- }
+function SignInPage({credentials, account}) {
+ const {translate, formatPhoneNumber} = useLocalize();
+ const {canUsePasswordlessLogins} = usePermissions();
+ const {isSmallScreenWidth} = useWindowDimensions();
+ const safeAreaInsets = useSafeAreaInsets();
- render() {
- // Show the login form if
- // - A login has not been entered yet
- // - AND a validateCode has not been cached with sign in link
- const showLoginForm = !this.props.credentials.login && !this.props.credentials.validateCode;
-
- // Show the unlink form if
- // - A login has been entered
- // - AND the login is not the primary login
- // - AND the login is not validated
- const showUnlinkLoginForm =
- Boolean(this.props.credentials.login && this.props.account.primaryLogin) && this.props.account.primaryLogin !== this.props.credentials.login && !this.props.account.validated;
-
- // Show the old password form if
- // - A login has been entered
- // - AND an account exists and is validated for this login
- // - AND a password hasn't been entered yet
- // - AND haven't forgotten password
- // - AND the login isn't an unvalidated secondary login
- // - AND the user is NOT on the passwordless beta
- const showPasswordForm =
- Boolean(this.props.credentials.login) &&
- this.props.account.validated &&
- !this.props.credentials.password &&
- !this.props.account.forgotPassword &&
- !showUnlinkLoginForm &&
- !Permissions.canUsePasswordlessLogins(this.props.betas);
-
- // Show the new magic code / validate code form if
- // - A login has been entered or a validateCode has been cached from sign in link
- // - AND the login isn't an unvalidated secondary login
- // - AND the user is on the 'passwordless' beta
- const showValidateCodeForm =
- Boolean(this.props.credentials.login || this.props.credentials.validateCode) && !showUnlinkLoginForm && Permissions.canUsePasswordlessLogins(this.props.betas);
-
- // Show the resend validation link form if
- // - A login has been entered
- // - AND is not validated or password is forgotten
- // - AND the login isn't an unvalidated secondary login
- // - AND user is not on 'passwordless' beta
- const showResendValidationForm =
- Boolean(this.props.credentials.login) &&
- (!this.props.account.validated || this.props.account.forgotPassword) &&
- !showUnlinkLoginForm &&
- !Permissions.canUsePasswordlessLogins(this.props.betas);
-
- let welcomeHeader = '';
- let welcomeText = '';
- if (showValidateCodeForm) {
- if (this.props.account.requiresTwoFactorAuth) {
- // We will only know this after a user signs in successfully, without their 2FA code
- welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack');
- welcomeText = this.props.translate('validateCodeForm.enterAuthenticatorCode');
+ useEffect(() => Performance.measureTTI(), []);
+ useEffect(() => {
+ App.setLocale(Localize.getDevicePreferredLocale());
+ }, []);
+
+ const {
+ shouldShowLoginForm,
+ shouldShowUnlinkLoginForm,
+ shouldShowPasswordForm,
+ shouldShowValidateCodeForm,
+ shouldShowResendValidationForm,
+ shouldShowWelcomeHeader,
+ shouldShowWelcomeText,
+ } = getRenderOptions({
+ hasLogin: Boolean(credentials.login),
+ hasPassword: Boolean(credentials.password),
+ hasValidateCode: Boolean(credentials.validateCode),
+ isPrimaryLogin: !account.primaryLogin || account.primaryLogin === credentials.login,
+ isAccountValidated: Boolean(account.validated),
+ didForgetPassword: Boolean(account.forgotPassword),
+ canUsePasswordlessLogins,
+ });
+
+ let welcomeHeader;
+ let welcomeText;
+ if (shouldShowValidateCodeForm) {
+ if (account.requiresTwoFactorAuth) {
+ // We will only know this after a user signs in successfully, without their 2FA code
+ welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack');
+ welcomeText = translate('validateCodeForm.enterAuthenticatorCode');
+ } else {
+ const userLogin = Str.removeSMSDomain(credentials.login || '');
+
+ // replacing spaces with "hard spaces" to prevent breaking the number
+ const userLoginToDisplay = Str.isSMSLogin(userLogin) ? formatPhoneNumber(userLogin).replace(/ /g, '\u00A0') : userLogin;
+ if (account.validated) {
+ welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack');
+ welcomeText = isSmallScreenWidth
+ ? `${translate('welcomeText.welcomeBack')} ${translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay})}`
+ : translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay});
} else {
- const userLogin = Str.removeSMSDomain(lodashGet(this.props, 'credentials.login', ''));
-
- // replacing spaces with "hard spaces" to prevent breaking the number
- const userLoginToDisplay = Str.isSMSLogin(userLogin) ? this.props.formatPhoneNumber(userLogin).replace(/ /g, '\u00A0') : userLogin;
- if (this.props.account.validated) {
- welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack');
- welcomeText = this.props.isSmallScreenWidth
- ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay})}`
- : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLoginToDisplay});
- } else {
- welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcome');
- welcomeText = this.props.isSmallScreenWidth
- ? `${this.props.translate('welcomeText.welcome')} ${this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay})}`
- : this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay});
- }
+ welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcome');
+ welcomeText = isSmallScreenWidth
+ ? `${translate('welcomeText.welcome')} ${translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay})}`
+ : translate('welcomeText.newFaceEnterMagicCode', {login: userLoginToDisplay});
}
- } else if (showPasswordForm) {
- welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack');
- welcomeText = this.props.isSmallScreenWidth
- ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.enterPassword')}`
- : this.props.translate('welcomeText.enterPassword');
- } else if (showUnlinkLoginForm) {
- welcomeHeader = this.props.isSmallScreenWidth ? this.props.translate('login.hero.header') : this.props.translate('welcomeText.welcomeBack');
- } else if (!showResendValidationForm) {
- welcomeHeader = this.props.isSmallScreenWidth ? this.props.translate('login.hero.header') : this.props.translate('welcomeText.getStarted');
- welcomeText = this.props.isSmallScreenWidth ? this.props.translate('welcomeText.getStarted') : '';
}
+ } else if (shouldShowPasswordForm) {
+ welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack');
+ welcomeText = isSmallScreenWidth ? `${translate('welcomeText.welcomeBack')} ${translate('welcomeText.enterPassword')}` : translate('welcomeText.enterPassword');
+ } else if (shouldShowUnlinkLoginForm) {
+ welcomeHeader = isSmallScreenWidth ? translate('login.hero.header') : translate('welcomeText.welcomeBack');
+ } else if (!shouldShowResendValidationForm) {
+ welcomeHeader = isSmallScreenWidth ? translate('login.hero.header') : translate('welcomeText.getStarted');
+ welcomeText = isSmallScreenWidth ? translate('welcomeText.getStarted') : '';
+ } else {
+ Log.warn('SignInPage in unexpected state!');
+ }
- return (
- // There is an issue SafeAreaView on Android where wrong insets flicker on app start.
- // Can be removed once https://github.com/th3rdwave/react-native-safe-area-context/issues/364 is resolved.
-
-
- {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden
+ return (
+
+
+ {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden
so that password managers can access the values. Conditionally rendering these components will break this feature. */}
-
- {showValidateCodeForm ? : }
- {showResendValidationForm && }
- {showUnlinkLoginForm && }
-
-
- );
- }
+
+ {shouldShowValidateCodeForm ? : }
+ {shouldShowResendValidationForm && }
+ {shouldShowUnlinkLoginForm && }
+
+
+ );
}
SignInPage.propTypes = propTypes;
SignInPage.defaultProps = defaultProps;
+SignInPage.displayName = 'SignInPage';
-export default compose(
- withSafeAreaInsets,
- withLocalize,
- withWindowDimensions,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- betas: {key: ONYXKEYS.BETAS},
- credentials: {key: ONYXKEYS.CREDENTIALS},
- }),
-)(SignInPage);
+export default withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ credentials: {key: ONYXKEYS.CREDENTIALS},
+})(SignInPage);
diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js
index 4271a81ce956..a5bb8493da51 100644
--- a/src/pages/workspace/WorkspaceInitialPage.js
+++ b/src/pages/workspace/WorkspaceInitialPage.js
@@ -201,7 +201,7 @@ function WorkspaceInitialPage(props) {
pendingAction={policy.pendingAction}
onClose={() => dismissError(policy.id)}
errors={policy.errors}
- errorRowStyles={[styles.ph6, styles.pv2]}
+ errorRowStyles={[styles.ph5, styles.pv2]}
>
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index 990320037b6d..23256757af47 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -13,7 +13,6 @@ import compose from '../../libs/compose';
import ONYXKEYS from '../../ONYXKEYS';
import * as Policy from '../../libs/actions/Policy';
import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton';
-import FormSubmit from '../../components/FormSubmit';
import OptionsSelector from '../../components/OptionsSelector';
import * as OptionsListUtils from '../../libs/OptionsListUtils';
import CONST from '../../CONST';
@@ -93,10 +92,7 @@ class WorkspaceInvitePage extends React.Component {
}
componentDidUpdate(prevProps) {
- if (!_.isEqual(prevProps.personalDetails, this.props.personalDetails)) {
- this.updateOptionsWithSearchTerm(this.props.searchTerm);
- }
- if (!_.isEqual(prevProps.policyMembers, this.props.policyMembers)) {
+ if (!_.isEqual(prevProps.personalDetails, this.props.personalDetails) || !_.isEqual(prevProps.policyMembers, this.props.policyMembers)) {
this.updateOptionsWithSearchTerm(this.state.searchTerm);
}
@@ -176,10 +172,20 @@ class WorkspaceInvitePage extends React.Component {
updateOptionsWithSearchTerm(searchTerm = '') {
const {personalDetails, userToInvite} = OptionsListUtils.getMemberInviteOptions(this.props.personalDetails, this.props.betas, searchTerm, this.getExcludedUsers());
+
+ // Update selectedOptions with the latest personalDetails and policyMembers information
+ const detailsMap = {};
+ _.forEach(personalDetails, (detail) => (detailsMap[detail.login] = detail));
+ const selectedOptions = [];
+ _.forEach(this.state.selectedOptions, (option) => {
+ selectedOptions.push(_.has(detailsMap, option.login) ? detailsMap[option.login] : option);
+ });
+
this.setState({
searchTerm,
userToInvite,
personalDetails,
+ selectedOptions,
});
}
@@ -267,52 +273,47 @@ class WorkspaceInvitePage extends React.Component {
shouldShow={_.isEmpty(this.props.policy)}
onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)}
>
-
- {
- this.clearErrors();
- Navigation.goBack(ROUTES.getWorkspaceMembersRoute(this.props.route.params.policyID));
- }}
+ {
+ this.clearErrors();
+ Navigation.goBack(ROUTES.getWorkspaceMembersRoute(this.props.route.params.policyID));
+ }}
+ />
+
+
+
+
+
-
-
-
-
-
-
-
+
);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js
index 361610266b42..c59b6687a809 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.js
@@ -1,11 +1,10 @@
-import React from 'react';
+import React, {useState, useCallback, useMemo, useEffect} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import {withOnyx} from 'react-native-onyx';
import PropTypes from 'prop-types';
import * as Report from '../../libs/actions/Report';
-import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize';
-import compose from '../../libs/compose';
+import useLocalize from '../../hooks/useLocalize';
import HeaderWithBackButton from '../../components/HeaderWithBackButton';
import Navigation from '../../libs/Navigation/Navigation';
import ScreenWrapper from '../../components/ScreenWrapper';
@@ -55,8 +54,6 @@ const propTypes = {
/** A collection of objects for all policies which key policy member objects by accountIDs */
allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)),
-
- ...withLocalizePropTypes,
};
const defaultProps = {
betas: [],
@@ -65,151 +62,145 @@ const defaultProps = {
allPolicyMembers: {},
};
-class WorkspaceNewRoomPage extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- visibilityDescription: this.props.translate('newRoomPage.restrictedDescription'),
- };
-
- this.validate = this.validate.bind(this);
- this.submit = this.submit.bind(this);
- this.updateVisibilityDescription = this.updateVisibilityDescription.bind(this);
- }
+function WorkspaceNewRoomPage(props) {
+ const {translate} = useLocalize();
+ const [visibility, setVisibility] = useState(CONST.REPORT.VISIBILITY.RESTRICTED);
+ const visibilityDescription = useMemo(() => translate(`newRoomPage.${visibility}Description`), [translate, visibility]);
/**
* @param {Object} values - form input values passed by the Form component
*/
- submit(values) {
- const policyMembers = _.map(_.keys(this.props.allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${values.policyID}`]), (accountID) => Number(accountID));
+ const submit = (values) => {
+ const policyMembers = _.map(_.keys(props.allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${values.policyID}`]), (accountID) => Number(accountID));
Report.addPolicyReport(values.policyID, values.roomName, values.visibility, policyMembers);
- }
-
- /**
- * @param {String} visibility - form input value passed by the Form component
- */
- updateVisibilityDescription(visibility) {
- const visibilityDescription = this.props.translate(`newRoomPage.${visibility}Description`);
- if (visibilityDescription === this.state.visibilityDescription) {
- return;
- }
- this.setState({visibilityDescription});
- }
+ };
/**
* @param {Object} values - form input values passed by the Form component
* @returns {Boolean}
*/
- validate(values) {
- const errors = {};
-
- if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) {
- // We error if the user doesn't enter a room name or left blank
- ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.pleaseEnterRoomName');
- } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.roomName)) {
- // We error if the room name has invalid characters
- ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomNameInvalidError');
- } 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, this.props.reports, values.policyID)) {
- // Certain names are reserved for default rooms and should not be used for policy rooms.
- ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError');
- }
-
- if (!values.policyID) {
- errors.policyID = 'newRoomPage.pleaseSelectWorkspace';
+ const validate = useCallback(
+ (values) => {
+ const errors = {};
+
+ if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) {
+ // We error if the user doesn't enter a room name or left blank
+ ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.pleaseEnterRoomName');
+ } else if (values.roomName !== CONST.POLICY.ROOM_PREFIX && !ValidationUtils.isValidRoomName(values.roomName)) {
+ // We error if the room name has invalid characters
+ ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomNameInvalidError');
+ } 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)) {
+ // Certain names are reserved for default rooms and should not be used for policy rooms.
+ ErrorUtils.addErrorMessage(errors, 'roomName', 'newRoomPage.roomAlreadyExistsError');
+ }
+
+ if (!values.policyID) {
+ errors.policyID = 'newRoomPage.pleaseSelectWorkspace';
+ }
+
+ return errors;
+ },
+ [props.reports],
+ );
+
+ // Workspaces are policies with type === 'free'
+ const workspaceOptions = useMemo(
+ () =>
+ _.map(
+ _.filter(props.policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE),
+ (policy) => ({label: policy.name, key: policy.id, value: policy.id}),
+ ),
+ [props.policies],
+ );
+
+ const visibilityOptions = useMemo(
+ () =>
+ _.map(
+ _.filter(_.values(CONST.REPORT.VISIBILITY), (visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE),
+ (visibilityOption) => ({
+ label: translate(`newRoomPage.visibilityOptions.${visibilityOption}`),
+ value: visibilityOption,
+ description: translate(`newRoomPage.${visibilityOption}Description`),
+ }),
+ ),
+ [translate],
+ );
+
+ useEffect(() => {
+ if (Permissions.canUsePolicyRooms(props.betas)) {
+ return;
}
+ Log.info('Not showing create Policy Room page since user is not on policy rooms beta');
+ Navigation.dismissModal();
+ }, [props.betas]);
- return errors;
+ if (!Permissions.canUsePolicyRooms(props.betas)) {
+ return null;
}
- render() {
- if (!Permissions.canUsePolicyRooms(this.props.betas)) {
- Log.info('Not showing create Policy Room page since user is not on policy rooms beta');
- Navigation.dismissModal();
- return null;
- }
-
- // Workspaces are policies with type === 'free'
- const workspaceOptions = _.map(
- _.filter(this.props.policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE),
- (policy) => ({label: policy.name, key: policy.id, value: policy.id}),
- );
-
- const visibilityOptions = _.map(
- _.filter(_.values(CONST.REPORT.VISIBILITY), (visibilityOption) => visibilityOption !== CONST.REPORT.VISIBILITY.PUBLIC_ANNOUNCE),
- (visibilityOption) => ({
- label: this.props.translate(`newRoomPage.visibilityOptions.${visibilityOption}`),
- value: visibilityOption,
- description: this.props.translate(`newRoomPage.${visibilityOption}Description`),
- }),
- );
-
- return (
-
+
+
-
- );
- }
+
+
+
+
+
+
+
+
+
+ {visibilityDescription}
+
+
+ );
}
WorkspaceNewRoomPage.propTypes = propTypes;
WorkspaceNewRoomPage.defaultProps = defaultProps;
-
-export default compose(
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- allPolicyMembers: {
- key: ONYXKEYS.COLLECTION.POLICY_MEMBERS,
- },
- }),
- withLocalize,
-)(WorkspaceNewRoomPage);
+WorkspaceNewRoomPage.displayName = 'WorkspaceNewRoomPage';
+
+export default withOnyx({
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ },
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ allPolicyMembers: {
+ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS,
+ },
+})(WorkspaceNewRoomPage);
diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js
index f88ced6fc9fb..6f11c70a6707 100755
--- a/src/pages/workspace/WorkspacesListPage.js
+++ b/src/pages/workspace/WorkspacesListPage.js
@@ -156,7 +156,7 @@ class WorkspacesListPage extends Component {
diff --git a/src/stories/TextInput.stories.js b/src/stories/TextInput.stories.js
index 64329ffed715..098828c65198 100644
--- a/src/stories/TextInput.stories.js
+++ b/src/stories/TextInput.stories.js
@@ -60,32 +60,6 @@ PlaceholderInput.args = {
placeholder: 'My placeholder text',
};
-const AutoGrowInput = Template.bind({});
-AutoGrowInput.args = {
- label: 'Autogrow input',
- name: 'AutoGrow',
- placeholder: 'My placeholder text',
- autoGrow: true,
- textInputContainerStyles: [
- {
- minWidth: 150,
- },
- ],
-};
-
-const AutoGrowHeightInput = Template.bind({});
-AutoGrowHeightInput.args = {
- label: 'Autogrowheight input',
- name: 'AutoGrowHeight',
- placeholder: 'My placeholder text',
- autoGrowHeight: true,
- textInputContainerStyles: [
- {
- maxHeight: 115,
- },
- ],
-};
-
const PrefixedInput = Template.bind({});
PrefixedInput.args = {
label: 'Prefixed input',
@@ -126,5 +100,50 @@ HintAndErrorInput.args = {
hint: 'Type "Oops!" to see the error',
};
+// To use autoGrow we need to control the TextInput's value
+function AutoGrowSupportInput(args) {
+ const [value, setValue] = useState(args.value || '');
+ React.useEffect(() => {
+ setValue(args.value || '');
+ }, [args.value]);
+
+ return (
+
+ );
+}
+
+const AutoGrowInput = AutoGrowSupportInput.bind({});
+AutoGrowInput.args = {
+ label: 'Autogrow input',
+ name: 'AutoGrow',
+ placeholder: 'My placeholder text',
+ autoGrow: true,
+ textInputContainerStyles: [
+ {
+ minWidth: 150,
+ maxWidth: 500,
+ },
+ ],
+ value: '',
+};
+
+const AutoGrowHeightInput = AutoGrowSupportInput.bind({});
+AutoGrowHeightInput.args = {
+ label: 'Autogrowheight input',
+ name: 'AutoGrowHeight',
+ placeholder: 'My placeholder text',
+ autoGrowHeight: true,
+ textInputContainerStyles: [
+ {
+ maxHeight: 115,
+ },
+ ],
+};
+
export default story;
export {AutoFocus, DefaultInput, DefaultValueInput, ErrorInput, ForceActiveLabel, PlaceholderInput, AutoGrowInput, AutoGrowHeightInput, PrefixedInput, MaxLengthInput, HintAndErrorInput};
diff --git a/src/styles/styles.js b/src/styles/styles.js
index f1bd92e11eb4..1d0770d5ceb8 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -551,6 +551,10 @@ const styles = {
marginVertical: 1,
},
+ noBorderRadius: {
+ borderRadius: 0,
+ },
+
noRightBorderRadius: {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
@@ -2842,10 +2846,6 @@ const styles = {
errorDot: {
marginRight: 12,
},
- menuItemErrorPadding: {
- paddingLeft: 44,
- paddingRight: 20,
- },
},
dotIndicatorMessage: {
diff --git a/tests/actions/ReportTest.js b/tests/actions/ReportTest.js
index 6fbf3b88dd82..5566c66df4b7 100644
--- a/tests/actions/ReportTest.js
+++ b/tests/actions/ReportTest.js
@@ -656,7 +656,7 @@ describe('actions/Report', () => {
const resultAction = _.first(_.values(reportActions));
// Add a reaction to the comment
- Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI);
+ Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, EMOJI);
return waitForPromisesToResolve();
})
.then(() => {
@@ -665,7 +665,7 @@ describe('actions/Report', () => {
// Now we toggle the reaction while the skin tone has changed.
// As the emoji doesn't support skin tones, the emoji
// should get removed instead of added again.
- Report.toggleEmojiReaction(REPORT_ID, resultAction, EMOJI, 2);
+ Report.toggleEmojiReaction(REPORT_ID, resultAction.reportActionID, EMOJI, 2);
return waitForPromisesToResolve();
})
.then(() => {
diff --git a/tests/unit/GooglePlacesUtilsTest.js b/tests/unit/GooglePlacesUtilsTest.js
index ef7a4491fec0..1bb27bdd9f2f 100644
--- a/tests/unit/GooglePlacesUtilsTest.js
+++ b/tests/unit/GooglePlacesUtilsTest.js
@@ -129,6 +129,12 @@ const addressComponents = [
types: ['postal_code'],
},
];
+
+const autoCompleteTerms = [
+ {offset: 0, value: 'Bangladesh Border Road'},
+ {offset: 24, value: 'Bangladesh'},
+];
+
describe('GooglePlacesUtilsTest', () => {
describe('getAddressComponents', () => {
it('should find address components by type', () => {
@@ -189,4 +195,14 @@ describe('GooglePlacesUtilsTest', () => {
expect(executionTime).toBeLessThan(5.0);
});
});
+ describe('getPlaceAutocompleteTerms', () => {
+ it('should find auto complete terms', () => {
+ expect(GooglePlacesUtils.getPlaceAutocompleteTerms(autoCompleteTerms)).toStrictEqual({
+ country: 'Bangladesh',
+ state: 'Bangladesh Border Road',
+ city: '',
+ street: '',
+ });
+ });
+ });
});
diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js
index 6162bded793b..6ca3b5b2d516 100644
--- a/tests/unit/MigrationTest.js
+++ b/tests/unit/MigrationTest.js
@@ -845,5 +845,30 @@ describe('Migrations', () => {
},
});
}));
+
+ it('Should succeed in removing the personalDetails object if found in Onyx', () =>
+ Onyx.multiSet({
+ [`${DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS}`]: {
+ 'test1@account.com': {
+ accountID: 100,
+ login: 'test1@account.com',
+ },
+ 'test2@account.com': {
+ accountID: 101,
+ login: 'test2@account.com',
+ },
+ },
+ })
+ .then(PersonalDetailsByAccountID)
+ .then(() => {
+ expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails');
+ const connectionID = Onyx.connect({
+ key: DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS,
+ callback: (allPersonalDetails) => {
+ Onyx.disconnect(connectionID);
+ expect(allPersonalDetails).toBeNull();
+ },
+ });
+ }));
});
});
diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js
index 75c7927888eb..f12c367b0038 100644
--- a/tests/unit/OptionsListUtilsTest.js
+++ b/tests/unit/OptionsListUtilsTest.js
@@ -296,7 +296,7 @@ describe('OptionsListUtils', () => {
it('getSearchOptions()', () => {
// When we filter in the Search view without providing a searchValue
- let results = OptionsListUtils.getSearchOptions(REPORTS, PERSONAL_DETAILS, '');
+ let results = OptionsListUtils.getSearchOptions(REPORTS, PERSONAL_DETAILS, '', [CONST.BETAS.ALL]);
// Then the 2 personalDetails that don't have reports should be returned
expect(results.personalDetails.length).toBe(2);
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 25df3fb01885..c00c69c3532a 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -168,37 +168,21 @@ function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorksp
* @param {String} [currentReportID]
*/
function getDefaultRenderedSidebarLinks(currentReportID = '') {
- // An ErrorBoundary needs to be added to the rendering so that any errors that happen while the component
- // renders are logged to the console. Without an error boundary, Jest only reports the error like "The above error
- // occurred in your component", except, there is no "above error". It's just swallowed up by Jest somewhere.
- // With the ErrorBoundary, those errors are caught and logged to the console so you can find exactly which error
- // might be causing a rendering issue when developing tests.
- class ErrorBoundary extends React.Component {
- // Error boundaries have to implement this method. It's for providing a fallback UI, but
- // we don't need that for unit testing, so this is basically a no-op.
- static getDerivedStateFromError(error) {
- return {error};
- }
+ // A try-catch block needs to be added to the rendering so that any errors that happen while the component
+ // renders are caught and logged to the console. Without the try-catch block, Jest might only report the error
+ // as "The above error occurred in your component", without providing specific details. By using a try-catch block,
+ // any errors are caught and logged, allowing you to identify the exact error that might be causing a rendering issue
+ // when developing tests.
- componentDidCatch(error, errorInfo) {
- console.error(error, errorInfo);
- }
-
- render() {
- // eslint-disable-next-line react/prop-types
- return this.props.children;
- }
+ try {
+ // Wrap the SideBarLinks inside of LocaleContextProvider so that all the locale props
+ // are passed to the component. If this is not done, then all the locale props are missing
+ // and there are a lot of render warnings. It needs to be done like this because normally in
+ // our app (App.js) is when the react application is wrapped in the context providers
+ render();
+ } catch (error) {
+ console.error(error);
}
-
- // Wrap the SideBarLinks inside of LocaleContextProvider so that all the locale props
- // are passed to the component. If this is not done, then all the locale props are missing
- // and there are a lot of render warnings. It needs to be done like this because normally in
- // our app (App.js) is when the react application is wrapped in the context providers
- render(
-
-
- ,
- );
}
/**
diff --git a/web/index.html b/web/index.html
index ec1250b23b08..90011c87e7a9 100644
--- a/web/index.html
+++ b/web/index.html
@@ -60,10 +60,6 @@
outline: 0;
box-shadow: inset 0px 0px 0px 1px #5AB0FF;
}
- div[role="checkbox"]:focus {
- outline: 0;
- box-shadow: inset 0px 0px 0px 1px #5AB0FF;
- }
input:focus-visible, input:focus[data-focusvisible-polyfill],
select:focus-visible, select:focus[data-focusvisible-polyfill] {
box-shadow: none;