diff --git a/src/CONST.js b/src/CONST.js index 96fe7084239e..02e9ec3ff7fd 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -110,6 +110,7 @@ const CONST = { IOU_SEND: 'sendMoney', POLICY_ROOMS: 'policyRooms', POLICY_EXPENSE_CHAT: 'policyExpenseChat', + COMMENT_LINKING: 'commentLinking', }, BUTTON_STATES: { DEFAULT: 'default', @@ -222,6 +223,7 @@ const CONST = { // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'http://localhost:', + DEV_NEW_EXPENSIFY_URL_WITH_PORT_REGEX: /^[a-z]{4,5}:\/{2}[a-z]{1,}:[0-9]{1,4}\//, OPTION_TYPE: { REPORT: 'report', diff --git a/src/ROUTES.js b/src/ROUTES.js index 0900bf9a2f9e..9bccdd15d62a 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -40,7 +40,7 @@ export default { NEW_GROUP: 'new/group', NEW_CHAT: 'new/chat', REPORT, - REPORT_WITH_ID: 'r/:reportID', + REPORT_WITH_ID: 'r/:reportID/:reportActionID?', getReportRoute: reportID => `r/${reportID}`, IOU_REQUEST, IOU_BILL, @@ -128,7 +128,9 @@ export default { const pathSegments = route.split('/'); return { reportID: lodashGet(pathSegments, 1), - isParticipantsRoute: Boolean(lodashGet(pathSegments, 2)), + isParticipantsRoute: lodashGet(pathSegments, 2) === 'participants', + isDetailsRoute: lodashGet(pathSegments, 2) === 'details', + isSettingsRoute: lodashGet(pathSegments, 2) === 'settings', }; }, }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index 0553b51cf650..3a2272f91176 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -22,8 +22,9 @@ const AnchorRenderer = (props) => { const displayName = lodashGet(props.tnode, 'domNode.children[0].data', ''); const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); const attrHref = htmlAttribs.href || ''; - const internalNewExpensifyPath = (attrHref.startsWith(CONST.NEW_EXPENSIFY_URL) && attrHref.replace(CONST.NEW_EXPENSIFY_URL, '')) - || (attrHref.startsWith(CONST.STAGING_NEW_EXPENSIFY_URL) && attrHref.replace(CONST.STAGING_NEW_EXPENSIFY_URL, '')); + const internalNewExpensifyPath = (attrHref.startsWith(CONST.DEV_NEW_EXPENSIFY_URL) && attrHref.replace(CONST.DEV_NEW_EXPENSIFY_URL_WITH_PORT_REGEX, '')) + || (attrHref.startsWith(CONST.STAGING_NEW_EXPENSIFY_URL) && attrHref.replace(CONST.STAGING_NEW_EXPENSIFY_URL, '')) + || (attrHref.startsWith(CONST.NEW_EXPENSIFY_URL) && attrHref.replace(CONST.NEW_EXPENSIFY_URL, '')); const internalExpensifyPath = attrHref.startsWith(CONFIG.EXPENSIFY.EXPENSIFY_URL) && attrHref.replace(CONFIG.EXPENSIFY.EXPENSIFY_URL, ''); // 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 diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.js b/src/components/InvertedFlatList/BaseInvertedFlatList.js index bd2961dd35cd..1213007fc2f2 100644 --- a/src/components/InvertedFlatList/BaseInvertedFlatList.js +++ b/src/components/InvertedFlatList/BaseInvertedFlatList.js @@ -26,12 +26,20 @@ const propTypes = { /** Should we remove the clipped sub views? */ shouldRemoveClippedSubviews: PropTypes.bool, + + /** Padding that we should account for in our sizeMap */ + measurementPadding: PropTypes.number, + + /** Callback for when measurement is done */ + onMeasurementEnd: PropTypes.func, }; const defaultProps = { data: [], shouldMeasureItems: false, shouldRemoveClippedSubviews: false, + measurementPadding: 0, + onMeasurementEnd: () => {}, }; class BaseInvertedFlatList extends Component { @@ -96,22 +104,36 @@ class BaseInvertedFlatList extends Component { measureItemLayout(nativeEvent, index) { const computedHeight = nativeEvent.layout.height; - // We've already measured this item so we don't need to - // measure it again. - if (this.sizeMap[index]) { + // Before an item is rendered on screen its possible its computedHeight is 0 so let's return early and once its rendered it will it this method again with proper values. + if (computedHeight === 0) { return; } - const previousItem = this.sizeMap[index - 1] || {}; + // We've already measured this item so we don't need to measure it again. + if (this.sizeMap[index]) { + return; + } - // If there is no previousItem this can mean we haven't yet measured - // the previous item or that we are at index 0 and there is no previousItem - const previousLength = previousItem.length || 0; - const previousOffset = previousItem.offset || 0; this.sizeMap[index] = { length: computedHeight, - offset: previousLength + previousOffset, }; + + if (_.size(this.sizeMap) === this.props.data.length) { + // All items have been measured so update the offset now that we have all heights + for (let i = 0; i < this.props.data.length; i++) { + // If there is no previousItem we are at index 0 and there is no previousItem + const previousItem = this.sizeMap[i - 1] || {}; + + if (i === 0 && this.props.measurementPadding) { + this.sizeMap[0].length += this.props.measurementPadding; + } + + const previousLength = previousItem.length || 0; + const previousOffset = previousItem.offset || 0; + this.sizeMap[i].offset = previousLength + previousOffset; + } + this.props.onMeasurementEnd(); + } } /** @@ -134,7 +156,10 @@ class BaseInvertedFlatList extends Component { ); } - return this.props.renderItem({item, index}); + // For native platforms, for scrollIndex to work properly since we're not measuring items we'll want to track + // when the reportActionID is rendered so that we can scroll to it. + const shouldTrackItemRendered = true; + return this.props.renderItem({item, index, shouldTrackItemRendered}); } render() { @@ -155,6 +180,7 @@ class BaseInvertedFlatList extends Component { windowSize={15} removeClippedSubviews={this.props.shouldRemoveClippedSubviews} maintainVisibleContentPosition={{minIndexForVisible: 0, autoscrollToTopThreshold: 0}} + onScrollToIndexFailed={() => {}} /> ); } diff --git a/src/libs/Navigation/CustomActions.js b/src/libs/Navigation/CustomActions.js index c5fb5f2a4154..9ea788bba961 100644 --- a/src/libs/Navigation/CustomActions.js +++ b/src/libs/Navigation/CustomActions.js @@ -79,13 +79,20 @@ function pushDrawerRoute(route) { navigateBackToRootDrawer(); } - // If we're trying to navigate to the same screen that is already active there's nothing more to do except close the drawer. - // This prevents unnecessary re-rendering the screen and adding duplicate items to the browser history. const activeState = getActiveState(); const activeScreenName = getScreenNameFromState(activeState); const activeScreenParams = getParamsFromState(activeState); - if (newScreenName === activeScreenName && _.isEqual(activeScreenParams, newScreenParams)) { - return DrawerActions.closeDrawer(); + if (newScreenName === activeScreenName && activeScreenParams.reportID === newScreenParams.reportID) { + // If we're trying to navigate to the same screen that is already active there's nothing more to do except close the drawer. + // This prevents unnecessary re-rendering the screen and adding duplicate items to the browser history. + if (!newScreenParams.reportActionID) { + return DrawerActions.closeDrawer(); + } + + // If we're trying to navigate to the same screen with a new prop then let's just set the new params and not reset the navigation. + if (activeScreenParams.reportActionID !== newScreenParams.reportActionID) { + return CommonActions.setParams(newScreenParams); + } } let state = currentState; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index c19f10c635d8..bbf2ec0723d2 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -105,7 +105,7 @@ function goBack(shouldOpenDrawer = true) { } /** - * We navigate to the certains screens with a custom action so that we can preserve the browser history in web. react-navigation does not handle this well + * We navigate to the certain screens with a custom action so that we can preserve the browser history in web. react-navigation does not handle this well * and only offers a "mobile" navigation paradigm e.g. in order to add a history item onto the browser history stack one would need to use the "push" action. * However, this is not performant as it would keep stacking ReportScreen instances (which are quite expensive to render). * We're also looking to see if we have a participants route since those also have a reportID param, but do not have the problem described above and should not use the custom action. @@ -114,8 +114,13 @@ function goBack(shouldOpenDrawer = true) { * @returns {Boolean} */ function isDrawerRoute(route) { - const {reportID, isParticipantsRoute} = ROUTES.parseReportRouteParams(route); - return reportID && !isParticipantsRoute; + const { + reportID, + isParticipantsRoute, + isDetailsRoute, + isSettingsRoute, + } = ROUTES.parseReportRouteParams(route); + return reportID && !isParticipantsRoute && !isDetailsRoute && !isSettingsRoute; } /** diff --git a/src/libs/Permissions.js b/src/libs/Permissions.js index 2f61c5ecde68..442e3df605b1 100644 --- a/src/libs/Permissions.js +++ b/src/libs/Permissions.js @@ -86,6 +86,14 @@ function canUsePolicyExpenseChat(betas) { return _.contains(betas, CONST.BETAS.POLICY_EXPENSE_CHAT) || canUseAllBetas(betas); } +/** + * @param {Array} betas + * @returns {Boolean} + */ +function canUseCommentLinking(betas) { + return _.contains(betas, CONST.BETAS.COMMENT_LINKING) || canUseAllBetas(betas); +} + export default { canUseChronos, canUseIOU, @@ -96,4 +104,5 @@ export default { canUseWallet, canUsePolicyRooms, canUsePolicyExpenseChat, + canUseCommentLinking, }; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 6410b34dcf3f..e8413f8c4277 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -31,6 +31,9 @@ const propTypes = { params: PropTypes.shape({ /** The ID of the report this screen should display */ reportID: PropTypes.string, + + /** The reportActionID to scroll to */ + reportActionID: PropTypes.string, }).isRequired, }).isRequired, @@ -229,6 +232,7 @@ class ReportScreen extends React.Component { {!this.shouldShowLoader() && ( { + Animated.timing(this.animatedBackgroundColor, { + toValue: 0, + duration: 5500, + useNativeDriver: false, + }).start(); + }); + } + /** * Show the ReportActionContextMenu modal popover. * @@ -166,7 +209,7 @@ class ReportActionItem extends Component { > {hovered => ( - + {this.props.shouldDisplayNewIndicator && ( )} @@ -201,7 +244,7 @@ class ReportActionItem extends Component { } draftMessage={this.props.draftMessage} /> - + )} diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index 4af69adc6672..27b10df3073b 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -35,6 +35,12 @@ const propTypes = { hasOutstandingIOU: PropTypes.bool, }).isRequired, + /** The reportActionID provided in the URL */ + reportActionID: PropTypes.string.isRequired, + + /** Should the reportActionID be highlighted */ + shouldHighlightReportActionID: PropTypes.bool.isRequired, + /** Sorted actions prepared for display */ sortedReportActions: PropTypes.arrayOf(PropTypes.shape({ /** Index of the action in the array */ @@ -56,6 +62,12 @@ const propTypes = { /** Callback executed on scroll */ onScroll: PropTypes.func.isRequired, + /** Callback for when the item is rendered */ + onItemRendered: PropTypes.func.isRequired, + + /** Callback for when measurement is done */ + onMeasurementEnd: PropTypes.func.isRequired, + /** Function to load more chats */ loadMoreChats: PropTypes.func.isRequired, @@ -115,6 +127,7 @@ class ReportActionsList extends React.Component { renderItem({ item, index, + shouldTrackItemRendered, }) { const shouldDisplayNewIndicator = this.props.report.newMarkerSequenceNumber > 0 && item.action.sequenceNumber === this.props.report.newMarkerSequenceNumber; @@ -127,6 +140,8 @@ class ReportActionsList extends React.Component { isMostRecentIOUReportAction={item.action.sequenceNumber === this.props.mostRecentIOUReportSequenceNumber} hasOutstandingIOU={this.props.report.hasOutstandingIOU} index={index} + shouldHighlight={this.props.shouldHighlightReportActionID && this.props.reportActionID === item.action.reportActionID} + onItemRendered={shouldTrackItemRendered ? this.props.onItemRendered : () => {}} /> ); } @@ -177,6 +192,8 @@ class ReportActionsList extends React.Component { onLayout={this.props.onLayout} onScroll={this.props.onScroll} extraData={extraData} + measurementPadding={shouldShowReportRecipientLocalTime ? 0 : styles.chatContentScrollView.paddingVertical} + onMeasurementEnd={this.props.onMeasurementEnd} /> ); } diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 1e919028393f..d2ea6a27608f 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -36,6 +36,9 @@ const propTypes = { /** The ID of the report actions will be created for */ reportID: PropTypes.number.isRequired, + /** The report actionID to scroll to */ + reportActionID: PropTypes.string, + /* Onyx Props */ /** The report currently being looked at */ @@ -85,6 +88,7 @@ const defaultProps = { maxSequenceNumber: 0, hasOutstandingIOU: false, }, + reportActionID: '', reportActions: {}, session: {}, isLoadingReportActions: false, @@ -96,15 +100,21 @@ class ReportActionsView extends React.Component { super(props); this.appStateChangeListener = null; - + this.renderedActionIDs = new Set(); this.didLayout = false; + // We first set it as -1 since there is no index calculated to scroll to just yet. + this.actionScrollTargetIndex = -1; + this.state = { isFloatingMessageCounterVisible: false, messageCounterCount: this.props.report.unreadActionCount, + shouldHighlightReportActionID: false, }; this.currentScrollOffset = 0; + this.isDoneMeasuring = false; + this.isDoneScrollingToReportActionID = false; this.sortedReportActions = ReportActionsUtils.getSortedReportActions(props.reportActions); this.mostRecentIOUReportSequenceNumber = ReportActionsUtils.getMostRecentIOUReportSequenceNumber(props.reportActions); this.trackScroll = this.trackScroll.bind(this); @@ -117,6 +127,10 @@ class ReportActionsView extends React.Component { this.recordTimeToMeasureItemLayout = this.recordTimeToMeasureItemLayout.bind(this); this.scrollToBottomAndUpdateLastRead = this.scrollToBottomAndUpdateLastRead.bind(this); this.updateNewMarkerAndMarkReadOnce = _.once(this.updateNewMarkerAndMarkRead.bind(this)); + this.scrollToReportActionID = this.scrollToReportActionID.bind(this); + this.recordReportActionIDRendered = this.recordReportActionIDRendered.bind(this); + this.recordMeasurementDone = this.recordMeasurementDone.bind(this); + this.checkScrollToReportAction = this.checkScrollToReportAction.bind(this); } componentDidMount() { @@ -192,6 +206,14 @@ class ReportActionsView extends React.Component { return true; } + if (this.props.reportActionID !== nextProps.reportActionID) { + return true; + } + + if (this.state.shouldHighlightReportActionID !== nextState.shouldHighlightReportActionID) { + return true; + } + if (this.props.isComposerFullSize !== nextProps.isComposerFullSize) { return true; } @@ -200,6 +222,13 @@ class ReportActionsView extends React.Component { } componentDidUpdate(prevProps) { + if (this.props.reportActionID && this.props.reportActionID !== prevProps.reportActionID && this.props.reportID === prevProps.reportID) { + // We've received a new reportActionID, we need to reset some variables to its initial state so that we can scroll to the new index. + this.actionScrollTargetIndex = -1; + this.isDoneScrollingToReportActionID = false; + this.checkScrollToReportAction(); + } + if (prevProps.network.isOffline && !this.props.network.isOffline) { this.fetchData(); } @@ -288,6 +317,10 @@ class ReportActionsView extends React.Component { return; } + // isDoneMeasuring is true once BaseInvertedFlatList completes measureItemLayout for all items. Since we're loading more chats + // we need to reset this variable until measurement is complete so that we can re-attempt to scroll to our target action from our route params + this.isDoneMeasuring = false; + // Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments, unless we're near the beginning, in which // case just get everything starting from 0. const offset = Math.max(minSequenceNumber - CONST.REPORT.ACTIONS.LIMIT, 0); @@ -404,6 +437,65 @@ class ReportActionsView extends React.Component { } } + /** + * Scrolls to a specific report action ID + */ + scrollToReportActionID() { + this.actionScrollTargetIndex = _.findIndex(this.sortedReportActions, ( + ({action}) => action.reportActionID === this.props.reportActionID + )); + + if (this.actionScrollTargetIndex !== -1) { + this.isDoneScrollingToReportActionID = true; + ReportScrollManager.scrollToIndex({index: this.actionScrollTargetIndex, viewPosition: 0.5}); + this.setState({shouldHighlightReportActionID: true}); + } + } + + /** + * Records when a report actionID is done rendering. + * + * @param {String} reportActionID + */ + recordReportActionIDRendered(reportActionID) { + this.renderedActionIDs.add(reportActionID); + this.checkScrollToReportAction(); + } + + /** + * Records when our FlatList is done measuring the heights and offset of items. + */ + recordMeasurementDone() { + this.isDoneMeasuring = true; + this.checkScrollToReportAction(); + } + + /** + * Determine if we can scroll now or not. + * When measuring items we must wait until all items have been measured before scrolling. + * When not measuring items we will scroll once the specific item we are looking for has rendered. + */ + checkScrollToReportAction() { + if (!this.props.reportActionID || this.isDoneScrollingToReportActionID) { + return; + } + + const reportAction = _.find(this.sortedReportActions, ({action}) => action.reportActionID === this.props.reportActionID); + if ((this.isDoneMeasuring && reportAction) || this.renderedActionIDs.has(this.props.reportActionID)) { + // We give a slight delay because if we attempt this immediately the scroll doesn't work as the item is not actually properly rendered yet. + setTimeout(this.scrollToReportActionID, 10); + } else if (!reportAction) { + const lastSortedReportAction = this.sortedReportActions[this.sortedReportActions.length - 1]; + const minSequenceNumber = lodashGet(lastSortedReportAction, ['action', 'sequenceNumber'], 0); + if (minSequenceNumber !== 0) { + this.loadMoreChats(); + } else { + // Mark it as done so that as the user scrolls up it does not auto scroll later + this.isDoneScrollingToReportActionID = true; + } + } + } + render() { // Comments have not loaded at all yet do nothing if (!_.size(this.props.reportActions)) { @@ -422,12 +514,16 @@ class ReportActionsView extends React.Component { /> diff --git a/src/styles/colors.js b/src/styles/colors.js index f195cd62ebb4..62a3fb53e6a1 100644 --- a/src/styles/colors.js +++ b/src/styles/colors.js @@ -10,6 +10,7 @@ export default { blueHover: '#0063bf', floralwhite: '#fffaf0', green: '#03d47c', + honeydew: '#E1FAEF', greenHover: '#03c775', orange: '#ff7101', pink: '#f68dfe',