From 68a36ae62a725eb4fc5ae4687397b297c5f3fe36 Mon Sep 17 00:00:00 2001 From: Pierre Michel Date: Fri, 21 Jul 2023 15:52:39 -0600 Subject: [PATCH 001/257] Add longPress support for TaskPreview Signed-off-by: Pierre Michel --- src/components/ReportActionItem/TaskPreview.js | 17 +++++++++++++++++ src/pages/home/report/ReportActionItem.js | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 472c71298852..7f18a65e420e 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -22,6 +22,10 @@ import * as ReportUtils from '../../libs/ReportUtils'; import RenderHTML from '../RenderHTML'; import PressableWithoutFeedback from '../Pressable/PressableWithoutFeedback'; import personalDetailsPropType from '../../pages/personalDetailsPropType'; +import {showContextMenuForReport} from '../ShowContextMenuContext'; +import reportPropTypes from '../../pages/reportPropTypes'; +import refPropTypes from '../refPropTypes'; +import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; const propTypes = { /** All personal details asssociated with user */ @@ -49,6 +53,16 @@ const propTypes = { ownerAccountID: PropTypes.number, }), + /* Onyx Props */ + /** chatReport associated with taskReport */ + chatReport: reportPropTypes, + + /** Popover context menu anchor, used for showing context menu */ + contextMenuAnchor: refPropTypes, + + /** Callback for updating context menu active state, used for showing context menu */ + checkIfContextMenuActive: PropTypes.func, + ...withLocalizePropTypes, }; @@ -74,6 +88,9 @@ function TaskPreview(props) { Navigation.navigate(ROUTES.getReportRoute(props.taskReportID))} + onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onLongPress={(event) => showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive)} style={[styles.flexRow, styles.justifyContentBetween]} accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} accessibilityLabel={props.translate('task.task')} diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 0b67a728ba0e..b15e92e29b62 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -281,8 +281,11 @@ function ReportActionItem(props) { children = ( ); } else { From 0efba40908b3ea408f222edee2d6e9b23d855387 Mon Sep 17 00:00:00 2001 From: Pierre Michel Date: Fri, 21 Jul 2023 16:27:14 -0600 Subject: [PATCH 002/257] Add ControlSelection to imports Signed-off-by: Pierre Michel --- src/components/ReportActionItem/TaskPreview.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 7f18a65e420e..9d66d16b9b54 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -26,6 +26,7 @@ import {showContextMenuForReport} from '../ShowContextMenuContext'; import reportPropTypes from '../../pages/reportPropTypes'; import refPropTypes from '../refPropTypes'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; +import ControlSelection from '../../libs/ControlSelection'; const propTypes = { /** All personal details asssociated with user */ From e14ba5d874daa03a3c244c7e97aa0aa70970a455 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 27 Sep 2023 15:31:00 +0700 Subject: [PATCH 003/257] don't allow go to next step if empty waypoint --- .../MoneyRequestParticipantsPage.js | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 8d745903eb40..c6d0cd541944 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -17,6 +17,7 @@ import * as IOU from '../../../../libs/actions/IOU'; import * as MoneyRequestUtils from '../../../../libs/MoneyRequestUtils'; import {iouPropTypes, iouDefaultProps} from '../../propTypes'; import useLocalize from '../../../../hooks/useLocalize'; +import compose from '../../../../libs/compose'; const propTypes = { /** React Navigation route */ @@ -42,7 +43,7 @@ const defaultProps = { iou: iouDefaultProps, }; -function MoneyRequestParticipantsPage({iou, selectedTab, route}) { +function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const {translate} = useLocalize(); const prevMoneyRequestId = useRef(iou.id); const isNewReportIDSelectedLocally = useRef(false); @@ -53,7 +54,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); - + const isEmptyWaypoint = _.isEmpty(lodashGet(transaction, 'comment.waypoints.waypoint0', {})); useEffect(() => { if (isDistanceRequest) { setHeaderTitle(translate('common.distance')); @@ -85,10 +86,12 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { }; useEffect(() => { + const isInvalidDistanceRequest = !isDistanceRequest || isEmptyWaypoint; + // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request if (prevMoneyRequestId.current !== iou.id) { // The ID is cleared on completing a request. In that case, we will do nothing - if (iou.id && !isDistanceRequest && !isSplitRequest && !isNewReportIDSelectedLocally.current) { + if (iou.id && isInvalidDistanceRequest && !isSplitRequest && !isNewReportIDSelectedLocally.current) { navigateBack(true); } return; @@ -100,14 +103,14 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route}) { if (shouldReset) { IOU.resetMoneyRequestInfo(moneyRequestId); } - if (!isDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { + if (isInvalidDistanceRequest && ((iou.amount === 0 && !iou.receiptPath) || shouldReset)) { navigateBack(true); } return () => { prevMoneyRequestId.current = iou.id; }; - }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest]); + }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, isEmptyWaypoint]); return ( `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, + }), +)(MoneyRequestParticipantsPage); From 0e23a7b844036cd1e9218d6c8b18c97d6d093326 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 27 Sep 2023 15:45:57 +0700 Subject: [PATCH 004/257] fix lint --- .../MoneyRequestParticipantsPage.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index c6d0cd541944..98e5e0976b7a 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -18,6 +18,7 @@ import * as MoneyRequestUtils from '../../../../libs/MoneyRequestUtils'; import {iouPropTypes, iouDefaultProps} from '../../propTypes'; import useLocalize from '../../../../hooks/useLocalize'; import compose from '../../../../libs/compose'; +import transactionPropTypes from '../../../../components/transactionPropTypes'; const propTypes = { /** React Navigation route */ @@ -37,10 +38,14 @@ const propTypes = { /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ selectedTab: PropTypes.oneOf([CONST.TAB.DISTANCE, CONST.TAB.MANUAL, CONST.TAB.SCAN]).isRequired, + + /** Transaction that stores the distance request data */ + transaction: transactionPropTypes, }; const defaultProps = { iou: iouDefaultProps, + transaction: {}, }; function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { From 537af3d2e4826966db8cc7a1bb46f344ad3901c5 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 27 Sep 2023 17:56:23 +0700 Subject: [PATCH 005/257] fix lint issue --- .../MoneyRequestParticipantsPage.js | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 98e5e0976b7a..31785081f6ab 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -17,7 +17,6 @@ import * as IOU from '../../../../libs/actions/IOU'; import * as MoneyRequestUtils from '../../../../libs/MoneyRequestUtils'; import {iouPropTypes, iouDefaultProps} from '../../propTypes'; import useLocalize from '../../../../hooks/useLocalize'; -import compose from '../../../../libs/compose'; import transactionPropTypes from '../../../../components/transactionPropTypes'; const propTypes = { @@ -151,18 +150,14 @@ MoneyRequestParticipantsPage.displayName = 'IOUParticipantsPage'; MoneyRequestParticipantsPage.propTypes = propTypes; MoneyRequestParticipantsPage.defaultProps = defaultProps; -export default compose( - withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - selectedTab: { - key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, - }, - }), - withOnyx({ - transaction: { - key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, - }, - }), -)(MoneyRequestParticipantsPage); +export default withOnyx({ + iou: { + key: ONYXKEYS.IOU, + }, + selectedTab: { + key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, + }, + transaction: { + key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, +})(MoneyRequestParticipantsPage); From add086352c19883fe1b34cad8c2243b31be674d2 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 28 Sep 2023 02:58:18 +0700 Subject: [PATCH 006/257] fix cannot read property undefined transactionID --- .../MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 31785081f6ab..87b27b27622a 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -158,6 +158,6 @@ export default withOnyx({ key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, }, transaction: { - key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou?.transactionID}`, }, })(MoneyRequestParticipantsPage); From bab3fc8c1ae4ca870c06eb253870631ad6f3731d Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 28 Sep 2023 03:10:00 +0700 Subject: [PATCH 007/257] fix optional chaining --- .../MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 87b27b27622a..98682e1af490 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -158,6 +158,6 @@ export default withOnyx({ key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, }, transaction: { - key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou?.transactionID}`, + key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou ? iou.transactionID : ''}`, }, })(MoneyRequestParticipantsPage); From ceaaf3359fdeb3569bf652059dc50f7a9a63ccc4 Mon Sep 17 00:00:00 2001 From: April Bekkala Date: Wed, 27 Sep 2023 22:33:14 -0500 Subject: [PATCH 008/257] Create Copilot Adding the Copilot page info --- .../account-settings/Copilot | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/articles/expensify-classic/account-settings/Copilot diff --git a/docs/articles/expensify-classic/account-settings/Copilot b/docs/articles/expensify-classic/account-settings/Copilot new file mode 100644 index 000000000000..dbd26af12d88 --- /dev/null +++ b/docs/articles/expensify-classic/account-settings/Copilot @@ -0,0 +1,78 @@ +--- +title: Copilot +description: Safely delegate tasks without sharing login information. +--- + + +# About + + +# How-to + + +# Deep Dive + + +# FAQ + From 8c3526d404d96f513fc6c093b97ca467c24af1f2 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 28 Sep 2023 13:30:40 +0700 Subject: [PATCH 009/257] make logic more accurate --- .../MoneyRequestParticipantsPage.js | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 98682e1af490..0059a6037fec 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import _ from 'underscore'; +import compose from '../../../../libs/compose'; import CONST from '../../../../CONST'; import ONYXKEYS from '../../../../ONYXKEYS'; import ROUTES from '../../../../ROUTES'; @@ -14,6 +15,7 @@ import Navigation from '../../../../libs/Navigation/Navigation'; import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import HeaderWithBackButton from '../../../../components/HeaderWithBackButton'; import * as IOU from '../../../../libs/actions/IOU'; +import * as TransactionUtils from '../../../../libs/TransactionUtils'; import * as MoneyRequestUtils from '../../../../libs/MoneyRequestUtils'; import {iouPropTypes, iouDefaultProps} from '../../propTypes'; import useLocalize from '../../../../hooks/useLocalize'; @@ -58,7 +60,9 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const isScanRequest = MoneyRequestUtils.isScanRequest(selectedTab); const isSplitRequest = iou.id === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT; const [headerTitle, setHeaderTitle] = useState(); - const isEmptyWaypoint = _.isEmpty(lodashGet(transaction, 'comment.waypoints.waypoint0', {})); + const waypoints = lodashGet(transaction, 'comment.waypoints', {}); + const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints); + const isInvalidWaypoint = _.size(validatedWaypoints) < 2; useEffect(() => { if (isDistanceRequest) { setHeaderTitle(translate('common.distance')); @@ -90,7 +94,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { }; useEffect(() => { - const isInvalidDistanceRequest = !isDistanceRequest || isEmptyWaypoint; + const isInvalidDistanceRequest = !isDistanceRequest || isInvalidWaypoint; // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request if (prevMoneyRequestId.current !== iou.id) { @@ -114,7 +118,7 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { return () => { prevMoneyRequestId.current = iou.id; }; - }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, isEmptyWaypoint]); + }, [iou.amount, iou.id, iou.receiptPath, isDistanceRequest, isSplitRequest, isInvalidWaypoint]); return ( `${ONYXKEYS.COLLECTION.TRANSACTION}${iou ? iou.transactionID : ''}`, - }, -})(MoneyRequestParticipantsPage); +export default compose( + withOnyx({ + iou: { + key: ONYXKEYS.IOU, + }, + selectedTab: { + key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({iou}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${iou.transactionID}`, + }, + }), +)(MoneyRequestParticipantsPage); From 3ecb3ef3587f347c41e4a7651854bab14cc3701d Mon Sep 17 00:00:00 2001 From: Etotaziba Olei Date: Thu, 28 Sep 2023 13:54:25 +0100 Subject: [PATCH 010/257] update index.js --- .../HTMLRenderers/PreRenderer/index.js | 84 +++++++++---------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js index efc9e432cba8..e4aec1446135 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js @@ -1,70 +1,66 @@ -import React from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import _ from 'underscore'; -import withLocalize from '../../../withLocalize'; + +import ControlSelection from '../../../../libs/ControlSelection'; +import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; import htmlRendererPropTypes from '../htmlRendererPropTypes'; import BasePreRenderer from './BasePreRenderer'; -import * as DeviceCapabilities from '../../../../libs/DeviceCapabilities'; -import ControlSelection from '../../../../libs/ControlSelection'; - -class PreRenderer extends React.Component { - constructor(props) { - super(props); - this.scrollNode = this.scrollNode.bind(this); - this.debouncedIsScrollingVertically = _.debounce(this.isScrollingVertically.bind(this), 100, true); - } +const isScrollingVertically = (event) => + // Mark as vertical scrolling only when absolute value of deltaY is more than the double of absolute + // value of deltaX, so user can use trackpad scroll on the code block horizontally at a wide angle. + Math.abs(event.deltaY) > Math.abs(event.deltaX) * 2; - componentDidMount() { - if (!this.ref) { - return; - } - this.ref.getScrollableNode().addEventListener('wheel', this.scrollNode); - } +const debouncedIsScrollingVertically = (event) => _.debounce(isScrollingVertically(event), 100, true); - componentWillUnmount() { - this.ref.getScrollableNode().removeEventListener('wheel', this.scrollNode); - } +function PreRenderer(props) { + const scrollViewRef = useRef(); /** - * Check if user is scrolling vertically based on deltaX and deltaY. We debounce this - * method in the constructor to make sure it's called only for the first event. + * Checks if user is scrolling vertically based on deltaX and deltaY. We debounce this + * method in order to make sure it's called only for the first event. * @param {WheelEvent} event Wheel event * @returns {Boolean} true if user is scrolling vertically */ - isScrollingVertically(event) { - // Mark as vertical scrolling only when absolute value of deltaY is more than the double of absolute - // value of deltaX, so user can use trackpad scroll on the code block horizontally at a wide angle. - return Math.abs(event.deltaY) > Math.abs(event.deltaX) * 2; - } /** * Manually scrolls the code block if code block horizontal scrollable, then prevents the event from being passed up to the parent. * @param {Object} event native event */ - scrollNode(event) { - const node = this.ref.getScrollableNode(); + const scrollNode = useCallback((event) => { + const node = scrollViewRef.current.getScrollableNode(); const horizontalOverflow = node.scrollWidth > node.offsetWidth; - const isScrollingVertically = this.debouncedIsScrollingVertically(event); - if (event.currentTarget === node && horizontalOverflow && !isScrollingVertically) { + if (event.currentTarget === node && horizontalOverflow && !debouncedIsScrollingVertically(event)) { node.scrollLeft += event.deltaX; event.preventDefault(); event.stopPropagation(); } - } + }, []); + + useEffect(() => { + const eventListenerRefValue = scrollViewRef.current; + if (!eventListenerRefValue) { + return; + } + eventListenerRefValue.getScrollableNode().addEventListener('wheel', scrollNode); + + return () => { + eventListenerRefValue.getScrollableNode().removeEventListener('wheel', scrollNode); + }; + }, [scrollNode]); - render() { - return ( - (this.ref = el)} - onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - /> - ); - } + return ( + DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={ControlSelection.unblock} + /> + ); } PreRenderer.propTypes = htmlRendererPropTypes; +PreRenderer.displayName = 'PreRenderer'; -export default withLocalize(PreRenderer); +export default PreRenderer; \ No newline at end of file From 2cde96c0d49f2679704855b635ad0e20f95757bf Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 29 Sep 2023 16:56:37 +0500 Subject: [PATCH 011/257] feat: add new key in onyx for draft report ids --- src/ONYXKEYS.ts | 4 +++ src/libs/actions/DraftReports.ts | 29 ++++++++++++++++++++ src/pages/home/sidebar/SidebarLinksData.js | 32 ++++++++++++++++++---- 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 src/libs/actions/DraftReports.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a1afc4fef2c1..c9b04c4c217a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -10,6 +10,9 @@ const ONYXKEYS = { /** Holds information about the users account that is logging in */ ACCOUNT: 'account', + /** Holds the reportIDs which are currently in draft */ + DRAFT_REPORT_IDS: 'draftReportIDs', + /** Holds the reportID for the report between the user and their account manager */ ACCOUNT_MANAGER_REPORT_ID: 'accountManagerReportID', @@ -297,6 +300,7 @@ type OnyxKey = DeepValueOf>; type OnyxValues = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; + [ONYXKEYS.DRAFT_REPORT_IDS]: Record; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; diff --git a/src/libs/actions/DraftReports.ts b/src/libs/actions/DraftReports.ts new file mode 100644 index 000000000000..dc1e7a8066f8 --- /dev/null +++ b/src/libs/actions/DraftReports.ts @@ -0,0 +1,29 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; +import DraftReportUtils from '../DraftReportUtils'; + +const draftReportUtils = DraftReportUtils.getInstance(); + +/** + * Immediate indication whether the report has a draft. + * + * @param reportID + * @param draft + */ +function setDraftStatusForReportID(reportID: string, draft: boolean) { + const draftReportIDs = {...draftReportUtils.getDraftReportIDs()}; + + if (draftReportIDs[reportID] && draft) { + return; + } + + if (draftReportIDs[reportID] && !draft) { + delete draftReportIDs[reportID]; + Onyx.set(ONYXKEYS.DRAFT_REPORT_IDS, draftReportIDs); + } else { + draftReportIDs[reportID] = draft; + Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: draft}); + } +} + +export default setDraftStatusForReportID; diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 243ba24cdd00..c204dddf872a 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -52,6 +52,10 @@ const propTypes = { /** The policies which the user has access to */ // eslint-disable-next-line react/forbid-prop-types policies: PropTypes.object, + + /** Holds the reportIDs which are in draft */ + // eslint-disable-next-line react/forbid-prop-types + draftReportIDs: PropTypes.object, }; const defaultProps = { @@ -61,15 +65,29 @@ const defaultProps = { priorityMode: CONST.PRIORITY_MODE.DEFAULT, betas: [], policies: [], + draftReportIDs: {}, }; -function SidebarLinksData({isFocused, allReportActions, betas, chatReports, currentReportID, insets, isLoadingReportData, isSmallScreenWidth, onLinkClick, policies, priorityMode}) { +function SidebarLinksData({ + isFocused, + allReportActions, + draftReportIDs, + betas, + chatReports, + currentReportID, + insets, + isLoadingReportData, + isSmallScreenWidth, + onLinkClick, + policies, + priorityMode, +}) { const {translate} = useLocalize(); const reportIDsRef = useRef(null); const isLoading = SessionUtils.didUserLogInDuringSession() && isLoadingReportData; const optionListItems = useMemo(() => { - const reportIDs = SidebarUtils.getOrderedReportIDs(null, chatReports, betas, policies, priorityMode, allReportActions); + const reportIDs = SidebarUtils.getOrderedReportIDs(null, draftReportIDs, chatReports, betas, policies, priorityMode, allReportActions); if (deepEqual(reportIDsRef.current, reportIDs)) { return reportIDsRef.current; } @@ -79,7 +97,7 @@ function SidebarLinksData({isFocused, allReportActions, betas, chatReports, curr reportIDsRef.current = reportIDs; } return reportIDsRef.current || []; - }, [allReportActions, betas, chatReports, policies, priorityMode, isLoading]); + }, [allReportActions, betas, chatReports, draftReportIDs, policies, priorityMode, isLoading]); // We need to make sure the current report is in the list of reports, but we do not want // to have to re-generate the list every time the currentReportID changes. To do that @@ -88,10 +106,10 @@ function SidebarLinksData({isFocused, allReportActions, betas, chatReports, curr // case we re-generate the list a 2nd time with the current report included. const optionListItemsWithCurrentReport = useMemo(() => { if (currentReportID && !_.contains(optionListItems, currentReportID)) { - return SidebarUtils.getOrderedReportIDs(currentReportID, chatReports, betas, policies, priorityMode, allReportActions); + return SidebarUtils.getOrderedReportIDs(currentReportID, draftReportIDs, chatReports, betas, policies, priorityMode, allReportActions); } return optionListItems; - }, [currentReportID, optionListItems, chatReports, betas, policies, priorityMode, allReportActions]); + }, [currentReportID, optionListItems, chatReports, betas, draftReportIDs, policies, priorityMode, allReportActions]); const currentReportIDRef = useRef(currentReportID); currentReportIDRef.current = currentReportID; @@ -133,7 +151,6 @@ const chatReportSelector = (report) => reportID: report.reportID, participants: report.participants, participantAccountIDs: report.participantAccountIDs, - hasDraft: report.hasDraft, isPinned: report.isPinned, isHidden: report.isHidden, errorFields: { @@ -202,6 +219,9 @@ export default compose( isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, + draftReportIDs: { + key: ONYXKEYS.DRAFT_REPORT_IDS, + }, priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, }, From 877a5f94828aaab64a45d0fe1c9d228afaa223e0 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 29 Sep 2023 17:01:58 +0500 Subject: [PATCH 012/257] feat: add singleton class for draft report utils --- src/libs/DraftReportUtils.ts | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/libs/DraftReportUtils.ts diff --git a/src/libs/DraftReportUtils.ts b/src/libs/DraftReportUtils.ts new file mode 100644 index 000000000000..71d16a1fce66 --- /dev/null +++ b/src/libs/DraftReportUtils.ts @@ -0,0 +1,40 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../ONYXKEYS'; + +class DraftReportUtils { + private static instance: DraftReportUtils; + + private draftReportIDs: Record; + + private constructor() { + DraftReportUtils.instance = this; + + this.draftReportIDs = {}; + + this.subscribeToDraftReportIDs(); + } + + public static getInstance(): DraftReportUtils { + // Ensure singleton instance + return DraftReportUtils.instance ?? new DraftReportUtils(); + } + + private subscribeToDraftReportIDs() { + Onyx.connect({ + key: ONYXKEYS.DRAFT_REPORT_IDS, + callback: (val) => { + if (!val) { + return; + } + + this.draftReportIDs = val; + }, + }); + } + + getDraftReportIDs() { + return this.draftReportIDs; + } +} + +export default DraftReportUtils; From 6903d5bb79e066ba53d32c3c2143ba92ecf43b86 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 29 Sep 2023 17:11:22 +0500 Subject: [PATCH 013/257] feat: confirm to new draft repord id onyx key --- src/components/LHNOptionsList/OptionRowLHN.js | 17 +++++++++-- .../LHNOptionsList/OptionRowLHNData.js | 4 +-- src/libs/ReportActionsUtils.js | 19 +++++++++---- src/libs/ReportUtils.js | 28 +++++++++++++++---- src/libs/actions/Policy.js | 11 +++++--- .../ComposerWithSuggestions.js | 19 ++++++++----- src/types/onyx/Report.ts | 1 - 7 files changed, 72 insertions(+), 27 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 3cfd7c4c4138..dddee8586eb8 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -3,6 +3,7 @@ import React, {useState, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet} from 'react-native'; import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; import * as optionRowStyles from '../../styles/optionRowStyles'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; @@ -25,6 +26,7 @@ import * as ReportUtils from '../../libs/ReportUtils'; import useLocalize from '../../hooks/useLocalize'; import Permissions from '../../libs/Permissions'; import Tooltip from '../Tooltip'; +import ONYXKEYS from '../../ONYXKEYS'; const propTypes = { /** Style for hovered state */ @@ -51,6 +53,9 @@ const propTypes = { /** The item that should be rendered */ // eslint-disable-next-line react/forbid-prop-types optionItem: PropTypes.object, + + // eslint-disable-next-line react/forbid-prop-types + draftReportIDs: PropTypes.object, }; const defaultProps = { @@ -61,6 +66,7 @@ const defaultProps = { optionItem: null, isFocused: false, betas: [], + draftReportIDs: {}, }; function OptionRowLHN(props) { @@ -135,6 +141,7 @@ function OptionRowLHN(props) { const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText; const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem); + const isDraft = props.draftReportIDs[props.reportID]; return ( )} - {optionItem.hasDraftComment && optionItem.isAllowedToComment && ( + {isDraft && optionItem.isAllowedToComment && ( { if (!key || !report) { return; @@ -47,7 +48,7 @@ Onyx.connect({ * @returns {Boolean} */ function isCreatedAction(reportAction) { - return lodashGet(reportAction, 'actionName') === CONST.REPORT.ACTIONS.TYPE.CREATED; + return reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; } /** @@ -421,7 +422,10 @@ function getLastVisibleAction(reportID, actionsToMerge = {}) { */ function getLastVisibleMessage(reportID, actionsToMerge = {}) { const lastVisibleAction = getLastVisibleAction(reportID, actionsToMerge); - const message = lodashGet(lastVisibleAction, ['message', 0], {}); + let message = {}; + if (lastVisibleAction.message) { + message = lastVisibleAction.message[0]; + } if (isReportMessageAttachment(message)) { return { @@ -437,9 +441,14 @@ function getLastVisibleMessage(reportID, actionsToMerge = {}) { }; } - const messageText = lodashGet(message, 'text', ''); + let messageText = message.text || ''; + + if (messageText) { + messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + } + return { - lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(), + lastMessageText: messageText, }; } @@ -638,7 +647,7 @@ function isTaskAction(reportAction) { * @returns {[Object]} */ function getAllReportActions(reportID) { - return lodashGet(allReportActions, reportID, []); + return allReportActions[reportID] || []; } /** diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index f41ad0b75b42..431fb05a1bb7 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -23,6 +23,7 @@ import isReportMessageAttachment from './isReportMessageAttachment'; import * as defaultWorkspaceAvatars from '../components/Icon/WorkspaceDefaultAvatars'; import * as CurrencyUtils from './CurrencyUtils'; import * as UserUtils from './UserUtils'; +import DraftReportUtils from './DraftReportUtils'; let currentUserEmail; let currentUserAccountID; @@ -52,13 +53,16 @@ Onyx.connect({ }, }); -let allReports; +let allReports = {}; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (val) => (allReports = val), }); +const draftReportUtils = DraftReportUtils.getInstance(); + let doesDomainHaveApprovedAccountant; Onyx.connect({ key: ONYXKEYS.ACCOUNT, @@ -1201,8 +1205,11 @@ function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantR * @returns {Object} */ function getReport(reportID) { - // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check - return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {}; + /** + * using typical string concatenation here due to performance issues + * with template literals. + */ + return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; } /** @@ -1295,14 +1302,23 @@ function getMoneyRequestTotal(report, allReportsDict = null) { * @returns {String} */ function getPolicyExpenseChatName(report, policy = undefined) { - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName; + const ownerAccountID = report.ownerAccountID; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || allPersonalDetails[ownerAccountID].login || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { return getPolicyName(report, false, policy); } - const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user'; + let policyExpenseChatRole = 'user'; + /** + * using typical string concatenation here due to performance issues + * with template literals. + */ + const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; + if (policyItem) { + policyExpenseChatRole = policyItem.role || 'user'; + } // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. @@ -2971,7 +2987,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, } // Include reports that are relevant to the user in any view mode. Criteria include having a draft, having an outstanding IOU, or being assigned to an open task. - if (report.hasDraft || isWaitingForIOUActionFromCurrentUser(report) || isWaitingForTaskCompleteFromAssignee(report)) { + if (draftReportUtils.getDraftReportIDs()[report.reportID] || isWaitingForIOUActionFromCurrentUser(report) || isWaitingForTaskCompleteFromAssignee(report)) { return true; } const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(report.reportID); diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index fcce909c5582..b3d68ff4395d 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -13,6 +13,9 @@ import * as ErrorUtils from '../ErrorUtils'; import * as ReportUtils from '../ReportUtils'; import * as PersonalDetailsUtils from '../PersonalDetailsUtils'; import Log from '../Log'; +import DraftReportUtils from '../DraftReportUtils'; + +const draftReportUtils = DraftReportUtils.getInstance(); const allPolicies = {}; Onyx.connect({ @@ -29,12 +32,14 @@ Onyx.connect({ const policyReports = ReportUtils.getAllPolicyReports(policyID); const cleanUpMergeQueries = {}; const cleanUpSetQueries = {}; + const draftReportIDs = {...draftReportUtils.getDraftReportIDs()}; _.each(policyReports, ({reportID}) => { - cleanUpMergeQueries[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = {hasDraft: false}; cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + delete draftReportIDs[reportID]; }); Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); Onyx.multiSet(cleanUpSetQueries); + Onyx.set(ONYXKEYS.DRAFT_REPORT_IDS, draftReportIDs); delete allPolicies[key]; return; } @@ -96,7 +101,6 @@ function deleteWorkspace(policyID, reports, policyName) { value: { stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, statusNum: CONST.REPORT.STATUS.CLOSED, - hasDraft: false, oldPolicyName: allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`].name, }, })), @@ -121,13 +125,12 @@ function deleteWorkspace(policyID, reports, policyName) { // Restore the old report stateNum and statusNum const failureData = [ - ..._.map(reports, ({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => ({ + ..._.map(reports, ({reportID, stateNum, statusNum, oldPolicyName}) => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { stateNum, statusNum, - hasDraft, oldPolicyName, }, })), diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index d04983dc2f75..1d7d1fbcb001 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -34,6 +34,7 @@ import withKeyboardState from '../../../../components/withKeyboardState'; import {propTypes, defaultProps} from './composerWithSuggestionsProps'; import focusWithDelay from '../../../../libs/focusWithDelay'; import useDebounce from '../../../../hooks/useDebounce'; +import setDraftStatusForReportID from '../../../../libs/actions/DraftReports'; const {RNTextInputReset} = NativeModules; @@ -203,7 +204,13 @@ function ComposerWithSuggestions({ debouncedUpdateFrequentlyUsedEmojis(); } - setIsCommentEmpty(!!newComment.match(/^(\s)*$/)); + const isNewCommentEmpty = !!newComment.match(/^(\s)*$/); + const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); + + /** Only update isCommentEmpty state if it's different from previous one */ + if (isNewCommentEmpty !== isPrevCommentEmpty) { + setIsCommentEmpty(isNewCommentEmpty); + } setValue(newComment); if (commentValue !== newComment) { // Ensure emoji suggestions are hidden even when the selection is not changed (so calculateEmojiSuggestion would not be called). @@ -220,12 +227,11 @@ function ComposerWithSuggestions({ // Indicate that draft has been created. if (commentRef.current.length === 0 && newComment.length !== 0) { - Report.setReportWithDraft(reportID, true); + setDraftStatusForReportID(reportID, true); } - // The draft has been deleted. - if (newComment.length === 0) { - Report.setReportWithDraft(reportID, false); + else if (newComment.length === 0) { + setDraftStatusForReportID(reportID, false); } commentRef.current = newComment; @@ -469,8 +475,7 @@ function ComposerWithSuggestions({ if (value.length === 0) { return; } - Report.setReportWithDraft(reportID, true); - + setDraftStatusForReportID(reportID, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index 46e51fe41238..1e24a5805084 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -66,7 +66,6 @@ type Report = { parentReportID?: string; parentReportActionID?: string; isOptimisticReport?: boolean; - hasDraft?: boolean; managerID?: number; lastVisibleActionLastModified?: string; displayName?: string; From 64b095fd30844b5fb7d2c8951a07289e9277787f Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 29 Sep 2023 17:11:46 +0500 Subject: [PATCH 014/257] feat: add waitForCollectionCallback --- src/libs/OptionsListUtils.js | 2 +- src/libs/actions/Report.js | 14 ++------------ src/libs/actions/Welcome.js | 1 + 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index e0f334ca36af..6382def58648 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -83,6 +83,7 @@ Onyx.connect({ const policyExpenseReports = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, callback: (report, key) => { if (!ReportUtils.isPolicyExpenseChat(report)) { return; @@ -486,7 +487,6 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { result.ownerAccountID = report.ownerAccountID; result.reportID = report.reportID; result.isUnread = ReportUtils.isUnread(report); - result.hasDraftComment = report.hasDraft; result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; result.keyForList = String(report.reportID); diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 66008ae5ae2a..941d7dce7b49 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -64,6 +64,7 @@ Onyx.connect({ const currentReportData = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, callback: (data, key) => { if (!key || !data) { return; @@ -919,17 +920,6 @@ function saveReportCommentNumberOfLines(reportID, numberOfLines) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`, numberOfLines); } -/** - * Immediate indication whether the report has a draft comment. - * - * @param {String} reportID - * @param {Boolean} hasDraft - * @returns {Promise} - */ -function setReportWithDraft(reportID, hasDraft) { - return Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {hasDraft}); -} - /** * Broadcasts whether or not a user is typing on a report over the report's private pusher channel. * @@ -994,6 +984,7 @@ function handleReportChanged(report) { Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, callback: handleReportChanged, }); @@ -2189,7 +2180,6 @@ export { saveReportActionDraftNumberOfLines, deleteReportComment, navigateToConciergeChat, - setReportWithDraft, addPolicyReport, deleteReport, navigateToConciergeChatAndDeleteReport, diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js index 8e1832edb9a7..fac2d031fe1e 100644 --- a/src/libs/actions/Welcome.js +++ b/src/libs/actions/Welcome.js @@ -58,6 +58,7 @@ Onyx.connect({ const allReports = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, initWithStoredValues: false, callback: (val, key) => { if (!val || !key) { From 268e57af777a3a61d9b0bb10a46596952e8d3f60 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 29 Sep 2023 17:12:27 +0500 Subject: [PATCH 015/257] perf: call updateUnread method if count is changed --- src/libs/UnreadIndicatorUpdater/index.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index 09fa82612314..dcfedce83d75 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -1,14 +1,26 @@ import _ from 'underscore'; import Onyx from 'react-native-onyx'; +import {InteractionManager} from 'react-native'; import ONYXKEYS from '../../ONYXKEYS'; import updateUnread from './updateUnread/index'; import * as ReportUtils from '../ReportUtils'; +let previousUnreadCount = 0; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (reportsFromOnyx) => { - const unreadReports = _.filter(reportsFromOnyx, ReportUtils.isUnread); - updateUnread(_.size(unreadReports)); + if (!reportsFromOnyx) { + return; + } + + InteractionManager.runAfterInteractions(() => { + const unreadReportsCount = _.filter(reportsFromOnyx, ReportUtils.isUnread).length || 0; + if (previousUnreadCount !== unreadReportsCount) { + previousUnreadCount = unreadReportsCount; + updateUnread(unreadReportsCount); + } + }); }, }); From 740700d7386a2489b88f03f95b20ca2798f6ad15 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 29 Sep 2023 17:13:24 +0500 Subject: [PATCH 016/257] perf: use dict keys instead of whole dict and refactor the foreach loop --- src/libs/SidebarUtils.js | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 7a32db660021..75f1782670b2 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -103,6 +103,7 @@ let hasInitialReportActions = false; /** * @param {String} currentReportId + * @param {Object} draftReportIDs * @param {Object} allReportsDict * @param {Object} betas * @param {String[]} policies @@ -110,11 +111,12 @@ let hasInitialReportActions = false; * @param {Object} allReportActions * @returns {String[]} An array of reportIDs sorted in the proper order */ -function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, priorityMode, allReportActions) { +function getOrderedReportIDs(currentReportId, draftReportIDs, allReportsDict, betas, policies, priorityMode, allReportActions) { + const allReportsDictKeys = Object.keys(allReportsDict); // Generate a unique cache key based on the function arguments const cachedReportsKey = JSON.stringify( // eslint-disable-next-line es/no-optional-chaining - [currentReportId, allReportsDict, betas, policies, priorityMode, allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportId}`]?.length || 1], + [currentReportId, allReportsDictKeys, betas, draftReportIDs, policies, priorityMode, allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportId}`]?.length || 1], (key, value) => { /** * Exclude 'participantAccountIDs', 'participants' and 'lastMessageText' not to overwhelm a cached key value with huge data, @@ -149,18 +151,6 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p } } - // There are a few properties that need to be calculated for the report which are used when sorting reports. - reportsToDisplay.forEach((report) => { - // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. - // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add - // the reportDisplayName property to the report object directly. - // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); - - // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, allReportsDict); - }); - // The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: // 1. Pinned - Always sorted by reportDisplayName // 2. Outstanding IOUs - Always sorted by iouReportAmount with the largest amounts at the top of the group @@ -171,17 +161,29 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p // 5. Archived reports // - Sorted by lastVisibleActionCreated in default (most recent) view mode // - Sorted by reportDisplayName in GSD (focus) view mode + const pinnedReports = []; const outstandingIOUReports = []; const draftReports = []; const nonArchivedReports = []; const archivedReports = []; + + // There are a few properties that need to be calculated for the report which are used when sorting reports. reportsToDisplay.forEach((report) => { + // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. + // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add + // the reportDisplayName property to the report object directly. + // eslint-disable-next-line no-param-reassign + report.displayName = ReportUtils.getReportName(report); + + // eslint-disable-next-line no-param-reassign + report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, allReportsDict); + if (report.isPinned) { pinnedReports.push(report); } else if (ReportUtils.isWaitingForIOUActionFromCurrentUser(report)) { outstandingIOUReports.push(report); - } else if (report.hasDraft) { + } else if (draftReportIDs[report.reportID]) { draftReports.push(report); } else if (ReportUtils.isArchivedRoom(report)) { archivedReports.push(report); @@ -296,7 +298,6 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, result.statusNum = report.statusNum; result.isUnread = ReportUtils.isUnread(report); result.isUnreadWithMention = ReportUtils.isUnreadWithMention(report); - result.hasDraftComment = report.hasDraft; result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; result.keyForList = String(report.reportID); From cd0346e0e9dffecf7d11fa0d13586a2c7e8824b3 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Mon, 2 Oct 2023 11:04:17 +0500 Subject: [PATCH 017/257] fix: prettier fixes --- src/libs/ReportUtils.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 431fb05a1bb7..a4554197c584 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1208,7 +1208,7 @@ function getReport(reportID) { /** * using typical string concatenation here due to performance issues * with template literals. - */ + */ return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; } @@ -1314,7 +1314,7 @@ function getPolicyExpenseChatName(report, policy = undefined) { /** * using typical string concatenation here due to performance issues * with template literals. - */ + */ const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; if (policyItem) { policyExpenseChatRole = policyItem.role || 'user'; From e48bd2f7a97333dbe779d49fa5f97d24a1fea76f Mon Sep 17 00:00:00 2001 From: April Bekkala Date: Mon, 2 Oct 2023 14:13:44 -0500 Subject: [PATCH 018/257] Rename Copilot to Copilot.md needed to add .md to the title --- .../expensify-classic/account-settings/{Copilot => Copilot.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/articles/expensify-classic/account-settings/{Copilot => Copilot.md} (100%) diff --git a/docs/articles/expensify-classic/account-settings/Copilot b/docs/articles/expensify-classic/account-settings/Copilot.md similarity index 100% rename from docs/articles/expensify-classic/account-settings/Copilot rename to docs/articles/expensify-classic/account-settings/Copilot.md From 03b62212e1ae0f97c92019f8585ffca275a67999 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 10:40:54 +0500 Subject: [PATCH 019/257] test: fix unit tests --- src/components/LHNOptionsList/OptionRowLHN.js | 17 ++++------------- .../LHNOptionsList/OptionRowLHNData.js | 8 +++++++- src/components/optionPropTypes.js | 3 --- src/libs/OptionsListUtils.js | 1 - src/libs/ReportUtils.js | 4 ++++ tests/unit/OptionsListUtilsTest.js | 1 - tests/utils/LHNTestUtils.js | 4 +--- 7 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index dddee8586eb8..0f0b196d715a 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -3,7 +3,6 @@ import React, {useState, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet} from 'react-native'; import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; import * as optionRowStyles from '../../styles/optionRowStyles'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; @@ -26,7 +25,6 @@ import * as ReportUtils from '../../libs/ReportUtils'; import useLocalize from '../../hooks/useLocalize'; import Permissions from '../../libs/Permissions'; import Tooltip from '../Tooltip'; -import ONYXKEYS from '../../ONYXKEYS'; const propTypes = { /** Style for hovered state */ @@ -54,8 +52,7 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types optionItem: PropTypes.object, - // eslint-disable-next-line react/forbid-prop-types - draftReportIDs: PropTypes.object, + hasDraft: PropTypes.bool, }; const defaultProps = { @@ -66,7 +63,7 @@ const defaultProps = { optionItem: null, isFocused: false, betas: [], - draftReportIDs: {}, + hasDraft: false, }; function OptionRowLHN(props) { @@ -141,7 +138,7 @@ function OptionRowLHN(props) { const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText; const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem); - const isDraft = props.draftReportIDs[props.reportID]; + const isDraft = props.hasDraft; return ( { - if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { + if (!optionItem || hasDraft || !comment || comment.length <= 0 || isFocused) { return; } setDraftStatusForReportID(reportID, true); @@ -131,6 +133,7 @@ function OptionRowLHNData({ {...propsToForward} isFocused={isFocused} optionItem={optionItem} + hasDraft={hasDraft} /> ); } @@ -181,6 +184,9 @@ export default React.memo( }, }), withOnyx({ + draftReportIDs: { + key: ONYXKEYS.DRAFT_REPORT_IDS, + }, fullReport: { key: (props) => ONYXKEYS.COLLECTION.REPORT + props.reportID, }, diff --git a/src/components/optionPropTypes.js b/src/components/optionPropTypes.js index 709298036f07..6f84fff24a52 100644 --- a/src/components/optionPropTypes.js +++ b/src/components/optionPropTypes.js @@ -25,9 +25,6 @@ export default PropTypes.shape({ // reportID (only present when there is a matching report) reportID: PropTypes.string, - // Whether the report has a draft comment or not - hasDraftComment: PropTypes.bool, - // Key used internally by React keyForList: PropTypes.string, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 6382def58648..eacbb1648122 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -444,7 +444,6 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { login: null, reportID: null, phoneNumber: null, - hasDraftComment: false, keyForList: null, searchText: null, isDefaultRoom: false, diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index a4554197c584..f25bb0ef0fa5 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1209,6 +1209,10 @@ function getReport(reportID) { * using typical string concatenation here due to performance issues * with template literals. */ + if (!allReports) { + return {}; + } + return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; } diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 6f20e48835fd..79e2cb79077a 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -16,7 +16,6 @@ describe('OptionsListUtils', () => { reportID: 1, participantAccountIDs: [2, 1], reportName: 'Iron Man, Mister Fantastic', - hasDraft: true, }, 2: { lastReadTime: '2021-01-14 11:25:39.296', diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js index 7cb69b23a578..2dbc390cb283 100644 --- a/tests/utils/LHNTestUtils.js +++ b/tests/utils/LHNTestUtils.js @@ -149,10 +149,9 @@ function getFakeReportAction(actor = 'email1@test.com', millisecondsInThePast = * @param {boolean} hasAddWorkspaceError * @param {boolean} isUnread * @param {boolean} isPinned - * @param {boolean} hasDraft * @returns {Object} */ -function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned, hasDraft) { +function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorkspaceError, isUnread, isPinned) { return { ...getFakeReport([1, 2], 0, isUnread), type: CONST.REPORT.TYPE.CHAT, @@ -161,7 +160,6 @@ function getAdvancedFakeReport(isArchived, isUserCreatedPolicyRoom, hasAddWorksp stateNum: isArchived ? CONST.REPORT.STATE_NUM.SUBMITTED : 0, errorFields: hasAddWorkspaceError ? {addWorkspaceRoom: 'blah'} : null, isPinned, - hasDraft, }; } From dbeef9830a3b7bfdfee9f63a63000d77deceb549 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 11:43:52 +0500 Subject: [PATCH 020/257] test: fix Sidebar tests --- src/libs/ReportUtils.js | 4 +- tests/unit/SidebarFilterTest.js | 21 ++++++----- tests/unit/SidebarOrderTest.js | 66 ++++++++++++++------------------- 3 files changed, 41 insertions(+), 50 deletions(-) diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index f25bb0ef0fa5..529bee905924 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1307,7 +1307,9 @@ function getMoneyRequestTotal(report, allReportsDict = null) { */ function getPolicyExpenseChatName(report, policy = undefined) { const ownerAccountID = report.ownerAccountID; - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || allPersonalDetails[ownerAccountID].login || report.reportName; + const personalDetails = allPersonalDetails[ownerAccountID]; + const login = personalDetails ? personalDetails.login : null; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { diff --git a/tests/unit/SidebarFilterTest.js b/tests/unit/SidebarFilterTest.js index 18e499d89293..982393b3e191 100644 --- a/tests/unit/SidebarFilterTest.js +++ b/tests/unit/SidebarFilterTest.js @@ -23,6 +23,7 @@ const ONYXKEYS = { POLICY: 'policy_', }, NETWORK: 'network', + DRAFT_REPORT_IDS: 'draftReportIDs', }; describe('Sidebar', () => { @@ -100,12 +101,7 @@ describe('Sidebar', () => { it('includes an empty chat report if it has a draft', () => { LHNTestUtils.getDefaultRenderedSidebarLinks(); - // Given a new report with a draft text - const report = { - ...LHNTestUtils.getFakeReport([1, 2], 0), - hasDraft: true, - }; - + const report = LHNTestUtils.getFakeReport([1, 2], 0); return ( waitForBatchedUpdates() // When Onyx is updated to contain that report @@ -114,6 +110,8 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + // Set the draft status for the given reportID + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report.reportID]: true}, }), ) @@ -341,6 +339,8 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, + // Set the draft status for the given reportID + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: boolArr[boolArr.length - 1]}, }), ) // Then depending on the outcome, either one or two reports are visible @@ -435,10 +435,7 @@ describe('Sidebar', () => { it('always shows pinned and draft chats', () => { // Given a draft report and a pinned report - const draftReport = { - ...LHNTestUtils.getFakeReport([1, 2]), - hasDraft: true, - }; + const draftReport = LHNTestUtils.getFakeReport([1, 2]); const pinnedReport = { ...LHNTestUtils.getFakeReport([3, 4]), isPinned: true, @@ -455,6 +452,8 @@ describe('Sidebar', () => { [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, [`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport, [`${ONYXKEYS.COLLECTION.REPORT}${pinnedReport.reportID}`]: pinnedReport, + // Set the draft status for the given reportID + [ONYXKEYS.DRAFT_REPORT_IDS]: {[draftReport.reportID]: true}, }), ) @@ -666,6 +665,8 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, + // Set the draft status for the given reportID + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: boolArr[boolArr.length - 1]}, }), ) diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.js index 4a693d679b86..0503d8f33dff 100644 --- a/tests/unit/SidebarOrderTest.js +++ b/tests/unit/SidebarOrderTest.js @@ -24,6 +24,7 @@ const ONYXKEYS = { REPORT_ACTIONS: 'reportActions_', }, NETWORK: 'network', + DRAFT_REPORT_IDS: 'draftReportIDs', }; describe('Sidebar', () => { @@ -148,12 +149,8 @@ describe('Sidebar', () => { it('changes the order when adding a draft to the active report', () => { // Given three reports in the recently updated order of 3, 2, 1 - // And the first report has a draft // And the currently viewed report is the first report - const report1 = { - ...LHNTestUtils.getFakeReport([1, 2], 3), - hasDraft: true, - }; + const report1 = LHNTestUtils.getFakeReport([1, 2], 3); const report2 = LHNTestUtils.getFakeReport([3, 4], 2); const report3 = LHNTestUtils.getFakeReport([5, 6], 1); @@ -176,6 +173,8 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, + // Setting the draft status for first report + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report1.reportID]: true}, }), ) @@ -188,7 +187,7 @@ describe('Sidebar', () => { const hintText = Localize.translateLocal('accessibilityHints.chatUserDisplayNames'); const displayNames = screen.queryAllByLabelText(hintText); expect(displayNames).toHaveLength(3); - expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two'); // this has `hasDraft` flag enabled so it will be on top + expect(lodashGet(displayNames, [0, 'props', 'children'])).toBe('One, Two'); // this has a `reportID` in `draftReportID` so it will be on top expect(lodashGet(displayNames, [1, 'props', 'children'])).toBe('Five, Six'); expect(lodashGet(displayNames, [2, 'props', 'children'])).toBe('Three, Four'); }) @@ -244,13 +243,9 @@ describe('Sidebar', () => { it('reorders the reports to keep draft reports on top', () => { // Given three reports in the recently updated order of 3, 2, 1 - // And the second report has a draft // And the currently viewed report is the second report const report1 = LHNTestUtils.getFakeReport([1, 2], 3); - const report2 = { - ...LHNTestUtils.getFakeReport([3, 4], 2), - hasDraft: true, - }; + const report2 = LHNTestUtils.getFakeReport([3, 4], 2); const report3 = LHNTestUtils.getFakeReport([5, 6], 1); // Each report has at least one ADDCOMMENT action so should be rendered in the LNH @@ -272,6 +267,8 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, + // Setting the draft status for second report + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: true}, }), ) @@ -300,11 +297,7 @@ describe('Sidebar', () => { LHNTestUtils.getDefaultRenderedSidebarLinks(); // Given a single report - // And the report has a draft - const report = { - ...LHNTestUtils.getFakeReport([1, 2]), - hasDraft: true, - }; + const report = LHNTestUtils.getFakeReport([1, 2]); return ( waitForBatchedUpdates() @@ -315,6 +308,8 @@ describe('Sidebar', () => { [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report, + // Setting the draft status for the report + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report.reportID]: true}, }), ) @@ -324,7 +319,7 @@ describe('Sidebar', () => { }) // When the draft is removed - .then(() => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, {hasDraft: null})) + .then(() => Onyx.set(ONYXKEYS.DRAFT_REPORT_IDS, {})) // Then the pencil icon goes away .then(() => { @@ -373,16 +368,13 @@ describe('Sidebar', () => { it('sorts chats by pinned > IOU > draft', () => { // Given three reports in the recently updated order of 3, 2, 1 // with the current user set to email9@ (someone not participating in any of the chats) - // with a report that has a draft, a report that is pinned, and + // with a report that is pinned, and // an outstanding IOU report that doesn't belong to the current user const report1 = { ...LHNTestUtils.getFakeReport([1, 2], 3), isPinned: true, }; - const report2 = { - ...LHNTestUtils.getFakeReport([3, 4], 2), - hasDraft: true, - }; + const report2 = LHNTestUtils.getFakeReport([3, 4], 2); const report3 = { ...LHNTestUtils.getFakeReport([5, 6], 1), hasOutstandingIOU: false, @@ -418,6 +410,8 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport, + // Setting the draft status for second report + [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: true}, }), ) @@ -499,23 +493,10 @@ describe('Sidebar', () => { it('alphabetizes all the chats that have drafts', () => { // Given three reports in the recently updated order of 3, 2, 1 - // and they all have drafts - const report1 = { - ...LHNTestUtils.getFakeReport([1, 2], 3), - hasDraft: true, - }; - const report2 = { - ...LHNTestUtils.getFakeReport([3, 4], 2), - hasDraft: true, - }; - const report3 = { - ...LHNTestUtils.getFakeReport([5, 6], 1), - hasDraft: true, - }; - const report4 = { - ...LHNTestUtils.getFakeReport([7, 8], 0), - hasDraft: true, - }; + const report1 = LHNTestUtils.getFakeReport([1, 2], 3); + const report2 = LHNTestUtils.getFakeReport([3, 4], 2); + const report3 = LHNTestUtils.getFakeReport([5, 6], 1); + const report4 = LHNTestUtils.getFakeReport([7, 8], 0); LHNTestUtils.getDefaultRenderedSidebarLinks('0'); return ( waitForBatchedUpdates() @@ -528,6 +509,13 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, + // Setting the draft status for all reports + [ONYXKEYS.DRAFT_REPORT_IDS]: { + [report1.reportID]: true, + [report2.reportID]: true, + [report3.reportID]: true, + [report4.reportID]: true, + }, }), ) From fcad1ec9cb4fccd0807bba81f820dc165b27eb0f Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 11:45:00 +0500 Subject: [PATCH 021/257] fix: use allReportsDict in getordeeredReportIDs --- src/libs/SidebarUtils.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 75f1782670b2..78839060ee5e 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -112,11 +112,10 @@ let hasInitialReportActions = false; * @returns {String[]} An array of reportIDs sorted in the proper order */ function getOrderedReportIDs(currentReportId, draftReportIDs, allReportsDict, betas, policies, priorityMode, allReportActions) { - const allReportsDictKeys = Object.keys(allReportsDict); // Generate a unique cache key based on the function arguments const cachedReportsKey = JSON.stringify( // eslint-disable-next-line es/no-optional-chaining - [currentReportId, allReportsDictKeys, betas, draftReportIDs, policies, priorityMode, allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportId}`]?.length || 1], + [currentReportId, draftReportIDs, allReportsDict, betas, policies, priorityMode, allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportId}`]?.length || 1], (key, value) => { /** * Exclude 'participantAccountIDs', 'participants' and 'lastMessageText' not to overwhelm a cached key value with huge data, @@ -254,7 +253,6 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, phoneNumber: null, isUnread: null, isUnreadWithMention: null, - hasDraftComment: false, keyForList: null, searchText: null, isPinned: false, From 8fbd7a91fddf5b8de0d93a7ab77865a8b256a254 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 12:21:44 +0500 Subject: [PATCH 022/257] test: fix unit test --- src/libs/actions/Report.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 941d7dce7b49..1ae0b70fed24 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -984,7 +984,6 @@ function handleReportChanged(report) { Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, callback: handleReportChanged, }); From 713a35cde701f3c631100fce1e24272b0dfb47c6 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 12:57:05 +0500 Subject: [PATCH 023/257] test: add test for draft report utils --- tests/unit/DraftReportUtilsTest.js | 59 ++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/unit/DraftReportUtilsTest.js diff --git a/tests/unit/DraftReportUtilsTest.js b/tests/unit/DraftReportUtilsTest.js new file mode 100644 index 000000000000..0ac8a77dc907 --- /dev/null +++ b/tests/unit/DraftReportUtilsTest.js @@ -0,0 +1,59 @@ +import Onyx from 'react-native-onyx'; +import {cleanup} from '@testing-library/react-native'; +import DraftReportUtils from '../../src/libs/DraftReportUtils'; + +const ONYXKEYS = { + DRAFT_REPORT_IDS: 'draftReportIDs', +}; + +const reportID = 1; + +describe('DraftReportUtils', () => { + beforeAll(() => + Onyx.init({ + keys: ONYXKEYS, + }), + ); + + // Clear out Onyx after each test so that each test starts with a clean slate + afterEach(() => { + cleanup(); + Onyx.clear(); + }); + + describe('Singleton', () => { + it('should return the same instance when called multiple times', () => { + // Call getInstance multiple times + const instance1 = DraftReportUtils.getInstance(); + const instance2 = DraftReportUtils.getInstance(); + const instance3 = DraftReportUtils.getInstance(); + + // Ensure that all instances are the same + expect(instance1).toBe(instance2); + expect(instance2).toBe(instance3); + }); + }); + + it('should return an empty object when there are no draft reports', () => { + const draftReportIDs = DraftReportUtils.getInstance().getDraftReportIDs(); + expect(draftReportIDs).toEqual({}); + }); + + it('should return an object of draft report IDs when draft is set through onyx', async () => { + await Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: true}); + const draftReportIDs = DraftReportUtils.getInstance().getDraftReportIDs(); + expect(draftReportIDs).toEqual({[`${reportID}`]: true}); + }); + + it('should return an empty object of draft report IDs when draft is unset through onyx', async () => { + const draftReportUtils = DraftReportUtils.getInstance(); + + await Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: true}); + let draftReportIDs = draftReportUtils.getDraftReportIDs(); + expect(draftReportIDs).toEqual({[`${reportID}`]: true}); + + await Onyx.set(ONYXKEYS.DRAFT_REPORT_IDS, {}); + draftReportIDs = draftReportUtils.getDraftReportIDs(); + expect(draftReportIDs).toEqual({}); + }); +}); From d21ae8d45a1a5563c6e959872633a0b961cbfac7 Mon Sep 17 00:00:00 2001 From: Youssef Lourayad Date: Tue, 3 Oct 2023 17:49:39 +0800 Subject: [PATCH 024/257] Add accessibility translation for floating action button --- src/languages/es.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/languages/es.ts b/src/languages/es.ts index 81e1c22b0ccc..7b6cfc372c48 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -458,7 +458,7 @@ export default { buttonSearch: 'Buscar', buttonMySettings: 'Mi configuración', fabNewChat: 'Enviar mensaje', - fabNewChatExplained: 'Enviar mensaje', + fabNewChatExplained: 'Enviar mensaje (Acción flotante)', chatPinned: 'Chat fijado', draftedMessage: 'Mensaje borrador', listOfChatMessages: 'Lista de mensajes del chat', From f7e50d653862e67ddd1bcfc1c222869d500b3458 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 15:33:42 +0500 Subject: [PATCH 025/257] fix: linting --- tests/unit/DraftReportUtilsTest.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/DraftReportUtilsTest.js b/tests/unit/DraftReportUtilsTest.js index 0ac8a77dc907..ec13b9c69de7 100644 --- a/tests/unit/DraftReportUtilsTest.js +++ b/tests/unit/DraftReportUtilsTest.js @@ -27,7 +27,7 @@ describe('DraftReportUtils', () => { const instance1 = DraftReportUtils.getInstance(); const instance2 = DraftReportUtils.getInstance(); const instance3 = DraftReportUtils.getInstance(); - + // Ensure that all instances are the same expect(instance1).toBe(instance2); expect(instance2).toBe(instance3); @@ -47,7 +47,7 @@ describe('DraftReportUtils', () => { it('should return an empty object of draft report IDs when draft is unset through onyx', async () => { const draftReportUtils = DraftReportUtils.getInstance(); - + await Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: true}); let draftReportIDs = draftReportUtils.getDraftReportIDs(); expect(draftReportIDs).toEqual({[`${reportID}`]: true}); From 359a003b771c55a5f50e09bb85dfd713c8e17f7b Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 15:34:06 +0500 Subject: [PATCH 026/257] fix: replace lodashGet --- src/libs/ReportActionsUtils.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 18bc28e19d9a..51cfa35ea120 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -57,8 +57,16 @@ function isCreatedAction(reportAction) { */ function isDeletedAction(reportAction) { // A deleted comment has either an empty array or an object with html field with empty string as value - const message = lodashGet(reportAction, 'message', []); - return message.length === 0 || lodashGet(message, [0, 'html']) === ''; + let message = []; + if (reportAction.message) { + message = reportAction.message; + } + + if (message.length === 0) { + return true; + } + + return message[0].html === ''; } /** @@ -66,7 +74,10 @@ function isDeletedAction(reportAction) { * @returns {Boolean} */ function isDeletedParentAction(reportAction) { - return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false) && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0; + const isDeleted = (reportAction && reportAction.message && reportAction.message[0] && reportAction.message[0].isDeletedParentAction) || false; + const childVisibleActionCount = (reportAction && reportAction.childVisibleActionCount) || 0; + + return isDeleted && childVisibleActionCount > 0; } /** @@ -74,7 +85,7 @@ function isDeletedParentAction(reportAction) { * @returns {Boolean} */ function isPendingRemove(reportAction) { - return lodashGet(reportAction, 'message[0].moderationDecision.decision') === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE; + return reportAction['message[0].moderationDecision.decision'] === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE; } /** From fb569663c9ff5f3772c849410f308ddae984bd54 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Tue, 3 Oct 2023 16:46:24 +0500 Subject: [PATCH 027/257] docs: add js doc to DraftReportUtil --- src/libs/DraftReportUtils.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/libs/DraftReportUtils.ts b/src/libs/DraftReportUtils.ts index 71d16a1fce66..214b92404e1b 100644 --- a/src/libs/DraftReportUtils.ts +++ b/src/libs/DraftReportUtils.ts @@ -1,6 +1,11 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../ONYXKEYS'; +/** + * A singleton class to manage the draft report IDs + * @class DraftReportUtils + * @singleton + */ class DraftReportUtils { private static instance: DraftReportUtils; @@ -14,11 +19,17 @@ class DraftReportUtils { this.subscribeToDraftReportIDs(); } + /** + * @returns The singleton instance + */ public static getInstance(): DraftReportUtils { // Ensure singleton instance return DraftReportUtils.instance ?? new DraftReportUtils(); } + /** + * Subscribe to the draft report IDs + */ private subscribeToDraftReportIDs() { Onyx.connect({ key: ONYXKEYS.DRAFT_REPORT_IDS, @@ -32,6 +43,9 @@ class DraftReportUtils { }); } + /** + * @returns The draft report IDs + */ getDraftReportIDs() { return this.draftReportIDs; } From eb52c3e5d55cc62c2a50d4ce067ff3caf3d92a62 Mon Sep 17 00:00:00 2001 From: Etotaziba Olei Date: Wed, 4 Oct 2023 07:28:32 +0100 Subject: [PATCH 028/257] migrate clipboard to TS --- src/libs/Clipboard/index.native.js | 18 ----- src/libs/Clipboard/index.native.ts | 19 ++++++ src/libs/Clipboard/{index.js => index.ts} | 83 +++++++++++++++-------- src/libs/Clipboard/types.ts | 9 +++ src/types/modules/react-native-web.d.ts | 7 ++ 5 files changed, 88 insertions(+), 48 deletions(-) delete mode 100644 src/libs/Clipboard/index.native.js create mode 100644 src/libs/Clipboard/index.native.ts rename src/libs/Clipboard/{index.js => index.ts} (53%) create mode 100644 src/libs/Clipboard/types.ts create mode 100644 src/types/modules/react-native-web.d.ts diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js deleted file mode 100644 index d6345ac94a36..000000000000 --- a/src/libs/Clipboard/index.native.js +++ /dev/null @@ -1,18 +0,0 @@ -import Clipboard from '@react-native-community/clipboard'; - -/** - * Sets a string on the Clipboard object via @react-native-community/clipboard - * - * @param {String} text - */ -const setString = (text) => { - Clipboard.setString(text); -}; - -export default { - setString, - - // We don't want to set HTML on native platforms so noop them. - canSetHtml: () => false, - setHtml: () => {}, -}; diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts new file mode 100644 index 000000000000..bc7da7ad9d3f --- /dev/null +++ b/src/libs/Clipboard/index.native.ts @@ -0,0 +1,19 @@ +import RNCClipboard from '@react-native-community/clipboard'; +import {SetString, Clipboard} from './types'; + +/** + * Sets a string on the Clipboard object via @react-native-community/clipboard + */ +const setString: SetString = (text) => { + RNCClipboard.setString(text); +}; + +const clipboard: Clipboard = { + setString, + + // We don't want to set HTML on native platforms so noop them. + canSetHtml: () => false, + setHtml: () => {}, +}; + +export default clipboard; diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.ts similarity index 53% rename from src/libs/Clipboard/index.js rename to src/libs/Clipboard/index.ts index b770b2f2c787..fe0515edc585 100644 --- a/src/libs/Clipboard/index.js +++ b/src/libs/Clipboard/index.ts @@ -1,17 +1,37 @@ // on Web/desktop this import will be replaced with `react-native-web` -import {Clipboard} from 'react-native-web'; -import lodashGet from 'lodash/get'; +import {Clipboard as RNWClipboard} from 'react-native-web'; import CONST from '../../CONST'; import * as Browser from '../Browser'; +import {SetString, Clipboard} from './types'; -const canSetHtml = () => lodashGet(navigator, 'clipboard.write'); +type ComposerSelection = { + start: number; + end: number; + direction: 'forward' | 'backward' | 'none'; +}; + +type AnchorSelection = { + anchorOffset: number; + focusOffset: number; + anchorNode: Node; + focusNode: Node; +}; + +type Nullable = {[K in keyof T]: T[K] | null}; +type OriginalSelection = ComposerSelection | Partial>; + +/* +* @param {this: void} object The object to query. +*/ + +const canSetHtml = () => (...args: ClipboardItems) => navigator?.clipboard?.write([...args]); /** * Deprecated method to write the content as HTML to clipboard. - * @param {String} html HTML representation - * @param {String} text Plain text representation + * @param HTML representation + * @param Plain text representation */ -function setHTMLSync(html, text) { +function setHTMLSync(html: string, text: string) { const node = document.createElement('span'); node.textContent = html; node.style.all = 'unset'; @@ -22,16 +42,16 @@ function setHTMLSync(html, text) { node.addEventListener('copy', (e) => { e.stopPropagation(); e.preventDefault(); - e.clipboardData.clearData(); - e.clipboardData.setData('text/html', html); - e.clipboardData.setData('text/plain', text); + e.clipboardData?.clearData(); + e.clipboardData?.setData('text/html', html); + e.clipboardData?.setData('text/plain', text); }); document.body.appendChild(node); - const selection = window.getSelection(); - const firstAnchorChild = selection.anchorNode && selection.anchorNode.firstChild; + const selection = window?.getSelection(); + const firstAnchorChild = selection?.anchorNode?.firstChild; const isComposer = firstAnchorChild instanceof HTMLTextAreaElement; - let originalSelection = null; + let originalSelection: OriginalSelection | null = null; if (isComposer) { originalSelection = { start: firstAnchorChild.selectionStart, @@ -40,17 +60,17 @@ function setHTMLSync(html, text) { }; } else { originalSelection = { - anchorNode: selection.anchorNode, - anchorOffset: selection.anchorOffset, - focusNode: selection.focusNode, - focusOffset: selection.focusOffset, + anchorNode: selection?.anchorNode, + anchorOffset: selection?.anchorOffset, + focusNode: selection?.focusNode, + focusOffset: selection?.focusOffset, }; } - selection.removeAllRanges(); + selection?.removeAllRanges(); const range = document.createRange(); range.selectNodeContents(node); - selection.addRange(range); + selection?.addRange(range); try { document.execCommand('copy'); @@ -59,12 +79,14 @@ function setHTMLSync(html, text) { // See https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#the-copy-command for more details. } - selection.removeAllRanges(); + selection?.removeAllRanges(); if (isComposer) { - firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction); + const composerSelection = originalSelection as ComposerSelection; + firstAnchorChild.setSelectionRange(composerSelection.start, composerSelection.end, composerSelection.direction); } else { - selection.setBaseAndExtent(originalSelection.anchorNode, originalSelection.anchorOffset, originalSelection.focusNode, originalSelection.focusOffset); + const anchorSelection = originalSelection as AnchorSelection; + selection?.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset); } document.body.removeChild(node); @@ -72,10 +94,10 @@ function setHTMLSync(html, text) { /** * Writes the content as HTML if the web client supports it. - * @param {String} html HTML representation - * @param {String} text Plain text representation + * @param HTML representation + * @param Plain text representation */ -const setHtml = (html, text) => { +const setHtml = (html: string, text: string) => { if (!html || !text) { return; } @@ -92,9 +114,10 @@ const setHtml = (html, text) => { setHTMLSync(html, text); } else { navigator.clipboard.write([ - // eslint-disable-next-line no-undef new ClipboardItem({ + // eslint-disable-next-line @typescript-eslint/naming-convention 'text/html': new Blob([html], {type: 'text/html'}), + // eslint-disable-next-line @typescript-eslint/naming-convention 'text/plain': new Blob([text], {type: 'text/plain'}), }), ]); @@ -103,15 +126,15 @@ const setHtml = (html, text) => { /** * Sets a string on the Clipboard object via react-native-web - * - * @param {String} text */ -const setString = (text) => { - Clipboard.setString(text); +const setString: SetString = (text) => { + RNWClipboard.setString(text); }; -export default { +const clipboard: Clipboard = { setString, canSetHtml, setHtml, }; + +export default clipboard; diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts new file mode 100644 index 000000000000..e4b7cb1b5332 --- /dev/null +++ b/src/libs/Clipboard/types.ts @@ -0,0 +1,9 @@ +type SetString = (text: string) => void; + +type Clipboard = { + setString: SetString; + canSetHtml: () => void; + setHtml: (html: string, text: string) => void; +} + +export type {SetString, Clipboard}; \ No newline at end of file diff --git a/src/types/modules/react-native-web.d.ts b/src/types/modules/react-native-web.d.ts new file mode 100644 index 000000000000..067e2f95e07a --- /dev/null +++ b/src/types/modules/react-native-web.d.ts @@ -0,0 +1,7 @@ +declare module 'react-native-web' { + type SetString = (text: string) => void; + + const Clipboard: { + setString: SetString; + } +} \ No newline at end of file From 82abacaa4985587dd730465d75652aa37a12b00d Mon Sep 17 00:00:00 2001 From: Etotaziba Olei Date: Wed, 4 Oct 2023 07:56:04 +0100 Subject: [PATCH 029/257] fix lint --- src/libs/Clipboard/index.ts | 9 ++++++--- src/libs/Clipboard/types.ts | 4 ++-- src/types/modules/react-native-web.d.ts | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/libs/Clipboard/index.ts b/src/libs/Clipboard/index.ts index fe0515edc585..8c2d1be1afd5 100644 --- a/src/libs/Clipboard/index.ts +++ b/src/libs/Clipboard/index.ts @@ -21,10 +21,13 @@ type Nullable = {[K in keyof T]: T[K] | null}; type OriginalSelection = ComposerSelection | Partial>; /* -* @param {this: void} object The object to query. -*/ + * @param {this: void} object The object to query. + */ -const canSetHtml = () => (...args: ClipboardItems) => navigator?.clipboard?.write([...args]); +const canSetHtml = + () => + (...args: ClipboardItems) => + navigator?.clipboard?.write([...args]); /** * Deprecated method to write the content as HTML to clipboard. diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts index e4b7cb1b5332..92a90df85e02 100644 --- a/src/libs/Clipboard/types.ts +++ b/src/libs/Clipboard/types.ts @@ -4,6 +4,6 @@ type Clipboard = { setString: SetString; canSetHtml: () => void; setHtml: (html: string, text: string) => void; -} +}; -export type {SetString, Clipboard}; \ No newline at end of file +export type {SetString, Clipboard}; diff --git a/src/types/modules/react-native-web.d.ts b/src/types/modules/react-native-web.d.ts index 067e2f95e07a..da723e9a811d 100644 --- a/src/types/modules/react-native-web.d.ts +++ b/src/types/modules/react-native-web.d.ts @@ -1,7 +1,7 @@ declare module 'react-native-web' { type SetString = (text: string) => void; - + const Clipboard: { setString: SetString; - } -} \ No newline at end of file + }; +} From 8ba2000e210551890e44f576250d50678c6bc9c2 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 4 Oct 2023 10:11:43 +0200 Subject: [PATCH 030/257] create MVCPScrollView --- .../MVCPScrollView/MVCPScrollView.js | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js diff --git a/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js b/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js new file mode 100644 index 000000000000..f0139a4ec39c --- /dev/null +++ b/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js @@ -0,0 +1,128 @@ +import React, {forwardRef, useEffect, useRef} from 'react'; +import {ScrollView, StyleSheet} from 'react-native'; +import PropTypes from 'prop-types'; + + +const MVCPScrollView = forwardRef(({maintainVisibleContentPosition, horizontal, ...props}, ref) => { + const scrollViewRef = useRef(null); + const prevFirstVisibleOffset = useRef(null); + const firstVisibleView = useRef(null); + const mutationObserver = useRef(null); + + const getContentView = () => scrollViewRef.current?.childNodes[0]; + + const prepareForMaintainVisibleContentPosition = () => { + if (maintainVisibleContentPosition == null || scrollViewRef.current == null) { + return; + } + + const contentView = getContentView(); + const minIdx = maintainVisibleContentPosition.minIndexForVisible; + for (let ii = minIdx; ii < contentView.childNodes.length; ii++) { + const subview = contentView.childNodes[ii]; + const hasNewView = horizontal ? subview.offsetLeft > scrollViewRef.current.scrollLeft : subview.offsetTop > scrollViewRef.current.scrollTop; + if (hasNewView || ii === contentView.childNodes.length - 1) { + prevFirstVisibleOffset.current = horizontal ? subview.offsetLeft : subview.offsetTop; + firstVisibleView.current = subview; + break; + } + } + }; + const scrollEventListener = useRef(() => { + prepareForMaintainVisibleContentPosition(); + }); + + const adjustForMaintainVisibleContentPosition = () => { + if (maintainVisibleContentPosition == null || scrollViewRef.current == null || firstVisibleView.current == null || prevFirstVisibleOffset.current == null) { + return; + } + + const autoscrollThreshold = maintainVisibleContentPosition.autoscrollToTopThreshold; + if (horizontal) { + const deltaX = firstVisibleView.current.offsetLeft - prevFirstVisibleOffset.current; + if (Math.abs(deltaX) > 0.5) { + const x = scrollViewRef.current.scrollLeft; + prevFirstVisibleOffset.current = firstVisibleView.current.offsetLeft; + scrollViewRef.current.scrollTo({x: x + deltaX, animated: false}); + if (autoscrollThreshold != null && x <= autoscrollThreshold) { + scrollViewRef.current.scrollTo({x: 0, animated: true}); + } + } + } else { + const deltaY = firstVisibleView.current.offsetTop - prevFirstVisibleOffset.current; + if (Math.abs(deltaY) > 0.5) { + const y = scrollViewRef.current.scrollTop; + prevFirstVisibleOffset.current = firstVisibleView.current.offsetTop; + scrollViewRef.current.scrollTo({y: y + deltaY, animated: false}); + if (autoscrollThreshold != null && y <= autoscrollThreshold) { + scrollViewRef.current.scrollTo({y: 0, animated: true}); + } + } + } + }; + + if (mutationObserver.current == null) { + mutationObserver.current = new MutationObserver(() => { + // This needs to execute after scroll events are dispatched, but + // in the same tick to avoid flickering. rAF provides the right timing. + requestAnimationFrame(adjustForMaintainVisibleContentPosition); + }); + } + + const onRef = (newRef) => { + scrollViewRef.current = newRef; + if (typeof ref === 'function') { + ref(newRef); + } else { + // eslint-disable-next-line no-param-reassign + ref.current = newRef; + } + prepareForMaintainVisibleContentPosition(); + mutationObserver.current.disconnect(); + mutationObserver.current.observe(getContentView(), { + attributes: true, + childList: true, + subtree: true, + }); + newRef.removeEventListener('scroll', scrollEventListener.current); + newRef.addEventListener('scroll', scrollEventListener.current); + }; + + useEffect(() => { + const currentObserver = mutationObserver.current; + const currentScrollEventListener = scrollEventListener.current; + return () => { + currentObserver.disconnect(); + scrollViewRef.current.removeEventListener('scroll', currentScrollEventListener); + }; + }, []); + + return ( + + ); +}); + +const styles = StyleSheet.create({ + inverted: { + transform: [{ scaleY: -1 }], + }, +}); + + +MVCPScrollView.propTypes = { + maintainVisibleContentPosition: PropTypes.shape({ + minIndexForVisible: PropTypes.number.isRequired, + autoscrollToTopThreshold: PropTypes.number, + }), + horizontal: PropTypes.bool, +}; + +export default MVCPScrollView; From ef49188d8f4c82342f51b3b830b289b5c4e682aa Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 4 Oct 2023 10:12:26 +0200 Subject: [PATCH 031/257] use renderScrollComponent --- src/components/InvertedFlatList/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index d46cd5801605..caa49eaccf58 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -2,6 +2,7 @@ import React, {forwardRef, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native'; import _ from 'underscore'; +import MVCPScrollView from './MVCPScrollView/MVCPScrollView'; import BaseInvertedFlatList from './BaseInvertedFlatList'; import styles from '../../styles/styles'; import CONST from '../../CONST'; @@ -122,6 +123,10 @@ function InvertedFlatList(props) { // We need to keep batch size to one to workaround a bug in react-native-web. // This can be removed once https://github.com/Expensify/App/pull/24482 is merged. maxToRenderPerBatch={1} + + // We need to use our own scroll component to workaround a maintainVisibleContentPosition for web + // eslint-disable-next-line react/jsx-props-no-spreading + renderScrollComponent={(_props) => } /> ); } From 40226f38242c79e2cf02d535af3713ce9d402d53 Mon Sep 17 00:00:00 2001 From: Etotaziba Olei Date: Wed, 4 Oct 2023 09:16:21 +0100 Subject: [PATCH 032/257] remove redundant JSDOC and Move Nullable to utils --- src/libs/Clipboard/index.ts | 15 +++------------ src/types/utils/Nullable.ts | 3 +++ 2 files changed, 6 insertions(+), 12 deletions(-) create mode 100644 src/types/utils/Nullable.ts diff --git a/src/libs/Clipboard/index.ts b/src/libs/Clipboard/index.ts index 8c2d1be1afd5..3166cab1bed9 100644 --- a/src/libs/Clipboard/index.ts +++ b/src/libs/Clipboard/index.ts @@ -3,6 +3,7 @@ import {Clipboard as RNWClipboard} from 'react-native-web'; import CONST from '../../CONST'; import * as Browser from '../Browser'; import {SetString, Clipboard} from './types'; +import Nullable from '../../types/utils/Nullable'; type ComposerSelection = { start: number; @@ -17,13 +18,8 @@ type AnchorSelection = { focusNode: Node; }; -type Nullable = {[K in keyof T]: T[K] | null}; type OriginalSelection = ComposerSelection | Partial>; -/* - * @param {this: void} object The object to query. - */ - const canSetHtml = () => (...args: ClipboardItems) => @@ -31,8 +27,6 @@ const canSetHtml = /** * Deprecated method to write the content as HTML to clipboard. - * @param HTML representation - * @param Plain text representation */ function setHTMLSync(html: string, text: string) { const node = document.createElement('span'); @@ -84,9 +78,8 @@ function setHTMLSync(html: string, text: string) { selection?.removeAllRanges(); - if (isComposer) { - const composerSelection = originalSelection as ComposerSelection; - firstAnchorChild.setSelectionRange(composerSelection.start, composerSelection.end, composerSelection.direction); + if (isComposer && 'start' in originalSelection) { + firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction); } else { const anchorSelection = originalSelection as AnchorSelection; selection?.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset); @@ -97,8 +90,6 @@ function setHTMLSync(html: string, text: string) { /** * Writes the content as HTML if the web client supports it. - * @param HTML representation - * @param Plain text representation */ const setHtml = (html: string, text: string) => { if (!html || !text) { diff --git a/src/types/utils/Nullable.ts b/src/types/utils/Nullable.ts new file mode 100644 index 000000000000..caba46ef5b58 --- /dev/null +++ b/src/types/utils/Nullable.ts @@ -0,0 +1,3 @@ +type Nullable = {[K in keyof T]: T[K] | null}; + +export default Nullable; From dfc7be9bd67acee2ba2253ab4afa41170e065bd9 Mon Sep 17 00:00:00 2001 From: Etotaziba Olei Date: Wed, 4 Oct 2023 09:25:30 +0100 Subject: [PATCH 033/257] Update src/types/modules/react-native-web.d.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fábio Henriques --- src/types/modules/react-native-web.d.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/types/modules/react-native-web.d.ts b/src/types/modules/react-native-web.d.ts index da723e9a811d..f7db951eadad 100644 --- a/src/types/modules/react-native-web.d.ts +++ b/src/types/modules/react-native-web.d.ts @@ -1,7 +1,11 @@ +/* eslint-disable import/prefer-default-export */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ declare module 'react-native-web' { - type SetString = (text: string) => void; + class Clipboard { + static isAvailable(): boolean; + static getString(): Promise; + static setString(text: string): boolean; + } - const Clipboard: { - setString: SetString; - }; + export {Clipboard}; } From 4bf68670f7af143b6c576cc8bf83076d6cd607f4 Mon Sep 17 00:00:00 2001 From: Etotaziba Olei Date: Wed, 4 Oct 2023 10:07:42 +0100 Subject: [PATCH 034/257] fix reviews --- src/libs/Clipboard/index.native.ts | 20 ++++++++++---------- src/libs/Clipboard/index.ts | 19 +++++++++---------- src/libs/Clipboard/types.ts | 10 +++------- src/types/utils/Nullable.ts | 3 --- 4 files changed, 22 insertions(+), 30 deletions(-) delete mode 100644 src/types/utils/Nullable.ts diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts index bc7da7ad9d3f..4a805b466d5b 100644 --- a/src/libs/Clipboard/index.native.ts +++ b/src/libs/Clipboard/index.native.ts @@ -1,19 +1,19 @@ -import RNCClipboard from '@react-native-community/clipboard'; -import {SetString, Clipboard} from './types'; +import Clipboard from '@react-native-community/clipboard'; +import {SetString, CanSetHtml, SetHtml} from './types'; /** * Sets a string on the Clipboard object via @react-native-community/clipboard */ const setString: SetString = (text) => { - RNCClipboard.setString(text); + Clipboard.setString(text); }; -const clipboard: Clipboard = { - setString, +// We don't want to set HTML on native platforms so noop them. +const canSetHtml: CanSetHtml = () => false; +const setHtml: SetHtml = () => {}; - // We don't want to set HTML on native platforms so noop them. - canSetHtml: () => false, - setHtml: () => {}, +export default { + setString, + canSetHtml, + setHtml, }; - -export default clipboard; diff --git a/src/libs/Clipboard/index.ts b/src/libs/Clipboard/index.ts index 3166cab1bed9..975780f561a3 100644 --- a/src/libs/Clipboard/index.ts +++ b/src/libs/Clipboard/index.ts @@ -1,9 +1,8 @@ // on Web/desktop this import will be replaced with `react-native-web` -import {Clipboard as RNWClipboard} from 'react-native-web'; +import {Clipboard} from 'react-native-web'; import CONST from '../../CONST'; import * as Browser from '../Browser'; -import {SetString, Clipboard} from './types'; -import Nullable from '../../types/utils/Nullable'; +import {SetString, SetHtml, CanSetHtml} from './types'; type ComposerSelection = { start: number; @@ -18,9 +17,11 @@ type AnchorSelection = { focusNode: Node; }; -type OriginalSelection = ComposerSelection | Partial>; +type NullableObject = {[K in keyof T]: T[K] | null}; -const canSetHtml = +type OriginalSelection = ComposerSelection | Partial>; + +const canSetHtml: CanSetHtml = () => (...args: ClipboardItems) => navigator?.clipboard?.write([...args]); @@ -91,7 +92,7 @@ function setHTMLSync(html: string, text: string) { /** * Writes the content as HTML if the web client supports it. */ -const setHtml = (html: string, text: string) => { +const setHtml: SetHtml = (html: string, text: string) => { if (!html || !text) { return; } @@ -122,13 +123,11 @@ const setHtml = (html: string, text: string) => { * Sets a string on the Clipboard object via react-native-web */ const setString: SetString = (text) => { - RNWClipboard.setString(text); + Clipboard.setString(text); }; -const clipboard: Clipboard = { +export default { setString, canSetHtml, setHtml, }; - -export default clipboard; diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts index 92a90df85e02..1d899144a2ba 100644 --- a/src/libs/Clipboard/types.ts +++ b/src/libs/Clipboard/types.ts @@ -1,9 +1,5 @@ type SetString = (text: string) => void; +type SetHtml = (html: string, text: string) => void; +type CanSetHtml = (() => (...args: ClipboardItems) => Promise) | (() => boolean); -type Clipboard = { - setString: SetString; - canSetHtml: () => void; - setHtml: (html: string, text: string) => void; -}; - -export type {SetString, Clipboard}; +export type {SetString, CanSetHtml, SetHtml}; diff --git a/src/types/utils/Nullable.ts b/src/types/utils/Nullable.ts deleted file mode 100644 index caba46ef5b58..000000000000 --- a/src/types/utils/Nullable.ts +++ /dev/null @@ -1,3 +0,0 @@ -type Nullable = {[K in keyof T]: T[K] | null}; - -export default Nullable; From fbfe6d2e8405fa6b19492b60a6ae599c61cddd0d Mon Sep 17 00:00:00 2001 From: hurali97 Date: Wed, 4 Oct 2023 19:31:29 +0500 Subject: [PATCH 035/257] fix: revert changes due to unit test fixes --- src/components/LHNOptionsList/OptionRowLHN.js | 17 +++++++++++++---- .../LHNOptionsList/OptionRowLHNData.js | 10 ++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 15568b99a6f9..c3a847144a55 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -3,6 +3,7 @@ import React, {useState, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet} from 'react-native'; import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; import * as optionRowStyles from '../../styles/optionRowStyles'; import styles from '../../styles/styles'; import * as StyleUtils from '../../styles/StyleUtils'; @@ -25,6 +26,7 @@ import * as ReportUtils from '../../libs/ReportUtils'; import useLocalize from '../../hooks/useLocalize'; import Permissions from '../../libs/Permissions'; import Tooltip from '../Tooltip'; +import ONYXKEYS from '../../ONYXKEYS'; const propTypes = { /** Style for hovered state */ @@ -52,7 +54,8 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types optionItem: PropTypes.object, - hasDraft: PropTypes.bool, + // eslint-disable-next-line react/forbid-prop-types + draftReportIDs: PropTypes.object, }; const defaultProps = { @@ -63,7 +66,7 @@ const defaultProps = { optionItem: null, isFocused: false, betas: [], - hasDraft: false, + draftReportIDs: {}, }; function OptionRowLHN(props) { @@ -143,7 +146,7 @@ function OptionRowLHN(props) { const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText; const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem); - const isDraft = props.hasDraft; + const isDraft = props.draftReportIDs[props.reportID]; return ( { + const draftReportIDs = DraftReportUtils.getInstance().getDraftReportIDs(); + const hasDraft = draftReportIDs[reportID]; + if (!optionItem || hasDraft || !comment || comment.length <= 0 || isFocused) { return; } @@ -133,7 +135,6 @@ function OptionRowLHNData({ {...propsToForward} isFocused={isFocused} optionItem={optionItem} - hasDraft={hasDraft} /> ); } @@ -184,9 +185,6 @@ export default React.memo( }, }), withOnyx({ - draftReportIDs: { - key: ONYXKEYS.DRAFT_REPORT_IDS, - }, fullReport: { key: (props) => ONYXKEYS.COLLECTION.REPORT + props.reportID, }, From b0fa108a9ef097ebc5358d8576c6173bc9cc16a0 Mon Sep 17 00:00:00 2001 From: April Bekkala Date: Wed, 4 Oct 2023 11:03:02 -0500 Subject: [PATCH 036/257] Update Copilot.md https://github.com/Expensify/App/pull/28361#pullrequestreview-1656620957 made necessary edit noted in above comment --- .../expensify-classic/account-settings/Copilot.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/articles/expensify-classic/account-settings/Copilot.md b/docs/articles/expensify-classic/account-settings/Copilot.md index dbd26af12d88..4fac402b7ced 100644 --- a/docs/articles/expensify-classic/account-settings/Copilot.md +++ b/docs/articles/expensify-classic/account-settings/Copilot.md @@ -2,15 +2,11 @@ title: Copilot description: Safely delegate tasks without sharing login information. --- - # About - # How-to - # Deep Dive - # FAQ - From 67125d17037f34d45c35d77862148cfd08995638 Mon Sep 17 00:00:00 2001 From: Pierre Michel Date: Wed, 4 Oct 2023 10:08:53 -0600 Subject: [PATCH 037/257] The merge was wrong Signed-off-by: Pierre Michel --- src/components/ReportActionItem/TaskPreview.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 79d016fa297f..6fbe2c175799 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -57,16 +57,17 @@ const propTypes = { ownerAccountID: PropTypes.number, }), - /* Onyx Props */ /** chatReport associated with taskReport */ chatReport: reportPropTypes, - + /** Popover context menu anchor, used for showing context menu */ contextMenuAnchor: refPropTypes, /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: PropTypes.func, - ...withLocalizePropTypes, + + /* Onyx Props */ + ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; @@ -87,8 +88,7 @@ function TaskPreview(props) { : props.action.childStateNum === CONST.REPORT.STATE_NUM.SUBMITTED && props.action.childStatusNum === CONST.REPORT.STATUS.APPROVED; const taskTitle = props.taskReport.reportName || props.action.childReportName; const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(props.taskReport) || props.action.childManagerAccountID; - const taskAssignee = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'login'], lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'displayName'], '')); - const htmlForTaskPreview = taskAssignee ? `@${taskAssignee} ${taskTitle}` : `${taskTitle}`; + const assigneeLogin = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'login'], ''); const assigneeDisplayName = lodashGet(props.personalDetailsList, [taskAssigneeAccountID, 'displayName'], ''); const taskAssignee = assigneeDisplayName || LocalePhoneNumber.formatPhoneNumber(assigneeLogin); const htmlForTaskPreview = From eb4fe936253d86232d0e096ca4c6328471d95e96 Mon Sep 17 00:00:00 2001 From: Pierre Michel Date: Wed, 4 Oct 2023 11:09:07 -0600 Subject: [PATCH 038/257] We use chatReportID not chatReport Signed-off-by: Pierre Michel --- src/components/ReportActionItem/TaskPreview.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 6fbe2c175799..3635e8dbb24d 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -57,8 +57,8 @@ const propTypes = { ownerAccountID: PropTypes.number, }), - /** chatReport associated with taskReport */ - chatReport: reportPropTypes, + /** The chat report associated with taskReport */ + chatReportID: PropTypes.string.isRequired, /** Popover context menu anchor, used for showing context menu */ contextMenuAnchor: refPropTypes, From eb985defedccd75ff4d347cff2c7adb433e068d6 Mon Sep 17 00:00:00 2001 From: Pierre Michel Date: Wed, 4 Oct 2023 11:16:09 -0600 Subject: [PATCH 039/257] reportPropTypes is not used Signed-off-by: Pierre Michel --- src/components/ReportActionItem/TaskPreview.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ReportActionItem/TaskPreview.js b/src/components/ReportActionItem/TaskPreview.js index 3635e8dbb24d..8c757b51790c 100644 --- a/src/components/ReportActionItem/TaskPreview.js +++ b/src/components/ReportActionItem/TaskPreview.js @@ -26,7 +26,6 @@ import personalDetailsPropType from '../../pages/personalDetailsPropType'; import * as Session from '../../libs/actions/Session'; import * as LocalePhoneNumber from '../../libs/LocalePhoneNumber'; import {showContextMenuForReport} from '../ShowContextMenuContext'; -import reportPropTypes from '../../pages/reportPropTypes'; import refPropTypes from '../refPropTypes'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import ControlSelection from '../../libs/ControlSelection'; From b2a9994d3c1b8e146e2d2571a4748ce2f68f6d9e Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Tue, 10 Oct 2023 12:58:32 +0200 Subject: [PATCH 040/257] Bump onyx --- package-lock.json | 18 +++++++++--------- package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index ddebbe8a3832..3b267a21489f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.98", + "react-native-onyx": "1.0.104", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -44710,17 +44710,17 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.98", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.98.tgz", - "integrity": "sha512-2wJNmZVBJs2Y0p1G/es4tQZnplJR8rOyVbHv9KZaq/SXluLUnIovttf1MMhVXidDLT+gcE+u20Mck/Gpb8bY0w==", + "version": "1.0.104", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.104.tgz", + "integrity": "sha512-XUrDUZNP9vJIvcCo9NC1PIjgCckgYlTqNL/ksZV2f7EN5HOXLhHqGnp7U5Dpd5qrNcU1jOyfI9AEci2bklevCA==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.1" }, "engines": { - "node": "16.15.1", - "npm": "8.11.0" + "node": ">=16.15.1 <=18.17.1", + "npm": ">=8.11.0 <=9.6.7" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -85286,9 +85286,9 @@ } }, "react-native-onyx": { - "version": "1.0.98", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.98.tgz", - "integrity": "sha512-2wJNmZVBJs2Y0p1G/es4tQZnplJR8rOyVbHv9KZaq/SXluLUnIovttf1MMhVXidDLT+gcE+u20Mck/Gpb8bY0w==", + "version": "1.0.104", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.104.tgz", + "integrity": "sha512-XUrDUZNP9vJIvcCo9NC1PIjgCckgYlTqNL/ksZV2f7EN5HOXLhHqGnp7U5Dpd5qrNcU1jOyfI9AEci2bklevCA==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index 9a3b9ed3af86..e3d480962032 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.98", + "react-native-onyx": "1.0.104", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", From c92ab2ef56ad8e714cdc0f67b73e1cb09829197d Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 11 Oct 2023 09:01:05 +0700 Subject: [PATCH 041/257] fix anonymous user can edit profile --- src/libs/Navigation/NavigationRoot.js | 6 ++++++ src/libs/actions/Session/index.js | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index 34a52adfeca9..d8f083b9c59b 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -12,6 +12,7 @@ import StatusBar from '../StatusBar'; import useCurrentReportID from '../../hooks/useCurrentReportID'; import useWindowDimensions from '../../hooks/useWindowDimensions'; import {SidebarNavigationContext} from '../../pages/home/sidebar/SidebarNavigationContext'; +import * as Session from '../actions/Session'; // https://reactnavigation.org/docs/themes const navigationTheme = { @@ -133,6 +134,11 @@ function NavigationRoot(props) { // Update the global navigation to show the correct selected menu items. globalNavigation.updateFromNavigationState(state); + + const route = Navigation.getActiveRoute(); + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { + Session.signOutAndRedirectToSignIn(); + } }; return ( diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 117a092c3875..30c5f3320e08 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -787,6 +787,20 @@ function waitForUserSignIn() { }); } +/** + * check if the route can be accessed by anonymous user + * + * @param {string} route + */ + +const canAccessRouteByAnonymousUser = (route) => { + const reportID = ReportUtils.getReportIDFromLink(route); + if (reportID) { + return true; + } + return false; +}; + export { beginSignIn, beginAppleSignIn, @@ -815,4 +829,5 @@ export { toggleTwoFactorAuth, validateTwoFactorAuth, waitForUserSignIn, + canAccessRouteByAnonymousUser, }; From fa0fc433829dc7e5749a7047ede3851cc3315ab9 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 11 Oct 2023 13:25:01 +0700 Subject: [PATCH 042/257] fix update util function --- src/libs/ReportUtils.js | 1 + src/libs/actions/Session/index.js | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index b5fc0bff6ec7..ffb34355845f 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3959,4 +3959,5 @@ export { getIOUReportActionDisplayMessage, isWaitingForTaskCompleteFromAssignee, isReportDraft, + parseReportRouteParams }; diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 30c5f3320e08..b52f172f2efa 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -798,6 +798,19 @@ const canAccessRouteByAnonymousUser = (route) => { if (reportID) { return true; } + const parsedReportRouteParams = ReportUtils.parseReportRouteParams(route); + let routeRemovedReportId = route; + if (parsedReportRouteParams.reportID) { + routeRemovedReportId = route.replace(lodashGet(parsedReportRouteParams, 'reportID', ''), ':reportID'); + } + if (route.startsWith('/')) { + routeRemovedReportId = routeRemovedReportId.slice(1); + } + const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route]; + + if (_.contains(routesCanAccessByAnonymousUser, routeRemovedReportId)) { + return true; + } return false; }; From 78ba2a46d5bfb9f2fce3a22f2c786d9810cd3565 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Wed, 11 Oct 2023 14:26:11 +0700 Subject: [PATCH 043/257] fix lint issue --- src/libs/ReportUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index ffb34355845f..50df076ba043 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -3959,5 +3959,5 @@ export { getIOUReportActionDisplayMessage, isWaitingForTaskCompleteFromAssignee, isReportDraft, - parseReportRouteParams + parseReportRouteParams, }; From 709d48fbab1fce7dfc3936ad3b3a81c8b5a0e1d6 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 13 Oct 2023 13:42:14 +0700 Subject: [PATCH 044/257] fix dissmiss modal login when clicking on back button --- src/pages/signin/SignInModal.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/pages/signin/SignInModal.js b/src/pages/signin/SignInModal.js index f1ce09def084..0ca8fa6838b2 100644 --- a/src/pages/signin/SignInModal.js +++ b/src/pages/signin/SignInModal.js @@ -24,7 +24,11 @@ function SignInModal() { shouldEnableMaxHeight testID={SignInModal.displayName} > - + { + Navigation.dismissModal(); + }} + /> ); From 0d08b325058d0a96f2bb543150d268f786f1a9e4 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 13 Oct 2023 13:45:17 +0700 Subject: [PATCH 045/257] fix refactor code --- src/pages/signin/SignInModal.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/signin/SignInModal.js b/src/pages/signin/SignInModal.js index 0ca8fa6838b2..98bd0692298c 100644 --- a/src/pages/signin/SignInModal.js +++ b/src/pages/signin/SignInModal.js @@ -24,11 +24,7 @@ function SignInModal() { shouldEnableMaxHeight testID={SignInModal.displayName} > - { - Navigation.dismissModal(); - }} - /> + ); From 4ef562b24f78f214e7d7e3b830cbed899dc4e694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Miko=C5=82ajczak?= Date: Fri, 13 Oct 2023 12:54:59 +0200 Subject: [PATCH 046/257] memoize useDebounce return value --- src/hooks/useDebounce.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js index 8995a0443b85..3435a2699e93 100644 --- a/src/hooks/useDebounce.js +++ b/src/hooks/useDebounce.js @@ -1,4 +1,4 @@ -import {useEffect, useRef} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; import lodashDebounce from 'lodash/debounce'; /** @@ -27,11 +27,13 @@ export default function useDebounce(func, wait, options) { return debouncedFn.cancel; }, [func, wait, leading, maxWait, trailing]); - return (...args) => { + const debounceCallback = useCallback((...args) => { const debouncedFn = debouncedFnRef.current; if (debouncedFn) { debouncedFn(...args); } - }; + }, []); + + return debounceCallback; } From 4a18a1ffe6d075fc3688c9f36948b8f6c2f62ba6 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Fri, 13 Oct 2023 15:04:17 +0200 Subject: [PATCH 047/257] [TS migration] Migrate 'withToggleVisibilityView.js' HOC to TypeScript --- .eslintrc.js | 2 +- src/components/withToggleVisibilityView.js | 47 --------------------- src/components/withToggleVisibilityView.tsx | 27 ++++++++++++ src/libs/getComponentDisplayName.ts | 2 +- src/pages/signin/LoginForm/BaseLoginForm.js | 6 +-- 5 files changed, 32 insertions(+), 52 deletions(-) delete mode 100644 src/components/withToggleVisibilityView.js create mode 100644 src/components/withToggleVisibilityView.tsx diff --git a/.eslintrc.js b/.eslintrc.js index 75a74ed371c4..83e9479ce0c4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -116,7 +116,7 @@ module.exports = { }, { selector: ['parameter', 'method'], - format: ['camelCase'], + format: ['camelCase', 'PascalCase'], }, ], '@typescript-eslint/ban-types': [ diff --git a/src/components/withToggleVisibilityView.js b/src/components/withToggleVisibilityView.js deleted file mode 100644 index eef5135d02b6..000000000000 --- a/src/components/withToggleVisibilityView.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import PropTypes from 'prop-types'; -import styles from '../styles/styles'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -const toggleVisibilityViewPropTypes = { - /** Whether the content is visible. */ - isVisible: PropTypes.bool, -}; - -export default function (WrappedComponent) { - function WithToggleVisibilityView(props) { - return ( - - - - ); - } - - WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; - WithToggleVisibilityView.propTypes = { - forwardedRef: refPropTypes, - - /** Whether the content is visible. */ - isVisible: PropTypes.bool, - }; - WithToggleVisibilityView.defaultProps = { - forwardedRef: undefined, - isVisible: false, - }; - return React.forwardRef((props, ref) => ( - - )); -} - -export {toggleVisibilityViewPropTypes}; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx new file mode 100644 index 000000000000..bdff40be3deb --- /dev/null +++ b/src/components/withToggleVisibilityView.tsx @@ -0,0 +1,27 @@ +import React, {ComponentType, ForwardedRef, RefAttributes} from 'react'; +import {View} from 'react-native'; +import styles from '../styles/styles'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; + +type ToggleVisibilityViewPropTypes = { + /** Whether the content is visible. */ + isVisible: boolean; +}; + +export default function withToggleVisibilityView(WrappedComponent: ComponentType>): ComponentType { + function WithToggleVisibilityView(props: Omit, ref: ForwardedRef) { + return ( + + + + ); + } + + WithToggleVisibilityView.displayName = `WithToggleVisibilityView(${getComponentDisplayName(WrappedComponent)})`; + return React.forwardRef(WithToggleVisibilityView); +} diff --git a/src/libs/getComponentDisplayName.ts b/src/libs/getComponentDisplayName.ts index fd1bbcaea521..0bf52d543a84 100644 --- a/src/libs/getComponentDisplayName.ts +++ b/src/libs/getComponentDisplayName.ts @@ -1,6 +1,6 @@ import {ComponentType} from 'react'; /** Returns the display name of a component */ -export default function getComponentDisplayName(component: ComponentType): string { +export default function getComponentDisplayName(component: ComponentType): string { return component.displayName ?? component.name ?? 'Component'; } diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 2c65b5ff5d37..14d9ee3f5dfc 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -16,7 +16,7 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal import TextInput from '../../../components/TextInput'; import * as ValidationUtils from '../../../libs/ValidationUtils'; import * as LoginUtils from '../../../libs/LoginUtils'; -import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../../components/withToggleVisibilityView'; +import withToggleVisibilityView from '../../../components/withToggleVisibilityView'; import FormAlertWithSubmitButton from '../../../components/FormAlertWithSubmitButton'; import {withNetwork} from '../../../components/OnyxProvider'; import networkPropTypes from '../../../components/networkPropTypes'; @@ -66,12 +66,12 @@ const propTypes = { /** Whether or not the sign in page is being rendered in the RHP modal */ isInModal: PropTypes.bool, + isVisible: PropTypes.bool.isRequired, + ...windowDimensionsPropTypes, ...withLocalizePropTypes, - ...toggleVisibilityViewPropTypes, - ...withNavigationFocusPropTypes, }; From 659831b526af983f91d76e3393465c2e0f6cf8e6 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Fri, 13 Oct 2023 15:07:41 +0200 Subject: [PATCH 048/257] Add default value for isVisible --- src/components/withToggleVisibilityView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index bdff40be3deb..b3c8d9bfb221 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -11,12 +11,12 @@ type ToggleVisibilityViewPropTypes = { export default function withToggleVisibilityView(WrappedComponent: ComponentType>): ComponentType { function WithToggleVisibilityView(props: Omit, ref: ForwardedRef) { return ( - + ); From 547ab90addaf9bb3db18b5527ff9a2d8c8d445c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Miko=C5=82ajczak?= Date: Fri, 13 Oct 2023 15:11:18 +0200 Subject: [PATCH 049/257] reuse personalDetails prop by introducing context instead of using withOnyx --- .../PersonalDetailsContext.js | 7 + .../ReportActionCompose.js | 223 +++++++++--------- .../ReportActionCompose/SuggestionMention.js | 45 +--- 3 files changed, 131 insertions(+), 144 deletions(-) create mode 100644 src/pages/home/report/ReportActionCompose/PersonalDetailsContext.js diff --git a/src/pages/home/report/ReportActionCompose/PersonalDetailsContext.js b/src/pages/home/report/ReportActionCompose/PersonalDetailsContext.js new file mode 100644 index 000000000000..83ce73404e2d --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/PersonalDetailsContext.js @@ -0,0 +1,7 @@ +import {createContext} from 'react'; + +const PersonalDetailsContext = createContext({}); + +PersonalDetailsContext.displayName = 'PersonalDetailsContext'; + +export default PersonalDetailsContext; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index dd4d51653546..831d03d2871c 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -38,6 +38,7 @@ import useWindowDimensions from '../../../../hooks/useWindowDimensions'; import * as EmojiPickerActions from '../../../../libs/actions/EmojiPickerAction'; import getDraftComment from '../../../../libs/ComposerUtils/getDraftComment'; import updatePropsPaperWorklet from '../../../../libs/updatePropsPaperWorklet'; +import PersonalDetailsContext from './PersonalDetailsContext'; const propTypes = { /** A method to call when the form is submitted */ @@ -342,121 +343,123 @@ function ReportActionCompose({ }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); return ( - - - + - {shouldShowReportRecipientLocalTime && hasReportRecipient && } - + - setIsAttachmentPreviewActive(true)} - onModalHide={onAttachmentPreviewClose} + {shouldShowReportRecipientLocalTime && hasReportRecipient && } + - {({displayFileInModal}) => ( - <> - - - { - if (isAttachmentPreviewActive) { - return; - } - const data = lodashGet(e, ['dataTransfer', 'items', 0]); - displayFileInModal(data); - }} - /> - + setIsAttachmentPreviewActive(true)} + onModalHide={onAttachmentPreviewClose} + > + {({displayFileInModal}) => ( + <> + + + { + if (isAttachmentPreviewActive) { + return; + } + const data = lodashGet(e, ['dataTransfer', 'items', 0]); + displayFileInModal(data); + }} + /> + + )} + + {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( + composerRef.current.replaceSelectionWithText(...args)} + emojiPickerID={report.reportID} + /> )} - - {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( - composerRef.current.replaceSelectionWithText(...args)} - emojiPickerID={report.reportID} + - )} - - - - {!isSmallScreenWidth && } - - - - - + + + {!isSmallScreenWidth && } + + + + + + ); } diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 84bee9c80c7f..e8dfab4eb9ac 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -1,7 +1,6 @@ -import React, {useState, useCallback, useRef, useImperativeHandle, useEffect} from 'react'; +import React, {useState, useCallback, useRef, useImperativeHandle, useEffect, useContext} from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; -import {withOnyx} from 'react-native-onyx'; import CONST from '../../../../CONST'; import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager'; import MentionSuggestions from '../../../../components/MentionSuggestions'; @@ -9,9 +8,8 @@ import * as UserUtils from '../../../../libs/UserUtils'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as SuggestionsUtils from '../../../../libs/SuggestionUtils'; import useLocalize from '../../../../hooks/useLocalize'; -import ONYXKEYS from '../../../../ONYXKEYS'; -import personalDetailsPropType from '../../../personalDetailsPropType'; import * as SuggestionProps from './suggestionProps'; +import PersonalDetailsContext from './PersonalDetailsContext'; /** * Check if this piece of string looks like a mention @@ -28,9 +26,6 @@ const defaultSuggestionsValues = { }; const propTypes = { - /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - /** A ref to this component */ forwardedRef: PropTypes.shape({current: PropTypes.shape({})}), @@ -38,23 +33,11 @@ const propTypes = { }; const defaultProps = { - personalDetails: {}, forwardedRef: null, }; -function SuggestionMention({ - value, - setValue, - selection, - setSelection, - isComposerFullSize, - personalDetails, - updateComment, - composerHeight, - forwardedRef, - isAutoSuggestionPickerLarge, - measureParentContainer, -}) { +function SuggestionMention({value, setValue, selection, setSelection, isComposerFullSize, updateComment, composerHeight, forwardedRef, isAutoSuggestionPickerLarge, measureParentContainer}) { + const personalDetails = useContext(PersonalDetailsContext); const {translate} = useLocalize(); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); @@ -296,16 +279,10 @@ SuggestionMention.propTypes = propTypes; SuggestionMention.defaultProps = defaultProps; SuggestionMention.displayName = 'SuggestionMention'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, -})( - React.forwardRef((props, ref) => ( - - )), -); +export default React.forwardRef((props, ref) => ( + +)); From 956d7175a6700caf91bbe7fd86a77ea835e8bf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Miko=C5=82ajczak?= Date: Fri, 13 Oct 2023 18:50:14 +0200 Subject: [PATCH 050/257] Remove draft comment dependency from OptionRowLHNData component --- .../LHNOptionsList/OptionRowLHNData.js | 33 ++----------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 3386dbe8c8cd..b2dcffa7b9bd 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -2,14 +2,12 @@ import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import _ from 'underscore'; import PropTypes from 'prop-types'; -import React, {useEffect, useRef, useMemo} from 'react'; +import React, {useRef, useMemo} from 'react'; import {deepEqual} from 'fast-equals'; -import {withReportCommentDrafts} from '../OnyxProvider'; import SidebarUtils from '../../libs/SidebarUtils'; import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; import OptionRowLHN, {propTypes as basePropTypes, defaultProps as baseDefaultProps} from './OptionRowLHN'; -import * as Report from '../../libs/actions/Report'; import * as UserUtils from '../../libs/UserUtils'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; import * as TransactionUtils from '../../libs/TransactionUtils'; @@ -70,19 +68,7 @@ const defaultProps = { * The OptionRowLHN component is memoized, so it will only * re-render if the data really changed. */ -function OptionRowLHNData({ - isFocused, - fullReport, - reportActions, - personalDetails, - preferredLocale, - comment, - policy, - receiptTransactions, - parentReportActions, - transaction, - ...propsToForward -}) { +function OptionRowLHNData({isFocused, fullReport, reportActions, personalDetails, preferredLocale, policy, receiptTransactions, parentReportActions, transaction, ...propsToForward}) { const reportID = propsToForward.reportID; const parentReportAction = parentReportActions[fullReport.parentReportActionID]; @@ -109,14 +95,6 @@ function OptionRowLHNData({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [fullReport, linkedTransaction, reportActions, personalDetails, preferredLocale, policy, parentReportAction, transaction]); - useEffect(() => { - if (!optionItem || optionItem.hasDraftComment || !comment || comment.length <= 0 || isFocused) { - return; - } - Report.setReportWithDraft(reportID, true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( */ export default React.memo( compose( - withReportCommentDrafts({ - propName: 'comment', - transformValue: (drafts, props) => { - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${props.reportID}`; - return lodashGet(drafts, draftKey, ''); - }, - }), withOnyx({ fullReport: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, From 46608d0bd1b9de33d9f592b38df733c751485b82 Mon Sep 17 00:00:00 2001 From: kimkurta Date: Fri, 13 Oct 2023 15:23:46 -0500 Subject: [PATCH 051/257] Update CSV-Import.md Resolving help site migration https://github.com/Expensify/Expensify/issues/309825 --- .../company-cards/CSV-Import.md | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index 6debce6240ff..14e6633d32c1 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -1,5 +1,98 @@ --- -title: CSV Import -description: CSV Import +title: Import and assign company cards from CSV file +description: uploading a CSV file containing your company card transactions --- -## Resource Coming Soon! + +# Overview +Expensify offers a convenient CSV import feature for managing company card expenses when direct connections or commercial card feeds aren't available. This feature allows you to upload a CSV file containing your company card transactions and assign them to cardholders within your Expensify domain. +This feature is available on Group Workspaces and requires Domain Admin access. + +# How to import company cards via CSV +1. Download a CSV of transactions from your bank by logging into their website and finding the relevant statement. +2. Format the CSV for upload using [this template](https://s3-us-west-1.amazonaws.com/concierge-responses-expensify-com/uploads%2F1594908368712-Best+Example+CSV+for+Domains.csv) as a guide. +- At a minimum, your file must include the following columns: + - **Card Number** - each number in this column should display at least the last four digits, and you can obscure up to 12 characters +(e.g., 543212XXXXXX12334). + - **Posted Date** - use the YYYY-MM-DD format in this column (and any other date column in your spreadsheet). + - **Merchant** - the name of the individual or business that provided goods or services for the transaction. This is a free-text field. + - **Posted Amount** - use the number format in this column, and indicate negative amounts with parentheses (e.g., (335.98) for -$335.98). + - **Posted Currency** - use currency codes (e.g., USD, GBP, EUR) to indicate the currency of the posted transactions. +- You can also add mapping for Categories and Tags, but those parameters are optional. +3. Log into Expensify on your web browser. +4. Head to Settings > Domains > Domain Name > Company Cards +5. Click Manage/Import CSV +6. Create a Company Card Layout Name for your spreadsheet +7. Click Upload CSV +8. Review the mapping of your spreadsheet to ensure that the Card Number, Date, Merchant, Amount, and Currency match your data. +9. Double-check the Output Preview for any errors and, if needed, refer to the common error solutions listed in the FAQ below. +10. Once the mapping is correct, click Submit Spreadsheet to complete the import. +11. After submitting the spreadsheet, click I'll wait a minute. Then, wait about 1-2 minutes for the import to process. The domain page will refresh once the upload is complete. + +# How to assign new cards +If you’re assigning cards via CSV upload for the first time: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Find the new CSV feed in the drop-down list underneath **Imported Cards** +3. Click **Assign New Cards** +4. Under **Assign a Card**, enter the relevant info +5. Click **Assign** +From there, transactions will be imported to the cardholder's account, where they can add receipts, code the expenses, and submit them for review and approval. +# How to upload new expenses for existing assigned cards +There's no need to create a new upload layout for subsequent CSV uploads. Instead, add new expenses to the existing CSV: +1. Head to **Settings > Domains > Domain Name > Company Cards** +2. Click **Manage/Import CSV** +3. Select the saved layout from the drop-down list +4. Click **Upload CSV** +5. After uploading the more recent CSV, click **Update All Cards** to retrieve the new expenses for the assigned cards. + +# Deep dive +If the CSV upload isn’t formatted correctly, it will cause issues when you try to import or assign cards. Let’s go over some common issues and how to fix them. + +## Error: “Attribute value mapping is missing” +If you encounter an error that says "Attribute-value mapping is missing,” the spreadsheet likely lacks critical details like Card Number, Date, Merchant, Amount, or Currency. To resolve: +1. Click the **X** at the top of the page to close the mapping window +2. Confirm what’s missing from the spreadsheet +3. Add a new column to your spreadsheet and add the missing detail +4. Upload the revised spreadsheet by clicking **Manage Spreadsheet** +5. Enter a **Company Card Layout Name** for the contents of your spreadsheet +6. Click **Upload CSV** + +## Error: “We’ve detected an error while processing your spreadsheet feed” +This error usually occurs when there’s an upload issue. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn’t listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + +## Error: “An unexpected error occurred, and we could not retrieve the list of cards” +This error occurs when there’s an issue uploading the spreadsheet or the upload fails. +To troubleshoot this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. In the **Upload Company Card transactions for** dropdown list, look for the layout name you previously created. +3. If the layout is listed, wait at least one hour and then sync the cards to see if new transactions are imported. +4. If the layout isn’t listed, create a new **Company Card Layout Name** and upload the spreadsheet again. + + +## I added a new parameter to an existing spreadsheet, but the data isn’t showing in Expensify after the upload completes. What’s going on? +If you added a new card to an existing spreadsheet and imported it via a saved layout, but it isn’t showing up for assignment, this suggests that the modification may have caused an issue. +The next step in troubleshooting this issue is to compare the number of rows on the revised spreadsheet to the Output Preview to ensure the row count matches the revised spreadsheet. +To check this: +1. Head to **Settings > Domains > Domain Name > Company Cards** and click **Manage/Import CSV** +2. Select your saved layout in the dropdown list +3. Click **Upload CSV** and select the revised spreadsheet +4. Compare the Output Preview row count to your revised spreadsheet to ensure they match +[insert image here] +If they don’t match, you’ll need to revise the spreadsheet by following the CSV formatting guidelines in step 2 of “How to import company cards via CSV” above. +Once you do that, save the revised spreadsheet with a new layout name. +Then, try to upload the revised spreadsheet again: +1. Click **Upload CSV** +2. Upload the revised file +3. Check the row count again on the Output Preview to confirm it matches the spreadsheet +4. Click **Submit Spreadsheet** +# FAQ +## Why can’t I see my CSV transactions immediately after uploading them? +Don’t worry! You’ll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!** + +## I'm trying to import a credit. Why isn't it uploading? +Negative expenses shouldn’t include a minus sign. Instead, they should just be wrapped in parentheses. For example, to indicate “-335.98,” you’ll want to make sure it’s formatted as “(335.98).” + From 5c1b12b64f05662f499a41d3a193358eb2a70462 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Mon, 16 Oct 2023 16:28:24 +0500 Subject: [PATCH 052/257] fix: add initialValue to reduce re-renders --- src/components/LHNOptionsList/OptionRowLHN.js | 1 + src/components/LHNOptionsList/OptionRowLHNData.js | 7 ++++++- src/pages/home/sidebar/SidebarLinksData.js | 6 ++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index e6cac266c10d..fcd13758d442 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -318,6 +318,7 @@ export default React.memo( withOnyx({ draftReportIDs: { key: ONYXKEYS.DRAFT_REPORT_IDS, + initialValue: {}, }, })(OptionRowLHN), ); diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index 883a29fb3117..a510edf9fc59 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -178,14 +178,17 @@ export default React.memo( withOnyx({ fullReport: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + initialValue: {}, }, reportActions: { key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, canEvict: false, + initialValue: {}, }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, selector: personalDetailsSelector, + initialValue: {}, }, preferredLocale: { key: ONYXKEYS.NVP_PREFERRED_LOCALE, @@ -196,15 +199,17 @@ export default React.memo( parentReportActions: { key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`, canEvict: false, + initialValue: {}, }, policy: { key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`, + initialValue: {}, }, // Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions. // In some scenarios, a transaction might be created after reportActions have been modified. // This can lead to situations where `lastTransaction` doesn't update and retains the previous value. // However, performance overhead of this is minimized by using memos inside the component. - receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION}, + receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION, initialValue: {}}, }), // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index e67c3d4de89f..d96245919b84 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -200,26 +200,32 @@ export default compose( chatReports: { key: ONYXKEYS.COLLECTION.REPORT, selector: chatReportSelector, + initialValue: {}, }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, draftReportIDs: { key: ONYXKEYS.DRAFT_REPORT_IDS, + initialValue: {}, }, priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, + initialValue: CONST.PRIORITY_MODE.DEFAULT, }, betas: { key: ONYXKEYS.BETAS, + initialValue: [], }, allReportActions: { key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, selector: reportActionsSelector, + initialValue: {}, }, policies: { key: ONYXKEYS.COLLECTION.POLICY, selector: policySelector, + initialValue: {}, }, }), )(SidebarLinksData); From b0fc9a48e4871b72de0522194c80cc98bca0b165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Miko=C5=82ajczak?= Date: Mon, 16 Oct 2023 16:53:48 +0200 Subject: [PATCH 053/257] remove unused variable --- src/components/LHNOptionsList/OptionRowLHNData.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js index b2dcffa7b9bd..b00fb65e33c7 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.js +++ b/src/components/LHNOptionsList/OptionRowLHNData.js @@ -69,8 +69,6 @@ const defaultProps = { * re-render if the data really changed. */ function OptionRowLHNData({isFocused, fullReport, reportActions, personalDetails, preferredLocale, policy, receiptTransactions, parentReportActions, transaction, ...propsToForward}) { - const reportID = propsToForward.reportID; - const parentReportAction = parentReportActions[fullReport.parentReportActionID]; const optionItemRef = useRef(); From a53c07a5122eafd4c85dc10315ad61d04fcd3da9 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Mon, 16 Oct 2023 19:28:46 +0200 Subject: [PATCH 054/257] Fixes --- src/components/withToggleVisibilityView.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index b3c8d9bfb221..3f5524913874 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -1,22 +1,25 @@ -import React, {ComponentType, ForwardedRef, RefAttributes} from 'react'; +import React, {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react'; import {View} from 'react-native'; import styles from '../styles/styles'; import getComponentDisplayName from '../libs/getComponentDisplayName'; +import {SetOptional} from 'type-fest'; type ToggleVisibilityViewPropTypes = { /** Whether the content is visible. */ isVisible: boolean; }; -export default function withToggleVisibilityView(WrappedComponent: ComponentType>): ComponentType { - function WithToggleVisibilityView(props: Omit, ref: ForwardedRef) { +export default function withToggleVisibilityView( + WrappedComponent: ComponentType>, +): (props: TProps & RefAttributes) => ReactElement | null { + function WithToggleVisibilityView({isVisible = false, ...rest}: SetOptional, ref: ForwardedRef) { return ( - + ); From f3dfe0a606237a0af01fd2ee1282d722923c5358 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Mon, 16 Oct 2023 19:29:13 +0200 Subject: [PATCH 055/257] Rename propTypes --- src/components/withToggleVisibilityView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index 3f5524913874..5cabdc5ffc0e 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -4,12 +4,12 @@ import styles from '../styles/styles'; import getComponentDisplayName from '../libs/getComponentDisplayName'; import {SetOptional} from 'type-fest'; -type ToggleVisibilityViewPropTypes = { +type ToggleVisibilityViewProp = { /** Whether the content is visible. */ isVisible: boolean; }; -export default function withToggleVisibilityView( +export default function withToggleVisibilityView( WrappedComponent: ComponentType>, ): (props: TProps & RefAttributes) => ReactElement | null { function WithToggleVisibilityView({isVisible = false, ...rest}: SetOptional, ref: ForwardedRef) { From 98f89ada062eec261414497e82626cd79bb8f783 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Tue, 17 Oct 2023 14:02:53 +0200 Subject: [PATCH 056/257] [TS migration] Migrate 'SafeAreaConsumer.js' component to TypeScript --- ...feAreaConsumer.js => SafeAreaConsumer.tsx} | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) rename src/components/{SafeAreaConsumer.js => SafeAreaConsumer.tsx} (55%) diff --git a/src/components/SafeAreaConsumer.js b/src/components/SafeAreaConsumer.tsx similarity index 55% rename from src/components/SafeAreaConsumer.js rename to src/components/SafeAreaConsumer.tsx index 78d7426ba380..c2439e25ecd3 100644 --- a/src/components/SafeAreaConsumer.js +++ b/src/components/SafeAreaConsumer.tsx @@ -1,29 +1,34 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import {EdgeInsets, SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import {DimensionValue} from 'react-native'; import * as StyleUtils from '../styles/StyleUtils'; -const propTypes = { - /** Children to render. */ - children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, +type ChildrenProps = { + paddingTop?: DimensionValue; + paddingBottom?: DimensionValue; + insets?: EdgeInsets; + safeAreaPaddingBottomStyle: { + paddingBottom?: DimensionValue; + }; +}; + +type SafeAreaConsumerProps = { + children: (props: ChildrenProps) => React.ReactNode; }; /** * This component is a light wrapper around the SafeAreaInsetsContext.Consumer. There are several places where we * may need not just the insets, but the computed styles so we save a few lines of code with this. - * - * @param {Object} props - * @returns {React.Component} */ -function SafeAreaConsumer(props) { +function SafeAreaConsumer({children}: SafeAreaConsumerProps) { return ( {(insets) => { - const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets); - return props.children({ + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + return children({ paddingTop, paddingBottom, - insets, + insets: insets ?? undefined, safeAreaPaddingBottomStyle: {paddingBottom}, }); }} @@ -32,5 +37,5 @@ function SafeAreaConsumer(props) { } SafeAreaConsumer.displayName = 'SafeAreaConsumer'; -SafeAreaConsumer.propTypes = propTypes; + export default SafeAreaConsumer; From d36a76282ef0aab476dc4538f8d523102844c963 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Tue, 17 Oct 2023 21:20:02 +0700 Subject: [PATCH 057/257] remove error when all workspace is deleted --- src/libs/actions/Policy.js | 87 +++++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 53753e193fb1..bd234d22a826 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -66,6 +66,12 @@ Onyx.connect({ callback: (val) => (allPersonalDetails = val), }); +let reimbursementAccount; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => (reimbursementAccount = val), +}); + let allRecentlyUsedCategories = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES, @@ -81,6 +87,36 @@ function updateLastAccessedWorkspace(policyID) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Check if the user has any active free policies (aka workspaces) + * + * @param {Array} policies + * @returns {Boolean} + */ +function hasActiveFreePolicy(policies) { + const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); + + if (adminFreePolicies.length === 0) { + return false; + } + + if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { + return true; + } + + if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { + return false; + } + + // If there are no add or delete pending actions the only option left is an update + // pendingAction, in which case we should return true. + return true; +} + /** * Delete the workspace * @@ -89,6 +125,10 @@ function updateLastAccessedWorkspace(policyID) { * @param {String} policyName */ function deleteWorkspace(policyID, reports, policyName) { + const filteredPolicies = _.filter(allPolicies, (policy) => policy.id !== policyID); + const hasActivePolicy = hasActiveFreePolicy(filteredPolicies); + const oldReimbursementAccount = reimbursementAccount; + const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -125,6 +165,18 @@ function deleteWorkspace(policyID, reports, policyName) { value: optimisticReportActions, }; }), + + ...(!hasActivePolicy + ? [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: { + errors: null, + }, + }, + ] + : []), ]; // Restore the old report stateNum and statusNum @@ -139,6 +191,11 @@ function deleteWorkspace(policyID, reports, policyName) { oldPolicyName, }, })), + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + value: oldReimbursementAccount, + }, ]; // We don't need success data since the push notification will update @@ -162,36 +219,6 @@ function isAdminOfFreePolicy(policies) { return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); } -/** - * Check if the user has any active free policies (aka workspaces) - * - * @param {Array} policies - * @returns {Boolean} - */ -function hasActiveFreePolicy(policies) { - const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN); - - if (adminFreePolicies.length === 0) { - return false; - } - - if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) { - return true; - } - - if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) { - return false; - } - - // If there are no add or delete pending actions the only option left is an update - // pendingAction, in which case we should return true. - return true; -} - /** * Remove the passed members from the policy employeeList * From 991662b0c61c0ca03a24f765eff8beed4b397d76 Mon Sep 17 00:00:00 2001 From: Jakub Butkiewicz Date: Wed, 18 Oct 2023 13:03:39 +0200 Subject: [PATCH 058/257] ref: moved PopoverProvider to TS --- .../{index.native.js => index.native.tsx} | 18 ++----- .../PopoverProvider/{index.js => index.tsx} | 47 +++++++------------ src/components/PopoverProvider/types.ts | 18 +++++++ 3 files changed, 40 insertions(+), 43 deletions(-) rename src/components/PopoverProvider/{index.native.js => index.native.tsx} (57%) rename src/components/PopoverProvider/{index.js => index.tsx} (65%) create mode 100644 src/components/PopoverProvider/types.ts diff --git a/src/components/PopoverProvider/index.native.js b/src/components/PopoverProvider/index.native.tsx similarity index 57% rename from src/components/PopoverProvider/index.native.js rename to src/components/PopoverProvider/index.native.tsx index f34abcb1fa62..d4ca6813f408 100644 --- a/src/components/PopoverProvider/index.native.js +++ b/src/components/PopoverProvider/index.native.tsx @@ -1,26 +1,20 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import {PopoverContextProps, PopoverContextValue} from './types'; -const propTypes = { - children: PropTypes.node.isRequired, -}; - -const defaultProps = {}; - -const PopoverContext = React.createContext({ +const PopoverContext = React.createContext({ onOpen: () => {}, - popover: {}, + popover: null, close: () => {}, isOpen: false, }); -function PopoverContextProvider(props) { +function PopoverContextProvider(props: PopoverContextProps) { return ( {}, close: () => {}, - popover: {}, + popover: null, isOpen: false, }} > @@ -29,8 +23,6 @@ function PopoverContextProvider(props) { ); } -PopoverContextProvider.defaultProps = defaultProps; -PopoverContextProvider.propTypes = propTypes; PopoverContextProvider.displayName = 'PopoverContextProvider'; export default PopoverContextProvider; diff --git a/src/components/PopoverProvider/index.js b/src/components/PopoverProvider/index.tsx similarity index 65% rename from src/components/PopoverProvider/index.js rename to src/components/PopoverProvider/index.tsx index efa230d920d5..728f9a207121 100644 --- a/src/components/PopoverProvider/index.js +++ b/src/components/PopoverProvider/index.tsx @@ -1,24 +1,18 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import {AnchorRef, PopoverContextProps, PopoverContextValue} from './types'; -const propTypes = { - children: PropTypes.node.isRequired, -}; - -const defaultProps = {}; - -const PopoverContext = React.createContext({ +const PopoverContext = React.createContext({ onOpen: () => {}, - popover: {}, + popover: null, close: () => {}, isOpen: false, }); -function PopoverContextProvider(props) { +function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = React.useState(false); - const activePopoverRef = React.useRef(null); + const activePopoverRef = React.useRef(null); - const closePopover = React.useCallback((anchorRef) => { + const closePopover = React.useCallback((anchorRef?: React.RefObject) => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { return; } @@ -28,17 +22,12 @@ function PopoverContextProvider(props) { }, []); React.useEffect(() => { - const listener = (e) => { - if ( - !activePopoverRef.current || - !activePopoverRef.current.ref || - !activePopoverRef.current.ref.current || - activePopoverRef.current.ref.current.contains(e.target) || - (activePopoverRef.current.anchorRef && activePopoverRef.current.anchorRef.current && activePopoverRef.current.anchorRef.current.contains(e.target)) - ) { + const listener = (e: Event) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (activePopoverRef.current?.ref?.current?.contains(e.target as Node) || activePopoverRef.current?.anchorRef?.current?.contains(e.target as Node)) { return; } - const ref = activePopoverRef.current.anchorRef; + const ref = activePopoverRef.current?.anchorRef; closePopover(ref); }; document.addEventListener('click', listener, true); @@ -48,8 +37,8 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = (e) => { - if (!activePopoverRef.current || !activePopoverRef.current.ref || !activePopoverRef.current.ref.current || activePopoverRef.current.ref.current.contains(e.target)) { + const listener = (e: Event) => { + if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { return; } closePopover(); @@ -61,7 +50,7 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = (e) => { + const listener = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; } @@ -87,8 +76,8 @@ function PopoverContextProvider(props) { }, [closePopover]); React.useEffect(() => { - const listener = (e) => { - if (activePopoverRef.current && activePopoverRef.current.ref && activePopoverRef.current.ref.current && activePopoverRef.current.ref.current.contains(e.target)) { + const listener = (e: Event) => { + if (activePopoverRef.current?.ref?.current?.contains(e.target as Node)) { return; } @@ -101,8 +90,8 @@ function PopoverContextProvider(props) { }, [closePopover]); const onOpen = React.useCallback( - (popoverParams) => { - if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams.ref) { + (popoverParams: AnchorRef) => { + if (activePopoverRef.current && activePopoverRef.current.ref !== popoverParams?.ref) { closePopover(activePopoverRef.current.anchorRef); } activePopoverRef.current = popoverParams; @@ -125,8 +114,6 @@ function PopoverContextProvider(props) { ); } -PopoverContextProvider.defaultProps = defaultProps; -PopoverContextProvider.propTypes = propTypes; PopoverContextProvider.displayName = 'PopoverContextProvider'; export default PopoverContextProvider; diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts new file mode 100644 index 000000000000..733e363bce00 --- /dev/null +++ b/src/components/PopoverProvider/types.ts @@ -0,0 +1,18 @@ +type PopoverContextProps = { + children: React.ReactNode; +}; + +type PopoverContextValue = { + onOpen?: (popoverParams: AnchorRef) => void; + popover?: AnchorRef | null; + close: (anchorRef?: React.RefObject) => void; + isOpen: boolean; +}; + +type AnchorRef = { + ref: React.RefObject; + close: (anchorRef?: React.RefObject) => void; + anchorRef?: React.RefObject; +}; + +export type {PopoverContextProps, PopoverContextValue, AnchorRef}; From eab87d28d3a3747f68f1096e2b68f03583799a64 Mon Sep 17 00:00:00 2001 From: Blazej Kustra Date: Wed, 18 Oct 2023 13:05:00 +0200 Subject: [PATCH 059/257] Adjust after internal review --- src/components/SafeAreaConsumer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SafeAreaConsumer.tsx b/src/components/SafeAreaConsumer.tsx index c2439e25ecd3..dec0964b34a9 100644 --- a/src/components/SafeAreaConsumer.tsx +++ b/src/components/SafeAreaConsumer.tsx @@ -1,6 +1,6 @@ import React from 'react'; import {EdgeInsets, SafeAreaInsetsContext} from 'react-native-safe-area-context'; -import {DimensionValue} from 'react-native'; +import type {DimensionValue} from 'react-native'; import * as StyleUtils from '../styles/StyleUtils'; type ChildrenProps = { @@ -13,7 +13,7 @@ type ChildrenProps = { }; type SafeAreaConsumerProps = { - children: (props: ChildrenProps) => React.ReactNode; + children: React.FC; }; /** From 2cdea51fd549f0d1dc7ea3b4a94747bb3fc8b897 Mon Sep 17 00:00:00 2001 From: Jakub Butkiewicz Date: Wed, 18 Oct 2023 15:01:00 +0200 Subject: [PATCH 060/257] ref: moved InlineErrorText to TS --- src/components/InlineErrorText.js | 31 ------------------------------ src/components/InlineErrorText.tsx | 19 ++++++++++++++++++ 2 files changed, 19 insertions(+), 31 deletions(-) delete mode 100644 src/components/InlineErrorText.js create mode 100644 src/components/InlineErrorText.tsx diff --git a/src/components/InlineErrorText.js b/src/components/InlineErrorText.js deleted file mode 100644 index ea701a3f6e8e..000000000000 --- a/src/components/InlineErrorText.js +++ /dev/null @@ -1,31 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from '../styles/styles'; -import Text from './Text'; - -const propTypes = { - /** Text to display */ - children: PropTypes.string.isRequired, - - /** Styling for inline error text */ - // eslint-disable-next-line react/forbid-prop-types - styles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - styles: [], -}; - -function InlineErrorText(props) { - if (_.isEmpty(props.children)) { - return null; - } - - return {props.children}; -} - -InlineErrorText.propTypes = propTypes; -InlineErrorText.defaultProps = defaultProps; -InlineErrorText.displayName = 'InlineErrorText'; -export default InlineErrorText; diff --git a/src/components/InlineErrorText.tsx b/src/components/InlineErrorText.tsx new file mode 100644 index 000000000000..109acfc1b893 --- /dev/null +++ b/src/components/InlineErrorText.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import {TextStyle} from 'react-native'; +import styles from '../styles/styles'; +import Text from './Text'; + +type InlineErrorTextProps = { + children: React.ReactNode; + styles: TextStyle[]; +}; +function InlineErrorText(props: InlineErrorTextProps) { + if (!props.children) { + return null; + } + + return {props.children}; +} + +InlineErrorText.displayName = 'InlineErrorText'; +export default InlineErrorText; From 806d3442624541a500d1c201054cbbc0db5404dd Mon Sep 17 00:00:00 2001 From: kimkurta Date: Wed, 18 Oct 2023 08:25:44 -0500 Subject: [PATCH 061/257] Update docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md Co-authored-by: Rushat Gabhane --- .../bank-accounts-and-credit-cards/company-cards/CSV-Import.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md index 14e6633d32c1..892cfdde1bde 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md +++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/CSV-Import.md @@ -89,6 +89,7 @@ Then, try to upload the revised spreadsheet again: 2. Upload the revised file 3. Check the row count again on the Output Preview to confirm it matches the spreadsheet 4. Click **Submit Spreadsheet** + # FAQ ## Why can’t I see my CSV transactions immediately after uploading them? Don’t worry! You’ll typically need to wait 1-2 minutes after clicking **I understand, I'll wait!** From 419e7be5f20b363254974576a8032f9be0cbb274 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Wed, 18 Oct 2023 16:23:30 +0200 Subject: [PATCH 062/257] Rename OpacityView --- src/components/{OpacityView.js => OpacityView.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/components/{OpacityView.js => OpacityView.tsx} (100%) diff --git a/src/components/OpacityView.js b/src/components/OpacityView.tsx similarity index 100% rename from src/components/OpacityView.js rename to src/components/OpacityView.tsx From 03af64861ae3e64fc41cfc4c19274b13f51f68af Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 19 Oct 2023 00:56:31 +0700 Subject: [PATCH 063/257] fix sign in modal appear for a second --- src/libs/Navigation/NavigationRoot.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index a920bfeeca9e..b39e5bbb0d5f 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -13,6 +13,8 @@ import useCurrentReportID from '../../hooks/useCurrentReportID'; import useWindowDimensions from '../../hooks/useWindowDimensions'; import {SidebarNavigationContext} from '../../pages/home/sidebar/SidebarNavigationContext'; import * as Session from '../actions/Session'; +import getCurrentUrl from './currentUrl'; +import ROUTES from '../../ROUTES'; // https://reactnavigation.org/docs/themes const navigationTheme = { @@ -102,7 +104,7 @@ function NavigationRoot(props) { const animateStatusBarBackgroundColor = () => { const currentRoute = navigationRef.getCurrentRoute(); - const currentScreenBackgroundColor = (currentRoute.params && currentRoute.params.backgroundColor) || themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG; + const currentScreenBackgroundColor = themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG; prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current; statusBarBackgroundColor.current = currentScreenBackgroundColor; @@ -136,7 +138,7 @@ function NavigationRoot(props) { globalNavigation.updateFromNavigationState(state); const route = Navigation.getActiveRoute(); - if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route) && !getCurrentUrl().includes(ROUTES.SIGN_IN_MODAL)) { Session.signOutAndRedirectToSignIn(); } }; From 0ac3e98f814025b0df91c793c1352e94a218f190 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Thu, 19 Oct 2023 01:02:12 +0700 Subject: [PATCH 064/257] fix revert unrelated change --- src/libs/Navigation/NavigationRoot.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index b39e5bbb0d5f..a22b6714a306 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -104,7 +104,7 @@ function NavigationRoot(props) { const animateStatusBarBackgroundColor = () => { const currentRoute = navigationRef.getCurrentRoute(); - const currentScreenBackgroundColor = themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG; + const currentScreenBackgroundColor = (currentRoute.params && currentRoute.params.backgroundColor) || themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG; prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current; statusBarBackgroundColor.current = currentScreenBackgroundColor; From 427f99168a34b73c6a795beb499cbab9b3941b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Miko=C5=82ajczak?= Date: Thu, 19 Oct 2023 10:54:33 +0200 Subject: [PATCH 065/257] wrap ComposerWithSuggestions with context instead of entire ReportActionCompose --- .../ReportActionCompose.js | 172 +++++++++--------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 831d03d2871c..2ca3103bad93 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -343,54 +343,54 @@ function ReportActionCompose({ }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); return ( - - + + - - } + - {shouldShowReportRecipientLocalTime && hasReportRecipient && } - setIsAttachmentPreviewActive(true)} + onModalHide={onAttachmentPreviewClose} > - setIsAttachmentPreviewActive(true)} - onModalHide={onAttachmentPreviewClose} - > - {({displayFileInModal}) => ( - <> - + {({displayFileInModal}) => ( + <> + + - { - if (isAttachmentPreviewActive) { - return; - } - const data = lodashGet(e, ['dataTransfer', 'items', 0]); - displayFileInModal(data); - }} - /> - - )} - - {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( - composerRef.current.replaceSelectionWithText(...args)} - emojiPickerID={report.reportID} - /> + + { + if (isAttachmentPreviewActive) { + return; + } + const data = lodashGet(e, ['dataTransfer', 'items', 0]); + displayFileInModal(data); + }} + /> + )} - + {DeviceCapabilities.canUseTouchScreen() && isMediumScreenWidth ? null : ( + composerRef.current.replaceSelectionWithText(...args)} + emojiPickerID={report.reportID} /> - - - {!isSmallScreenWidth && } - - - - - - + )} + + + + {!isSmallScreenWidth && } + + + + + ); } From bd2274c63dd1c23c8bb509949a44ab6e72d7e75f Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Thu, 19 Oct 2023 16:20:14 +0200 Subject: [PATCH 066/257] WIP --- src/components/FullscreenLoadingIndicator.js | 33 ------------------- src/components/FullscreenLoadingIndicator.tsx | 22 +++++++++++++ 2 files changed, 22 insertions(+), 33 deletions(-) delete mode 100644 src/components/FullscreenLoadingIndicator.js create mode 100644 src/components/FullscreenLoadingIndicator.tsx diff --git a/src/components/FullscreenLoadingIndicator.js b/src/components/FullscreenLoadingIndicator.js deleted file mode 100644 index 5c212b6dc29e..000000000000 --- a/src/components/FullscreenLoadingIndicator.js +++ /dev/null @@ -1,33 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import {ActivityIndicator, StyleSheet, View} from 'react-native'; -import styles from '../styles/styles'; -import themeColors from '../styles/themes/default'; -import stylePropTypes from '../styles/stylePropTypes'; - -const propTypes = { - /** Additional style props */ - style: stylePropTypes, -}; - -const defaultProps = { - style: [], -}; - -function FullScreenLoadingIndicator(props) { - const additionalStyles = _.isArray(props.style) ? props.style : [props.style]; - return ( - - - - ); -} - -FullScreenLoadingIndicator.propTypes = propTypes; -FullScreenLoadingIndicator.defaultProps = defaultProps; -FullScreenLoadingIndicator.displayName = 'FullScreenLoadingIndicator'; - -export default FullScreenLoadingIndicator; diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx new file mode 100644 index 000000000000..3f5f62f533d7 --- /dev/null +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import {ActivityIndicator, StyleSheet, View} from 'react-native'; +import styles from '../styles/styles'; +import themeColors from '../styles/themes/default'; + +type FullScreenLoadingIndicatorProps = { + style: Record | Array> | (() => void); +}; + +function FullScreenLoadingIndicator({style = []}: FullScreenLoadingIndicatorProps) { + const additionalStyles = Array.isArray(style) ? style : [style]; + return ( + + + + ); +} + +export default FullScreenLoadingIndicator; From 8d4678fa56bb60d5c5cf9e4371ffe56f1e2d924b Mon Sep 17 00:00:00 2001 From: Maciej Dobosz Date: Thu, 19 Oct 2023 16:37:39 +0200 Subject: [PATCH 067/257] Drop func from possible types --- src/components/FullscreenLoadingIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx index 3f5f62f533d7..92e59d0e2d1c 100644 --- a/src/components/FullscreenLoadingIndicator.tsx +++ b/src/components/FullscreenLoadingIndicator.tsx @@ -4,7 +4,7 @@ import styles from '../styles/styles'; import themeColors from '../styles/themes/default'; type FullScreenLoadingIndicatorProps = { - style: Record | Array> | (() => void); + style: Record | Array>; }; function FullScreenLoadingIndicator({style = []}: FullScreenLoadingIndicatorProps) { From 1dff6dd3fd3d1582d7eae15f3e9aa668c1c3fe2e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Fri, 20 Oct 2023 12:20:53 +0700 Subject: [PATCH 068/257] using lodash instead of underscore --- src/libs/actions/Policy.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index bd234d22a826..4417d5f00853 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import filter from 'lodash/filter'; import Onyx from 'react-native-onyx'; import lodashGet from 'lodash/get'; import lodashUnion from 'lodash/union'; @@ -125,8 +126,7 @@ function hasActiveFreePolicy(policies) { * @param {String} policyName */ function deleteWorkspace(policyID, reports, policyName) { - const filteredPolicies = _.filter(allPolicies, (policy) => policy.id !== policyID); - const hasActivePolicy = hasActiveFreePolicy(filteredPolicies); + const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID); const oldReimbursementAccount = reimbursementAccount; const optimisticData = [ @@ -166,7 +166,7 @@ function deleteWorkspace(policyID, reports, policyName) { }; }), - ...(!hasActivePolicy + ...(!hasActiveFreePolicy(filteredPolicies) ? [ { onyxMethod: Onyx.METHOD.MERGE, @@ -194,7 +194,9 @@ function deleteWorkspace(policyID, reports, policyName) { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - value: oldReimbursementAccount, + value: { + errors: lodashGet(oldReimbursementAccount, 'errors', null), + }, }, ]; From 30a906496814f4023fbb11bdaff3a69c789b7772 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 20 Oct 2023 12:14:24 +0500 Subject: [PATCH 069/257] fix: PR feedbacks --- src/components/LHNOptionsList/OptionRowLHN.js | 20 ++++++- .../LHNOptionsList/OptionRowLHNData.js | 18 +----- src/libs/DraftReportUtils.ts | 54 ----------------- src/libs/ReportUtils.js | 21 +++++-- src/libs/UnreadIndicatorUpdater/index.js | 10 ++++ src/libs/actions/DraftReports.ts | 17 +----- src/libs/actions/Policy.js | 14 ++++- .../ComposerWithSuggestions.js | 3 +- tests/unit/DraftReportUtilsTest.js | 59 ------------------- tests/unit/SidebarFilterTest.js | 4 ++ tests/unit/SidebarOrderTest.js | 4 ++ 11 files changed, 66 insertions(+), 158 deletions(-) delete mode 100644 src/libs/DraftReportUtils.ts delete mode 100644 tests/unit/DraftReportUtilsTest.js diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 6e886ba6e360..0a2a5d26f10d 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useState, useRef, useCallback} from 'react'; +import React, {useState, useRef, useCallback, useEffect} from 'react'; import PropTypes from 'prop-types'; import {View, StyleSheet} from 'react-native'; import lodashGet from 'lodash/get'; @@ -31,6 +31,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import DomUtils from '../../libs/DomUtils'; import useWindowDimensions from '../../hooks/useWindowDimensions'; import ReportActionComposeFocusManager from '../../libs/ReportActionComposeFocusManager'; +import setDraftStatusForReportID from '../../libs/actions/DraftReports'; const propTypes = { /** Style for hovered state */ @@ -60,6 +61,8 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types draftReportIDs: PropTypes.object, + + shouldUpdateDraftStatus: PropTypes.bool, }; const defaultProps = { @@ -71,6 +74,7 @@ const defaultProps = { isFocused: false, betas: [], draftReportIDs: {}, + shouldUpdateDraftStatus: false, }; function OptionRowLHN(props) { @@ -83,6 +87,17 @@ function OptionRowLHN(props) { const optionItem = props.optionItem; const [isContextMenuActive, setIsContextMenuActive] = useState(false); + const hasDraft = props.draftReportIDs[props.reportID]; + + useEffect(() => { + if (props.shouldUpdateDraftStatus || hasDraft) { + return; + } + + setDraftStatusForReportID(props.reportID, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useFocusEffect( useCallback(() => { isFocusedRef.current = true; @@ -159,7 +174,6 @@ function OptionRowLHN(props) { const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate); const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText; const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(optionItem); - const isDraft = props.draftReportIDs[props.reportID]; return ( )} - {isDraft && optionItem.isAllowedToComment && ( + {hasDraft && optionItem.isAllowedToComment && ( { - const draftReportIDs = DraftReportUtils.getInstance().getDraftReportIDs(); - const hasDraft = draftReportIDs[reportID]; - - if (!optionItem || hasDraft || !comment || comment.length <= 0 || isFocused) { - return; - } - setDraftStatusForReportID(reportID, true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return ( ); } diff --git a/src/libs/DraftReportUtils.ts b/src/libs/DraftReportUtils.ts deleted file mode 100644 index 214b92404e1b..000000000000 --- a/src/libs/DraftReportUtils.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Onyx from 'react-native-onyx'; -import ONYXKEYS from '../ONYXKEYS'; - -/** - * A singleton class to manage the draft report IDs - * @class DraftReportUtils - * @singleton - */ -class DraftReportUtils { - private static instance: DraftReportUtils; - - private draftReportIDs: Record; - - private constructor() { - DraftReportUtils.instance = this; - - this.draftReportIDs = {}; - - this.subscribeToDraftReportIDs(); - } - - /** - * @returns The singleton instance - */ - public static getInstance(): DraftReportUtils { - // Ensure singleton instance - return DraftReportUtils.instance ?? new DraftReportUtils(); - } - - /** - * Subscribe to the draft report IDs - */ - private subscribeToDraftReportIDs() { - Onyx.connect({ - key: ONYXKEYS.DRAFT_REPORT_IDS, - callback: (val) => { - if (!val) { - return; - } - - this.draftReportIDs = val; - }, - }); - } - - /** - * @returns The draft report IDs - */ - getDraftReportIDs() { - return this.draftReportIDs; - } -} - -export default DraftReportUtils; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index a12b4a48f393..30c375c2e574 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -23,7 +23,6 @@ import isReportMessageAttachment from './isReportMessageAttachment'; import * as defaultWorkspaceAvatars from '../components/Icon/WorkspaceDefaultAvatars'; import * as CurrencyUtils from './CurrencyUtils'; import * as UserUtils from './UserUtils'; -import DraftReportUtils from './DraftReportUtils'; let currentUserEmail; let currentUserAccountID; @@ -53,7 +52,7 @@ Onyx.connect({ }, }); -let allReports = {}; +let allReports; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -61,7 +60,17 @@ Onyx.connect({ callback: (val) => (allReports = val), }); -const draftReportUtils = DraftReportUtils.getInstance(); +let draftReportIDs = {}; +Onyx.connect({ + key: ONYXKEYS.DRAFT_REPORT_IDS, + callback: (val) => { + if (!val) { + return; + } + + draftReportIDs = val; + }, +}); let doesDomainHaveApprovedAccountant; Onyx.connect({ @@ -767,7 +776,7 @@ function isMoneyRequestReport(reportOrID) { */ function getReport(reportID) { /** - * using typical string concatenation here due to performance issues + * Using typical string concatenation here due to performance issues * with template literals. */ if (!allReports) { @@ -1414,7 +1423,7 @@ function getPolicyExpenseChatName(report, policy = undefined) { let policyExpenseChatRole = 'user'; /** - * using typical string concatenation here due to performance issues + * Using typical string concatenation here due to performance issues * with template literals. */ const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; @@ -3198,7 +3207,7 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas, } // Include reports that are relevant to the user in any view mode. Criteria include having a draft, having an outstanding IOU, or being assigned to an open task. - if (draftReportUtils.getDraftReportIDs()[report.reportID] || isWaitingForIOUActionFromCurrentUser(report) || isWaitingForTaskCompleteFromAssignee(report)) { + if (draftReportIDs[report.reportID] || isWaitingForIOUActionFromCurrentUser(report) || isWaitingForTaskCompleteFromAssignee(report)) { return true; } const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(report.reportID); diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index dcfedce83d75..e67b5b1feb06 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -15,6 +15,16 @@ Onyx.connect({ return; } + /** + * We need to wait until after interactions have finished to update the unread count because otherwise + * the unread count will be updated while the interactions/animations are in progress and we don't want + * to put more work on the main thread. + * + * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions + * have finished. + * + * More info: https://reactnative.dev/docs/interactionmanager + */ InteractionManager.runAfterInteractions(() => { const unreadReportsCount = _.filter(reportsFromOnyx, ReportUtils.isUnread).length || 0; if (previousUnreadCount !== unreadReportsCount) { diff --git a/src/libs/actions/DraftReports.ts b/src/libs/actions/DraftReports.ts index dc1e7a8066f8..97e5073030ca 100644 --- a/src/libs/actions/DraftReports.ts +++ b/src/libs/actions/DraftReports.ts @@ -1,8 +1,5 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../ONYXKEYS'; -import DraftReportUtils from '../DraftReportUtils'; - -const draftReportUtils = DraftReportUtils.getInstance(); /** * Immediate indication whether the report has a draft. @@ -11,19 +8,7 @@ const draftReportUtils = DraftReportUtils.getInstance(); * @param draft */ function setDraftStatusForReportID(reportID: string, draft: boolean) { - const draftReportIDs = {...draftReportUtils.getDraftReportIDs()}; - - if (draftReportIDs[reportID] && draft) { - return; - } - - if (draftReportIDs[reportID] && !draft) { - delete draftReportIDs[reportID]; - Onyx.set(ONYXKEYS.DRAFT_REPORT_IDS, draftReportIDs); - } else { - draftReportIDs[reportID] = draft; - Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: draft}); - } + Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: draft}); } export default setDraftStatusForReportID; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index fffe71a5c931..1fef24a92b11 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -14,9 +14,18 @@ import * as ErrorUtils from '../ErrorUtils'; import * as ReportUtils from '../ReportUtils'; import * as PersonalDetailsUtils from '../PersonalDetailsUtils'; import Log from '../Log'; -import DraftReportUtils from '../DraftReportUtils'; -const draftReportUtils = DraftReportUtils.getInstance(); +let draftReportIDs = {}; +Onyx.connect({ + key: ONYXKEYS.DRAFT_REPORT_IDS, + callback: (val) => { + if (!val) { + return; + } + + draftReportIDs = val; + }, +}); const allPolicies = {}; Onyx.connect({ @@ -33,7 +42,6 @@ Onyx.connect({ const policyReports = ReportUtils.getAllPolicyReports(policyID); const cleanUpMergeQueries = {}; const cleanUpSetQueries = {}; - const draftReportIDs = {...draftReportUtils.getDraftReportIDs()}; _.each(policyReports, ({reportID}) => { cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; delete draftReportIDs[reportID]; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 91471d8fe145..b470528eede5 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -247,8 +247,9 @@ function ComposerWithSuggestions({ if (commentRef.current.length === 0 && newComment.length !== 0) { setDraftStatusForReportID(reportID, true); } + // The draft has been deleted. - else if (newComment.length === 0) { + if (newComment.length === 0) { setDraftStatusForReportID(reportID, false); } diff --git a/tests/unit/DraftReportUtilsTest.js b/tests/unit/DraftReportUtilsTest.js deleted file mode 100644 index ec13b9c69de7..000000000000 --- a/tests/unit/DraftReportUtilsTest.js +++ /dev/null @@ -1,59 +0,0 @@ -import Onyx from 'react-native-onyx'; -import {cleanup} from '@testing-library/react-native'; -import DraftReportUtils from '../../src/libs/DraftReportUtils'; - -const ONYXKEYS = { - DRAFT_REPORT_IDS: 'draftReportIDs', -}; - -const reportID = 1; - -describe('DraftReportUtils', () => { - beforeAll(() => - Onyx.init({ - keys: ONYXKEYS, - }), - ); - - // Clear out Onyx after each test so that each test starts with a clean slate - afterEach(() => { - cleanup(); - Onyx.clear(); - }); - - describe('Singleton', () => { - it('should return the same instance when called multiple times', () => { - // Call getInstance multiple times - const instance1 = DraftReportUtils.getInstance(); - const instance2 = DraftReportUtils.getInstance(); - const instance3 = DraftReportUtils.getInstance(); - - // Ensure that all instances are the same - expect(instance1).toBe(instance2); - expect(instance2).toBe(instance3); - }); - }); - - it('should return an empty object when there are no draft reports', () => { - const draftReportIDs = DraftReportUtils.getInstance().getDraftReportIDs(); - expect(draftReportIDs).toEqual({}); - }); - - it('should return an object of draft report IDs when draft is set through onyx', async () => { - await Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: true}); - const draftReportIDs = DraftReportUtils.getInstance().getDraftReportIDs(); - expect(draftReportIDs).toEqual({[`${reportID}`]: true}); - }); - - it('should return an empty object of draft report IDs when draft is unset through onyx', async () => { - const draftReportUtils = DraftReportUtils.getInstance(); - - await Onyx.merge(ONYXKEYS.DRAFT_REPORT_IDS, {[reportID]: true}); - let draftReportIDs = draftReportUtils.getDraftReportIDs(); - expect(draftReportIDs).toEqual({[`${reportID}`]: true}); - - await Onyx.set(ONYXKEYS.DRAFT_REPORT_IDS, {}); - draftReportIDs = draftReportUtils.getDraftReportIDs(); - expect(draftReportIDs).toEqual({}); - }); -}); diff --git a/tests/unit/SidebarFilterTest.js b/tests/unit/SidebarFilterTest.js index 982393b3e191..bf839b0b36d8 100644 --- a/tests/unit/SidebarFilterTest.js +++ b/tests/unit/SidebarFilterTest.js @@ -110,6 +110,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report, [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, + // Set the draft status for the given reportID [ONYXKEYS.DRAFT_REPORT_IDS]: {[report.reportID]: true}, }), @@ -339,6 +340,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, + // Set the draft status for the given reportID [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: boolArr[boolArr.length - 1]}, }), @@ -452,6 +454,7 @@ describe('Sidebar', () => { [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, [`${ONYXKEYS.COLLECTION.REPORT}${draftReport.reportID}`]: draftReport, [`${ONYXKEYS.COLLECTION.REPORT}${pinnedReport.reportID}`]: pinnedReport, + // Set the draft status for the given reportID [ONYXKEYS.DRAFT_REPORT_IDS]: {[draftReport.reportID]: true}, }), @@ -665,6 +668,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.POLICY}${policy.policyID}`]: policy, + // Set the draft status for the given reportID [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: boolArr[boolArr.length - 1]}, }), diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.js index 0503d8f33dff..1b8f231d7b0e 100644 --- a/tests/unit/SidebarOrderTest.js +++ b/tests/unit/SidebarOrderTest.js @@ -267,6 +267,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, + // Setting the draft status for second report [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: true}, }), @@ -308,6 +309,7 @@ describe('Sidebar', () => { [ONYXKEYS.PERSONAL_DETAILS_LIST]: LHNTestUtils.fakePersonalDetails, [ONYXKEYS.IS_LOADING_REPORT_DATA]: false, [`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]: report, + // Setting the draft status for the report [ONYXKEYS.DRAFT_REPORT_IDS]: {[report.reportID]: true}, }), @@ -410,6 +412,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, [`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`]: iouReport, + // Setting the draft status for second report [ONYXKEYS.DRAFT_REPORT_IDS]: {[report2.reportID]: true}, }), @@ -509,6 +512,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, + // Setting the draft status for all reports [ONYXKEYS.DRAFT_REPORT_IDS]: { [report1.reportID]: true, From c846ff11670c8b9d10c890f4e0319a73e4f062c8 Mon Sep 17 00:00:00 2001 From: hurali97 Date: Fri, 20 Oct 2023 12:18:00 +0500 Subject: [PATCH 070/257] fix: PR feedbacks --- tests/unit/SidebarOrderTest.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/SidebarOrderTest.js b/tests/unit/SidebarOrderTest.js index 1b8f231d7b0e..6b89b8a94179 100644 --- a/tests/unit/SidebarOrderTest.js +++ b/tests/unit/SidebarOrderTest.js @@ -173,6 +173,7 @@ describe('Sidebar', () => { [`${ONYXKEYS.COLLECTION.REPORT}${report1.reportID}`]: report1, [`${ONYXKEYS.COLLECTION.REPORT}${report2.reportID}`]: report2, [`${ONYXKEYS.COLLECTION.REPORT}${report3.reportID}`]: report3, + // Setting the draft status for first report [ONYXKEYS.DRAFT_REPORT_IDS]: {[report1.reportID]: true}, }), From ce653159b75e12465402d199712bd2e02e9f4a37 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 20 Oct 2023 14:30:33 +0700 Subject: [PATCH 071/257] fix error when open sign in modal --- .../HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js | 5 +++++ src/libs/Navigation/NavigationRoot.js | 8 -------- src/libs/actions/Report.js | 8 +++++++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index 92a313cf1e0a..5b15d7014d1f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -15,6 +15,7 @@ import * as Url from '../../../libs/Url'; import ROUTES from '../../../ROUTES'; import tryResolveUrlFromApiRoot from '../../../libs/tryResolveUrlFromApiRoot'; import useEnvironment from '../../../hooks/useEnvironment'; +import * as Session from '../../../libs/actions/Session'; function AnchorRenderer(props) { const htmlAttribs = props.tnode.attributes; @@ -52,6 +53,10 @@ function AnchorRenderer(props) { // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) if (internalNewExpensifyPath && hasSameOrigin) { + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(internalNewExpensifyPath)) { + Session.signOutAndRedirectToSignIn(); + return; + } Navigation.navigate(internalNewExpensifyPath); return; } diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index a22b6714a306..c7a3b14e4fb0 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -12,9 +12,6 @@ import StatusBar from '../StatusBar'; import useCurrentReportID from '../../hooks/useCurrentReportID'; import useWindowDimensions from '../../hooks/useWindowDimensions'; import {SidebarNavigationContext} from '../../pages/home/sidebar/SidebarNavigationContext'; -import * as Session from '../actions/Session'; -import getCurrentUrl from './currentUrl'; -import ROUTES from '../../ROUTES'; // https://reactnavigation.org/docs/themes const navigationTheme = { @@ -136,11 +133,6 @@ function NavigationRoot(props) { // Update the global navigation to show the correct selected menu items. globalNavigation.updateFromNavigationState(state); - - const route = Navigation.getActiveRoute(); - if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route) && !getCurrentUrl().includes(ROUTES.SIGN_IN_MODAL)) { - Session.signOutAndRedirectToSignIn(); - } }; return ( diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index af1b4a0ac1dd..d3e2f9c749d2 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1909,7 +1909,13 @@ function openReportFromDeepLink(url, isAuthenticated) { InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(true); + navigateToConciergeChat(); + return; + } + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { + Navigation.isNavigationReady().then(() => { + Session.signOutAndRedirectToSignIn(); + }); return; } Navigation.navigate(route, CONST.NAVIGATION.TYPE.PUSH); From ced932a1d2c8af33881b72772bec64293b6a014a Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 20 Oct 2023 14:36:09 +0700 Subject: [PATCH 072/257] fix revert not related changes --- src/libs/actions/Report.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index d3e2f9c749d2..51dcdc49847d 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1909,7 +1909,7 @@ function openReportFromDeepLink(url, isAuthenticated) { InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(); + navigateToConciergeChat(true); return; } if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { From dfddd3300c375d738e491fe71db1f2cb4d6afadb Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Fri, 20 Oct 2023 14:40:30 +0700 Subject: [PATCH 073/257] fix remove not related change --- src/libs/ReportUtils.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 88261a0061ea..011907c2c88b 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -4065,6 +4065,5 @@ export { getIOUReportActionDisplayMessage, isWaitingForTaskCompleteFromAssignee, isReportDraft, - parseReportRouteParams, shouldUseFullTitleToDisplay, }; From b9316bf5e22eecaf3136ef0f955436d27c8cf478 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Fri, 20 Oct 2023 09:52:35 +0200 Subject: [PATCH 074/257] Fix prettier --- src/components/withToggleVisibilityView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index 5cabdc5ffc0e..066cbae223b1 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -1,8 +1,8 @@ import React, {ComponentType, ForwardedRef, ReactElement, RefAttributes} from 'react'; +import {SetOptional} from 'type-fest'; import {View} from 'react-native'; import styles from '../styles/styles'; import getComponentDisplayName from '../libs/getComponentDisplayName'; -import {SetOptional} from 'type-fest'; type ToggleVisibilityViewProp = { /** Whether the content is visible. */ From 7af7d79a70d807900423534e3981174fa564b289 Mon Sep 17 00:00:00 2001 From: Viktoryia Kliushun Date: Fri, 20 Oct 2023 14:17:47 +0200 Subject: [PATCH 075/257] [TS migration] Migrate 'withNavigationFallback.js' HOC --- src/components/withNavigationFallback.js | 43 ----------------------- src/components/withNavigationFallback.tsx | 43 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 43 deletions(-) delete mode 100644 src/components/withNavigationFallback.js create mode 100644 src/components/withNavigationFallback.tsx diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js deleted file mode 100644 index e82946c9e049..000000000000 --- a/src/components/withNavigationFallback.js +++ /dev/null @@ -1,43 +0,0 @@ -import React, {forwardRef, useContext, useMemo} from 'react'; -import {NavigationContext} from '@react-navigation/core'; -import getComponentDisplayName from '../libs/getComponentDisplayName'; -import refPropTypes from './refPropTypes'; - -export default function (WrappedComponent) { - function WithNavigationFallback(props) { - const context = useContext(NavigationContext); - - const navigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []); - - return context ? ( - - ) : ( - - - - ); - } - WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; - WithNavigationFallback.propTypes = { - forwardedRef: refPropTypes, - }; - WithNavigationFallback.defaultProps = { - forwardedRef: undefined, - }; - - return forwardRef((props, ref) => ( - - )); -} diff --git a/src/components/withNavigationFallback.tsx b/src/components/withNavigationFallback.tsx new file mode 100644 index 000000000000..63dbdaf02ce9 --- /dev/null +++ b/src/components/withNavigationFallback.tsx @@ -0,0 +1,43 @@ +import React, {ComponentType, RefAttributes, ForwardedRef, ReactElement, forwardRef, useContext, useMemo} from 'react'; +import {NavigationContext} from '@react-navigation/core'; +import {NavigationProp} from '@react-navigation/native'; +import {ParamListBase} from '@react-navigation/routers'; +import getComponentDisplayName from '../libs/getComponentDisplayName'; + +type AddListenerCallback = () => void; + +type RemoveListenerCallback = () => void; + +type NavigationContextValue = { + isFocused: () => boolean; + addListener: () => AddListenerCallback; + removeListener: () => RemoveListenerCallback; +}; + +export default function (WrappedComponent: ComponentType>): (props: TProps & RefAttributes) => ReactElement | null { + function WithNavigationFallback(props: TProps, ref: ForwardedRef) { + const context = useContext(NavigationContext); + + const navigationContextValue: NavigationContextValue = useMemo(() => ({isFocused: () => true, addListener: () => () => {}, removeListener: () => () => {}}), []); + + return context ? ( + + ) : ( + }> + + + ); + } + + WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; + + return forwardRef(WithNavigationFallback); +} From 722043e9115259eb26153b817a114ad84ee5d165 Mon Sep 17 00:00:00 2001 From: Bartosz Grajdek Date: Fri, 20 Oct 2023 14:38:38 +0200 Subject: [PATCH 076/257] Resolve problem with styles --- src/components/OpacityView.tsx | 36 +++++++++++++--------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/components/OpacityView.tsx b/src/components/OpacityView.tsx index daef93cdc09b..74ed2a0c92ba 100644 --- a/src/components/OpacityView.tsx +++ b/src/components/OpacityView.tsx @@ -1,69 +1,61 @@ import React from 'react'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; -import PropTypes from 'prop-types'; +import {ViewStyle} from 'react-native'; import variables from '../styles/variables'; import * as StyleUtils from '../styles/StyleUtils'; import shouldRenderOffscreen from '../libs/shouldRenderOffscreen'; -const propTypes = { +type OpacityViewProps = { /** * Should we dim the view */ - shouldDim: PropTypes.bool.isRequired, + shouldDim: boolean; /** * Content to render */ - children: PropTypes.node.isRequired, + children: React.ReactNode; /** * Array of style objects * @default [] */ // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), + style: ViewStyle | ViewStyle[]; /** * The value to use for the opacity when the view is dimmed * @default 0.5 */ - dimmingValue: PropTypes.number, + dimmingValue?: number; /** Whether the view needs to be rendered offscreen (for Android only) */ - needsOffscreenAlphaCompositing: PropTypes.bool, + needsOffscreenAlphaCompositing?: boolean; }; -const defaultProps = { - style: [], - dimmingValue: variables.hoverDimValue, - needsOffscreenAlphaCompositing: false, -}; - -function OpacityView(props) { +function OpacityView({shouldDim, children, style = [], dimmingValue = variables.hoverDimValue, needsOffscreenAlphaCompositing = false}: OpacityViewProps) { const opacity = useSharedValue(1); const opacityStyle = useAnimatedStyle(() => ({ opacity: opacity.value, })); React.useEffect(() => { - if (props.shouldDim) { - opacity.value = withTiming(props.dimmingValue, {duration: 50}); + if (shouldDim) { + opacity.value = withTiming(dimmingValue, {duration: 50}); } else { opacity.value = withTiming(1, {duration: 50}); } - }, [props.shouldDim, props.dimmingValue, opacity]); + }, [shouldDim, dimmingValue, opacity]); return ( - {props.children} + {children} ); } OpacityView.displayName = 'OpacityView'; -OpacityView.propTypes = propTypes; -OpacityView.defaultProps = defaultProps; export default OpacityView; From 3cc63f83d8953d7719429b15814f23847721e777 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 4 Oct 2023 10:11:43 +0200 Subject: [PATCH 077/257] create MVCPScrollView --- .../MVCPScrollView/MVCPScrollView.js | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js diff --git a/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js b/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js new file mode 100644 index 000000000000..f0139a4ec39c --- /dev/null +++ b/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js @@ -0,0 +1,128 @@ +import React, {forwardRef, useEffect, useRef} from 'react'; +import {ScrollView, StyleSheet} from 'react-native'; +import PropTypes from 'prop-types'; + + +const MVCPScrollView = forwardRef(({maintainVisibleContentPosition, horizontal, ...props}, ref) => { + const scrollViewRef = useRef(null); + const prevFirstVisibleOffset = useRef(null); + const firstVisibleView = useRef(null); + const mutationObserver = useRef(null); + + const getContentView = () => scrollViewRef.current?.childNodes[0]; + + const prepareForMaintainVisibleContentPosition = () => { + if (maintainVisibleContentPosition == null || scrollViewRef.current == null) { + return; + } + + const contentView = getContentView(); + const minIdx = maintainVisibleContentPosition.minIndexForVisible; + for (let ii = minIdx; ii < contentView.childNodes.length; ii++) { + const subview = contentView.childNodes[ii]; + const hasNewView = horizontal ? subview.offsetLeft > scrollViewRef.current.scrollLeft : subview.offsetTop > scrollViewRef.current.scrollTop; + if (hasNewView || ii === contentView.childNodes.length - 1) { + prevFirstVisibleOffset.current = horizontal ? subview.offsetLeft : subview.offsetTop; + firstVisibleView.current = subview; + break; + } + } + }; + const scrollEventListener = useRef(() => { + prepareForMaintainVisibleContentPosition(); + }); + + const adjustForMaintainVisibleContentPosition = () => { + if (maintainVisibleContentPosition == null || scrollViewRef.current == null || firstVisibleView.current == null || prevFirstVisibleOffset.current == null) { + return; + } + + const autoscrollThreshold = maintainVisibleContentPosition.autoscrollToTopThreshold; + if (horizontal) { + const deltaX = firstVisibleView.current.offsetLeft - prevFirstVisibleOffset.current; + if (Math.abs(deltaX) > 0.5) { + const x = scrollViewRef.current.scrollLeft; + prevFirstVisibleOffset.current = firstVisibleView.current.offsetLeft; + scrollViewRef.current.scrollTo({x: x + deltaX, animated: false}); + if (autoscrollThreshold != null && x <= autoscrollThreshold) { + scrollViewRef.current.scrollTo({x: 0, animated: true}); + } + } + } else { + const deltaY = firstVisibleView.current.offsetTop - prevFirstVisibleOffset.current; + if (Math.abs(deltaY) > 0.5) { + const y = scrollViewRef.current.scrollTop; + prevFirstVisibleOffset.current = firstVisibleView.current.offsetTop; + scrollViewRef.current.scrollTo({y: y + deltaY, animated: false}); + if (autoscrollThreshold != null && y <= autoscrollThreshold) { + scrollViewRef.current.scrollTo({y: 0, animated: true}); + } + } + } + }; + + if (mutationObserver.current == null) { + mutationObserver.current = new MutationObserver(() => { + // This needs to execute after scroll events are dispatched, but + // in the same tick to avoid flickering. rAF provides the right timing. + requestAnimationFrame(adjustForMaintainVisibleContentPosition); + }); + } + + const onRef = (newRef) => { + scrollViewRef.current = newRef; + if (typeof ref === 'function') { + ref(newRef); + } else { + // eslint-disable-next-line no-param-reassign + ref.current = newRef; + } + prepareForMaintainVisibleContentPosition(); + mutationObserver.current.disconnect(); + mutationObserver.current.observe(getContentView(), { + attributes: true, + childList: true, + subtree: true, + }); + newRef.removeEventListener('scroll', scrollEventListener.current); + newRef.addEventListener('scroll', scrollEventListener.current); + }; + + useEffect(() => { + const currentObserver = mutationObserver.current; + const currentScrollEventListener = scrollEventListener.current; + return () => { + currentObserver.disconnect(); + scrollViewRef.current.removeEventListener('scroll', currentScrollEventListener); + }; + }, []); + + return ( + + ); +}); + +const styles = StyleSheet.create({ + inverted: { + transform: [{ scaleY: -1 }], + }, +}); + + +MVCPScrollView.propTypes = { + maintainVisibleContentPosition: PropTypes.shape({ + minIndexForVisible: PropTypes.number.isRequired, + autoscrollToTopThreshold: PropTypes.number, + }), + horizontal: PropTypes.bool, +}; + +export default MVCPScrollView; From d82cf206706c0b6c566d54a1bb3fdfaf29693898 Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Wed, 4 Oct 2023 10:12:26 +0200 Subject: [PATCH 078/257] use renderScrollComponent --- src/components/InvertedFlatList/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index 564db6296c9b..e7f6c14f52d8 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -2,6 +2,7 @@ import React, {forwardRef, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native'; import _ from 'underscore'; +import MVCPScrollView from './MVCPScrollView/MVCPScrollView'; import BaseInvertedFlatList from './BaseInvertedFlatList'; import styles from '../../styles/styles'; import CONST from '../../CONST'; @@ -121,6 +122,10 @@ function InvertedFlatList(props) { // We need to keep batch size to one to workaround a bug in react-native-web. // This can be removed once https://github.com/Expensify/App/pull/24482 is merged. maxToRenderPerBatch={1} + + // We need to use our own scroll component to workaround a maintainVisibleContentPosition for web + // eslint-disable-next-line react/jsx-props-no-spreading + renderScrollComponent={(_props) => } /> ); } From 3c5d2796c9e926cb7accd3396c43cda722f8851a Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Fri, 20 Oct 2023 13:08:06 -0400 Subject: [PATCH 079/257] update implementation --- src/components/FlatList/MVCPFlatList.js | 190 ++++++++++++++++++ src/components/FlatList/index.web.js | 3 + .../MVCPScrollView/MVCPScrollView.js | 128 ------------ src/components/InvertedFlatList/index.js | 5 - 4 files changed, 193 insertions(+), 133 deletions(-) create mode 100644 src/components/FlatList/MVCPFlatList.js create mode 100644 src/components/FlatList/index.web.js delete mode 100644 src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js diff --git a/src/components/FlatList/MVCPFlatList.js b/src/components/FlatList/MVCPFlatList.js new file mode 100644 index 000000000000..357db1535e6b --- /dev/null +++ b/src/components/FlatList/MVCPFlatList.js @@ -0,0 +1,190 @@ +/* eslint-disable es/no-optional-chaining, es/no-nullish-coalescing-operators, react/prop-types */ + +import React from 'react'; +import {FlatList} from 'react-native'; + +function mergeRefs(...args) { + return function forwardRef(node) { + args.forEach((ref) => { + if (ref == null) { + return; + } + if (typeof ref === 'function') { + ref(node); + return; + } + if (typeof ref === 'object') { + // eslint-disable-next-line no-param-reassign + ref.current = node; + return; + } + console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`); + }); + }; +} + +function useMergeRefs(...args) { + return React.useMemo( + () => mergeRefs(...args), + // eslint-disable-next-line + [...args], + ); +} + +const MVCPFlatList = React.forwardRef(({maintainVisibleContentPosition, horizontal, inverted, onScroll, ...props}, forwardedRef) => { + const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {}; + const scrollRef = React.useRef(null); + const prevFirstVisibleOffsetRef = React.useRef(null); + const firstVisibleViewRef = React.useRef(null); + const mutationObserverRef = React.useRef(null); + const lastScrollOffsetRef = React.useRef(0); + + const getScrollOffset = React.useCallback(() => { + if (scrollRef.current == null) { + return 0; + } + return horizontal ? scrollRef.current.getScrollableNode().scrollLeft : scrollRef.current.getScrollableNode().scrollTop; + }, [horizontal]); + + const getContentView = React.useCallback(() => scrollRef.current?.getScrollableNode().childNodes[0], []); + + const scrollToOffset = React.useCallback( + (offset, animated) => { + const behavior = animated ? 'smooth' : 'instant'; + scrollRef.current?.getScrollableNode().scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior}); + }, + [horizontal], + ); + + const prepareForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const contentView = getContentView(); + if (contentView == null) { + return; + } + + const scrollOffset = getScrollOffset(); + + for (let i = mvcpMinIndexForVisible; i < contentView.childNodes.length; i++) { + const subview = contentView.childNodes[i]; + const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop; + if (subviewOffset > scrollOffset || i === contentView.childNodes.length - 1) { + prevFirstVisibleOffsetRef.current = subviewOffset; + firstVisibleViewRef.current = subview; + break; + } + } + }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal]); + + const adjustForMaintainVisibleContentPosition = React.useCallback(() => { + if (mvcpMinIndexForVisible == null) { + return; + } + + const firstVisibleView = firstVisibleViewRef.current; + const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current; + if (firstVisibleView == null || prevFirstVisibleOffset == null) { + return; + } + + const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop; + const delta = firstVisibleViewOffset - prevFirstVisibleOffset; + if (Math.abs(delta) > 0.5) { + const scrollOffset = getScrollOffset(); + prevFirstVisibleOffsetRef.current = firstVisibleViewOffset; + scrollToOffset(scrollOffset + delta, false); + if (mvcpAutoscrollToTopThreshold != null && scrollOffset <= mvcpAutoscrollToTopThreshold) { + scrollToOffset(0, true); + } + } + }, [getScrollOffset, scrollToOffset, mvcpMinIndexForVisible, mvcpAutoscrollToTopThreshold, horizontal]); + + const setupMutationObserver = React.useCallback(() => { + const contentView = getContentView(); + if (contentView == null) { + return; + } + + mutationObserverRef.current?.disconnect(); + + const mutationObserver = new MutationObserver(() => { + // Chrome adjusts scroll position when elements are added at the top of the + // view. We want to have the same behavior as react-native / Safari so we + // reset the scroll position to the last value we got from an event. + const lastScrollOffset = lastScrollOffsetRef.current; + const scrollOffset = getScrollOffset(); + if (lastScrollOffset !== scrollOffset) { + scrollToOffset(lastScrollOffset, false); + } + + // This needs to execute after scroll events are dispatched, but + // in the same tick to avoid flickering. rAF provides the right timing. + requestAnimationFrame(() => { + adjustForMaintainVisibleContentPosition(); + }); + }); + mutationObserver.observe(contentView, { + attributes: true, + childList: true, + subtree: true, + }); + + mutationObserverRef.current = mutationObserver; + }, [adjustForMaintainVisibleContentPosition, getContentView, getScrollOffset, scrollToOffset]); + + React.useEffect(() => { + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]); + + const setMergedRef = useMergeRefs(scrollRef, forwardedRef); + + const onRef = React.useCallback( + (newRef) => { + // Make sure to only call refs and re-attach listeners if the node changed. + if (newRef == null || newRef === scrollRef.current) { + return; + } + + setMergedRef(newRef); + prepareForMaintainVisibleContentPosition(); + setupMutationObserver(); + }, + [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver], + ); + + React.useEffect(() => { + const mutationObserver = mutationObserverRef.current; + return () => { + mutationObserver?.disconnect(); + }; + }, []); + + const onScrollInternal = React.useCallback( + (ev) => { + lastScrollOffsetRef.current = getScrollOffset(); + + prepareForMaintainVisibleContentPosition(); + + onScroll?.(ev); + }, + [getScrollOffset, prepareForMaintainVisibleContentPosition, onScroll], + ); + + return ( + + ); +}); + +export default MVCPFlatList; diff --git a/src/components/FlatList/index.web.js b/src/components/FlatList/index.web.js new file mode 100644 index 000000000000..7299776db9bc --- /dev/null +++ b/src/components/FlatList/index.web.js @@ -0,0 +1,3 @@ +import MVCPFlatList from './MVCPFlatList'; + +export default MVCPFlatList; diff --git a/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js b/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js deleted file mode 100644 index f0139a4ec39c..000000000000 --- a/src/components/InvertedFlatList/MVCPScrollView/MVCPScrollView.js +++ /dev/null @@ -1,128 +0,0 @@ -import React, {forwardRef, useEffect, useRef} from 'react'; -import {ScrollView, StyleSheet} from 'react-native'; -import PropTypes from 'prop-types'; - - -const MVCPScrollView = forwardRef(({maintainVisibleContentPosition, horizontal, ...props}, ref) => { - const scrollViewRef = useRef(null); - const prevFirstVisibleOffset = useRef(null); - const firstVisibleView = useRef(null); - const mutationObserver = useRef(null); - - const getContentView = () => scrollViewRef.current?.childNodes[0]; - - const prepareForMaintainVisibleContentPosition = () => { - if (maintainVisibleContentPosition == null || scrollViewRef.current == null) { - return; - } - - const contentView = getContentView(); - const minIdx = maintainVisibleContentPosition.minIndexForVisible; - for (let ii = minIdx; ii < contentView.childNodes.length; ii++) { - const subview = contentView.childNodes[ii]; - const hasNewView = horizontal ? subview.offsetLeft > scrollViewRef.current.scrollLeft : subview.offsetTop > scrollViewRef.current.scrollTop; - if (hasNewView || ii === contentView.childNodes.length - 1) { - prevFirstVisibleOffset.current = horizontal ? subview.offsetLeft : subview.offsetTop; - firstVisibleView.current = subview; - break; - } - } - }; - const scrollEventListener = useRef(() => { - prepareForMaintainVisibleContentPosition(); - }); - - const adjustForMaintainVisibleContentPosition = () => { - if (maintainVisibleContentPosition == null || scrollViewRef.current == null || firstVisibleView.current == null || prevFirstVisibleOffset.current == null) { - return; - } - - const autoscrollThreshold = maintainVisibleContentPosition.autoscrollToTopThreshold; - if (horizontal) { - const deltaX = firstVisibleView.current.offsetLeft - prevFirstVisibleOffset.current; - if (Math.abs(deltaX) > 0.5) { - const x = scrollViewRef.current.scrollLeft; - prevFirstVisibleOffset.current = firstVisibleView.current.offsetLeft; - scrollViewRef.current.scrollTo({x: x + deltaX, animated: false}); - if (autoscrollThreshold != null && x <= autoscrollThreshold) { - scrollViewRef.current.scrollTo({x: 0, animated: true}); - } - } - } else { - const deltaY = firstVisibleView.current.offsetTop - prevFirstVisibleOffset.current; - if (Math.abs(deltaY) > 0.5) { - const y = scrollViewRef.current.scrollTop; - prevFirstVisibleOffset.current = firstVisibleView.current.offsetTop; - scrollViewRef.current.scrollTo({y: y + deltaY, animated: false}); - if (autoscrollThreshold != null && y <= autoscrollThreshold) { - scrollViewRef.current.scrollTo({y: 0, animated: true}); - } - } - } - }; - - if (mutationObserver.current == null) { - mutationObserver.current = new MutationObserver(() => { - // This needs to execute after scroll events are dispatched, but - // in the same tick to avoid flickering. rAF provides the right timing. - requestAnimationFrame(adjustForMaintainVisibleContentPosition); - }); - } - - const onRef = (newRef) => { - scrollViewRef.current = newRef; - if (typeof ref === 'function') { - ref(newRef); - } else { - // eslint-disable-next-line no-param-reassign - ref.current = newRef; - } - prepareForMaintainVisibleContentPosition(); - mutationObserver.current.disconnect(); - mutationObserver.current.observe(getContentView(), { - attributes: true, - childList: true, - subtree: true, - }); - newRef.removeEventListener('scroll', scrollEventListener.current); - newRef.addEventListener('scroll', scrollEventListener.current); - }; - - useEffect(() => { - const currentObserver = mutationObserver.current; - const currentScrollEventListener = scrollEventListener.current; - return () => { - currentObserver.disconnect(); - scrollViewRef.current.removeEventListener('scroll', currentScrollEventListener); - }; - }, []); - - return ( - - ); -}); - -const styles = StyleSheet.create({ - inverted: { - transform: [{ scaleY: -1 }], - }, -}); - - -MVCPScrollView.propTypes = { - maintainVisibleContentPosition: PropTypes.shape({ - minIndexForVisible: PropTypes.number.isRequired, - autoscrollToTopThreshold: PropTypes.number, - }), - horizontal: PropTypes.bool, -}; - -export default MVCPScrollView; diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index e7f6c14f52d8..564db6296c9b 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -2,7 +2,6 @@ import React, {forwardRef, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native'; import _ from 'underscore'; -import MVCPScrollView from './MVCPScrollView/MVCPScrollView'; import BaseInvertedFlatList from './BaseInvertedFlatList'; import styles from '../../styles/styles'; import CONST from '../../CONST'; @@ -122,10 +121,6 @@ function InvertedFlatList(props) { // We need to keep batch size to one to workaround a bug in react-native-web. // This can be removed once https://github.com/Expensify/App/pull/24482 is merged. maxToRenderPerBatch={1} - - // We need to use our own scroll component to workaround a maintainVisibleContentPosition for web - // eslint-disable-next-line react/jsx-props-no-spreading - renderScrollComponent={(_props) => } /> ); } From 4a67723154ac01bc139a5a06e5c0ea229a6dae07 Mon Sep 17 00:00:00 2001 From: Aswin S Date: Sun, 22 Oct 2023 05:53:01 +0530 Subject: [PATCH 080/257] fix: append whitespace after emoji --- src/libs/ComposerUtils/index.ts | 5 +- .../ComposerWithSuggestions.js | 59 +++++++++++++------ 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 5e2a42fc65dd..987615f17695 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -32,7 +32,10 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo */ function getCommonSuffixLength(str1: string, str2: string): number { let i = 0; - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { + if(str1.length===0||str2.length===0){ + return 0; + } + while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { i++; } return i; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index e194d0870885..a1950ad2a96e 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -116,6 +116,7 @@ function ComposerWithSuggestions({ return draft; }); const commentRef = useRef(value); + const lastTextRef = useRef(value); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -194,6 +195,31 @@ function ComposerWithSuggestions({ RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current)); }, [textInputRef]); + const findNewlyAddedChars = useCallback( + (prevText, newText) => { + const isTextReplace = selection.end - selection.start > 0; + const commonSuffixLength =ComposerUtils.getCommonSuffixLength(prevText, newText); + let startIndex = -1; + let endIndex = -1; + let i = 0; + + while (i < newText.length && prevText.charAt(i) === newText.charAt(i) && selection.start > i) { + i++; + } + + if (i < newText.length) { + startIndex = i; + // if text is getting pasted over find length of common suffix and subtract it from new text length + endIndex = isTextReplace ? newText.length-commonSuffixLength : i + (newText.length - prevText.length); + } + + return {startIndex, endIndex, diff: newText.substring(startIndex, endIndex)}; + }, + [selection.end, selection.start], + ); + + const insertWhiteSpace = (text, index) => `${text.slice(0, index)} ${text.slice(index)}`; + const debouncedSaveReportComment = useMemo( () => _.debounce((selectedReportID, newComment) => { @@ -211,7 +237,13 @@ function ComposerWithSuggestions({ const updateComment = useCallback( (commentValue, shouldDebounceSaveComment) => { raiseIsScrollLikelyLayoutTriggered(); - const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale); + const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); + const isEmojiInserted = diff.length && endIndex > startIndex && EmojiUtils.containsOnlyEmojis(diff); + const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis( + isEmojiInserted ? insertWhiteSpace(commentValue, endIndex) : commentValue, + preferredSkinTone, + preferredLocale, + ); if (!_.isEmpty(emojis)) { const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); @@ -255,16 +287,7 @@ function ComposerWithSuggestions({ debouncedBroadcastUserIsTyping(reportID); } }, - [ - debouncedUpdateFrequentlyUsedEmojis, - preferredLocale, - preferredSkinTone, - reportID, - setIsCommentEmpty, - suggestionsRef, - raiseIsScrollLikelyLayoutTriggered, - debouncedSaveReportComment, - ], + [raiseIsScrollLikelyLayoutTriggered, findNewlyAddedChars, preferredSkinTone, preferredLocale, setIsCommentEmpty, debouncedUpdateFrequentlyUsedEmojis, suggestionsRef, reportID, debouncedSaveReportComment], ); /** @@ -313,14 +336,8 @@ function ComposerWithSuggestions({ * @param {Boolean} shouldAddTrailSpace */ const replaceSelectionWithText = useCallback( - (text, shouldAddTrailSpace = true) => { - const updatedText = shouldAddTrailSpace ? `${text} ` : text; - const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0; - updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText)); - setSelection((prevSelection) => ({ - start: prevSelection.start + text.length + selectionSpaceLength, - end: prevSelection.start + text.length + selectionSpaceLength, - })); + (text) => { + updateComment(ComposerUtils.insertText(commentRef.current, selection, text)); }, [selection, updateComment], ); @@ -508,6 +525,10 @@ function ComposerWithSuggestions({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + lastTextRef.current = value; + }, [value]); + useImperativeHandle( forwardedRef, () => ({ From 6db20146c0d0d11ff350255c0ba8e6e02c5020b3 Mon Sep 17 00:00:00 2001 From: Aswin S Date: Sun, 22 Oct 2023 06:18:40 +0530 Subject: [PATCH 081/257] fix: prevent infinite loop --- src/libs/ComposerUtils/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 987615f17695..bf22fcb04a49 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -35,7 +35,8 @@ function getCommonSuffixLength(str1: string, str2: string): number { if(str1.length===0||str2.length===0){ return 0; } - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { + const minLen = Math.min(str1.length, str2.length); + while (i>minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { i++; } return i; From a8bd00576b2efad25380615217eb9904203dcda4 Mon Sep 17 00:00:00 2001 From: Aswin S Date: Sun, 22 Oct 2023 06:24:05 +0530 Subject: [PATCH 082/257] fix: clean lint --- src/libs/ComposerUtils/index.ts | 4 ++-- .../ComposerWithSuggestions.js | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index bf22fcb04a49..3167ce851e60 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -32,11 +32,11 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo */ function getCommonSuffixLength(str1: string, str2: string): number { let i = 0; - if(str1.length===0||str2.length===0){ + if (str1.length === 0 || str2.length === 0) { return 0; } const minLen = Math.min(str1.length, str2.length); - while (i>minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { + while (i < minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { i++; } return i; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index a1950ad2a96e..d8b3bc8f820a 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -198,7 +198,7 @@ function ComposerWithSuggestions({ const findNewlyAddedChars = useCallback( (prevText, newText) => { const isTextReplace = selection.end - selection.start > 0; - const commonSuffixLength =ComposerUtils.getCommonSuffixLength(prevText, newText); + const commonSuffixLength = ComposerUtils.getCommonSuffixLength(prevText, newText); let startIndex = -1; let endIndex = -1; let i = 0; @@ -210,7 +210,7 @@ function ComposerWithSuggestions({ if (i < newText.length) { startIndex = i; // if text is getting pasted over find length of common suffix and subtract it from new text length - endIndex = isTextReplace ? newText.length-commonSuffixLength : i + (newText.length - prevText.length); + endIndex = isTextReplace ? newText.length - commonSuffixLength : i + (newText.length - prevText.length); } return {startIndex, endIndex, diff: newText.substring(startIndex, endIndex)}; @@ -287,7 +287,17 @@ function ComposerWithSuggestions({ debouncedBroadcastUserIsTyping(reportID); } }, - [raiseIsScrollLikelyLayoutTriggered, findNewlyAddedChars, preferredSkinTone, preferredLocale, setIsCommentEmpty, debouncedUpdateFrequentlyUsedEmojis, suggestionsRef, reportID, debouncedSaveReportComment], + [ + raiseIsScrollLikelyLayoutTriggered, + findNewlyAddedChars, + preferredSkinTone, + preferredLocale, + setIsCommentEmpty, + debouncedUpdateFrequentlyUsedEmojis, + suggestionsRef, + reportID, + debouncedSaveReportComment, + ], ); /** From 69272012a274d2c350b165573ca2d24bc29f85cd Mon Sep 17 00:00:00 2001 From: Taras Perun Date: Mon, 23 Oct 2023 11:14:29 +0200 Subject: [PATCH 083/257] WIP testing maintainVisibleContentPosition --- src/components/InvertedFlatList/index.js | 325 ++++++++++++++--------- 1 file changed, 194 insertions(+), 131 deletions(-) diff --git a/src/components/InvertedFlatList/index.js b/src/components/InvertedFlatList/index.js index 564db6296c9b..f2414d577222 100644 --- a/src/components/InvertedFlatList/index.js +++ b/src/components/InvertedFlatList/index.js @@ -1,140 +1,203 @@ -import React, {forwardRef, useEffect, useRef} from 'react'; +import React, {forwardRef, useEffect, useRef, useState} from 'react'; import PropTypes from 'prop-types'; -import {DeviceEventEmitter, FlatList, StyleSheet} from 'react-native'; +import {DeviceEventEmitter, StyleSheet, View, Text, Button} from 'react-native'; import _ from 'underscore'; import BaseInvertedFlatList from './BaseInvertedFlatList'; -import styles from '../../styles/styles'; +// import styles from '../../styles/styles'; import CONST from '../../CONST'; +import FlatList from '../FlatList/index.web'; + +// const propTypes = { +// /** Passed via forwardRef so we can access the FlatList ref */ +// innerRef: PropTypes.shape({ +// current: PropTypes.instanceOf(FlatList), +// }).isRequired, + +// /** Any additional styles to apply */ +// // eslint-disable-next-line react/forbid-prop-types +// contentContainerStyle: PropTypes.any, + +// /** Same as for FlatList */ +// onScroll: PropTypes.func, +// }; + +// // This is adapted from https://codesandbox.io/s/react-native-dsyse +// // It's a HACK alert since FlatList has inverted scrolling on web +// function InvertedFlatList(props) { +// const {innerRef, contentContainerStyle} = props; +// const listRef = React.createRef(); + +// const lastScrollEvent = useRef(null); +// const scrollEndTimeout = useRef(null); +// const updateInProgress = useRef(false); +// const eventHandler = useRef(null); + +// useEffect(() => { +// if (!_.isFunction(innerRef)) { +// // eslint-disable-next-line no-param-reassign +// innerRef.current = listRef.current; +// } else { +// innerRef(listRef); +// } + +// return () => { +// if (scrollEndTimeout.current) { +// clearTimeout(scrollEndTimeout.current); +// } + +// if (eventHandler.current) { +// eventHandler.current.remove(); +// } +// }; +// }, [innerRef, listRef]); + +// /** +// * Emits when the scrolling is in progress. Also, +// * invokes the onScroll callback function from props. +// * +// * @param {Event} event - The onScroll event from the FlatList +// */ +// const onScroll = (event) => { +// props.onScroll(event); + +// if (!updateInProgress.current) { +// updateInProgress.current = true; +// eventHandler.current = DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, true); +// } +// }; + +// /** +// * Emits when the scrolling has ended. +// */ +// const onScrollEnd = () => { +// eventHandler.current = DeviceEventEmitter.emit(CONST.EVENTS.SCROLLING, false); +// updateInProgress.current = false; +// }; + +// /** +// * Decides whether the scrolling has ended or not. If it has ended, +// * then it calls the onScrollEnd function. Otherwise, it calls the +// * onScroll function and pass the event to it. +// * +// * This is a temporary work around, since react-native-web doesn't +// * support onScrollBeginDrag and onScrollEndDrag props for FlatList. +// * More info: +// * https://github.com/necolas/react-native-web/pull/1305 +// * +// * This workaround is taken from below and refactored to fit our needs: +// * https://github.com/necolas/react-native-web/issues/1021#issuecomment-984151185 +// * +// * @param {Event} event - The onScroll event from the FlatList +// */ +// const handleScroll = (event) => { +// onScroll(event); +// const timestamp = Date.now(); + +// if (scrollEndTimeout.current) { +// clearTimeout(scrollEndTimeout.current); +// } + +// if (lastScrollEvent.current) { +// scrollEndTimeout.current = setTimeout(() => { +// if (lastScrollEvent.current !== timestamp) { +// return; +// } +// // Scroll has ended +// lastScrollEvent.current = null; +// onScrollEnd(); +// }, 250); +// } + +// lastScrollEvent.current = timestamp; +// }; + +// return ( +// +// ); +// } + +// InvertedFlatList.propTypes = propTypes; +// InvertedFlatList.defaultProps = { +// contentContainerStyle: {}, +// onScroll: () => {}, +// }; + +// export default forwardRef((props, ref) => ( +// +// )); + + + +function ReportScreen() { + const [data, setData] = useState(generatePosts(15)); + + const loadNewerChats = () => { + const lastId = data[0].id - 1; + setData([...generatePosts(5, lastId - 4), ...data]); + }; + + const renderItem = ({ item }) => ; + const keyExtractor = (item) => item.id.toString(); + + return ( + <> + +