diff --git a/android/app/build.gradle b/android/app/build.gradle index 678c40309eb..e1bfcab211b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -148,8 +148,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001002903 - versionName "1.0.29-3" + versionCode 1001003001 + versionName "1.0.30-1" } splits { abi { diff --git a/ios/ExpensifyCash/Info.plist b/ios/ExpensifyCash/Info.plist index c359b2f37c5..5e856bc6679 100644 --- a/ios/ExpensifyCash/Info.plist +++ b/ios/ExpensifyCash/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.29 + 1.0.30 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.0.29.3 + 1.0.30.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/ExpensifyCashTests/Info.plist b/ios/ExpensifyCashTests/Info.plist index c16db31b438..67c1c8a7a78 100644 --- a/ios/ExpensifyCashTests/Info.plist +++ b/ios/ExpensifyCashTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.0.29 + 1.0.30 CFBundleSignature ???? CFBundleVersion - 1.0.29.3 + 1.0.30.1 diff --git a/package-lock.json b/package-lock.json index 992c0db7754..3e0c2963072 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.29-3", + "version": "1.0.30-1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 889046cfb9b..96558898515 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "expensify.cash", - "version": "1.0.29-3", + "version": "1.0.30-1", "author": "Expensify, Inc.", "homepage": "https://expensify.cash", "description": "Expensify.cash is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.js b/src/CONST.js index 95791869e8f..8f0c8b21e0c 100644 --- a/src/CONST.js +++ b/src/CONST.js @@ -32,6 +32,10 @@ const CONST = { IOU: 'IOU', }, }, + TYPE: { + CHAT: 'chat', + IOU: 'iou', + }, }, MODAL: { MODAL_TYPE: { diff --git a/src/ROUTES.js b/src/ROUTES.js index a6c60a4809d..404b8c4cfdd 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -1,5 +1,5 @@ import lodashGet from 'lodash/get'; -import {wrapWithForwardSlash} from './libs/Url'; +import {addTrailingForwardSlash} from './libs/Url'; /** * This is a file containing constants for all of the routes we want to be able to go to @@ -43,13 +43,14 @@ export default { * @returns {Object} */ parseReportRouteParams: (route) => { - if (!route.startsWith(wrapWithForwardSlash(REPORT))) { + if (!route.startsWith(addTrailingForwardSlash(REPORT))) { return {}; } const pathSegments = route.split('/'); return { reportID: lodashGet(pathSegments, 1), + isParticipantsRoute: Boolean(lodashGet(pathSegments, 2)), }; }, }; diff --git a/src/components/ReportActionItemIOUPreview.js b/src/components/ReportActionItemIOUPreview.js new file mode 100644 index 00000000000..aa3d3a0778a --- /dev/null +++ b/src/components/ReportActionItemIOUPreview.js @@ -0,0 +1,140 @@ +import React from 'react'; +import {View, TouchableOpacity} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import Str from 'expensify-common/lib/str'; +import ONYXKEYS from '../ONYXKEYS'; +import ReportActionItemIOUQuote from './ReportActionItemIOUQuote'; +import ReportActionPropTypes from '../pages/home/report/ReportActionPropTypes'; +import Text from './Text'; +import MultipleAvatars from './MultipleAvatars'; +import styles from '../styles/styles'; + +const propTypes = { + // All the data of the action + action: PropTypes.shape(ReportActionPropTypes).isRequired, + + // Is this the most recent IOU Action? + isMostRecentIOUReportAction: PropTypes.bool.isRequired, + + // Whether there is an outstanding amount in IOU + hasOutstandingIOU: PropTypes.bool.isRequired, + + /* --- Onyx Props --- */ + // Active IOU Report for current report + iou: PropTypes.shape({ + // Email address of the manager in this iou report + managerEmail: PropTypes.string, + + // Email address of the creator of this iou report + ownerEmail: PropTypes.string, + + // Outstanding amount of this transaction + cachedTotal: PropTypes.string, + }), + + // All of the personal details for everyone + personalDetails: PropTypes.objectOf(PropTypes.shape({ + + // This is either the user's full name, or their login if full name is an empty string + displayName: PropTypes.string.isRequired, + })).isRequired, + + // Session info for the currently logged in user. + session: PropTypes.shape({ + // Currently logged in user email + email: PropTypes.string, + }).isRequired, +}; + +const defaultProps = { + iou: {}, +}; + +const ReportActionItemIOUPreview = ({ + action, + isMostRecentIOUReportAction, + hasOutstandingIOU, + iou, + personalDetails, + session, +}) => { + const managerName = lodashGet( + personalDetails, + [iou.managerEmail, 'displayName'], + iou.managerEmail ? Str.removeSMSDomain(iou.managerEmail) : '', + ); + const ownerName = lodashGet( + personalDetails, + [iou.ownerEmail, 'displayName'], + iou.ownerEmail ? Str.removeSMSDomain(iou.ownerEmail) : '', + ); + const managerAvatar = lodashGet(personalDetails, [iou.managerEmail, 'avatar'], ''); + const ownerAvatar = lodashGet(personalDetails, [iou.ownerEmail, 'avatar'], ''); + const sessionEmail = lodashGet(session, 'email', null); + const cachedTotal = iou.cachedTotal ? iou.cachedTotal.replace(/[()]/g, '') : ''; + + // Pay button should be visible to manager person in the report + // Check if the currently logged in user is the manager. + const isCurrentUserManager = iou.managerEmail === sessionEmail; + + return ( + + + {isMostRecentIOUReportAction + && hasOutstandingIOU + && !_.isEmpty(iou) && ( + + + + {cachedTotal} + + {managerName} + {' owes '} + {ownerName} + + + + + + + {isCurrentUserManager && ( + + + Pay + + + )} + + )} + + ); +}; + +ReportActionItemIOUPreview.propTypes = propTypes; +ReportActionItemIOUPreview.defaultProps = defaultProps; +ReportActionItemIOUPreview.displayName = 'ReportActionItemIOUPreview'; + +export default withOnyx({ + iou: { + key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT_IOUS}${iouReportID}`, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS, + }, + session: { + key: ONYXKEYS.SESSION, + }, +})(ReportActionItemIOUPreview); diff --git a/src/components/ReportActionItemIOUQuote.js b/src/components/ReportActionItemIOUQuote.js new file mode 100644 index 00000000000..bf56ed9d243 --- /dev/null +++ b/src/components/ReportActionItemIOUQuote.js @@ -0,0 +1,29 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../styles/styles'; +import ReportActionPropTypes from '../pages/home/report/ReportActionPropTypes'; +import RenderHTML from './RenderHTML'; + +const propTypes = { + // All the data of the action + action: PropTypes.shape(ReportActionPropTypes).isRequired, +}; + +const ReportActionItemIOUQuote = ({action}) => ( + + {_.map(action.message, (fragment, index) => { + const viewDetails = '
View Details'; + const html = `
${fragment.text}${viewDetails}
`; + return ( + + ); + })} +
+); + +ReportActionItemIOUQuote.propTypes = propTypes; +ReportActionItemIOUQuote.displayName = 'ReportActionItemIOUQuote'; + +export default ReportActionItemIOUQuote; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index d0c8b1988b9..d136fdf2cdf 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -10,7 +10,7 @@ import CONST from '../../../CONST'; import compose from '../../compose'; import { subscribeToReportCommentEvents, - fetchAll as fetchAllReports, + fetchAllReports, } from '../../actions/Report'; import * as PersonalDetails from '../../actions/PersonalDetails'; import * as Pusher from '../../Pusher/pusher'; @@ -119,7 +119,7 @@ class AuthScreens extends React.Component { PersonalDetails.fetch(); User.getUserDetails(); User.getBetas(); - fetchAllReports(true, true); + fetchAllReports(true, true, true); fetchCountryCodeByRequestIP(); UnreadIndicatorUpdater.listenForReportChanges(); diff --git a/src/libs/Navigation/CustomActions.js b/src/libs/Navigation/CustomActions.js index 74570a88ede..d29cbed73b9 100644 --- a/src/libs/Navigation/CustomActions.js +++ b/src/libs/Navigation/CustomActions.js @@ -14,8 +14,15 @@ import {CommonActions} from '@react-navigation/native'; */ function pushDrawerRoute(screenName, params) { return (state) => { + // Non Drawer navigators have routes and not history so we'll fallback to navigate() in the case where we are + // unable to push a new screen onto the history stack e.g. navigating to a ReportScreen via a modal screen. + // Note: One downside of this is that the history will be reset. + if (state.type !== 'drawer') { + return CommonActions.navigate(screenName, params); + } + const screenRoute = {type: 'route', name: screenName}; - const history = [...state.history].map(() => screenRoute); + const history = [...(state.history || [])].map(() => screenRoute); history.push(screenRoute); return CommonActions.reset({ ...state, diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js index 30cce16a51a..28dbfe330da 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.js @@ -48,8 +48,10 @@ function navigate(route = ROUTES.HOME) { return; } - const {reportID} = ROUTES.parseReportRouteParams(route); - if (reportID) { + // Navigate to the ReportScreen with a custom action so that we can preserve the history. We're looking to see if we + // have a participants route since those should go through linkTo() as they open a different screen. + const {reportID, isParticipantsRoute} = ROUTES.parseReportRouteParams(route); + if (reportID && !isParticipantsRoute) { navigationRef.current.dispatch(CustomActions.pushDrawerRoute(SCREENS.REPORT, {reportID})); return; } diff --git a/src/libs/Url.js b/src/libs/Url.js index 004e9c5fe17..e166c127a83 100644 --- a/src/libs/Url.js +++ b/src/libs/Url.js @@ -10,23 +10,7 @@ function addTrailingForwardSlash(url) { return url; } -/** - * Add / to the beginning and end of any URL if not present - * @param {String} url - * @returns {String} - */ -function wrapWithForwardSlash(url) { - const newUrl = addTrailingForwardSlash(url); - if (newUrl.startsWith('/')) { - return newUrl; - } - - return `/${newUrl}`; -} - - export { // eslint-disable-next-line import/prefer-default-export addTrailingForwardSlash, - wrapWithForwardSlash, }; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index cd3e7cfbe6f..d4769920c34 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -44,6 +44,16 @@ Onyx.connect({ callback: val => myPersonalDetails = val, }); +const allReports = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (val) => { + if (val && val.reportID) { + allReports[val.reportID] = val; + } + }, +}); + const typingWatchTimers = {}; // Keeps track of the max sequence number for each report @@ -195,82 +205,69 @@ function getSimplifiedIOUReport(reportData, chatReportID) { } /** - * Fetches the updated data for an IOU Report and updates the IOU collection in Onyx + * Given IOU and chat report ID fetches most recent IOU data from API. * - * @param {Object} chatReport - * @param {Object[]} chatReport.reportActionList - * @param {Number} chatReport.reportID - * @return {Promise} + * @param {Number} iouReportID + * @param {Number} chatReportID + * @returns {Promise} */ -function updateIOUReportData(chatReport) { - const reportActionList = chatReport.reportActionList || []; - const containsIOUAction = _.any(reportActionList, - reportAction => reportAction.action === CONST.REPORT.ACTIONS.TYPE.IOU); - - // If there aren't any IOU actions, we don't need to fetch any additional data - if (!containsIOUAction) { - return; - } - - // If we don't have one participant (other than the current user), this is not an IOU - const participants = getParticipantEmailsFromReport(chatReport); - if (participants.length !== 1) { - Log.alert('[Report] Report with IOU action has more than 2 participants', true, { - reportID: chatReport.reportID, - participants, - }); - return; - } - - // Since the Chat and the IOU are different reports with different reportIDs, and GetIOUReport only returns the - // IOU's reportID, keep track of the IOU's reportID so we can use it to get the IOUReport data via `GetReportStuff`. - // Note: GetIOUReport does not return IOU reports that have been settled. - let iouReportID = 0; - return API.GetIOUReport({ - debtorEmail: participants[0], - }).then((response) => { - iouReportID = response.reportID || 0; - if (response.jsonCode !== 200) { - throw new Error(response.message); - } else if (iouReportID === 0) { - // If there is no IOU report for this user then we will assume it has been paid and do nothing here. - // All reports are initialized with hasOutstandingIOU: false. Since the IOU report we were looking for has - // been settled then there's nothing more to do. - console.debug('GetIOUReport returned a reportID of 0, not fetching IOU report data'); - return; - } - - return API.Get({ - returnValueList: 'reportStuff', - reportIDList: iouReportID, - shouldLoadOptionalKeys: true, - includePinnedReports: true, - }); +function fetchIOUReport(iouReportID, chatReportID) { + return API.Get({ + returnValueList: 'reportStuff', + reportIDList: iouReportID, + shouldLoadOptionalKeys: true, + includePinnedReports: true, }).then((response) => { if (!response) { return; } - if (response.jsonCode !== 200) { - throw new Error(response.message); + console.error(response.message); + return; } - const iouReportData = response.reports[iouReportID]; if (!iouReportData) { - throw new Error(`No iouReportData found for reportID ${iouReportID}`); + console.error(`No iouReportData found for reportID ${iouReportID}`); + return; } - return getSimplifiedIOUReport(iouReportData, chatReport.reportID); + return getSimplifiedIOUReport(iouReportData, chatReportID); }).catch((error) => { console.debug(`[Report] Failed to populate IOU Collection: ${error.message}`); }); } +/** + * Given debtorEmail finds active IOU report ID via GetIOUReport API call + * + * @param {String} debtorEmail + * @returns {Promise} + */ +function fetchIOUReportID(debtorEmail) { + return API.GetIOUReport({ + debtorEmail, + }).then((response) => { + const iouReportID = response.reportID || 0; + if (response.jsonCode !== 200) { + console.error(response.message); + return; + } + if (iouReportID === 0) { + // If there is no IOU report for this user then we will assume it has been paid and do nothing here. + // All reports are initialized with hasOutstandingIOU: false. Since the IOU report we were looking for has + // been settled then there's nothing more to do. + console.debug('GetIOUReport returned a reportID of 0, not fetching IOU report data'); + return; + } + return iouReportID; + }); +} + /** * Fetches chat reports when provided a list of * chat report IDs * * @param {Array} chatList - * @return {Promise} only used internally when fetchAll() is called + * @return {Promise} only used internally when fetchAllReports() is called */ function fetchChatReportsByIDs(chatList) { let fetchedReports; @@ -284,7 +281,32 @@ function fetchChatReportsByIDs(chatList) { .then(({reports}) => { Log.info('[Report] successfully fetched report data', true); fetchedReports = reports; - return Promise.all(_.map(fetchedReports, updateIOUReportData)); + return Promise.all(_.map(fetchedReports, (chatReport) => { + const reportActionList = chatReport.reportActionList || []; + const containsIOUAction = _.any(reportActionList, + reportAction => reportAction.action === CONST.REPORT.ACTIONS.TYPE.IOU); + + // If there aren't any IOU actions, we don't need to fetch any additional data + if (!containsIOUAction) { + return; + } + + // Group chat reports cannot and should not be associated with a specific IOU report + const participants = getParticipantEmailsFromReport(chatReport); + if (participants.length > 1) { + return; + } + if (participants.length === 0) { + Log.alert('[Report] Report with IOU action but does not have any participant.', true, { + reportID: chatReport.reportID, + participants, + }); + return; + } + + return fetchIOUReportID(participants[0]) + .then(iouReportID => fetchIOUReport(iouReportID, chatReport.reportID)); + })); }) .then((iouReportObjects) => { // Process the reports and store them in Onyx. At the same time we'll save the simplified reports in this @@ -315,7 +337,6 @@ function fetchChatReportsByIDs(chatList) { // than updating props for each report and re-rendering had merge been used. Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_IOUS, reportIOUData); Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, simplifiedReports); - Onyx.set(ONYXKEYS.INITIAL_REPORT_DATA_LOADED, true); // Fetch the personal details if there are any PersonalDetails.getFromReportParticipants(Object.values(simplifiedReports)); @@ -324,6 +345,29 @@ function fetchChatReportsByIDs(chatList) { }); } +/** + * Given IOU object and chat report ID save the data to Onyx. + * + * @param {Object} iouReportObject + * @param {Number} iouReportObject.stateNum + * @param {Number} iouReportObject.total + * @param {Number} iouReportObject.reportID + * @param {Number} chatReportID + */ +function setLocalIOUReportData(iouReportObject, chatReportID) { + const chatReportObject = { + hasOutstandingIOU: iouReportObject.stateNum === 1 && iouReportObject.total !== 0, + iouReportID: iouReportObject.reportID, + }; + if (!chatReportObject.hasOutstandingIOU) { + chatReportObject.iouReportID = null; + } + const iouReportKey = `${ONYXKEYS.COLLECTION.REPORT_IOUS}${iouReportObject.reportID}`; + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`; + Onyx.merge(iouReportKey, iouReportObject); + Onyx.merge(reportKey, chatReportObject); +} + /** * Update the lastRead actionID and timestamp in local memory and Onyx * @@ -417,6 +461,20 @@ function updateReportWithNewAction(reportID, reportAction) { Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, reportActionsToMerge); + // If chat report receives an action with IOU, update IOU object + if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { + const chatReport = lodashGet(allReports, reportID); + const iouReportID = lodashGet(chatReport, 'iouReportID'); + if (iouReportID) { + fetchIOUReport(iouReportID, reportID) + .then(iouReportObject => setLocalIOUReportData(iouReportObject, reportID)); + } else if (!chatReport || chatReport.participants.length === 1) { + fetchIOUReportID(chatReport ? chatReport.participants[0] : reportAction.actorEmail) + .then(iouID => fetchIOUReport(iouID, reportID)) + .then(iouReportObject => setLocalIOUReportData(iouReportObject, reportID)); + } + } + if (!ActiveClientManager.isClientTheLeader()) { console.debug('[LOCAL_NOTIFICATION] Skipping notification because this client is not the leader'); return; @@ -577,56 +635,34 @@ function unsubscribeFromReportChannel(reportID) { /** * Get the report ID for a chat report for a specific - * set of participants and redirect to it. + * set of participants and navigate to it if wanted. * * @param {String[]} participants + * @param {Boolean} shouldNavigate + * @returns {Promise} */ -function fetchOrCreateChatReport(participants) { +function fetchOrCreateChatReport(participants, shouldNavigate = true) { if (participants.length < 2) { - throw new Error('fetchOrCreateChatReport() must have at least two participants'); + throw new Error('fetchOrCreateChatReport() must have at least two participants.'); } - API.CreateChatReport({ + return API.CreateChatReport({ emailList: participants.join(','), }) .then((data) => { if (data.jsonCode !== 200) { - throw new Error(data.message); + console.error(data.message); + return; } // Merge report into Onyx - const reportID = data.reportID; - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {reportID}); - - // Redirect the logged in person to the new report - Navigation.navigate(ROUTES.getReportRoute(reportID)); - }); -} - -/** - * Get all chat reports and provide the proper report name - * by fetching sharedReportList and personalDetails - * - * @returns {Promise} only used internally when fetchAll() is called - */ -function fetchChatReports() { - return API.Get({ - returnValueList: 'chatList', - }) - .then((response) => { - if (response.jsonCode !== 200) { - return; - } + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${data.reportID}`, {reportID: data.reportID}); - // Get all the chat reports if they have any, otherwise create one with concierge - if (lodashGet(response, 'chatList', []).length) { - // The string cast here is necessary as Get rvl='chatList' may return an int - fetchChatReportsByIDs(String(response.chatList).split(',')); - } else { - fetchOrCreateChatReport([currentUserEmail, 'concierge@expensify.com']); + if (shouldNavigate) { + // Redirect the logged in person to the new report + Navigation.navigate(ROUTES.getReportRoute(data.reportID)); } - - return response.chatList; + return data.reportID; }); } @@ -674,30 +710,60 @@ function fetchActions(reportID, offset) { * * @param {Boolean} shouldRedirectToReport this is set to false when the network reconnect code runs * @param {Boolean} shouldRecordHomePageTiming whether or not performance timing should be measured + * @param {Boolean} shouldDelayActionsFetch when the app loads we want to delay the fetching of additional actions */ -function fetchAll(shouldRedirectToReport = true, shouldRecordHomePageTiming = false) { - fetchChatReports() - .then((reportIDs) => { - if (shouldRedirectToReport) { - // Update currentlyViewedReportID to be our first reportID from our report collection if we don't have - // one already. - if (lastViewedReportID) { - return; - } +function fetchAllReports( + shouldRedirectToReport = true, + shouldRecordHomePageTiming = false, + shouldDelayActionsFetch = false, +) { + let reportIDs = []; + + API.Get({ + returnValueList: 'chatList', + }) + .then((response) => { + if (response.jsonCode !== 200) { + return; + } + + // The string cast here is necessary as Get rvl='chatList' may return an int + reportIDs = String(response.chatList).split(','); - const firstReportID = _.first(reportIDs); - const currentReportID = firstReportID ? String(firstReportID) : ''; - Onyx.merge(ONYXKEYS.CURRENTLY_VIEWED_REPORTID, currentReportID); + // Get all the chat reports if they have any, otherwise create one with concierge + if (reportIDs.length) { + return fetchChatReportsByIDs(reportIDs); } - Log.info('[Report] Fetching report actions for reports', true, {reportIDs}); - _.each(reportIDs, (reportID) => { - fetchActions(reportID); - }); + return fetchOrCreateChatReport([currentUserEmail, 'concierge@expensify.com']); + }) + .then(() => { + Onyx.set(ONYXKEYS.INITIAL_REPORT_DATA_LOADED, true); if (shouldRecordHomePageTiming) { Timing.end(CONST.TIMING.HOMEPAGE_REPORTS_LOADED); } + + // Optionally delay fetching report history as it significantly increases sign in to interactive time + _.delay(() => { + Log.info('[Report] Fetching report actions for reports', true, {reportIDs}); + _.each(reportIDs, (reportID) => { + fetchActions(reportID); + }); + + // We are waiting 8 seconds since this provides a good time window to allow the UI to finish loading before + // bogging it down with more requests and operations. + }, shouldDelayActionsFetch ? 8000 : 0); + + // Update currentlyViewedReportID to be our first reportID from our report collection if we don't have + // one already. + if (!shouldRedirectToReport || lastViewedReportID) { + return; + } + + const firstReportID = _.first(reportIDs); + const currentReportID = firstReportID ? String(firstReportID) : ''; + Onyx.merge(ONYXKEYS.CURRENTLY_VIEWED_REPORTID, currentReportID); }); } @@ -894,11 +960,11 @@ Onyx.connect({ // When the app reconnects from being offline, fetch all of the reports and their actions NetworkConnection.onReconnect(() => { - fetchAll(false); + fetchAllReports(false); }); export { - fetchAll, + fetchAllReports, fetchActions, fetchOrCreateChatReport, addAction, diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index ecf3d7db91f..f711ce21a83 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -55,7 +55,7 @@ const propTypes = { // participants associated with current report participants: PropTypes.arrayOf(PropTypes.string), - }).isRequired, + }), /* Is the report view covered by the drawer */ isDrawerOpen: PropTypes.bool.isRequired, @@ -77,6 +77,7 @@ const propTypes = { const defaultProps = { comment: '', modal: {}, + report: {}, network: {isOffline: false}, }; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 51a48e7dc3e..b6f9e4ec3f0 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -13,6 +13,8 @@ import PopoverWithMeasuredContent from '../../../components/PopoverWithMeasuredC import ReportActionItemSingle from './ReportActionItemSingle'; import ReportActionItemGrouped from './ReportActionItemGrouped'; import ReportActionContextMenu from './ReportActionContextMenu'; +import ReportActionItemIOUPreview from '../../../components/ReportActionItemIOUPreview'; +import ReportActionItemMessage from './ReportActionItemMessage'; import UnreadActionIndicator from '../../../components/UnreadActionIndicator'; const propTypes = { @@ -25,10 +27,24 @@ const propTypes = { // Should the comment have the appearance of being grouped with the previous comment? displayAsGroup: PropTypes.bool.isRequired, + // Is this the most recent IOU Action? + isMostRecentIOUReportAction: PropTypes.bool.isRequired, + + // Whether there is an outstanding amount in IOU + hasOutstandingIOU: PropTypes.bool, + + // IOU report ID associated with current report + iouReportID: PropTypes.number, + // Should we display the new indicator on top of the comment? shouldDisplayNewIndicator: PropTypes.bool.isRequired, }; +const defaultProps = { + iouReportID: undefined, + hasOutstandingIOU: false, +}; + class ReportActionItem extends Component { constructor(props) { super(props); @@ -50,6 +66,9 @@ class ReportActionItem extends Component { shouldComponentUpdate(nextProps, nextState) { return this.state.isPopoverVisible !== nextState.isPopoverVisible || this.props.displayAsGroup !== nextProps.displayAsGroup + || this.props.isMostRecentIOUReportAction !== nextProps.isMostRecentIOUReportAction + || this.props.hasOutstandingIOU !== nextProps.hasOutstandingIOU + || this.props.iouReportID !== nextProps.iouReportID || (this.props.shouldDisplayNewIndicator !== nextProps.shouldDisplayNewIndicator) || !_.isEqual(this.props.action, nextProps.action); } @@ -85,6 +104,16 @@ class ReportActionItem extends Component { } render() { + const children = this.props.action.actionName === 'IOU' + ? ( + + ) + : ; return ( @@ -95,8 +124,16 @@ class ReportActionItem extends Component { )} {!this.props.displayAsGroup - ? - : } + ? ( + + {children} + + ) + : ( + + {children} + + )} ( +const ReportActionItemGrouped = ({children}) => ( - + {children} ); diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 9b026c7db55..fe28b1a3868 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -2,6 +2,8 @@ import React from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; +import {withOnyx} from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; import styles from '../../../styles/styles'; import ReportActionItemFragment from './ReportActionItemFragment'; import ReportActionPropTypes from './ReportActionPropTypes'; @@ -9,11 +11,20 @@ import ReportActionPropTypes from './ReportActionPropTypes'; const propTypes = { // The report action action: PropTypes.shape(ReportActionPropTypes).isRequired, + + // Information about the network + network: PropTypes.shape({ + // Is the network currently offline or not + isOffline: PropTypes.bool, + }), +}; + +const defaultProps = { + network: {isOffline: false}, }; -const ReportActionItemMessage = ({action}) => { - // reportActionID is only present when the action is saved onto server. - const isUnsent = action.loading && !action.reportActionID; +const ReportActionItemMessage = ({action, network}) => { + const isUnsent = network.isOffline && action.loading; return ( {_.map(_.compact(action.message), (fragment, index) => ( @@ -29,6 +40,11 @@ const ReportActionItemMessage = ({action}) => { }; ReportActionItemMessage.propTypes = propTypes; +ReportActionItemMessage.defaultProps = defaultProps; ReportActionItemMessage.displayName = 'ReportActionItemMessage'; -export default ReportActionItemMessage; +export default withOnyx({ + network: { + key: ONYXKEYS.NETWORK, + }, +})(ReportActionItemMessage); diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index e9cc33f9d08..84e08efdc84 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -4,7 +4,6 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; import ReportActionPropTypes from './ReportActionPropTypes'; -import ReportActionItemMessage from './ReportActionItemMessage'; import ReportActionItemFragment from './ReportActionItemFragment'; import styles from '../../../styles/styles'; import CONST from '../../../CONST'; @@ -18,10 +17,21 @@ const propTypes = { action: PropTypes.shape(ReportActionPropTypes).isRequired, // All of the personalDetails - personalDetails: PropTypes.objectOf(personalDetailsPropType).isRequired, + personalDetails: PropTypes.objectOf(personalDetailsPropType), + + // Children view component for this action item + children: PropTypes.node.isRequired, +}; + +const defaultProps = { + personalDetails: {}, }; -const ReportActionItemSingle = ({action, personalDetails}) => { +const ReportActionItemSingle = ({ + action, + personalDetails, + children, +}) => { const {avatar, displayName} = personalDetails[action.actorEmail] || {}; const avatarUrl = action.automatic ? `${CONST.CLOUDFRONT_URL}/images/icons/concierge_2019.svg` @@ -52,13 +62,14 @@ const ReportActionItemSingle = ({action, personalDetails}) => { ))} - + {children} ); }; ReportActionItemSingle.propTypes = propTypes; +ReportActionItemSingle.defaultProps = defaultProps; export default withOnyx({ personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS, diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 347dd3d5ee4..398fd16bb73 100644 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -40,6 +40,12 @@ const propTypes = { // The largest sequenceNumber on this report maxSequenceNumber: PropTypes.number, + + // Whether there is an outstanding amount in IOU + hasOutstandingIOU: PropTypes.bool, + + // IOU report ID associated with current report + iouReportID: PropTypes.number, }), // Array of report actions for this report @@ -56,6 +62,7 @@ const defaultProps = { report: { unreadActionCount: 0, maxSequenceNumber: 0, + hasOutstandingIOU: false, }, reportActions: {}, session: {}, @@ -83,6 +90,7 @@ class ReportActionsView extends React.Component { }; this.updateSortedReportActions(props.reportActions); + this.updateMostRecentIOUReportActionNumber(props.reportActions); } componentDidMount() { @@ -97,6 +105,7 @@ class ReportActionsView extends React.Component { shouldComponentUpdate(nextProps, nextState) { if (!_.isEqual(nextProps.reportActions, this.props.reportActions)) { this.updateSortedReportActions(nextProps.reportActions); + this.updateMostRecentIOUReportActionNumber(nextProps.reportActions); return true; } @@ -104,6 +113,11 @@ class ReportActionsView extends React.Component { return true; } + if (this.props.report.hasOutstandingIOU !== nextProps.report.hasOutstandingIOU + || this.props.report.iouReportID !== nextProps.report.iouReportID) { + return true; + } + return false; } @@ -232,6 +246,19 @@ class ReportActionsView extends React.Component { updateLastReadActionID(this.props.reportID, maxVisibleSequenceNumber); } + /** + * Finds and updates most recent IOU report action number + * + * @param {Array<{sequenceNumber, actionName}>} reportActions + */ + updateMostRecentIOUReportActionNumber(reportActions) { + this.mostRecentIOUReportSequenceNumber = _.chain(reportActions) + .sortBy('sequenceNumber') + .filter(action => action.actionName === 'IOU') + .max(action => action.sequenceNumber) + .value().sequenceNumber; + } + /** * This function is triggered from the ref callback for the scrollview. That way it can be scrolled once all the * items have been rendered. If the number of actions has changed since it was last rendered, then @@ -286,6 +313,9 @@ class ReportActionsView extends React.Component { displayAsGroup={this.isConsecutiveActionMadeByPreviousActor(index)} shouldDisplayNewIndicator={this.initialNewMarkerPosition > 0 && item.action.sequenceNumber === this.initialNewMarkerPosition} + isMostRecentIOUReportAction={item.action.sequenceNumber === this.mostRecentIOUReportSequenceNumber} + iouReportID={this.props.report.iouReportID} + hasOutstandingIOU={this.props.report.hasOutstandingIOU} /> ); } diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 7f24fa96180..c42f2e4a4e0 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -68,6 +68,9 @@ const propTypes = { // The chat priority mode priorityMode: PropTypes.string, + + // Whether we have the necessary report data to load the sidebar + initialReportDataLoaded: PropTypes.bool, }; const defaultProps = { @@ -80,6 +83,7 @@ const defaultProps = { network: null, currentlyViewedReportID: '', priorityMode: CONST.PRIORITY_MODE.DEFAULT, + initialReportDataLoaded: false, }; class SidebarLinks extends React.Component { @@ -88,6 +92,11 @@ class SidebarLinks extends React.Component { } render() { + // Wait until the reports are actually loaded before displaying the LHN + if (!this.props.initialReportDataLoaded) { + return null; + } + const activeReportID = parseInt(this.props.currentlyViewedReportID, 10); const {recentReports} = getSidebarOptions( @@ -184,5 +193,8 @@ export default compose( priorityMode: { key: ONYXKEYS.NVP_PRIORITY_MODE, }, + initialReportDataLoaded: { + key: ONYXKEYS.INITIAL_REPORT_DATA_LOADED, + }, }), )(SidebarLinks); diff --git a/src/styles/styles.js b/src/styles/styles.js index 3f3fa97e678..baf5dc75041 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -122,6 +122,7 @@ const styles = { }, buttonSmall: { + borderRadius: variables.componentBorderRadiusNormal, height: variables.componentSizeSmall, paddingTop: 6, paddingRight: 10, @@ -132,6 +133,9 @@ const styles = { buttonSmallText: { fontSize: variables.fontSizeSmall, lineHeight: 16, + fontFamily: fontFamily.GTA_BOLD, + fontWeight: fontWeightBold, + textAlign: 'center', }, buttonSuccess: { @@ -943,6 +947,14 @@ const styles = { borderColor: 'transparent', }, + secondAvatarInline: { + bottom: -3, + right: -25, + borderWidth: 3, + borderRadius: 18, + borderColor: themeColors.componentBG, + }, + avatarNormal: { height: variables.componentSizeNormal, width: variables.componentSizeNormal, @@ -1308,6 +1320,20 @@ const styles = { color: themeColors.heading, }, 0), + iouPreviewBox: { + borderColor: themeColors.border, + borderWidth: 1, + borderRadius: variables.componentBorderRadiusCard, + padding: 20, + marginTop: 16, + maxWidth: 300, + width: '100%', + }, + + iouPreviewBoxAvatar: { + marginRight: -10, + }, + noScrollbars: { scrollbarWidth: 'none', }, diff --git a/src/styles/variables.js b/src/styles/variables.js index 9b70c5d5b07..fff0f2fd099 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -6,6 +6,7 @@ export default { componentBorderRadius: 8, componentBorderRadiusSmall: 4, componentBorderRadiusNormal: 8, + componentBorderRadiusCard: 12, avatarSizeNormal: 40, avatarSizeSmall: 28, fontSizeSmall: 11,