diff --git a/docs/_sass/_search-bar.scss b/docs/_sass/_search-bar.scss
index e9c56835af50..c2185ef8f36a 100644
--- a/docs/_sass/_search-bar.scss
+++ b/docs/_sass/_search-bar.scss
@@ -210,9 +210,7 @@ label.search-label {
width: auto;
}
-/* Change the path of the Google Search Button icon into Expensify icon */
.gsc-search-button.gsc-search-button-v2 svg path {
- d: path('M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z');
fill-rule: evenodd;
clip-rule: evenodd;
}
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index bc816a7dd2cc..dde3af22e900 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -123,25 +123,12 @@ function changeSVGViewBoxGoogle() {
// Get all inline Google SVG elements on the page
const svgsGoogle = document.querySelectorAll('svg');
- // Create a media query for screens wider than tablet
- const mediaQuery = window.matchMedia('(min-width: 800px)');
-
- // Check if the viewport is smaller than tablet
- if (!mediaQuery.matches) {
- Array.from(svgsGoogle).forEach((svg) => {
- // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view
- svg.setAttribute('viewBox', '0 0 13 13');
- svg.setAttribute('height', '13');
- svg.setAttribute('width', '13');
- });
- } else {
- Array.from(svgsGoogle).forEach((svg) => {
- // Set the viewBox attribute to '0 0 20 20' to make the svg fit in the tablet-desktop view
- svg.setAttribute('viewBox', '0 0 20 20');
- svg.setAttribute('height', '16');
- svg.setAttribute('width', '16');
- });
- }
+ Array.from(svgsGoogle).forEach((svg) => {
+ // Set the viewBox attribute to '0 0 13 13' to make the svg fit in the mobile view
+ svg.setAttribute('viewBox', '0 0 20 20');
+ svg.setAttribute('height', '16');
+ svg.setAttribute('width', '16');
+ });
}
// Function to insert element after another
@@ -150,11 +137,23 @@ function insertElementAfter(referenceNode, newNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}
+// Update the ICON for search input.
+/* Change the path of the Google Search Button icon into Expensify icon */
+function updateGoogleSearchIcon() {
+ const node = document.querySelector('.gsc-search-button.gsc-search-button-v2 svg path');
+ node.setAttribute(
+ 'd',
+ 'M8 1c3.9 0 7 3.1 7 7 0 1.4-.4 2.7-1.1 3.8l5.2 5.2c.6.6.6 1.5 0 2.1-.6.6-1.5.6-2.1 0l-5.2-5.2C10.7 14.6 9.4 15 8 15c-3.9 0-7-3.1-7-7s3.1-7 7-7zm0 3c2.2 0 4 1.8 4 4s-1.8 4-4 4-4-1.8-4-4 1.8-4 4-4z',
+ );
+}
+
// Need to wait up until page is load, so the svg viewBox can be changed
// And the search label can be inserted
window.addEventListener('load', () => {
changeSVGViewBoxGoogle();
+ updateGoogleSearchIcon();
+
// Add required into the search input
const searchInput = document.getElementById('gsc-i-id1');
searchInput.setAttribute('required', '');
diff --git a/package-lock.json b/package-lock.json
index 2b32065e96bb..b34cde433719 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -74,7 +74,7 @@
"react-native": "0.72.4",
"react-native-android-location-enabler": "^1.2.2",
"react-native-blob-util": "^0.17.3",
- "react-native-collapsible": "^1.6.0",
+ "react-native-collapsible": "^1.6.1",
"react-native-config": "^1.4.5",
"react-native-dev-menu": "^4.1.1",
"react-native-device-info": "^10.3.0",
@@ -44080,8 +44080,9 @@
}
},
"node_modules/react-native-collapsible": {
- "version": "1.6.0",
- "license": "MIT",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/react-native-collapsible/-/react-native-collapsible-1.6.1.tgz",
+ "integrity": "sha512-orF4BeiXd2hZW7fu9YcqIJXzN6TJcFcddY807D3MAOVktLuW9oQ+RIkrTJ5DR3v9ZOFfREkOjEmS79qeUTvkBQ==",
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -84475,7 +84476,9 @@
"dev": true
},
"react-native-collapsible": {
- "version": "1.6.0",
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/react-native-collapsible/-/react-native-collapsible-1.6.1.tgz",
+ "integrity": "sha512-orF4BeiXd2hZW7fu9YcqIJXzN6TJcFcddY807D3MAOVktLuW9oQ+RIkrTJ5DR3v9ZOFfREkOjEmS79qeUTvkBQ==",
"requires": {}
},
"react-native-config": {
diff --git a/package.json b/package.json
index c72d52ab4bba..e68f10940d6a 100644
--- a/package.json
+++ b/package.json
@@ -121,7 +121,7 @@
"react-native": "0.72.4",
"react-native-android-location-enabler": "^1.2.2",
"react-native-blob-util": "^0.17.3",
- "react-native-collapsible": "^1.6.0",
+ "react-native-collapsible": "^1.6.1",
"react-native-config": "^1.4.5",
"react-native-dev-menu": "^4.1.1",
"react-native-device-info": "^10.3.0",
diff --git a/patches/react-native-web+0.19.9+004+fix-pointer-events.patch b/patches/react-native-web+0.19.9+004+fix-pointer-events.patch
new file mode 100644
index 000000000000..a457fbcfe36c
--- /dev/null
+++ b/patches/react-native-web+0.19.9+004+fix-pointer-events.patch
@@ -0,0 +1,22 @@
+diff --git a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js
+index bdcecc2..63f1364 100644
+--- a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js
++++ b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js
+@@ -353,7 +353,7 @@ function createAtomicRules(identifier, property, value) {
+ var _block2 = createDeclarationBlock({
+ pointerEvents: 'none'
+ });
+- rules.push(selector + ">*" + _block2);
++ rules.push(selector + " *" + _block2);
+ }
+ } else if (value === 'none' || value === 'box-none') {
+ finalValue = 'none!important';
+@@ -361,7 +361,7 @@ function createAtomicRules(identifier, property, value) {
+ var _block3 = createDeclarationBlock({
+ pointerEvents: 'auto'
+ });
+- rules.push(selector + ">*" + _block3);
++ rules.push(selector + " *" + _block3);
+ }
+ }
+ var _block4 = createDeclarationBlock({
diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js
index 4e9f9ddaf696..61460a93650e 100644
--- a/src/components/AddressSearch/index.js
+++ b/src/components/AddressSearch/index.js
@@ -229,6 +229,7 @@ function AddressSearch(props) {
street2: subpremise,
// Make sure country is updated first, since city and state will be reset if the country changes
country: '',
+ state: state || stateAutoCompleteFallback,
// When locality is not returned, many countries return the city as postalTown (e.g. 5 New Street
// Square, London), otherwise as sublocality (e.g. 384 Court Street Brooklyn). If postalTown is
// returned, the sublocality will be a city subdivision so shouldn't take precedence (e.g.
@@ -236,7 +237,6 @@ function AddressSearch(props) {
city: locality || postalTown || sublocality || cityAutocompleteFallback,
zipCode,
- state: state || stateAutoCompleteFallback,
lat: lodashGet(details, 'geometry.location.lat', 0),
lng: lodashGet(details, 'geometry.location.lng', 0),
address: lodashGet(details, 'formatted_address', ''),
diff --git a/src/components/CollapsibleSection/Collapsible/index.js b/src/components/CollapsibleSection/Collapsible/index.js
deleted file mode 100644
index 51d650ed5748..000000000000
--- a/src/components/CollapsibleSection/Collapsible/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import Collapsible from 'react-collapse';
-
-export default Collapsible;
diff --git a/src/components/CollapsibleSection/Collapsible/index.native.js b/src/components/CollapsibleSection/Collapsible/index.native.js
deleted file mode 100644
index 9b800304beeb..000000000000
--- a/src/components/CollapsibleSection/Collapsible/index.native.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import CollapsibleRN from 'react-native-collapsible';
-
-const propTypes = {
- /** Whether the section should start expanded. False by default */
- isOpened: PropTypes.bool,
-
- /** Children to display inside the Collapsible component */
- children: PropTypes.node.isRequired,
-};
-
-const defaultProps = {
- isOpened: false,
-};
-
-function Collapsible(props) {
- return {props.children};
-}
-
-Collapsible.displayName = 'Collapsible';
-Collapsible.propTypes = propTypes;
-Collapsible.defaultProps = defaultProps;
-export default Collapsible;
diff --git a/src/components/CollapsibleSection/Collapsible/index.native.tsx b/src/components/CollapsibleSection/Collapsible/index.native.tsx
new file mode 100644
index 000000000000..e8d3dc9439d0
--- /dev/null
+++ b/src/components/CollapsibleSection/Collapsible/index.native.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import CollapsibleRN from 'react-native-collapsible';
+import CollapsibleProps from './types';
+
+function Collapsible({isOpened = false, children}: CollapsibleProps) {
+ return {children};
+}
+
+Collapsible.displayName = 'Collapsible';
+export default Collapsible;
diff --git a/src/components/CollapsibleSection/Collapsible/index.tsx b/src/components/CollapsibleSection/Collapsible/index.tsx
new file mode 100644
index 000000000000..2585fd92f42b
--- /dev/null
+++ b/src/components/CollapsibleSection/Collapsible/index.tsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import {Collapse} from 'react-collapse';
+import CollapsibleProps from './types';
+
+function Collapsible({isOpened = false, children}: CollapsibleProps) {
+ return {children};
+}
+export default Collapsible;
diff --git a/src/components/CollapsibleSection/Collapsible/types.ts b/src/components/CollapsibleSection/Collapsible/types.ts
new file mode 100644
index 000000000000..8b8e8aba6860
--- /dev/null
+++ b/src/components/CollapsibleSection/Collapsible/types.ts
@@ -0,0 +1,8 @@
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type CollapsibleProps = ChildrenProps & {
+ /** Whether the section should start expanded. False by default */
+ isOpened?: boolean;
+};
+
+export default CollapsibleProps;
diff --git a/src/components/CollapsibleSection/index.js b/src/components/CollapsibleSection/index.js
deleted file mode 100644
index 46210e57f543..000000000000
--- a/src/components/CollapsibleSection/index.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import Text from '@components/Text';
-import styles from '@styles/styles';
-import CONST from '@src/CONST';
-import Collapsible from './Collapsible';
-
-const propTypes = {
- /** Title of the Collapsible section */
- title: PropTypes.string.isRequired,
-
- /** Children to display inside the Collapsible component */
- children: PropTypes.node.isRequired,
-};
-
-class CollapsibleSection extends React.Component {
- constructor(props) {
- super(props);
- this.toggleSection = this.toggleSection.bind(this);
- this.state = {
- isExpanded: false,
- };
- }
-
- /**
- * Expands/collapses the section
- */
- toggleSection() {
- this.setState((prevState) => ({
- isExpanded: !prevState.isExpanded,
- }));
- }
-
- render() {
- const src = this.state.isExpanded ? Expensicons.UpArrow : Expensicons.DownArrow;
-
- return (
-
-
-
- {this.props.title}
-
-
-
-
-
-
- {this.props.children}
-
-
- );
- }
-}
-
-CollapsibleSection.propTypes = propTypes;
-export default CollapsibleSection;
diff --git a/src/components/CollapsibleSection/index.tsx b/src/components/CollapsibleSection/index.tsx
new file mode 100644
index 000000000000..434017f2a547
--- /dev/null
+++ b/src/components/CollapsibleSection/index.tsx
@@ -0,0 +1,55 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import Text from '@components/Text';
+import styles from '@styles/styles';
+import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import Collapsible from './Collapsible';
+
+type CollapsibleSectionProps = ChildrenProps & {
+ /** Title of the Collapsible section */
+ title: string;
+};
+
+function CollapsibleSection({title, children}: CollapsibleSectionProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ /**
+ * Expands/collapses the section
+ */
+ const toggleSection = () => {
+ setIsExpanded(!isExpanded);
+ };
+
+ const src = isExpanded ? Expensicons.UpArrow : Expensicons.DownArrow;
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+export default CollapsibleSection;
diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.tsx
similarity index 64%
rename from src/components/MentionSuggestions.js
rename to src/components/MentionSuggestions.tsx
index d18b8947e68d..2d0f3bf32b41 100644
--- a/src/components/MentionSuggestions.js
+++ b/src/components/MentionSuggestions.tsx
@@ -1,73 +1,61 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
-import _ from 'underscore';
import getStyledTextArray from '@libs/GetStyledTextArray';
import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
import themeColors from '@styles/themes/default';
import CONST from '@src/CONST';
+import {Icon} from '@src/types/onyx/OnyxCommon';
import AutoCompleteSuggestions from './AutoCompleteSuggestions';
import Avatar from './Avatar';
-import avatarPropTypes from './avatarPropTypes';
import Text from './Text';
-const propTypes = {
- /** The index of the highlighted mention */
- highlightedMentionIndex: PropTypes.number,
+type Mention = {
+ /** Display name of the user */
+ text: string;
- /** Array of suggested mentions */
- mentions: PropTypes.arrayOf(
- PropTypes.shape({
- /** Display name of the user */
- text: PropTypes.string,
+ /** Email/phone number of the user */
+ alternateText: string;
+
+ /** Array of icons of the user. We use the first element of this array */
+ icons: Icon[];
+};
- /** Email/phone number of the user */
- alternateText: PropTypes.string,
+type MentionSuggestionsProps = {
+ /** The index of the highlighted mention */
+ highlightedMentionIndex?: number;
- /** Array of icons of the user. We use the first element of this array */
- icons: PropTypes.arrayOf(avatarPropTypes),
- }),
- ).isRequired,
+ /** Array of suggested mentions */
+ mentions: Mention[];
/** Fired when the user selects an mention */
- onSelect: PropTypes.func.isRequired,
+ onSelect: () => void;
/** Mention prefix that follows the @ sign */
- prefix: PropTypes.string.isRequired,
+ prefix: string;
/** Show that we can use large mention picker.
* Depending on available space and whether the input is expanded, we can have a small or large mention suggester.
* When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */
- isMentionPickerLarge: PropTypes.bool.isRequired,
+ isMentionPickerLarge: boolean;
/** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func,
-};
-
-const defaultProps = {
- highlightedMentionIndex: 0,
- measureParentContainer: () => {},
+ measureParentContainer: () => void;
};
/**
* Create unique keys for each mention item
- * @param {Object} item
- * @param {Number} index
- * @returns {String}
*/
-const keyExtractor = (item) => item.alternateText;
+const keyExtractor = (item: Mention) => item.alternateText;
-function MentionSuggestions(props) {
+function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSelect, isMentionPickerLarge, measureParentContainer = () => {}}: MentionSuggestionsProps) {
/**
* Render a suggestion menu item component.
- * @param {Object} item
- * @returns {JSX.Element}
*/
- const renderSuggestionMenuItem = (item) => {
+ const renderSuggestionMenuItem = (item: Mention) => {
const isIcon = item.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT;
- const styledDisplayName = getStyledTextArray(item.text, props.prefix);
- const styledHandle = item.text === item.alternateText ? '' : getStyledTextArray(item.alternateText, props.prefix);
+ const styledDisplayName = getStyledTextArray(item.text, prefix);
+ const styledHandle = item.text === item.alternateText ? undefined : getStyledTextArray(item.alternateText, prefix);
return (
@@ -85,8 +73,9 @@ function MentionSuggestions(props) {
style={[styles.mentionSuggestionsText, styles.flexShrink1]}
numberOfLines={1}
>
- {_.map(styledDisplayName, ({text, isColored}, i) => (
+ {styledDisplayName?.map(({text, isColored}, i) => (
@@ -98,13 +87,13 @@ function MentionSuggestions(props) {
style={[styles.mentionSuggestionsText, styles.flex1]}
numberOfLines={1}
>
- {_.map(
- styledHandle,
+ {styledHandle?.map(
({text, isColored}, i) =>
- text !== '' && (
+ Boolean(text) && (
{text}
@@ -117,20 +106,18 @@ function MentionSuggestions(props) {
return (
);
}
-MentionSuggestions.propTypes = propTypes;
-MentionSuggestions.defaultProps = defaultProps;
MentionSuggestions.displayName = 'MentionSuggestions';
export default MentionSuggestions;
diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.tsx
similarity index 79%
rename from src/components/Modal/BaseModal.js
rename to src/components/Modal/BaseModal.tsx
index bf1fdc8ee7de..e428b062798f 100644
--- a/src/components/Modal/BaseModal.js
+++ b/src/components/Modal/BaseModal.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import ReactNativeModal from 'react-native-modal';
@@ -14,44 +13,34 @@ import themeColors from '@styles/themes/default';
import variables from '@styles/variables';
import * as Modal from '@userActions/Modal';
import CONST from '@src/CONST';
-import {defaultProps as modalDefaultProps, propTypes as modalPropTypes} from './modalPropTypes';
-
-const propTypes = {
- ...modalPropTypes,
-
- /** The ref to the modal container */
- forwardedRef: PropTypes.func,
-};
-
-const defaultProps = {
- ...modalDefaultProps,
- forwardedRef: () => {},
-};
-
-function BaseModal({
- isVisible,
- onClose,
- shouldSetModalVisibility,
- onModalHide,
- type,
- popoverAnchorPosition,
- innerContainerStyle,
- outerStyle,
- onModalShow,
- propagateSwipe,
- fullscreen,
- animationIn,
- animationOut,
- useNativeDriver: useNativeDriverProp,
- hideModalContentWhileAnimating,
- animationInTiming,
- animationOutTiming,
- statusBarTranslucent,
- onLayout,
- avoidKeyboard,
- forwardedRef,
- children,
-}) {
+import BaseModalProps from './types';
+
+function BaseModal(
+ {
+ isVisible,
+ onClose,
+ shouldSetModalVisibility = true,
+ onModalHide = () => {},
+ type,
+ popoverAnchorPosition = {},
+ innerContainerStyle = {},
+ outerStyle,
+ onModalShow = () => {},
+ propagateSwipe,
+ fullscreen = true,
+ animationIn,
+ animationOut,
+ useNativeDriver: useNativeDriverProp,
+ hideModalContentWhileAnimating = false,
+ animationInTiming,
+ animationOutTiming,
+ statusBarTranslucent = true,
+ onLayout,
+ avoidKeyboard = false,
+ children,
+ }: BaseModalProps,
+ ref: React.ForwardedRef,
+) {
const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
@@ -61,7 +50,7 @@ function BaseModal({
/**
* Hides modal
- * @param {Boolean} [callHideCallback=true] Should we call the onModalHide callback
+ * @param callHideCallback - Should we call the onModalHide callback
*/
const hideModal = useCallback(
(callHideCallback = true) => {
@@ -113,10 +102,11 @@ function BaseModal({
onModalShow();
};
- const handleBackdropPress = (e) => {
- if (e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
+ const handleBackdropPress = (e?: KeyboardEvent) => {
+ if (e?.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
return;
}
+
onClose();
};
@@ -196,8 +186,8 @@ function BaseModal({
style={modalStyle}
deviceHeight={windowHeight}
deviceWidth={windowWidth}
- animationIn={animationIn || modalStyleAnimationIn}
- animationOut={animationOut || modalStyleAnimationOut}
+ animationIn={animationIn ?? modalStyleAnimationIn}
+ animationOut={animationOut ?? modalStyleAnimationOut}
useNativeDriver={useNativeDriverProp && useNativeDriver}
hideModalContentWhileAnimating={hideModalContentWhileAnimating}
animationInTiming={animationInTiming}
@@ -208,7 +198,7 @@ function BaseModal({
>
{children}
@@ -216,18 +206,6 @@ function BaseModal({
);
}
-BaseModal.propTypes = propTypes;
-BaseModal.defaultProps = defaultProps;
-BaseModal.displayName = 'BaseModal';
-
-const BaseModalWithRef = forwardRef((props, ref) => (
-
-));
-
-BaseModalWithRef.displayName = 'BaseModalWithRef';
+BaseModal.displayName = 'BaseModalWithRef';
-export default BaseModalWithRef;
+export default forwardRef(BaseModal);
diff --git a/src/components/Modal/index.android.js b/src/components/Modal/index.android.tsx
similarity index 78%
rename from src/components/Modal/index.android.js
rename to src/components/Modal/index.android.tsx
index 51745ae6a20f..2343cb4c70a9 100644
--- a/src/components/Modal/index.android.js
+++ b/src/components/Modal/index.android.tsx
@@ -3,7 +3,7 @@ import {AppState} from 'react-native';
import withWindowDimensions from '@components/withWindowDimensions';
import ComposerFocusManager from '@libs/ComposerFocusManager';
import BaseModal from './BaseModal';
-import {defaultProps, propTypes} from './modalPropTypes';
+import BaseModalProps from './types';
AppState.addEventListener('focus', () => {
ComposerFocusManager.setReadyToFocus();
@@ -15,19 +15,17 @@ AppState.addEventListener('blur', () => {
// Only want to use useNativeDriver on Android. It has strange flashes issue on IOS
// https://github.com/react-native-modal/react-native-modal#the-modal-flashes-in-a-weird-way-when-animating
-function Modal(props) {
+function Modal({useNativeDriver = true, ...rest}: BaseModalProps) {
return (
- {props.children}
+ {rest.children}
);
}
-Modal.propTypes = propTypes;
-Modal.defaultProps = defaultProps;
Modal.displayName = 'Modal';
export default withWindowDimensions(Modal);
diff --git a/src/components/Modal/index.ios.js b/src/components/Modal/index.ios.tsx
similarity index 63%
rename from src/components/Modal/index.ios.js
rename to src/components/Modal/index.ios.tsx
index 38f477e2049b..f780775ec216 100644
--- a/src/components/Modal/index.ios.js
+++ b/src/components/Modal/index.ios.tsx
@@ -1,20 +1,18 @@
import React from 'react';
import withWindowDimensions from '@components/withWindowDimensions';
import BaseModal from './BaseModal';
-import {defaultProps, propTypes} from './modalPropTypes';
+import BaseModalProps from './types';
-function Modal(props) {
+function Modal({children, ...rest}: BaseModalProps) {
return (
- {props.children}
+ {children}
);
}
-Modal.propTypes = propTypes;
-Modal.defaultProps = defaultProps;
Modal.displayName = 'Modal';
export default withWindowDimensions(Modal);
diff --git a/src/components/Modal/index.web.js b/src/components/Modal/index.tsx
similarity index 54%
rename from src/components/Modal/index.web.js
rename to src/components/Modal/index.tsx
index 3bea0eb58aa9..b4cfc1f06211 100644
--- a/src/components/Modal/index.web.js
+++ b/src/components/Modal/index.tsx
@@ -5,13 +5,13 @@ import * as StyleUtils from '@styles/StyleUtils';
import themeColors from '@styles/themes/default';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
-import {defaultProps, propTypes} from './modalPropTypes';
+import BaseModalProps from './types';
-function Modal(props) {
- const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
+ const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
const setStatusBarColor = (color = themeColors.appBG) => {
- if (!props.fullscreen) {
+ if (!fullscreen) {
return;
}
@@ -20,33 +20,37 @@ function Modal(props) {
const hideModal = () => {
setStatusBarColor(previousStatusBarColor);
- props.onModalHide();
+ onModalHide();
};
const showModal = () => {
const statusBarColor = StatusBar.getBackgroundColor();
- const isFullScreenModal =
- props.type === CONST.MODAL.MODAL_TYPE.CENTERED || props.type === CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE || props.type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED;
- setPreviousStatusBarColor(statusBarColor);
- // If it is a full screen modal then match it with appBG, otherwise we use the backdrop color
- setStatusBarColor(isFullScreenModal ? themeColors.appBG : StyleUtils.getThemeBackgroundColor(statusBarColor));
- props.onModalShow();
+
+ const isFullScreenModal = type === CONST.MODAL.MODAL_TYPE.CENTERED || type === CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE || type === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED;
+
+ if (statusBarColor) {
+ setPreviousStatusBarColor(statusBarColor);
+ // If it is a full screen modal then match it with appBG, otherwise we use the backdrop color
+ setStatusBarColor(isFullScreenModal ? themeColors.appBG : StyleUtils.getThemeBackgroundColor(statusBarColor));
+ }
+
+ onModalShow?.();
};
return (
- {props.children}
+ {children}
);
}
-Modal.propTypes = propTypes;
-Modal.defaultProps = defaultProps;
Modal.displayName = 'Modal';
export default withWindowDimensions(Modal);
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
new file mode 100644
index 000000000000..3fa60e6ac765
--- /dev/null
+++ b/src/components/Modal/types.ts
@@ -0,0 +1,66 @@
+import {ViewStyle} from 'react-native';
+import {ModalProps} from 'react-native-modal';
+import {ValueOf} from 'type-fest';
+import {WindowDimensionsProps} from '@components/withWindowDimensions/types';
+import CONST from '@src/CONST';
+
+type PopoverAnchorPosition = {
+ top?: number;
+ right?: number;
+ bottom?: number;
+ left?: number;
+};
+
+type BaseModalProps = WindowDimensionsProps &
+ ModalProps & {
+ /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */
+ fullscreen?: boolean;
+
+ /** Should we close modal on outside click */
+ shouldCloseOnOutsideClick?: boolean;
+
+ /** Should we announce the Modal visibility changes? */
+ shouldSetModalVisibility?: boolean;
+
+ /** Callback method fired when the user requests to close the modal */
+ onClose: () => void;
+
+ /** State that determines whether to display the modal or not */
+ isVisible: boolean;
+
+ /** Callback method fired when the user requests to submit the modal content. */
+ onSubmit?: () => void;
+
+ /** Callback method fired when the modal is hidden */
+ onModalHide?: () => void;
+
+ /** Callback method fired when the modal is shown */
+ onModalShow?: () => void;
+
+ /** Style of modal to display */
+ type?: ValueOf;
+
+ /** The anchor position of a popover modal. Has no effect on other modal types. */
+ popoverAnchorPosition?: PopoverAnchorPosition;
+
+ outerStyle?: ViewStyle;
+
+ /** Whether the modal should go under the system statusbar */
+ statusBarTranslucent?: boolean;
+
+ /** Whether the modal should avoid the keyboard */
+ avoidKeyboard?: boolean;
+
+ /** Modal container styles */
+ innerContainerStyle?: ViewStyle;
+
+ /**
+ * Whether the modal should hide its content while animating. On iOS, set to true
+ * if `useNativeDriver` is also true, to avoid flashes in the UI.
+ *
+ * See: https://github.com/react-native-modal/react-native-modal/pull/116
+ * */
+ hideModalContentWhileAnimating?: boolean;
+ };
+
+export default BaseModalProps;
diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.tsx
similarity index 57%
rename from src/components/MultipleAvatars.js
rename to src/components/MultipleAvatars.tsx
index 209540189b69..e867de7ddb97 100644
--- a/src/components/MultipleAvatars.js
+++ b/src/components/MultipleAvatars.tsx
@@ -1,77 +1,69 @@
-import PropTypes from 'prop-types';
import React, {memo, useMemo} from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import {ValueOf} from 'type-fest';
+import {AvatarSource} from '@libs/UserUtils';
import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
import themeColors from '@styles/themes/default';
import variables from '@styles/variables';
import CONST from '@src/CONST';
+import type {Icon} from '@src/types/onyx/OnyxCommon';
import Avatar from './Avatar';
-import avatarPropTypes from './avatarPropTypes';
import Text from './Text';
import Tooltip from './Tooltip';
import UserDetailsTooltip from './UserDetailsTooltip';
-const propTypes = {
+type MultipleAvatarsProps = {
/** Array of avatar URLs or icons */
- icons: PropTypes.arrayOf(avatarPropTypes),
+ icons: Icon[];
/** Set the size of avatars */
- size: PropTypes.oneOf(_.values(CONST.AVATAR_SIZE)),
+ size?: ValueOf;
/** Style for Second Avatar */
- // eslint-disable-next-line react/forbid-prop-types
- secondAvatarStyle: PropTypes.arrayOf(PropTypes.object),
+ secondAvatarStyle?: StyleProp;
/** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */
- fallbackIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ fallbackIcon?: AvatarSource;
/** Prop to identify if we should load avatars vertically instead of diagonally */
- shouldStackHorizontally: PropTypes.bool,
+ shouldStackHorizontally?: boolean;
/** Prop to identify if we should display avatars in rows */
- shouldDisplayAvatarsInRows: PropTypes.bool,
+ shouldDisplayAvatarsInRows?: boolean;
/** Whether the avatars are hovered */
- isHovered: PropTypes.bool,
+ isHovered?: boolean;
/** Whether the avatars are in an element being pressed */
- isPressed: PropTypes.bool,
+ isPressed?: boolean;
/** Whether #focus mode is on */
- isFocusMode: PropTypes.bool,
+ isFocusMode?: boolean;
/** Whether avatars are displayed within a reportAction */
- isInReportAction: PropTypes.bool,
+ isInReportAction?: boolean;
/** Whether to show the toolip text */
- shouldShowTooltip: PropTypes.bool,
+ shouldShowTooltip?: boolean;
/** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */
- shouldUseCardBackground: PropTypes.bool,
+ shouldUseCardBackground?: boolean;
/** Prop to limit the amount of avatars displayed horizontally */
- maxAvatarsInRow: PropTypes.number,
+ maxAvatarsInRow?: number;
};
-const defaultProps = {
- icons: [],
- size: CONST.AVATAR_SIZE.DEFAULT,
- secondAvatarStyle: [StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)],
- fallbackIcon: undefined,
- shouldStackHorizontally: false,
- shouldDisplayAvatarsInRows: false,
- isHovered: false,
- isPressed: false,
- isFocusMode: false,
- isInReportAction: false,
- shouldShowTooltip: true,
- shouldUseCardBackground: false,
- maxAvatarsInRow: CONST.AVATAR_ROW_SIZE.DEFAULT,
+type AvatarStyles = {
+ singleAvatarStyle: ViewStyle;
+ secondAvatarStyles: ViewStyle;
};
-const avatarSizeToStylesMap = {
+type AvatarSizeToStyles = typeof CONST.AVATAR_SIZE.SMALL | typeof CONST.AVATAR_SIZE.LARGE | typeof CONST.AVATAR_SIZE.DEFAULT;
+
+type AvatarSizeToStylesMap = Record;
+
+const avatarSizeToStylesMap: AvatarSizeToStylesMap = {
[CONST.AVATAR_SIZE.SMALL]: {
singleAvatarStyle: styles.singleAvatarSmall,
secondAvatarStyles: styles.secondAvatarSmall,
@@ -80,78 +72,92 @@ const avatarSizeToStylesMap = {
singleAvatarStyle: styles.singleAvatarMedium,
secondAvatarStyles: styles.secondAvatarMedium,
},
- default: {
+ [CONST.AVATAR_SIZE.DEFAULT]: {
singleAvatarStyle: styles.singleAvatar,
secondAvatarStyles: styles.secondAvatar,
},
};
-function MultipleAvatars(props) {
- let avatarContainerStyles = StyleUtils.getContainerStyles(props.size, props.isInReportAction);
- const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[props.size] || avatarSizeToStylesMap.default, [props.size]);
- const tooltipTexts = props.shouldShowTooltip ? _.pluck(props.icons, 'name') : [''];
+function MultipleAvatars({
+ fallbackIcon,
+ icons = [],
+ size = CONST.AVATAR_SIZE.DEFAULT,
+ secondAvatarStyle = [StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)],
+ shouldStackHorizontally = false,
+ shouldDisplayAvatarsInRows = false,
+ isHovered = false,
+ isPressed = false,
+ isFocusMode = false,
+ isInReportAction = false,
+ shouldShowTooltip = true,
+ shouldUseCardBackground = false,
+ maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT,
+}: MultipleAvatarsProps) {
+ let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction);
+ const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size]);
+
+ const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => icon.name) : ['']), [shouldShowTooltip, icons]);
const avatarSize = useMemo(() => {
- if (props.isFocusMode) {
+ if (isFocusMode) {
return CONST.AVATAR_SIZE.MID_SUBSCRIPT;
}
- if (props.size === CONST.AVATAR_SIZE.LARGE) {
+ if (size === CONST.AVATAR_SIZE.LARGE) {
return CONST.AVATAR_SIZE.MEDIUM;
}
return CONST.AVATAR_SIZE.SMALLER;
- }, [props.isFocusMode, props.size]);
+ }, [isFocusMode, size]);
const avatarRows = useMemo(() => {
// If we're not displaying avatars in rows or the number of icons is less than or equal to the max avatars in a row, return a single row
- if (!props.shouldDisplayAvatarsInRows || props.icons.length <= props.maxAvatarsInRow) {
- return [props.icons];
+ if (!shouldDisplayAvatarsInRows || icons.length <= maxAvatarsInRow) {
+ return [icons];
}
// Calculate the size of each row
- const rowSize = Math.min(Math.ceil(props.icons.length / 2), props.maxAvatarsInRow);
+ const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow);
// Slice the icons array into two rows
- const firstRow = props.icons.slice(rowSize);
- const secondRow = props.icons.slice(0, rowSize);
+ const firstRow = icons.slice(rowSize);
+ const secondRow = icons.slice(0, rowSize);
// Update the state with the two rows as an array
return [firstRow, secondRow];
- }, [props.icons, props.maxAvatarsInRow, props.shouldDisplayAvatarsInRows]);
+ }, [icons, maxAvatarsInRow, shouldDisplayAvatarsInRows]);
- if (!props.icons.length) {
+ if (!icons.length) {
return null;
}
- if (props.icons.length === 1 && !props.shouldStackHorizontally) {
+ if (icons.length === 1 && !shouldStackHorizontally) {
return (
);
}
- const oneAvatarSize = StyleUtils.getAvatarStyle(props.size);
- const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(props.size).borderWidth;
+ const oneAvatarSize = StyleUtils.getAvatarStyle(size);
+ const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0;
const overlapSize = oneAvatarSize.width / 3;
- if (props.shouldStackHorizontally) {
+ if (shouldStackHorizontally) {
// Height of one avatar + border space
const height = oneAvatarSize.height + 2 * oneAvatarBorderWidth;
avatarContainerStyles = StyleUtils.combineStyles([styles.alignItemsCenter, styles.flexRow, StyleUtils.getHeight(height)]);
@@ -159,36 +165,36 @@ function MultipleAvatars(props) {
return (
<>
- {props.shouldStackHorizontally ? (
- _.map(avatarRows, (avatars, rowIndex) => (
+ {shouldStackHorizontally ? (
+ avatarRows.map((avatars, rowIndex) => (
- {_.map([...avatars].splice(0, props.maxAvatarsInRow), (icon, index) => (
+ {[...avatars].splice(0, maxAvatarsInRow).map((icon, index) => (
-
+
))}
- {avatars.length > props.maxAvatarsInRow && (
+ {avatars.length > maxAvatarsInRow && (
{`+${avatars.length - props.maxAvatarsInRow}`}
+ >{`+${avatars.length - maxAvatarsInRow}`}
@@ -238,53 +244,45 @@ function MultipleAvatars(props) {
))
) : (
-
+
{/* View is necessary for tooltip to show for multiple avatars in LHN */}
-
- {props.icons.length === 2 ? (
+
+ {icons.length === 2 ? (
@@ -292,10 +290,10 @@ function MultipleAvatars(props) {
- {`+${props.icons.length - 1}`}
+ {`+${icons.length - 1}`}
@@ -308,8 +306,6 @@ function MultipleAvatars(props) {
);
}
-MultipleAvatars.defaultProps = defaultProps;
-MultipleAvatars.propTypes = propTypes;
MultipleAvatars.displayName = 'MultipleAvatars';
export default memo(MultipleAvatars);
diff --git a/src/components/withTabAnimation.js b/src/components/withTabAnimation.js
deleted file mode 100644
index 2af96f0215a3..000000000000
--- a/src/components/withTabAnimation.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import {useTabAnimation} from '@react-navigation/material-top-tabs';
-import PropTypes from 'prop-types';
-import * as React from 'react';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-import refPropTypes from './refPropTypes';
-
-const propTypes = {
- /** The HOC takes an optional ref as a prop and passes it as a ref to the wrapped component.
- * That way, if a ref is passed to a component wrapped in the HOC, the ref is a reference to the wrapped component, not the HOC. */
- forwardedRef: refPropTypes,
-
- /* Whether we're in a tab navigator */
- isInTabNavigator: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
- forwardedRef: () => {},
-};
-
-export default function (WrappedComponent) {
- // The component with tab animation prop
- function WrappedComponentWithTabAnimation(props) {
- const animation = useTabAnimation();
-
- return (
-
- );
- }
-
- WrappedComponentWithTabAnimation.displayName = `withAnimation(${getComponentDisplayName(WrappedComponent)})`;
-
- // Return a component with tab animation prop if this component is in tab navigator, otherwise return itself
- function WithTabAnimation({forwardedRef, ...rest}) {
- if (rest.isInTabNavigator) {
- return (
-
- );
- }
- return (
-
- );
- }
-
- WithTabAnimation.propTypes = propTypes;
- WithTabAnimation.defaultProps = defaultProps;
- WithTabAnimation.displayName = `withTabAnimation(${getComponentDisplayName(WrappedComponent)})`;
-
- // eslint-disable-next-line rulesdir/no-negated-variables
- const WithTabAnimationWithRef = React.forwardRef((props, ref) => (
-
- ));
-
- WithTabAnimationWithRef.displayName = `withTabAnimationWithRef(${getComponentDisplayName(WrappedComponent)})`;
-
- return WithTabAnimationWithRef;
-}
diff --git a/src/components/withWindowDimensions/index.native.js b/src/components/withWindowDimensions/index.native.tsx
similarity index 65%
rename from src/components/withWindowDimensions/index.native.js
rename to src/components/withWindowDimensions/index.native.tsx
index 91d81f5fb4e0..0c9f61a45c0b 100644
--- a/src/components/withWindowDimensions/index.native.js
+++ b/src/components/withWindowDimensions/index.native.tsx
@@ -1,12 +1,14 @@
import PropTypes from 'prop-types';
-import React, {createContext, forwardRef, useEffect, useMemo, useState} from 'react';
+import React, {ComponentType, createContext, ForwardedRef, RefAttributes, useEffect, useMemo, useState} from 'react';
import {Dimensions} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import getComponentDisplayName from '@libs/getComponentDisplayName';
import getWindowHeightAdjustment from '@libs/getWindowHeightAdjustment';
import variables from '@styles/variables';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import {NewDimensions, WindowDimensionsContextData, WindowDimensionsProps} from './types';
-const WindowDimensionsContext = createContext(null);
+const WindowDimensionsContext = createContext(null);
const windowDimensionsPropTypes = {
// Width of the window
windowWidth: PropTypes.number.isRequired,
@@ -27,12 +29,7 @@ const windowDimensionsPropTypes = {
isLargeScreenWidth: PropTypes.bool.isRequired,
};
-const windowDimensionsProviderPropTypes = {
- /* Actual content wrapped by this component */
- children: PropTypes.node.isRequired,
-};
-
-function WindowDimensionsProvider(props) {
+function WindowDimensionsProvider(props: ChildrenProps) {
const [windowDimension, setWindowDimension] = useState(() => {
const initialDimensions = Dimensions.get('window');
return {
@@ -42,9 +39,8 @@ function WindowDimensionsProvider(props) {
});
useEffect(() => {
- const onDimensionChange = (newDimensions) => {
+ const onDimensionChange = (newDimensions: NewDimensions) => {
const {window} = newDimensions;
-
setWindowDimension({
windowHeight: window.height,
windowWidth: window.width,
@@ -76,30 +72,29 @@ function WindowDimensionsProvider(props) {
return {props.children};
}
-WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes;
WindowDimensionsProvider.displayName = 'WindowDimensionsProvider';
-/**
- * @param {React.Component} WrappedComponent
- * @returns {React.Component}
- */
-export default function withWindowDimensions(WrappedComponent) {
- const WithWindowDimensions = forwardRef((props, ref) => (
-
- {(windowDimensionsProps) => (
-
- )}
-
- ));
+export default function withWindowDimensions(
+ WrappedComponent: ComponentType>,
+): (props: Omit & React.RefAttributes) => React.ReactElement | null {
+ function WithWindowDimensions(props: Omit, ref: ForwardedRef) {
+ return (
+
+ {(windowDimensionsProps) => (
+
+ )}
+
+ );
+ }
WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`;
- return WithWindowDimensions;
+ return React.forwardRef(WithWindowDimensions);
}
export {WindowDimensionsProvider, windowDimensionsPropTypes};
diff --git a/src/components/withWindowDimensions/index.js b/src/components/withWindowDimensions/index.tsx
similarity index 68%
rename from src/components/withWindowDimensions/index.js
rename to src/components/withWindowDimensions/index.tsx
index f46624b2f41c..1479450deec4 100644
--- a/src/components/withWindowDimensions/index.js
+++ b/src/components/withWindowDimensions/index.tsx
@@ -1,13 +1,15 @@
import lodashDebounce from 'lodash/debounce';
import PropTypes from 'prop-types';
-import React, {createContext, forwardRef, useEffect, useMemo, useState} from 'react';
+import React, {ComponentType, createContext, ForwardedRef, RefAttributes, useEffect, useMemo, useState} from 'react';
import {Dimensions} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import getComponentDisplayName from '@libs/getComponentDisplayName';
import getWindowHeightAdjustment from '@libs/getWindowHeightAdjustment';
import variables from '@styles/variables';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import {NewDimensions, WindowDimensionsContextData, WindowDimensionsProps} from './types';
-const WindowDimensionsContext = createContext(null);
+const WindowDimensionsContext = createContext(null);
const windowDimensionsPropTypes = {
// Width of the window
windowWidth: PropTypes.number.isRequired,
@@ -28,12 +30,7 @@ const windowDimensionsPropTypes = {
isLargeScreenWidth: PropTypes.bool.isRequired,
};
-const windowDimensionsProviderPropTypes = {
- /* Actual content wrapped by this component */
- children: PropTypes.node.isRequired,
-};
-
-function WindowDimensionsProvider(props) {
+function WindowDimensionsProvider(props: ChildrenProps) {
const [windowDimension, setWindowDimension] = useState(() => {
const initialDimensions = Dimensions.get('window');
return {
@@ -43,7 +40,7 @@ function WindowDimensionsProvider(props) {
});
useEffect(() => {
- const onDimensionChange = (newDimensions) => {
+ const onDimensionChange = (newDimensions: NewDimensions) => {
const {window} = newDimensions;
setWindowDimension({
windowHeight: window.height,
@@ -81,30 +78,29 @@ function WindowDimensionsProvider(props) {
return {props.children};
}
-WindowDimensionsProvider.propTypes = windowDimensionsProviderPropTypes;
WindowDimensionsProvider.displayName = 'WindowDimensionsProvider';
-/**
- * @param {React.Component} WrappedComponent
- * @returns {React.Component}
- */
-export default function withWindowDimensions(WrappedComponent) {
- const WithWindowDimensions = forwardRef((props, ref) => (
-
- {(windowDimensionsProps) => (
-
- )}
-
- ));
+export default function withWindowDimensions(
+ WrappedComponent: ComponentType>,
+): (props: Omit & React.RefAttributes) => React.ReactElement | null {
+ function WithWindowDimensions(props: Omit, ref: ForwardedRef) {
+ return (
+
+ {(windowDimensionsProps) => (
+
+ )}
+
+ );
+ }
WithWindowDimensions.displayName = `withWindowDimensions(${getComponentDisplayName(WrappedComponent)})`;
- return WithWindowDimensions;
+ return React.forwardRef(WithWindowDimensions);
}
export {WindowDimensionsProvider, windowDimensionsPropTypes};
diff --git a/src/components/withWindowDimensions/types.ts b/src/components/withWindowDimensions/types.ts
new file mode 100644
index 000000000000..514c86616b87
--- /dev/null
+++ b/src/components/withWindowDimensions/types.ts
@@ -0,0 +1,34 @@
+import {ScaledSize} from 'react-native';
+
+type WindowDimensionsContextData = {
+ windowHeight: number;
+ windowWidth: number;
+ isExtraSmallScreenWidth: boolean;
+ isSmallScreenWidth: boolean;
+ isMediumScreenWidth: boolean;
+ isLargeScreenWidth: boolean;
+};
+
+type WindowDimensionsProps = WindowDimensionsContextData & {
+ // Width of the window
+ windowWidth: number;
+
+ // Height of the window
+ windowHeight: number;
+
+ // Is the window width extra narrow, like on a Fold mobile device?
+ isExtraSmallScreenWidth: boolean;
+
+ // Is the window width narrow, like on a mobile device?
+ isSmallScreenWidth: boolean;
+
+ // Is the window width medium sized, like on a tablet device?
+ isMediumScreenWidth: boolean;
+
+ // Is the window width wide, like on a browser or desktop?
+ isLargeScreenWidth: boolean;
+};
+
+type NewDimensions = {window: ScaledSize};
+
+export type {WindowDimensionsContextData, WindowDimensionsProps, NewDimensions};
diff --git a/src/hooks/useTabNavigatorFocus/index.js b/src/hooks/useTabNavigatorFocus/index.js
new file mode 100644
index 000000000000..f83ec5bd9270
--- /dev/null
+++ b/src/hooks/useTabNavigatorFocus/index.js
@@ -0,0 +1,79 @@
+import {useTabAnimation} from '@react-navigation/material-top-tabs';
+import {useIsFocused} from '@react-navigation/native';
+import {useEffect, useState} from 'react';
+import DomUtils from '@libs/DomUtils';
+
+/**
+ * Custom React hook to determine the focus status of a tab in a Material Top Tab Navigator.
+ * It evaluates whether the current tab is focused based on the tab's animation position and
+ * the screen's focus status within a React Navigation environment.
+ *
+ * This hook is designed for use with the Material Top Tabs provided by '@react-navigation/material-top-tabs'.
+ * It leverages the `useTabAnimation` hook from the same package to track the animated position of tabs
+ * and the `useIsFocused` hook from '@react-navigation/native' to ascertain if the current screen is in focus.
+ *
+ * Note: This hook contains a conditional invocation of another hook (`useTabAnimation`),
+ * which is typically an anti-pattern in React. This is done to account for scenarios where the hook
+ * might not be used within a Material Top Tabs Navigator context. Proper usage should ensure that
+ * this hook is only used where appropriate.
+ *
+ * @param {Object} params - The parameters object.
+ * @param {Number} params.tabIndex - The index of the tab for which focus status is being determined.
+ * @returns {Boolean} Returns `true` if the tab is both animation-focused and screen-focused, otherwise `false`.
+ *
+ * @example
+ * const isTabFocused = useTabNavigatorFocus({ tabIndex: 1 });
+ */
+function useTabNavigatorFocus({tabIndex}) {
+ let tabPositionAnimation = null;
+ try {
+ // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed.
+ // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness.
+ // STOP!!!!!!! This is not a pattern to be followed! We are conditionally rendering this hook becase when used in the edit flow we'll never be inside a tab navigator.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ tabPositionAnimation = useTabAnimation();
+ } catch (error) {
+ tabPositionAnimation = null;
+ }
+ const isPageFocused = useIsFocused();
+ // set to true if the hook is not used within the MaterialTopTabs context
+ // the hook will then return true if the screen is focused
+ const [isTabFocused, setIsTabFocused] = useState(!tabPositionAnimation);
+
+ useEffect(() => {
+ if (!tabPositionAnimation) {
+ return;
+ }
+ const index = Number(tabIndex);
+
+ const listenerId = tabPositionAnimation.addListener(({value}) => {
+ // Activate camera as soon the index is animating towards the `tabIndex`
+ DomUtils.requestAnimationFrame(() => {
+ setIsTabFocused(value > index - 1 && value < index + 1);
+ });
+ });
+
+ // We need to get the position animation value on component initialization to determine
+ // if the tab is focused or not. Since it's an Animated.Value the only synchronous way
+ // to retrieve the value is to use a private method.
+ // eslint-disable-next-line no-underscore-dangle
+ const initialTabPositionValue = tabPositionAnimation.__getValue();
+
+ if (typeof initialTabPositionValue === 'number') {
+ DomUtils.requestAnimationFrame(() => {
+ setIsTabFocused(initialTabPositionValue > index - 1 && initialTabPositionValue < index + 1);
+ });
+ }
+
+ return () => {
+ if (!tabPositionAnimation) {
+ return;
+ }
+ tabPositionAnimation.removeListener(listenerId);
+ };
+ }, [tabIndex, tabPositionAnimation]);
+
+ return isTabFocused && isPageFocused;
+}
+
+export default useTabNavigatorFocus;
diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts
index 9a9758228776..0864f1a16ac0 100644
--- a/src/libs/DomUtils/index.native.ts
+++ b/src/libs/DomUtils/index.native.ts
@@ -2,6 +2,15 @@ import GetActiveElement from './types';
const getActiveElement: GetActiveElement = () => null;
+const requestAnimationFrame = (callback: () => void) => {
+ if (!callback) {
+ return;
+ }
+
+ callback();
+};
+
export default {
getActiveElement,
+ requestAnimationFrame,
};
diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts
index 94dd54547454..6a2eed57fbe6 100644
--- a/src/libs/DomUtils/index.ts
+++ b/src/libs/DomUtils/index.ts
@@ -4,4 +4,5 @@ const getActiveElement: GetActiveElement = () => document.activeElement;
export default {
getActiveElement,
+ requestAnimationFrame: window.requestAnimationFrame.bind(window),
};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
index de3fb9e79659..3fd8dec90d8d 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js
@@ -37,10 +37,6 @@ function RightModalNavigator(props) {
void;
+let closeModal: ((isNavigating: boolean) => void) | null;
let onModalClose: null | (() => void);
/**
* Allows other parts of the app to call modal close function
*/
-function setCloseModal(onClose: () => void) {
+function setCloseModal(onClose: (() => void) | null) {
closeModal = onClose;
}
diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js
index ab813db2f2e9..bdef759d0ed6 100644
--- a/src/pages/home/report/ReportActionItemFragment.js
+++ b/src/pages/home/report/ReportActionItemFragment.js
@@ -1,4 +1,3 @@
-import Str from 'expensify-common/lib/str';
import PropTypes from 'prop-types';
import React, {memo} from 'react';
import avatarPropTypes from '@components/avatarPropTypes';
@@ -8,16 +7,13 @@ import Text from '@components/Text';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
-import ZeroWidthView from '@components/ZeroWidthView';
import compose from '@libs/compose';
import convertToLTR from '@libs/convertToLTR';
-import * as DeviceCapabilities from '@libs/DeviceCapabilities';
-import * as EmojiUtils from '@libs/EmojiUtils';
-import editedLabelStyles from '@styles/editedLabelStyles';
+import * as ReportUtils from '@libs/ReportUtils';
import styles from '@styles/styles';
-import themeColors from '@styles/themes/default';
-import variables from '@styles/variables';
import CONST from '@src/CONST';
+import AttachmentCommentFragment from './comment/AttachmentCommentFragment';
+import TextCommentFragment from './comment/TextCommentFragment';
import reportActionFragmentPropTypes from './reportActionFragmentPropTypes';
const propTypes = {
@@ -63,6 +59,9 @@ const propTypes = {
/** Whether the comment is a thread parent message/the first message in a thread */
isThreadParentMessage: PropTypes.bool,
+ /** Should the comment have the appearance of being grouped with the previous comment? */
+ displayAsGroup: PropTypes.bool,
+
/** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */
isApprovedOrSubmittedReportAction: PropTypes.bool,
@@ -73,9 +72,6 @@ const propTypes = {
/** localization props */
...withLocalizePropTypes,
-
- /** Should the comment have the appearance of being grouped with the previous comment? */
- displayAsGroup: PropTypes.bool,
};
const defaultProps = {
@@ -98,70 +94,39 @@ const defaultProps = {
};
function ReportActionItemFragment(props) {
- switch (props.fragment.type) {
+ const fragment = props.fragment;
+
+ switch (fragment.type) {
case 'COMMENT': {
- const {html, text} = props.fragment;
- const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && props.network.isOffline;
+ const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
// Threaded messages display "[Deleted message]" instead of being hidden altogether.
// While offline we display the previous message with a strikethrough style. Once online we want to
// immediately display "[Deleted message]" while the delete action is pending.
- if ((!props.network.isOffline && props.isThreadParentMessage && props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) || props.fragment.isDeletedParentAction) {
+ if ((!props.network.isOffline && props.isThreadParentMessage && isPendingDelete) || props.fragment.isDeletedParentAction) {
return ${props.translate('parentReportAction.deletedMessage')}`} />;
}
- // If the only difference between fragment.text and fragment.html is
tags
- // we render it as text, not as html.
- // This is done to render emojis with line breaks between them as text.
- const differByLineBreaksOnly = Str.replaceAll(html, '
', '\n') === text;
-
- // Only render HTML if we have html in the fragment
- if (!differByLineBreaksOnly) {
- const editedTag = props.fragment.isEdited ? `` : '';
- const htmlContent = isPendingDelete ? `${html}` : html;
-
- const htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent;
-
- return ${htmlWithTag}` : `${htmlWithTag}`} />;
+ if (ReportUtils.isReportMessageAttachment(fragment)) {
+ return (
+
+ );
}
- const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text);
return (
-
-
-
- {convertToLTR(props.iouMessage || text)}
-
- {Boolean(props.fragment.isEdited) && (
- <>
-
- {' '}
-
-
- {props.translate('reportActionCompose.edited')}
-
- >
- )}
-
+
);
}
case 'TEXT': {
@@ -182,7 +147,7 @@ function ReportActionItemFragment(props) {
numberOfLines={props.isSingleLine ? 1 : undefined}
style={[styles.chatItemMessageHeaderSender, props.isSingleLine ? styles.pre : styles.preWrap]}
>
- {props.fragment.text}
+ {fragment.text}
);
diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js
index 4c6603c052a3..75e316342165 100644
--- a/src/pages/home/report/ReportActionItemMessage.js
+++ b/src/pages/home/report/ReportActionItemMessage.js
@@ -37,8 +37,7 @@ const defaultProps = {
};
function ReportActionItemMessage(props) {
- const messages = _.compact(props.action.previousMessage || props.action.message);
- const isAttachment = ReportUtils.isReportMessageAttachment(_.last(messages));
+ const fragments = _.compact(props.action.previousMessage || props.action.message);
const isIOUReport = ReportActionsUtils.isMoneyRequestAction(props.action);
let iouMessage;
if (isIOUReport) {
@@ -56,7 +55,7 @@ function ReportActionItemMessage(props) {
* @returns {Object} report action item fragments
*/
const renderReportActionItemFragments = (shouldWrapInText) => {
- const reportActionItemFragments = _.map(messages, (fragment, index) => (
+ const reportActionItemFragments = _.map(fragments, (fragment, index) => (
+
{!props.isHidden ? (
renderReportActionItemFragments(isApprovedOrSubmittedReportAction)
) : (
diff --git a/src/pages/home/report/comment/AttachmentCommentFragment.js b/src/pages/home/report/comment/AttachmentCommentFragment.js
new file mode 100644
index 000000000000..8ee161600aee
--- /dev/null
+++ b/src/pages/home/report/comment/AttachmentCommentFragment.js
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {View} from 'react-native';
+import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType';
+import styles from '@styles/styles';
+import RenderCommentHTML from './RenderCommentHTML';
+
+const propTypes = {
+ /** The reportAction's source */
+ source: reportActionSourcePropType.isRequired,
+
+ /** The message fragment's HTML */
+ html: PropTypes.string.isRequired,
+
+ /** Should extra margin be added on top of the component? */
+ addExtraMargin: PropTypes.bool.isRequired,
+};
+
+function AttachmentCommentFragment({addExtraMargin, html, source}) {
+ return (
+
+
+
+ );
+}
+
+AttachmentCommentFragment.propTypes = propTypes;
+AttachmentCommentFragment.displayName = 'AttachmentCommentFragment';
+
+export default AttachmentCommentFragment;
diff --git a/src/pages/home/report/comment/RenderCommentHTML.js b/src/pages/home/report/comment/RenderCommentHTML.js
new file mode 100644
index 000000000000..14039af21189
--- /dev/null
+++ b/src/pages/home/report/comment/RenderCommentHTML.js
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import RenderHTML from '@components/RenderHTML';
+import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType';
+
+const propTypes = {
+ /** The reportAction's source */
+ source: reportActionSourcePropType.isRequired,
+
+ /** The comment's HTML */
+ html: PropTypes.string.isRequired,
+};
+
+function RenderCommentHTML({html, source}) {
+ const commentHtml = source === 'email' ? `${html}` : `${html}`;
+
+ return ;
+}
+
+RenderCommentHTML.propTypes = propTypes;
+RenderCommentHTML.displayName = 'RenderCommentHTML';
+
+export default RenderCommentHTML;
diff --git a/src/pages/home/report/comment/TextCommentFragment.js b/src/pages/home/report/comment/TextCommentFragment.js
new file mode 100644
index 000000000000..9dccf8de7f9d
--- /dev/null
+++ b/src/pages/home/report/comment/TextCommentFragment.js
@@ -0,0 +1,118 @@
+import Str from 'expensify-common/lib/str';
+import PropTypes from 'prop-types';
+import React, {memo} from 'react';
+import Text from '@components/Text';
+import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
+import ZeroWidthView from '@components/ZeroWidthView';
+import compose from '@libs/compose';
+import convertToLTR from '@libs/convertToLTR';
+import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import * as EmojiUtils from '@libs/EmojiUtils';
+import reportActionFragmentPropTypes from '@pages/home/report/reportActionFragmentPropTypes';
+import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType';
+import editedLabelStyles from '@styles/editedLabelStyles';
+import styles from '@styles/styles';
+import themeColors from '@styles/themes/default';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import RenderCommentHTML from './RenderCommentHTML';
+
+const propTypes = {
+ /** The reportAction's source */
+ source: reportActionSourcePropType.isRequired,
+
+ /** The message fragment needing to be displayed */
+ fragment: reportActionFragmentPropTypes.isRequired,
+
+ /** Should this message fragment be styled as deleted? */
+ styleAsDeleted: PropTypes.bool.isRequired,
+
+ /** Text of an IOU report action */
+ iouMessage: PropTypes.string,
+
+ /** Should the comment have the appearance of being grouped with the previous comment? */
+ displayAsGroup: PropTypes.bool.isRequired,
+
+ /** Additional styles to add after local styles. */
+ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]).isRequired,
+
+ ...windowDimensionsPropTypes,
+
+ /** localization props */
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {
+ iouMessage: undefined,
+};
+
+function TextCommentFragment(props) {
+ const {fragment, styleAsDeleted} = props;
+ const {html, text} = fragment;
+
+ // If the only difference between fragment.text and fragment.html is
tags
+ // we render it as text, not as html.
+ // This is done to render emojis with line breaks between them as text.
+ const differByLineBreaksOnly = Str.replaceAll(html, '
', '\n') === text;
+
+ // Only render HTML if we have html in the fragment
+ if (!differByLineBreaksOnly) {
+ const editedTag = fragment.isEdited ? `` : '';
+ const htmlContent = styleAsDeleted ? `${html}` : html;
+
+ const htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent;
+
+ return (
+
+ );
+ }
+
+ const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text);
+
+ return (
+
+
+
+ {convertToLTR(props.iouMessage || text)}
+
+ {Boolean(fragment.isEdited) && (
+ <>
+
+ {' '}
+
+
+ {props.translate('reportActionCompose.edited')}
+
+ >
+ )}
+
+ );
+}
+
+TextCommentFragment.propTypes = propTypes;
+TextCommentFragment.defaultProps = defaultProps;
+TextCommentFragment.displayName = 'TextCommentFragment';
+
+export default compose(withWindowDimensions, withLocalize)(memo(TextCommentFragment));
diff --git a/src/pages/home/report/reportActionSourcePropType.js b/src/pages/home/report/reportActionSourcePropType.js
new file mode 100644
index 000000000000..0ad9662eb693
--- /dev/null
+++ b/src/pages/home/report/reportActionSourcePropType.js
@@ -0,0 +1,3 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.oneOf(['Chronos', 'email', 'ios', 'android', 'web', 'email', '']);
diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js
index e4b24f8a0ad8..10b16da13b6e 100644
--- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js
+++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.js
@@ -1,17 +1,20 @@
-import {useIsFocused} from '@react-navigation/native';
import PropTypes from 'prop-types';
import React, {useEffect, useRef} from 'react';
import {View} from 'react-native';
import Webcam from 'react-webcam';
+import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus';
const propTypes = {
- /* Flag to turn on/off the torch/flashlight - if available */
+ /** Flag to turn on/off the torch/flashlight - if available */
torchOn: PropTypes.bool,
- /* Callback function when media stream becomes available - user granted camera permissions and camera starts to work */
+ /** The index of the tab that contains this camera */
+ cameraTabIndex: PropTypes.number.isRequired,
+
+ /** Callback function when media stream becomes available - user granted camera permissions and camera starts to work */
onUserMedia: PropTypes.func,
- /* Callback function passing torch/flashlight capability as bool param of the browser */
+ /** Callback function passing torch/flashlight capability as bool param of the browser */
onTorchAvailability: PropTypes.func,
};
@@ -22,9 +25,11 @@ const defaultProps = {
};
// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused.
-const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, ...props}, ref) => {
+const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, cameraTabIndex, ...props}, ref) => {
const trackRef = useRef(null);
- const isCameraActive = useIsFocused();
+ const shouldShowCamera = useTabNavigatorFocus({
+ tabIndex: cameraTabIndex,
+ });
const handleOnUserMedia = (stream) => {
if (props.onUserMedia) {
@@ -51,7 +56,7 @@ const NavigationAwareCamera = React.forwardRef(({torchOn, onTorchAvailability, .
});
}, [torchOn]);
- if (!isCameraActive) {
+ if (!shouldShowCamera) {
return null;
}
return (
diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js
index eca8042a6965..65c17d3cb7ab 100644
--- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js
+++ b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js
@@ -1,77 +1,16 @@
-import {useNavigation} from '@react-navigation/native';
import PropTypes from 'prop-types';
-import React, {useEffect, useState} from 'react';
+import React from 'react';
import {Camera} from 'react-native-vision-camera';
-import withTabAnimation from '@components/withTabAnimation';
-import CONST from '@src/CONST';
+import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus';
const propTypes = {
/* The index of the tab that contains this camera */
cameraTabIndex: PropTypes.number.isRequired,
-
- /* Whether we're in a tab navigator */
- isInTabNavigator: PropTypes.bool.isRequired,
-
- /** Name of the selected receipt tab */
- selectedTab: PropTypes.string.isRequired,
-
- /** The tab animation from hook */
- tabAnimation: PropTypes.shape({
- addListener: PropTypes.func,
- removeListener: PropTypes.func,
- }),
-};
-
-const defaultProps = {
- tabAnimation: undefined,
};
// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused.
-const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, isInTabNavigator, selectedTab, tabAnimation, ...props}, ref) => {
- // Get navigation to get initial isFocused value (only needed once during init!)
- const navigation = useNavigation();
- const [isCameraActive, setIsCameraActive] = useState(() => navigation.isFocused());
-
- // Retrieve the animation value from the tab navigator, which ranges from 0 to the total number of pages displayed.
- // Even a minimal scroll towards the camera page (e.g., a value of 0.001 at start) should activate the camera for immediate responsiveness.
-
- useEffect(() => {
- if (!isInTabNavigator) {
- return;
- }
-
- const listenerId = tabAnimation.addListener(({value}) => {
- if (selectedTab !== CONST.TAB.SCAN) {
- return;
- }
- // Activate camera as soon the index is animating towards the `cameraTabIndex`
- setIsCameraActive(value > cameraTabIndex - 1 && value < cameraTabIndex + 1);
- });
-
- return () => {
- tabAnimation.removeListener(listenerId);
- };
- }, [cameraTabIndex, tabAnimation, isInTabNavigator, selectedTab]);
-
- // Note: The useEffect can be removed once VisionCamera V3 is used.
- // Its only needed for android, because there is a native cameraX android bug. With out this flow would break the camera:
- // 1. Open camera tab
- // 2. Take a picture
- // 3. Go back from the opened screen
- // 4. The camera is not working anymore
- useEffect(() => {
- const removeBlurListener = navigation.addListener('blur', () => {
- setIsCameraActive(false);
- });
- const removeFocusListener = navigation.addListener('focus', () => {
- setIsCameraActive(true);
- });
-
- return () => {
- removeBlurListener();
- removeFocusListener();
- };
- }, [navigation]);
+const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, ...props}, ref) => {
+ const isCameraActive = useTabNavigatorFocus({tabIndex: cameraTabIndex});
return (
!state, false);
- const [isTorchAvailable, setIsTorchAvailable] = useState(true);
+ const [isTorchAvailable, setIsTorchAvailable] = useState(false);
const cameraRef = useRef(null);
const hideReciptModal = () => {
@@ -200,6 +196,7 @@ function ReceiptSelector({route, transactionID, iou, report}) {
torchOn={isFlashLightOn}
onTorchAvailability={setIsTorchAvailable}
forceScreenshotSourceSize
+ cameraTabIndex={pageIndex}
/>
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 824c242cf02f..ef81109ffb90 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -50,23 +50,15 @@ const propTypes = {
/** The id of the transaction we're editing */
transactionID: PropTypes.string,
-
- /** Whether or not the receipt selector is in a tab navigator for tab animations */
- isInTabNavigator: PropTypes.bool,
-
- /** Name of the selected receipt tab */
- selectedTab: PropTypes.string,
};
const defaultProps = {
report: {},
iou: iouDefaultProps,
transactionID: '',
- isInTabNavigator: true,
- selectedTab: '',
};
-function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, selectedTab}) {
+function ReceiptSelector({route, report, iou, transactionID}) {
const devices = useCameraDevices('wide-angle-camera');
const device = devices.back;
@@ -218,8 +210,6 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator, s
zoom={device.neutralZoom}
photo
cameraTabIndex={pageIndex}
- isInTabNavigator={isInTabNavigator}
- selectedTab={selectedTab}
/>
)}
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js
index 5b55965d1539..9cd620cf8f12 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.js
+++ b/src/pages/signin/LoginForm/BaseLoginForm.js
@@ -26,6 +26,7 @@ import Log from '@libs/Log';
import * as LoginUtils from '@libs/LoginUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
+import Visibility from '@libs/Visibility';
import styles from '@styles/styles';
import * as CloseAccount from '@userActions/CloseAccount';
import * as MemoryOnlyKeys from '@userActions/MemoryOnlyKeys/MemoryOnlyKeys';
@@ -98,18 +99,52 @@ function LoginForm(props) {
const [login, setLogin] = useState(() => Str.removeSMSDomain(props.credentials.login || ''));
const [formError, setFormError] = useState(false);
const prevIsVisible = usePrevious(props.isVisible);
+ const firstBlurred = useRef(false);
const {translate} = props;
/**
- * Handle text input and clear formError upon text change
+ * Validate the input value and set the error for formError
+ *
+ * @param {String} value
+ */
+ const validate = useCallback(
+ (value) => {
+ const loginTrim = value.trim();
+ if (!loginTrim) {
+ setFormError('common.pleaseEnterEmailOrPhoneNumber');
+ return false;
+ }
+
+ const phoneLogin = LoginUtils.appendCountryCode(LoginUtils.getPhoneNumberWithoutSpecialChars(loginTrim));
+ const parsedPhoneNumber = parsePhoneNumber(phoneLogin);
+
+ if (!Str.isValidEmail(loginTrim) && !parsedPhoneNumber.possible) {
+ if (ValidationUtils.isNumericWithSpecialChars(loginTrim)) {
+ setFormError('common.error.phoneNumber');
+ } else {
+ setFormError('loginForm.error.invalidFormatEmailLogin');
+ }
+ return false;
+ }
+
+ setFormError(null);
+ return true;
+ },
+ [setFormError],
+ );
+
+ /**
+ * Handle text input and validate the text input if it is blurred
*
* @param {String} text
*/
const onTextInput = useCallback(
(text) => {
setLogin(text);
- setFormError(null);
+ if (firstBlurred.current) {
+ validate(text);
+ }
if (props.account.errors || props.account.message) {
Session.clearAccountMessages();
@@ -120,7 +155,7 @@ function LoginForm(props) {
CloseAccount.setDefaultData();
}
},
- [props.account, props.closeAccount, input, setFormError, setLogin],
+ [props.account, props.closeAccount, input, setLogin, validate],
);
function getSignInWithStyles() {
@@ -140,35 +175,30 @@ function LoginForm(props) {
CloseAccount.setDefaultData();
}
- const loginTrim = login.trim();
- if (!loginTrim) {
- setFormError('common.pleaseEnterEmailOrPhoneNumber');
- return;
+ // For native, the single input doesn't lost focus when we click outside.
+ // So we need to change firstBlurred here to make the validate function is called whenever the text input is changed after the first validation.
+ if (!firstBlurred.current) {
+ firstBlurred.current = true;
}
- const phoneLogin = LoginUtils.appendCountryCode(LoginUtils.getPhoneNumberWithoutSpecialChars(loginTrim));
- const parsedPhoneNumber = parsePhoneNumber(phoneLogin);
-
- if (!Str.isValidEmail(loginTrim) && !parsedPhoneNumber.possible) {
- if (ValidationUtils.isNumericWithSpecialChars(loginTrim)) {
- setFormError('common.error.phoneNumber');
- } else {
- setFormError('loginForm.error.invalidFormatEmailLogin');
- }
+ if (!validate(login)) {
return;
}
+ const loginTrim = login.trim();
+
// If the user has entered a guide email, then we are going to enable an experimental Onyx mode to help with performance
if (PolicyUtils.isExpensifyGuideTeam(loginTrim)) {
Log.info('Detected guide email in login field, setting memory only keys.');
MemoryOnlyKeys.enable();
}
- setFormError(null);
+ const phoneLogin = LoginUtils.appendCountryCode(LoginUtils.getPhoneNumberWithoutSpecialChars(loginTrim));
+ const parsedPhoneNumber = parsePhoneNumber(phoneLogin);
// Check if this login has an account associated with it or not
Session.beginSignIn(parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : loginTrim);
- }, [login, props.account, props.closeAccount, props.network, setFormError]);
+ }, [login, props.account, props.closeAccount, props.network, validate]);
useEffect(() => {
// Just call clearAccountMessages on the login page (home route), because when the user is in the transition route and not yet authenticated,
@@ -227,6 +257,13 @@ function LoginForm(props) {
textContentType="username"
id="username"
name="username"
+ onBlur={() => {
+ if (firstBlurred.current || !Visibility.isVisible() || !Visibility.hasFocus()) {
+ return;
+ }
+ firstBlurred.current = true;
+ validate(login);
+ }}
onChangeText={onTextInput}
onSubmitEditing={validateAndSubmitForm}
autoCapitalize="none"
@@ -276,8 +313,12 @@ function LoginForm(props) {
-
-
+ e.preventDefault()}>
+
+
+ e.preventDefault()}>
+
+
)
diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts
index de936570291f..a99d292e9dc6 100644
--- a/src/styles/StyleUtils.ts
+++ b/src/styles/StyleUtils.ts
@@ -17,6 +17,12 @@ import variables from './variables';
type AllStyles = ViewStyle | TextStyle | ImageStyle;
type ParsableStyle = StyleProp | ((state: PressableStateCallbackType) => StyleProp);
+type AvatarStyle = {
+ width: number;
+ height: number;
+ borderRadius: number;
+ backgroundColor: string;
+};
type ColorValue = ValueOf;
type AvatarSizeName = ValueOf;
@@ -56,10 +62,10 @@ type ModalPaddingStylesParams = {
safeAreaPaddingBottom: number;
safeAreaPaddingLeft: number;
safeAreaPaddingRight: number;
- modalContainerStyleMarginTop: number;
- modalContainerStyleMarginBottom: number;
- modalContainerStylePaddingTop: number;
- modalContainerStylePaddingBottom: number;
+ modalContainerStyleMarginTop: DimensionValue | undefined;
+ modalContainerStyleMarginBottom: DimensionValue | undefined;
+ modalContainerStylePaddingTop: DimensionValue | undefined;
+ modalContainerStylePaddingBottom: DimensionValue | undefined;
insets: EdgeInsets;
};
@@ -210,7 +216,7 @@ function getAvatarWidthStyle(size: AvatarSizeName): ViewStyle {
/**
* Return the style from an avatar size constant
*/
-function getAvatarStyle(size: AvatarSizeName): ViewStyle {
+function getAvatarStyle(size: AvatarSizeName): AvatarStyle {
const avatarSize = getAvatarSize(size);
return {
height: avatarSize,
@@ -241,7 +247,7 @@ function getAvatarBorderWidth(size: AvatarSizeName): ViewStyle {
/**
* Return the border radius for an avatar
*/
-function getAvatarBorderRadius(size: AvatarSizeName, type: string): ViewStyle {
+function getAvatarBorderRadius(size: AvatarSizeName, type?: string): ViewStyle {
if (type === CONST.ICON_TYPE_WORKSPACE) {
return {borderRadius: avatarBorderSizes[size]};
}
@@ -288,12 +294,19 @@ function getEReceiptColorStyles(colorCode: EReceiptColorName): EreceiptColorStyl
return eReceiptColorStyles[colorCode];
}
+type SafeAreaPadding = {
+ paddingTop: number;
+ paddingBottom: number;
+ paddingLeft: number;
+ paddingRight: number;
+};
+
/**
* Takes safe area insets and returns padding to use for a View
*/
-function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentage: number = variables.safeInsertPercentage): ViewStyle {
+function getSafeAreaPadding(insets?: EdgeInsets, insetsPercentage: number = variables.safeInsertPercentage): SafeAreaPadding {
return {
- paddingTop: insets?.top,
+ paddingTop: insets?.top ?? 0,
paddingBottom: (insets?.bottom ?? 0) * insetsPercentage,
paddingLeft: (insets?.left ?? 0) * insetsPercentage,
paddingRight: (insets?.right ?? 0) * insetsPercentage,
@@ -569,6 +582,22 @@ function getWidthAndHeightStyle(width: number, height?: number): ViewStyle {
};
}
+/**
+ * Combine margin/padding with safe area inset
+ *
+ * @param modalContainerValue - margin or padding value
+ * @param safeAreaValue - safe area inset
+ * @param shouldAddSafeAreaValue - indicator whether safe area inset should be applied
+ */
+function getCombinedSpacing(modalContainerValue: DimensionValue | undefined, safeAreaValue: number, shouldAddSafeAreaValue: boolean): number | DimensionValue | undefined {
+ // modalContainerValue can only be added to safe area inset if it's a number, otherwise it's returned as is
+ if (typeof modalContainerValue === 'number' || !modalContainerValue) {
+ return (modalContainerValue ?? 0) + (shouldAddSafeAreaValue ? safeAreaValue : 0);
+ }
+
+ return modalContainerValue;
+}
+
function getModalPaddingStyles({
shouldAddBottomSafeAreaMargin,
shouldAddTopSafeAreaMargin,
@@ -586,12 +615,12 @@ function getModalPaddingStyles({
}: ModalPaddingStylesParams): ViewStyle {
// use fallback value for safeAreaPaddingBottom to keep padding bottom consistent with padding top.
// More info: issue #17376
- const safeAreaPaddingBottomWithFallback = insets.bottom === 0 ? modalContainerStylePaddingTop ?? 0 : safeAreaPaddingBottom;
+ const safeAreaPaddingBottomWithFallback = insets.bottom === 0 && typeof modalContainerStylePaddingTop === 'number' ? modalContainerStylePaddingTop ?? 0 : safeAreaPaddingBottom;
return {
- marginTop: (modalContainerStyleMarginTop ?? 0) + (shouldAddTopSafeAreaMargin ? safeAreaPaddingTop : 0),
- marginBottom: (modalContainerStyleMarginBottom ?? 0) + (shouldAddBottomSafeAreaMargin ? safeAreaPaddingBottomWithFallback : 0),
- paddingTop: shouldAddTopSafeAreaPadding ? (modalContainerStylePaddingTop ?? 0) + safeAreaPaddingTop : modalContainerStylePaddingTop ?? 0,
- paddingBottom: shouldAddBottomSafeAreaPadding ? (modalContainerStylePaddingBottom ?? 0) + safeAreaPaddingBottomWithFallback : modalContainerStylePaddingBottom ?? 0,
+ marginTop: getCombinedSpacing(modalContainerStyleMarginTop, safeAreaPaddingTop, shouldAddTopSafeAreaMargin),
+ marginBottom: getCombinedSpacing(modalContainerStyleMarginBottom, safeAreaPaddingBottomWithFallback, shouldAddBottomSafeAreaMargin),
+ paddingTop: getCombinedSpacing(modalContainerStylePaddingTop, safeAreaPaddingTop, shouldAddTopSafeAreaPadding),
+ paddingBottom: getCombinedSpacing(modalContainerStylePaddingBottom, safeAreaPaddingBottomWithFallback, shouldAddBottomSafeAreaPadding),
paddingLeft: safeAreaPaddingLeft ?? 0,
paddingRight: safeAreaPaddingRight ?? 0,
};
@@ -1018,7 +1047,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle
/**
* Select the correct color for text.
*/
-function getColoredBackgroundStyle(isColored: boolean): TextStyle {
+function getColoredBackgroundStyle(isColored: boolean): StyleProp {
return {backgroundColor: isColored ? themeColors.link : undefined};
}
diff --git a/src/styles/getModalStyles.ts b/src/styles/getModalStyles.ts
index 984bf018e42d..c250bdf9498d 100644
--- a/src/styles/getModalStyles.ts
+++ b/src/styles/getModalStyles.ts
@@ -21,8 +21,6 @@ type WindowDimensions = {
windowWidth: number;
windowHeight: number;
isSmallScreenWidth: boolean;
- isMediumScreenWidth: boolean;
- isLargeScreenWidth: boolean;
};
type GetModalStyles = {
@@ -39,7 +37,7 @@ type GetModalStyles = {
};
export default function getModalStyles(
- type: ModalType,
+ type: ModalType | undefined,
windowDimensions: WindowDimensions,
popoverAnchorPosition: ViewStyle = {},
innerContainerStyle: ViewStyle = {},
diff --git a/src/styles/styles.ts b/src/styles/styles.ts
index b70b50ffbbbc..644b809ebae4 100644
--- a/src/styles/styles.ts
+++ b/src/styles/styles.ts
@@ -328,10 +328,6 @@ const styles = (theme: ThemeColors) =>
textAlign: 'left',
},
- textUnderline: {
- textDecorationLine: 'underline',
- },
-
verticalAlignMiddle: {
verticalAlign: 'middle',
},
@@ -392,10 +388,6 @@ const styles = (theme: ThemeColors) =>
fontSize: variables.fontSizeLarge,
},
- textXLarge: {
- fontSize: variables.fontSizeXLarge,
- },
-
textXXLarge: {
fontSize: variables.fontSizeXXLarge,
},
@@ -415,11 +407,6 @@ const styles = (theme: ThemeColors) =>
fontWeight: fontWeightBold,
},
- textItalic: {
- fontFamily: fontFamily.EXP_NEUE_ITALIC,
- fontStyle: 'italic',
- },
-
textHeadline: {
...headlineFont,
...whiteSpace.preWrap,
@@ -436,10 +423,6 @@ const styles = (theme: ThemeColors) =>
lineHeight: variables.lineHeightSizeh1,
},
- textDecorationNoLine: {
- textDecorationLine: 'none',
- },
-
textWhite: {
color: theme.textLight,
},
@@ -448,10 +431,6 @@ const styles = (theme: ThemeColors) =>
color: theme.link,
},
- textUppercase: {
- textTransform: 'uppercase',
- },
-
textNoWrap: {
...whiteSpace.noWrap,
},
diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts
index ef2944d6af82..ac69baed3ef1 100644
--- a/src/types/onyx/OnyxCommon.ts
+++ b/src/types/onyx/OnyxCommon.ts
@@ -1,5 +1,5 @@
-import * as React from 'react';
import {ValueOf} from 'type-fest';
+import {AvatarSource} from '@libs/UserUtils';
import CONST from '@src/CONST';
type PendingAction = ValueOf;
@@ -11,9 +11,20 @@ type ErrorFields = Record;
type Icon = {
- source: React.ReactNode | string;
- type: 'avatar' | 'workspace';
+ /** Avatar source to display */
+ source: AvatarSource;
+
+ /** Denotes whether it is an avatar or a workspace avatar */
+ type: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE;
+
+ /** Owner of the avatar. If user, displayName. If workspace, policy name */
name: string;
+
+ /** Avatar id */
+ id: number | string;
+
+ /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */
+ fallbackIcon?: AvatarSource;
};
export type {Icon, PendingAction, PendingFields, ErrorFields, Errors};