, shouldIncludeDebitCard = true): boolean {
const validBankAccount = Object.values(bankAccountList).some((bankAccountJSON) => {
const bankAccount = new BankAccountModel(bankAccountJSON);
- return bankAccount.isDefaultCredit();
+
+ return bankAccount.getPendingAction() !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && bankAccount.isOpen() && bankAccount.getType() === CONST.BANK_ACCOUNT.TYPE.PERSONAL;
});
// Hide any billing cards that are not P2P debit cards for now because you cannot make them your default method, or delete them
const validDebitCard = Object.values(fundList).some((card) => card?.accountData?.additionalData?.isP2PDebitCard ?? false);
- return validBankAccount || validDebitCard;
+ return validBankAccount || (shouldIncludeDebitCard && validDebitCard);
}
function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData']): string {
@@ -53,22 +48,28 @@ function formatPaymentMethods(bankAccountList: Record, fund
return;
}
- const {icon, iconSize} = getBankIcon(bankAccount?.accountData?.additionalData?.bankName ?? '', false);
+ const {icon, iconSize, iconHeight, iconWidth, iconStyles} = getBankIcon(bankAccount?.accountData?.additionalData?.bankName ?? '', false);
combinedPaymentMethods.push({
...bankAccount,
description: getPaymentMethodDescription(bankAccount?.accountType, bankAccount.accountData),
icon,
iconSize,
+ iconHeight,
+ iconWidth,
+ iconStyles,
});
});
Object.values(fundList).forEach((card) => {
- const {icon, iconSize} = getBankIcon(card?.accountData?.bank ?? '', true);
+ const {icon, iconSize, iconHeight, iconWidth, iconStyles} = getBankIcon(card?.accountData?.bank ?? '', true);
combinedPaymentMethods.push({
...card,
description: getPaymentMethodDescription(card?.accountType, card.accountData),
icon,
iconSize,
+ iconHeight,
+ iconWidth,
+ iconStyles,
});
});
diff --git a/src/libs/Performance.js b/src/libs/Performance.tsx
similarity index 52%
rename from src/libs/Performance.js
rename to src/libs/Performance.tsx
index 0207fd20c564..cfb5e258c9f8 100644
--- a/src/libs/Performance.js
+++ b/src/libs/Performance.tsx
@@ -1,39 +1,73 @@
-import _ from 'underscore';
-import lodashTransform from 'lodash/transform';
import React, {Profiler, forwardRef} from 'react';
import {Alert, InteractionManager} from 'react-native';
+import lodashTransform from 'lodash/transform';
+import isObject from 'lodash/isObject';
+import isEqual from 'lodash/isEqual';
+import {Performance as RNPerformance, PerformanceEntry, PerformanceMark, PerformanceMeasure} from 'react-native-performance';
+import {PerformanceObserverEntryList} from 'react-native-performance/lib/typescript/performance-observer';
import * as Metrics from './Metrics';
import getComponentDisplayName from './getComponentDisplayName';
import CONST from '../CONST';
import isE2ETestSession from './E2E/isE2ETestSession';
-/** @type {import('react-native-performance').Performance} */
-let rnPerformance;
+type WrappedComponentConfig = {id: string};
+
+type PerformanceEntriesCallback = (entry: PerformanceEntry) => void;
+
+type Phase = 'mount' | 'update';
+
+type WithRenderTraceHOC = >(WrappedComponent: React.ComponentType
) => React.ComponentType
>;
+
+type BlankHOC =
>(Component: React.ComponentType
) => React.ComponentType
;
+
+type SetupPerformanceObserver = () => void;
+type DiffObject = (object: Record, base: Record) => Record;
+type GetPerformanceMetrics = () => PerformanceEntry[];
+type PrintPerformanceMetrics = () => void;
+type MarkStart = (name: string, detail?: Record) => PerformanceMark | void;
+type MarkEnd = (name: string, detail?: Record) => PerformanceMark | void;
+type MeasureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => void;
+type MeasureTTI = (endMark: string) => void;
+type TraceRender = (id: string, phase: Phase, actualDuration: number, baseDuration: number, startTime: number, commitTime: number, interactions: Set) => PerformanceMeasure | void;
+type WithRenderTrace = ({id}: WrappedComponentConfig) => WithRenderTraceHOC | BlankHOC;
+type SubscribeToMeasurements = (callback: PerformanceEntriesCallback) => void;
+
+type PerformanceModule = {
+ diffObject: DiffObject;
+ setupPerformanceObserver: SetupPerformanceObserver;
+ getPerformanceMetrics: GetPerformanceMetrics;
+ printPerformanceMetrics: PrintPerformanceMetrics;
+ markStart: MarkStart;
+ markEnd: MarkEnd;
+ measureFailSafe: MeasureFailSafe;
+ measureTTI: MeasureTTI;
+ traceRender: TraceRender;
+ withRenderTrace: WithRenderTrace;
+ subscribeToMeasurements: SubscribeToMeasurements;
+};
+
+let rnPerformance: RNPerformance;
/**
* Deep diff between two objects. Useful for figuring out what changed about an object from one render to the next so
* that state and props updates can be optimized.
- *
- * @param {Object} object
- * @param {Object} base
- * @return {Object}
*/
-function diffObject(object, base) {
- function changes(obj, comparisonObject) {
+function diffObject(object: Record, base: Record): Record {
+ function changes(obj: Record, comparisonObject: Record): Record {
return lodashTransform(obj, (result, value, key) => {
- if (_.isEqual(value, comparisonObject[key])) {
+ if (isEqual(value, comparisonObject[key])) {
return;
}
// eslint-disable-next-line no-param-reassign
- result[key] = _.isObject(value) && _.isObject(comparisonObject[key]) ? changes(value, comparisonObject[key]) : value;
+ result[key] = isObject(value) && isObject(comparisonObject[key]) ? changes(value as Record, comparisonObject[key] as Record) : value;
});
}
return changes(object, base);
}
-const Performance = {
+const Performance: PerformanceModule = {
// When performance monitoring is disabled the implementations are blank
diffObject,
setupPerformanceObserver: () => {},
@@ -44,7 +78,11 @@ const Performance = {
measureFailSafe: () => {},
measureTTI: () => {},
traceRender: () => {},
- withRenderTrace: () => (Component) => Component,
+ withRenderTrace:
+ () =>
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ >(Component: React.ComponentType
): React.ComponentType
=>
+ Component,
subscribeToMeasurements: () => {},
};
@@ -53,20 +91,21 @@ if (Metrics.canCapturePerformanceMetrics()) {
perfModule.setResourceLoggingEnabled(true);
rnPerformance = perfModule.default;
- Performance.measureFailSafe = (measureName, startOrMeasureOptions, endMark) => {
+ Performance.measureFailSafe = (measureName: string, startOrMeasureOptions: string, endMark: string) => {
try {
rnPerformance.measure(measureName, startOrMeasureOptions, endMark);
} catch (error) {
// Sometimes there might be no start mark recorded and the measure will fail with an error
- console.debug(error.message);
+ if (error instanceof Error) {
+ console.debug(error.message);
+ }
}
};
/**
* Measures the TTI time. To be called when the app is considered to be interactive.
- * @param {String} [endMark] Optional end mark name
*/
- Performance.measureTTI = (endMark) => {
+ Performance.measureTTI = (endMark: string) => {
// Make sure TTI is captured when the app is really usable
InteractionManager.runAfterInteractions(() => {
requestAnimationFrame(() => {
@@ -88,8 +127,8 @@ if (Metrics.canCapturePerformanceMetrics()) {
performanceReported.setupDefaultFlipperReporter();
// Monitor some native marks that we want to put on the timeline
- new perfModule.PerformanceObserver((list, observer) => {
- list.getEntries().forEach((entry) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList, observer: PerformanceObserver) => {
+ list.getEntries().forEach((entry: PerformanceEntry) => {
if (entry.name === 'nativeLaunchEnd') {
Performance.measureFailSafe('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd');
}
@@ -108,8 +147,8 @@ if (Metrics.canCapturePerformanceMetrics()) {
}).observe({type: 'react-native-mark', buffered: true});
// Monitor for "_end" marks and capture "_start" to "_end" measures
- new perfModule.PerformanceObserver((list) => {
- list.getEntriesByType('mark').forEach((mark) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => {
+ list.getEntriesByType('mark').forEach((mark: PerformanceEntry) => {
if (mark.name.endsWith('_end')) {
const end = mark.name;
const name = end.replace(/_end$/, '');
@@ -125,65 +164,64 @@ if (Metrics.canCapturePerformanceMetrics()) {
}).observe({type: 'mark', buffered: true});
};
- Performance.getPerformanceMetrics = () =>
- _.chain([
+ Performance.getPerformanceMetrics = (): PerformanceEntry[] =>
+ [
...rnPerformance.getEntriesByName('nativeLaunch'),
...rnPerformance.getEntriesByName('runJsBundle'),
...rnPerformance.getEntriesByName('jsBundleDownload'),
...rnPerformance.getEntriesByName('TTI'),
...rnPerformance.getEntriesByName('regularAppStart'),
...rnPerformance.getEntriesByName('appStartedToReady'),
- ])
- .filter((entry) => entry.duration > 0)
- .value();
+ ].filter((entry) => entry.duration > 0);
/**
* Outputs performance stats. We alert these so that they are easy to access in release builds.
*/
Performance.printPerformanceMetrics = () => {
const stats = Performance.getPerformanceMetrics();
- const statsAsText = _.map(stats, (entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n');
+ const statsAsText = stats.map((entry) => `\u2022 ${entry.name}: ${entry.duration.toFixed(1)}ms`).join('\n');
if (stats.length > 0) {
Alert.alert('Performance', statsAsText);
}
};
- Performance.subscribeToMeasurements = (callback) => {
- new perfModule.PerformanceObserver((list) => {
+ Performance.subscribeToMeasurements = (callback: PerformanceEntriesCallback) => {
+ new perfModule.PerformanceObserver((list: PerformanceObserverEntryList) => {
list.getEntriesByType('measure').forEach(callback);
}).observe({type: 'measure', buffered: true});
};
/**
* Add a start mark to the performance entries
- * @param {string} name
- * @param {Object} [detail]
- * @returns {PerformanceMark}
*/
- Performance.markStart = (name, detail) => rnPerformance.mark(`${name}_start`, {detail});
+ Performance.markStart = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_start`, {detail});
/**
* Add an end mark to the performance entries
* A measure between start and end is captured automatically
- * @param {string} name
- * @param {Object} [detail]
- * @returns {PerformanceMark}
*/
- Performance.markEnd = (name, detail) => rnPerformance.mark(`${name}_end`, {detail});
+ Performance.markEnd = (name: string, detail?: Record): PerformanceMark => rnPerformance.mark(`${name}_end`, {detail});
/**
* Put data emitted by Profiler components on the timeline
- * @param {string} id the "id" prop of the Profiler tree that has just committed
- * @param {'mount'|'update'} phase either "mount" (if the tree just mounted) or "update" (if it re-rendered)
- * @param {number} actualDuration time spent rendering the committed update
- * @param {number} baseDuration estimated time to render the entire subtree without memoization
- * @param {number} startTime when React began rendering this update
- * @param {number} commitTime when React committed this update
- * @param {Set} interactions the Set of interactions belonging to this update
- * @returns {PerformanceMeasure}
+ * @param id the "id" prop of the Profiler tree that has just committed
+ * @param phase either "mount" (if the tree just mounted) or "update" (if it re-rendered)
+ * @param actualDuration time spent rendering the committed update
+ * @param baseDuration estimated time to render the entire subtree without memoization
+ * @param startTime when React began rendering this update
+ * @param commitTime when React committed this update
+ * @param interactions the Set of interactions belonging to this update
*/
- Performance.traceRender = (id, phase, actualDuration, baseDuration, startTime, commitTime, interactions) =>
+ Performance.traceRender = (
+ id: string,
+ phase: Phase,
+ actualDuration: number,
+ baseDuration: number,
+ startTime: number,
+ commitTime: number,
+ interactions: Set,
+ ): PerformanceMeasure =>
rnPerformance.measure(id, {
start: startTime,
duration: actualDuration,
@@ -197,14 +235,12 @@ if (Metrics.canCapturePerformanceMetrics()) {
/**
* A HOC that captures render timings of the Wrapped component
- * @param {object} config
- * @param {string} config.id
- * @returns {function(React.Component): React.FunctionComponent}
*/
Performance.withRenderTrace =
- ({id}) =>
- (WrappedComponent) => {
- const WithRenderTrace = forwardRef((props, ref) => (
+ ({id}: WrappedComponentConfig) =>
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ >(WrappedComponent: React.ComponentType
): React.ComponentType
> => {
+ const WithRenderTrace: React.ComponentType
> = forwardRef((props: P, ref) => (
));
- WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent)})`;
+ WithRenderTrace.displayName = `withRenderTrace(${getComponentDisplayName(WrappedComponent as React.ComponentType)})`;
return WithRenderTrace;
};
}
diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js
index 6b9335ab263d..29c49427bc81 100644
--- a/src/libs/PersonalDetailsUtils.js
+++ b/src/libs/PersonalDetailsUtils.js
@@ -36,21 +36,21 @@ function getDisplayNameOrDefault(passedPersonalDetails, pathToDisplayName, defau
* @returns {Array} - Array of personal detail objects
*/
function getPersonalDetailsByIDs(accountIDs, currentUserAccountID, shouldChangeUserDisplayName = false) {
- const result = [];
- _.each(
- _.filter(personalDetails, (detail) => accountIDs.includes(detail.accountID)),
- (detail) => {
+ return _.chain(accountIDs)
+ .filter((accountID) => !!allPersonalDetails[accountID])
+ .map((accountID) => {
+ const detail = allPersonalDetails[accountID];
+
if (shouldChangeUserDisplayName && currentUserAccountID === detail.accountID) {
- result.push({
+ return {
...detail,
displayName: Localize.translateLocal('common.you'),
- });
- } else {
- result.push(detail);
+ };
}
- },
- );
- return result;
+
+ return detail;
+ })
+ .value();
}
/**
diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js
index 347a825f59cc..de902b53a7a4 100644
--- a/src/libs/PolicyUtils.js
+++ b/src/libs/PolicyUtils.js
@@ -155,6 +155,14 @@ function isExpensifyGuideTeam(email) {
*/
const isPolicyAdmin = (policy) => lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN;
+/**
+ *
+ * @param {String} policyID
+ * @param {Object} policies
+ * @returns {Boolean}
+ */
+const isPolicyMember = (policyID, policies) => _.some(policies, (policy) => policy.id === policyID);
+
/**
* @param {Object} policyMembers
* @param {Object} personalDetails
@@ -174,7 +182,7 @@ function getMemberAccountIDsForWorkspace(policyMembers, personalDetails) {
if (!personalDetail || !personalDetail.login) {
return;
}
- memberEmailsToAccountIDs[personalDetail.login] = accountID;
+ memberEmailsToAccountIDs[personalDetail.login] = Number(accountID);
});
return memberEmailsToAccountIDs;
}
@@ -276,6 +284,7 @@ export {
isPolicyAdmin,
getMemberAccountIDsForWorkspace,
getIneligibleInvitees,
+ isPolicyMember,
getTag,
getTagListName,
getTagList,
diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts
index cdc45cb119d5..13e8a195cccb 100644
--- a/src/libs/ReceiptUtils.ts
+++ b/src/libs/ReceiptUtils.ts
@@ -6,10 +6,13 @@ import ReceiptHTML from '../../assets/images/receipt-html.png';
import ReceiptDoc from '../../assets/images/receipt-doc.png';
import ReceiptGeneric from '../../assets/images/receipt-generic.png';
import ReceiptSVG from '../../assets/images/receipt-svg.png';
+import {Transaction} from '../types/onyx';
+import ROUTES from '../ROUTES';
type ThumbnailAndImageURI = {
image: ImageSourcePropType | string;
thumbnail: string | null;
+ transaction?: Transaction;
};
type FileNameAndExtension = {
@@ -20,12 +23,23 @@ type FileNameAndExtension = {
/**
* Grab the appropriate receipt image and thumbnail URIs based on file type
*
- * @param path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
- * @param filename of uploaded image or last part of remote URI
+ * @param transaction
+ * @param receiptPath
+ * @param receiptFileName
*/
-function getThumbnailAndImageURIs(path: string, filename: string): ThumbnailAndImageURI {
+function getThumbnailAndImageURIs(transaction: Transaction, receiptPath: string | null = null, receiptFileName: string | null = null): ThumbnailAndImageURI {
+ // URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg
+ const path = transaction?.receipt?.source ?? receiptPath ?? '';
+ // filename of uploaded image or last part of remote URI
+ const filename = transaction?.filename ?? receiptFileName ?? '';
const isReceiptImage = Str.isImage(filename);
+ const hasEReceipt = transaction?.hasEReceipt;
+
+ if (hasEReceipt) {
+ return {thumbnail: null, image: ROUTES.ERECEIPT.getRoute(transaction.transactionID), transaction};
+ }
+
// For local files, we won't have a thumbnail yet
if (isReceiptImage && (path.startsWith('blob:') || path.startsWith('file:'))) {
return {thumbnail: null, image: path};
diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js
deleted file mode 100644
index fa1883dd9b98..000000000000
--- a/src/libs/ReportActionsUtils.js
+++ /dev/null
@@ -1,698 +0,0 @@
-/* eslint-disable rulesdir/prefer-underscore-method */
-import lodashGet from 'lodash/get';
-import _ from 'underscore';
-import {max, parseISO, isEqual} from 'date-fns';
-import lodashFindLast from 'lodash/findLast';
-import Onyx from 'react-native-onyx';
-import * as CollectionUtils from './CollectionUtils';
-import CONST from '../CONST';
-import ONYXKEYS from '../ONYXKEYS';
-import Log from './Log';
-import isReportMessageAttachment from './isReportMessageAttachment';
-
-const allReports = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT,
- callback: (report, key) => {
- if (!key || !report) {
- return;
- }
-
- const reportID = CollectionUtils.extractCollectionItemID(key);
- allReports[reportID] = report;
- },
-});
-
-const allReportActions = {};
-Onyx.connect({
- key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
- callback: (actions, key) => {
- if (!key || !actions) {
- return;
- }
-
- const reportID = CollectionUtils.extractCollectionItemID(key);
- allReportActions[reportID] = actions;
- },
-});
-
-let isNetworkOffline = false;
-Onyx.connect({
- key: ONYXKEYS.NETWORK,
- callback: (val) => (isNetworkOffline = lodashGet(val, 'isOffline', false)),
-});
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isCreatedAction(reportAction) {
- return lodashGet(reportAction, 'actionName') === CONST.REPORT.ACTIONS.TYPE.CREATED;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-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']) === '';
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isDeletedParentAction(reportAction) {
- return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false) && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isReversedTransaction(reportAction) {
- return lodashGet(reportAction, ['message', 0, 'isReversedTransaction'], false) && lodashGet(reportAction, 'childVisibleActionCount', 0) > 0;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isPendingRemove(reportAction) {
- return lodashGet(reportAction, 'message[0].moderationDecision.decision') === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isMoneyRequestAction(reportAction) {
- return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.IOU;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isReportPreviewAction(reportAction) {
- return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
-}
-
-/**
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isModifiedExpenseAction(reportAction) {
- return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE;
-}
-
-function isWhisperAction(action) {
- return (action.whisperedToAccountIDs || []).length > 0;
-}
-
-/**
- * Returns whether the comment is a thread parent message/the first message in a thread
- *
- * @param {Object} reportAction
- * @param {String} reportID
- * @returns {Boolean}
- */
-function isThreadParentMessage(reportAction = {}, reportID) {
- const {childType, childVisibleActionCount = 0, childReportID} = reportAction;
- return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID);
-}
-
-/**
- * Returns the parentReportAction if the given report is a thread/task.
- *
- * @param {Object} report
- * @param {Object} [allReportActionsParam]
- * @returns {Object}
- * @deprecated Use Onyx.connect() or withOnyx() instead
- */
-function getParentReportAction(report, allReportActionsParam = undefined) {
- if (!report || !report.parentReportID || !report.parentReportActionID) {
- return {};
- }
- return lodashGet(allReportActionsParam || allReportActions, [report.parentReportID, report.parentReportActionID], {});
-}
-
-/**
- * Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isSentMoneyReportAction(reportAction) {
- return (
- reportAction &&
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
- lodashGet(reportAction, 'originalMessage.type') === CONST.IOU.REPORT_ACTION_TYPE.PAY &&
- _.has(reportAction.originalMessage, 'IOUDetails')
- );
-}
-
-/**
- * Returns whether the thread is a transaction thread, which is any thread with IOU parent
- * report action from requesting money (type - create) or from sending money (type - pay with IOUDetails field)
- *
- * @param {Object} parentReportAction
- * @returns {Boolean}
- */
-function isTransactionThread(parentReportAction) {
- const originalMessage = lodashGet(parentReportAction, 'originalMessage', {});
- return (
- parentReportAction &&
- parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
- (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails')))
- );
-}
-
-/**
- * Sort an array of reportActions by their created timestamp first, and reportActionID second
- * This gives us a stable order even in the case of multiple reportActions created on the same millisecond
- *
- * @param {Array} reportActions
- * @param {Boolean} shouldSortInDescendingOrder
- * @returns {Array}
- */
-function getSortedReportActions(reportActions, shouldSortInDescendingOrder = false) {
- if (!_.isArray(reportActions)) {
- throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`);
- }
-
- const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1;
- return _.chain(reportActions)
- .compact()
- .sort((first, second) => {
- // First sort by timestamp
- if (first.created !== second.created) {
- return (first.created < second.created ? -1 : 1) * invertedMultiplier;
- }
-
- // Then by action type, ensuring that `CREATED` actions always come first if they have the same timestamp as another action type
- if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) {
- return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier;
- }
- // Ensure that `REPORTPREVIEW` actions always come after if they have the same timestamp as another action type
- if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW || second.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) && first.actionName !== second.actionName) {
- return (first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? 1 : -1) * invertedMultiplier;
- }
-
- // Then fallback on reportActionID as the final sorting criteria. It is a random number,
- // but using this will ensure that the order of reportActions with the same created time and action type
- // will be consistent across all users and devices
- return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
- })
- .value();
-}
-
-/**
- * Finds most recent IOU request action ID.
- *
- * @param {Array} reportActions
- * @returns {String}
- */
-function getMostRecentIOURequestActionID(reportActions) {
- const iouRequestTypes = [CONST.IOU.REPORT_ACTION_TYPE.CREATE, CONST.IOU.REPORT_ACTION_TYPE.SPLIT];
- const iouRequestActions = _.filter(reportActions, (action) => iouRequestTypes.includes(lodashGet(action, 'originalMessage.type')));
-
- if (_.isEmpty(iouRequestActions)) {
- return null;
- }
-
- const sortedReportActions = getSortedReportActions(iouRequestActions);
- return _.last(sortedReportActions).reportActionID;
-}
-
-/**
- * Returns array of links inside a given report action
- *
- * @param {Object} reportAction
- * @returns {Array}
- */
-function extractLinksFromMessageHtml(reportAction) {
- const htmlContent = lodashGet(reportAction, ['message', 0, 'html']);
-
- // Regex to get link in href prop inside of component
- const regex = /]*?\s+)?href="([^"]*)"/gi;
-
- if (!htmlContent) {
- return [];
- }
-
- return _.map([...htmlContent.matchAll(regex)], (match) => match[1]);
-}
-
-/**
- * Returns the report action immediately before the specified index.
- * @param {Array} reportActions - all actions
- * @param {Number} actionIndex - index of the action
- * @returns {Object|null}
- */
-function findPreviousAction(reportActions, actionIndex) {
- for (let i = actionIndex + 1; i < reportActions.length; i++) {
- // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list.
- // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete.
- if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- return reportActions[i];
- }
- }
- return null;
-}
-
-/**
- * Returns true when the report action immediately before the specified index is a comment made by the same actor who who is leaving a comment in the action at the specified index.
- * Also checks to ensure that the comment is not too old to be shown as a grouped comment.
- *
- * @param {Array} reportActions
- * @param {Number} actionIndex - index of the comment item in state to check
- * @returns {Boolean}
- */
-function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) {
- const previousAction = findPreviousAction(reportActions, actionIndex);
- const currentAction = reportActions[actionIndex];
-
- // It's OK for there to be no previous action, and in that case, false will be returned
- // so that the comment isn't grouped
- if (!currentAction || !previousAction) {
- return false;
- }
-
- // Comments are only grouped if they happen within 5 minutes of each other
- if (new Date(currentAction.created).getTime() - new Date(previousAction.created).getTime() > 300000) {
- return false;
- }
-
- // Do not group if previous action was a created action
- if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
- return false;
- }
-
- // Do not group if previous or current action was a renamed action
- if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
- return false;
- }
-
- // Do not group if the delegate account ID is different
- if (previousAction.delegateAccountID !== currentAction.delegateAccountID) {
- return false;
- }
-
- return currentAction.actorAccountID === previousAction.actorAccountID;
-}
-
-/**
- * Checks if a reportAction is deprecated.
- *
- * @param {Object} reportAction
- * @param {String} key
- * @returns {Boolean}
- */
-function isReportActionDeprecated(reportAction, key) {
- if (!reportAction) {
- return true;
- }
-
- // HACK ALERT: We're temporarily filtering out any reportActions keyed by sequenceNumber
- // to prevent bugs during the migration from sequenceNumber -> reportActionID
- if (String(reportAction.sequenceNumber) === key) {
- Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction);
- return true;
- }
-
- return false;
-}
-
-/**
- * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid
- * and supported type, it's not deleted and also not closed.
- *
- * @param {Object} reportAction
- * @param {String} key
- * @returns {Boolean}
- */
-function shouldReportActionBeVisible(reportAction, key) {
- if (isReportActionDeprecated(reportAction, key)) {
- return false;
- }
-
- if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKEDITED) {
- return false;
- }
-
- // Filter out any unsupported reportAction types
- if (!Object.values(CONST.REPORT.ACTIONS.TYPE).includes(reportAction.actionName) && !Object.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG).includes(reportAction.actionName)) {
- return false;
- }
-
- // Ignore closed action here since we're already displaying a footer that explains why the report was closed
- if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) {
- return false;
- }
-
- if (isPendingRemove(reportAction)) {
- return false;
- }
-
- // All other actions are displayed except thread parents, deleted, or non-pending actions
- const isDeleted = isDeletedAction(reportAction);
- const isPending = !!reportAction.pendingAction;
- return !isDeleted || isPending || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction);
-}
-
-/**
- * Checks if a reportAction is fit for display as report last action, meaning that
- * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted.
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function shouldReportActionBeVisibleAsLastAction(reportAction) {
- if (!reportAction) {
- return false;
- }
-
- if (!_.isEmpty(reportAction.errors)) {
- return false;
- }
-
- // If a whisper action is the REPORTPREVIEW action, we are displaying it.
- return (
- shouldReportActionBeVisible(reportAction, reportAction.reportActionID) &&
- !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) &&
- !isDeletedAction(reportAction)
- );
-}
-
-/**
- * @param {String} reportID
- * @param {Object} [actionsToMerge]
- * @return {Object}
- */
-function getLastVisibleAction(reportID, actionsToMerge = {}) {
- const updatedActionsToMerge = {};
- if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) {
- Object.keys(actionsToMerge).forEach(
- (actionToMergeID) => (updatedActionsToMerge[actionToMergeID] = {...allReportActions[reportID][actionToMergeID], ...actionsToMerge[actionToMergeID]}),
- );
- }
- const actions = Object.values({
- ...allReportActions[reportID],
- ...updatedActionsToMerge,
- });
- const visibleActions = actions.filter((action) => shouldReportActionBeVisibleAsLastAction(action));
-
- if (visibleActions.length === 0) {
- return {};
- }
- const maxDate = max(visibleActions.map((action) => parseISO(action.created)));
- const maxAction = visibleActions.find((action) => isEqual(parseISO(action.created), maxDate));
- return maxAction;
-}
-
-/**
- * @param {String} reportID
- * @param {Object} [actionsToMerge]
- * @return {Object}
- */
-function getLastVisibleMessage(reportID, actionsToMerge = {}) {
- const lastVisibleAction = getLastVisibleAction(reportID, actionsToMerge);
- const message = lodashGet(lastVisibleAction, ['message', 0], {});
-
- if (isReportMessageAttachment(message)) {
- return {
- lastMessageTranslationKey: CONST.TRANSLATION_KEYS.ATTACHMENT,
- lastMessageText: CONST.ATTACHMENT_MESSAGE_TEXT,
- lastMessageHtml: CONST.TRANSLATION_KEYS.ATTACHMENT,
- };
- }
-
- if (isCreatedAction(lastVisibleAction)) {
- return {
- lastMessageText: '',
- };
- }
-
- const messageText = lodashGet(message, 'text', '');
- return {
- lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
- };
-}
-
-/**
- * A helper method to filter out report actions keyed by sequenceNumbers.
- *
- * @param {Object} reportActions
- * @returns {Array}
- */
-function filterOutDeprecatedReportActions(reportActions) {
- return _.filter(reportActions, (reportAction, key) => !isReportActionDeprecated(reportAction, key));
-}
-
-/**
- * This method returns the report actions that are ready for display in the ReportActionsView.
- * The report actions need to be sorted by created timestamp first, and reportActionID second
- * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp).
- * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY.
- *
- * @param {Object} reportActions
- * @returns {Array}
- */
-function getSortedReportActionsForDisplay(reportActions) {
- const filteredReportActions = _.filter(reportActions, (reportAction, key) => shouldReportActionBeVisible(reportAction, key));
- return getSortedReportActions(filteredReportActions, true);
-}
-
-/**
- * In some cases, there can be multiple closed report actions in a chat report.
- * This method returns the last closed report action so we can always show the correct archived report reason.
- * Additionally, archived #admins and #announce do not have the closed report action so we will return null if none is found.
- *
- * @param {Object} reportActions
- * @returns {Object|null}
- */
-function getLastClosedReportAction(reportActions) {
- // If closed report action is not present, return early
- if (!_.some(reportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED)) {
- return null;
- }
- const filteredReportActions = filterOutDeprecatedReportActions(reportActions);
- const sortedReportActions = getSortedReportActions(filteredReportActions);
- return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED);
-}
-
-/**
- * @param {Array} onyxData
- * @returns {Object} The latest report action in the `onyxData` or `null` if one couldn't be found
- */
-function getLatestReportActionFromOnyxData(onyxData) {
- const reportActionUpdate = _.find(onyxData, (onyxUpdate) => onyxUpdate.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS));
-
- if (!reportActionUpdate) {
- return null;
- }
-
- const reportActions = _.values(reportActionUpdate.value);
- const sortedReportActions = getSortedReportActions(reportActions);
- return _.last(sortedReportActions);
-}
-
-/**
- * Find the transaction associated with this reportAction, if one exists.
- *
- * @param {String} reportID
- * @param {String} reportActionID
- * @returns {String|null}
- */
-function getLinkedTransactionID(reportID, reportActionID) {
- const reportAction = lodashGet(allReportActions, [reportID, reportActionID]);
- if (!reportAction || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
- return null;
- }
- return reportAction.originalMessage.IOUTransactionID;
-}
-
-/**
- *
- * @param {String} reportID
- * @param {String} reportActionID
- * @returns {Object}
- */
-function getReportAction(reportID, reportActionID) {
- return lodashGet(allReportActions, [reportID, reportActionID], {});
-}
-
-/**
- * @returns {string}
- */
-function getMostRecentReportActionLastModified() {
- // Start with the oldest date possible
- let mostRecentReportActionLastModified = new Date(0).toISOString();
-
- // Flatten all the actions
- // Loop over them all to find the one that is the most recent
- const flatReportActions = _.flatten(_.map(allReportActions, (actions) => _.values(actions)));
- _.each(flatReportActions, (action) => {
- // Pending actions should not be counted here as a user could create a comment or some other action while offline and the server might know about
- // messages they have not seen yet.
- if (!_.isEmpty(action.pendingAction)) {
- return;
- }
-
- const lastModified = action.lastModified || action.created;
- if (lastModified < mostRecentReportActionLastModified) {
- return;
- }
-
- mostRecentReportActionLastModified = lastModified;
- });
-
- // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get
- // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these
- _.each(allReports, (report) => {
- const reportLastVisibleActionLastModified = report.lastVisibleActionLastModified || report.lastVisibleActionCreated;
- if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) {
- return;
- }
-
- mostRecentReportActionLastModified = reportLastVisibleActionLastModified;
- });
-
- return mostRecentReportActionLastModified;
-}
-
-/**
- * @param {*} chatReportID
- * @param {*} iouReportID
- * @returns {Object} The report preview action or `null` if one couldn't be found
- */
-function getReportPreviewAction(chatReportID, iouReportID) {
- return _.find(
- allReportActions[chatReportID],
- (reportAction) => reportAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lodashGet(reportAction, 'originalMessage.linkedReportID') === iouReportID,
- );
-}
-
-/**
- * Get the iouReportID for a given report action.
- *
- * @param {Object} reportAction
- * @returns {String}
- */
-function getIOUReportIDFromReportActionPreview(reportAction) {
- return lodashGet(reportAction, 'originalMessage.linkedReportID', '');
-}
-
-function isCreatedTaskReportAction(reportAction) {
- return reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && _.has(reportAction.originalMessage, 'taskReportID');
-}
-
-/**
- * A helper method to identify if the message is deleted or not.
- *
- * @param {Object} reportAction
- * @returns {Boolean}
- */
-function isMessageDeleted(reportAction) {
- return lodashGet(reportAction, ['message', 0, 'isDeletedParentAction'], false);
-}
-
-/**
- * Returns the number of money requests associated with a report preview
- *
- * @param {Object|null} reportPreviewAction
- * @returns {Number}
- */
-function getNumberOfMoneyRequests(reportPreviewAction) {
- return lodashGet(reportPreviewAction, 'childMoneyRequestCount', 0);
-}
-
-/**
- * @param {*} reportAction
- * @returns {Boolean}
- */
-function isSplitBillAction(reportAction) {
- return lodashGet(reportAction, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
-}
-
-/**
- *
- * @param {*} reportAction
- * @returns {Boolean}
- */
-function isTaskAction(reportAction) {
- const reportActionName = lodashGet(reportAction, 'actionName', '');
- return (
- reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
- reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ||
- reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED
- );
-}
-
-/**
- * @param {*} reportID
- * @returns {[Object]}
- */
-function getAllReportActions(reportID) {
- return lodashGet(allReportActions, reportID, []);
-}
-
-/**
- * Check whether a report action is an attachment (a file, such as an image or a zip).
- *
- * @param {Object} reportAction report action
- * @returns {Boolean}
- */
-function isReportActionAttachment(reportAction) {
- const message = _.first(lodashGet(reportAction, 'message', [{}]));
- return _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : isReportMessageAttachment(message);
-}
-
-// eslint-disable-next-line rulesdir/no-negated-variables
-function isNotifiableReportAction(reportAction) {
- return reportAction && _.contains([CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE], reportAction.actionName);
-}
-
-export {
- getSortedReportActions,
- getLastVisibleAction,
- getLastVisibleMessage,
- getMostRecentIOURequestActionID,
- extractLinksFromMessageHtml,
- isCreatedAction,
- isDeletedAction,
- shouldReportActionBeVisible,
- shouldReportActionBeVisibleAsLastAction,
- isReportActionDeprecated,
- isConsecutiveActionMadeByPreviousActor,
- getSortedReportActionsForDisplay,
- getLastClosedReportAction,
- getLatestReportActionFromOnyxData,
- isMoneyRequestAction,
- isThreadParentMessage,
- getLinkedTransactionID,
- getMostRecentReportActionLastModified,
- getReportPreviewAction,
- isCreatedTaskReportAction,
- getParentReportAction,
- isTransactionThread,
- isSentMoneyReportAction,
- isDeletedParentAction,
- isReversedTransaction,
- isReportPreviewAction,
- isModifiedExpenseAction,
- getIOUReportIDFromReportActionPreview,
- isMessageDeleted,
- isWhisperAction,
- isPendingRemove,
- getReportAction,
- getNumberOfMoneyRequests,
- isSplitBillAction,
- isTaskAction,
- getAllReportActions,
- isReportActionAttachment,
- isNotifiableReportAction,
-};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
new file mode 100644
index 000000000000..c795e5d1c3b1
--- /dev/null
+++ b/src/libs/ReportActionsUtils.ts
@@ -0,0 +1,647 @@
+import {isEqual, max} from 'date-fns';
+import _ from 'lodash';
+import lodashFindLast from 'lodash/findLast';
+import Onyx, {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
+import CONST from '../CONST';
+import ONYXKEYS from '../ONYXKEYS';
+import ReportAction, {ReportActions} from '../types/onyx/ReportAction';
+import Report from '../types/onyx/Report';
+import {ActionName} from '../types/onyx/OriginalMessage';
+import * as CollectionUtils from './CollectionUtils';
+import Log from './Log';
+import isReportMessageAttachment from './isReportMessageAttachment';
+import * as Environment from './Environment/Environment';
+
+type LastVisibleMessage = {
+ lastMessageTranslationKey?: string;
+ lastMessageText: string;
+ lastMessageHtml?: string;
+};
+
+const allReports: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT,
+ callback: (report, key) => {
+ if (!key || !report) {
+ return;
+ }
+
+ const reportID = CollectionUtils.extractCollectionItemID(key);
+ allReports[reportID] = report;
+ },
+});
+
+const allReportActions: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
+ callback: (actions, key) => {
+ if (!key || !actions) {
+ return;
+ }
+
+ const reportID = CollectionUtils.extractCollectionItemID(key);
+ allReportActions[reportID] = actions;
+ },
+});
+
+let isNetworkOffline = false;
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ callback: (val) => (isNetworkOffline = val?.isOffline ?? false),
+});
+
+let environmentURL: string;
+Environment.getEnvironmentURL().then((url: string) => (environmentURL = url));
+
+function isCreatedAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
+}
+
+function isDeletedAction(reportAction: OnyxEntry): boolean {
+ // A deleted comment has either an empty array or an object with html field with empty string as value
+ const message = reportAction?.message ?? [];
+ return message.length === 0 || message[0]?.html === '';
+}
+
+function isDeletedParentAction(reportAction: OnyxEntry): boolean {
+ return (reportAction?.message?.[0]?.isDeletedParentAction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+}
+
+function isReversedTransaction(reportAction: OnyxEntry) {
+ return (reportAction?.message?.[0].isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+}
+
+function isPendingRemove(reportAction: OnyxEntry): boolean {
+ return reportAction?.message?.[0]?.moderationDecision?.decision === CONST.MODERATION.MODERATOR_DECISION_PENDING_REMOVE;
+}
+
+function isMoneyRequestAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU;
+}
+
+function isReportPreviewAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW;
+}
+
+function isModifiedExpenseAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE;
+}
+
+function isWhisperAction(reportAction: OnyxEntry): boolean {
+ return (reportAction?.whisperedToAccountIDs ?? []).length > 0;
+}
+
+/**
+ * Returns whether the comment is a thread parent message/the first message in a thread
+ */
+function isThreadParentMessage(reportAction: OnyxEntry, reportID: string): boolean {
+ const {childType, childVisibleActionCount = 0, childReportID} = reportAction ?? {};
+ return childType === CONST.REPORT.TYPE.CHAT && (childVisibleActionCount > 0 || String(childReportID) === reportID);
+}
+
+/**
+ * Returns the parentReportAction if the given report is a thread/task.
+ *
+ * @deprecated Use Onyx.connect() or withOnyx() instead
+ */
+function getParentReportAction(report: OnyxEntry, allReportActionsParam?: OnyxCollection): ReportAction | Record {
+ if (!report?.parentReportID || !report.parentReportActionID) {
+ return {};
+ }
+ return (allReportActionsParam ?? allReportActions)?.[report.parentReportID]?.[report.parentReportActionID] ?? {};
+}
+
+/**
+ * Determines if the given report action is sent money report action by checking for 'pay' type and presence of IOUDetails object.
+ */
+function isSentMoneyReportAction(reportAction: OnyxEntry): boolean {
+ return (
+ reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction?.originalMessage?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!reportAction?.originalMessage?.IOUDetails
+ );
+}
+
+/**
+ * Returns whether the thread is a transaction thread, which is any thread with IOU parent
+ * report action from requesting money (type - create) or from sending money (type - pay with IOUDetails field)
+ */
+function isTransactionThread(parentReportAction: OnyxEntry): boolean {
+ return (
+ parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU &&
+ (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE ||
+ (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!parentReportAction.originalMessage.IOUDetails))
+ );
+}
+
+/**
+ * Sort an array of reportActions by their created timestamp first, and reportActionID second
+ * This gives us a stable order even in the case of multiple reportActions created on the same millisecond
+ *
+ */
+function getSortedReportActions(reportActions: ReportAction[] | null, shouldSortInDescendingOrder = false): ReportAction[] {
+ if (!Array.isArray(reportActions)) {
+ throw new Error(`ReportActionsUtils.getSortedReportActions requires an array, received ${typeof reportActions}`);
+ }
+
+ const invertedMultiplier = shouldSortInDescendingOrder ? -1 : 1;
+
+ return reportActions?.filter(Boolean).sort((first, second) => {
+ // First sort by timestamp
+ if (first.created !== second.created) {
+ return (first.created < second.created ? -1 : 1) * invertedMultiplier;
+ }
+
+ // Then by action type, ensuring that `CREATED` actions always come first if they have the same timestamp as another action type
+ if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED || second.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) && first.actionName !== second.actionName) {
+ return (first.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED ? -1 : 1) * invertedMultiplier;
+ }
+ // Ensure that `REPORTPREVIEW` actions always come after if they have the same timestamp as another action type
+ if ((first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW || second.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) && first.actionName !== second.actionName) {
+ return (first.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? 1 : -1) * invertedMultiplier;
+ }
+
+ // Then fallback on reportActionID as the final sorting criteria. It is a random number,
+ // but using this will ensure that the order of reportActions with the same created time and action type
+ // will be consistent across all users and devices
+ return (first.reportActionID < second.reportActionID ? -1 : 1) * invertedMultiplier;
+ });
+}
+
+/**
+ * Finds most recent IOU request action ID.
+ */
+function getMostRecentIOURequestActionID(reportActions: ReportAction[] | null): string | null {
+ if (!Array.isArray(reportActions)) {
+ return null;
+ }
+ const iouRequestTypes: Array> = [CONST.IOU.REPORT_ACTION_TYPE.CREATE, CONST.IOU.REPORT_ACTION_TYPE.SPLIT];
+ const iouRequestActions = reportActions?.filter((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && iouRequestTypes.includes(action.originalMessage.type)) ?? [];
+
+ if (iouRequestActions.length === 0) {
+ return null;
+ }
+
+ const sortedReportActions = getSortedReportActions(iouRequestActions);
+ return sortedReportActions.at(-1)?.reportActionID ?? null;
+}
+
+/**
+ * Returns array of links inside a given report action
+ */
+function extractLinksFromMessageHtml(reportAction: OnyxEntry): string[] {
+ const htmlContent = reportAction?.message?.[0]?.html;
+
+ // Regex to get link in href prop inside of component
+ const regex = /]*?\s+)?href="([^"]*)"/gi;
+
+ if (!htmlContent) {
+ return [];
+ }
+
+ return [...htmlContent.matchAll(regex)].map((match) => match[1]);
+}
+
+/**
+ * Returns the report action immediately before the specified index.
+ * @param reportActions - all actions
+ * @param actionIndex - index of the action
+ */
+function findPreviousAction(reportActions: ReportAction[] | null, actionIndex: number): OnyxEntry {
+ if (!reportActions) {
+ return null;
+ }
+
+ for (let i = actionIndex + 1; i < reportActions.length; i++) {
+ // Find the next non-pending deletion report action, as the pending delete action means that it is not displayed in the UI, but still is in the report actions list.
+ // If we are offline, all actions are pending but shown in the UI, so we take the previous action, even if it is a delete.
+ if (isNetworkOffline || reportActions[i].pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ return reportActions[i];
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Returns true when the report action immediately before the specified index is a comment made by the same actor who who is leaving a comment in the action at the specified index.
+ * Also checks to ensure that the comment is not too old to be shown as a grouped comment.
+ *
+ * @param actionIndex - index of the comment item in state to check
+ */
+function isConsecutiveActionMadeByPreviousActor(reportActions: ReportAction[] | null, actionIndex: number): boolean {
+ const previousAction = findPreviousAction(reportActions, actionIndex);
+ const currentAction = reportActions?.[actionIndex];
+
+ // It's OK for there to be no previous action, and in that case, false will be returned
+ // so that the comment isn't grouped
+ if (!currentAction || !previousAction) {
+ return false;
+ }
+
+ // Comments are only grouped if they happen within 5 minutes of each other
+ if (new Date(currentAction.created).getTime() - new Date(previousAction.created).getTime() > 300000) {
+ return false;
+ }
+
+ // Do not group if previous action was a created action
+ if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ return false;
+ }
+
+ // Do not group if previous or current action was a renamed action
+ if (previousAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED || currentAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
+ return false;
+ }
+
+ // Do not group if the delegate account ID is different
+ if (previousAction.delegateAccountID !== currentAction.delegateAccountID) {
+ return false;
+ }
+
+ return currentAction.actorAccountID === previousAction.actorAccountID;
+}
+
+/**
+ * Checks if a reportAction is deprecated.
+ */
+function isReportActionDeprecated(reportAction: OnyxEntry, key: string): boolean {
+ if (!reportAction) {
+ return true;
+ }
+
+ // HACK ALERT: We're temporarily filtering out any reportActions keyed by sequenceNumber
+ // to prevent bugs during the migration from sequenceNumber -> reportActionID
+ if (String(reportAction.sequenceNumber) === key) {
+ Log.info('Front-end filtered out reportAction keyed by sequenceNumber!', false, reportAction);
+ return true;
+ }
+
+ return false;
+}
+
+const {POLICYCHANGELOG: policyChangelogTypes, ROOMCHANGELOG: roomChangeLogTypes, ...otherActionTypes} = CONST.REPORT.ACTIONS.TYPE;
+const supportedActionTypes: ActionName[] = [...Object.values(otherActionTypes), ...Object.values(policyChangelogTypes), ...Object.values(roomChangeLogTypes)];
+
+/**
+ * Checks if a reportAction is fit for display, meaning that it's not deprecated, is of a valid
+ * and supported type, it's not deleted and also not closed.
+ */
+function shouldReportActionBeVisible(reportAction: OnyxEntry, key: string): boolean {
+ if (!reportAction) {
+ return false;
+ }
+
+ if (isReportActionDeprecated(reportAction, key)) {
+ return false;
+ }
+
+ if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKEDITED) {
+ return false;
+ }
+
+ // Filter out any unsupported reportAction types
+ if (!supportedActionTypes.includes(reportAction.actionName)) {
+ return false;
+ }
+
+ // Ignore closed action here since we're already displaying a footer that explains why the report was closed
+ if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) {
+ return false;
+ }
+
+ if (isPendingRemove(reportAction)) {
+ return false;
+ }
+
+ // All other actions are displayed except thread parents, deleted, or non-pending actions
+ const isDeleted = isDeletedAction(reportAction);
+ const isPending = !!reportAction.pendingAction;
+ return !isDeleted || isPending || isDeletedParentAction(reportAction) || isReversedTransaction(reportAction);
+}
+
+/**
+ * Checks if a reportAction is fit for display as report last action, meaning that
+ * it satisfies shouldReportActionBeVisible, it's not whisper action and not deleted.
+ */
+function shouldReportActionBeVisibleAsLastAction(reportAction: OnyxEntry): boolean {
+ if (!reportAction) {
+ return false;
+ }
+
+ if (Object.keys(reportAction.errors ?? {}).length > 0) {
+ return false;
+ }
+
+ // If a whisper action is the REPORTPREVIEW action, we are displaying it.
+ // If the action's message text is empty and it is not a deleted parent with visible child actions, hide it. Else, consider the action to be displayable.
+ return (
+ shouldReportActionBeVisible(reportAction, reportAction.reportActionID) &&
+ !(isWhisperAction(reportAction) && !isReportPreviewAction(reportAction) && !isMoneyRequestAction(reportAction)) &&
+ !(isDeletedAction(reportAction) && !isDeletedParentAction(reportAction))
+ );
+}
+
+/**
+ * For invite to room and remove from room policy change logs, report URLs are generated in the server,
+ * which includes a baseURL placeholder that's replaced in the client.
+ */
+function replaceBaseURL(reportAction: ReportAction): ReportAction {
+ if (!reportAction) {
+ return reportAction;
+ }
+
+ if (
+ !reportAction ||
+ (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.REMOVE_FROM_ROOM)
+ ) {
+ return reportAction;
+ }
+ if (!reportAction.message) {
+ return reportAction;
+ }
+ const updatedReportAction = _.clone(reportAction);
+ if (!updatedReportAction.message) {
+ return updatedReportAction;
+ }
+ updatedReportAction.message[0].html = reportAction.message[0].html.replace('%baseURL', environmentURL);
+ return updatedReportAction;
+}
+
+/**
+ */
+function getLastVisibleAction(reportID: string, actionsToMerge: ReportActions = {}): OnyxEntry {
+ const updatedActionsToMerge: ReportActions = {};
+ if (actionsToMerge && Object.keys(actionsToMerge).length !== 0) {
+ Object.keys(actionsToMerge).forEach(
+ (actionToMergeID) => (updatedActionsToMerge[actionToMergeID] = {...allReportActions?.[reportID]?.[actionToMergeID], ...actionsToMerge[actionToMergeID]}),
+ );
+ }
+ const actions = Object.values({
+ ...allReportActions?.[reportID],
+ ...updatedActionsToMerge,
+ });
+ const visibleActions = actions.filter((action) => shouldReportActionBeVisibleAsLastAction(action));
+
+ if (visibleActions.length === 0) {
+ return null;
+ }
+ const maxDate = max(visibleActions.map((action) => new Date(action.created)));
+ const maxAction = visibleActions.find((action) => isEqual(new Date(action.created), maxDate));
+ return maxAction ?? null;
+}
+
+function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = {}): LastVisibleMessage {
+ const lastVisibleAction = getLastVisibleAction(reportID, actionsToMerge);
+ const message = lastVisibleAction?.message?.[0];
+
+ if (message && isReportMessageAttachment(message)) {
+ return {
+ lastMessageTranslationKey: CONST.TRANSLATION_KEYS.ATTACHMENT,
+ lastMessageText: CONST.ATTACHMENT_MESSAGE_TEXT,
+ lastMessageHtml: CONST.TRANSLATION_KEYS.ATTACHMENT,
+ };
+ }
+
+ if (isCreatedAction(lastVisibleAction)) {
+ return {
+ lastMessageText: '',
+ };
+ }
+
+ const messageText = message?.text ?? '';
+ return {
+ lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
+ };
+}
+
+/**
+ * A helper method to filter out report actions keyed by sequenceNumbers.
+ */
+function filterOutDeprecatedReportActions(reportActions: ReportActions | null): ReportAction[] {
+ return Object.entries(reportActions ?? {})
+ .filter(([key, reportAction]) => !isReportActionDeprecated(reportAction, key))
+ .map((entry) => entry[1]);
+}
+
+/**
+ * This method returns the report actions that are ready for display in the ReportActionsView.
+ * The report actions need to be sorted by created timestamp first, and reportActionID second
+ * to ensure they will always be displayed in the same order (in case multiple actions have the same timestamp).
+ * This is all handled with getSortedReportActions() which is used by several other methods to keep the code DRY.
+ */
+function getSortedReportActionsForDisplay(reportActions: ReportActions | null): ReportAction[] {
+ const filteredReportActions = Object.entries(reportActions ?? {})
+ .filter(([key, reportAction]) => shouldReportActionBeVisible(reportAction, key))
+ .map((entry) => entry[1]);
+ const baseURLAdjustedReportActions = filteredReportActions.map((reportAction) => replaceBaseURL(reportAction));
+ return getSortedReportActions(baseURLAdjustedReportActions, true);
+}
+
+/**
+ * In some cases, there can be multiple closed report actions in a chat report.
+ * This method returns the last closed report action so we can always show the correct archived report reason.
+ * Additionally, archived #admins and #announce do not have the closed report action so we will return null if none is found.
+ *
+ */
+function getLastClosedReportAction(reportActions: ReportActions | null): OnyxEntry {
+ // If closed report action is not present, return early
+ if (!Object.values(reportActions ?? {}).some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED)) {
+ return null;
+ }
+
+ const filteredReportActions = filterOutDeprecatedReportActions(reportActions);
+ const sortedReportActions = getSortedReportActions(filteredReportActions);
+ return lodashFindLast(sortedReportActions, (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) ?? null;
+}
+
+/**
+ * @returns The latest report action in the `onyxData` or `null` if one couldn't be found
+ */
+function getLatestReportActionFromOnyxData(onyxData: OnyxUpdate[] | null): OnyxEntry {
+ const reportActionUpdate = onyxData?.find((onyxUpdate) => onyxUpdate.key.startsWith(ONYXKEYS.COLLECTION.REPORT_ACTIONS));
+
+ if (!reportActionUpdate) {
+ return null;
+ }
+
+ const reportActions = Object.values((reportActionUpdate.value as ReportActions) ?? {});
+ const sortedReportActions = getSortedReportActions(reportActions);
+ return sortedReportActions.at(-1) ?? null;
+}
+
+/**
+ * Find the transaction associated with this reportAction, if one exists.
+ */
+function getLinkedTransactionID(reportID: string, reportActionID: string): string | null {
+ const reportAction = allReportActions?.[reportID]?.[reportActionID];
+ if (!reportAction || reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) {
+ return null;
+ }
+ return reportAction.originalMessage.IOUTransactionID ?? null;
+}
+
+function getReportAction(reportID: string, reportActionID: string): OnyxEntry {
+ return allReportActions?.[reportID]?.[reportActionID] ?? null;
+}
+
+function getMostRecentReportActionLastModified(): string {
+ // Start with the oldest date possible
+ let mostRecentReportActionLastModified = new Date(0).toISOString();
+
+ // Flatten all the actions
+ // Loop over them all to find the one that is the most recent
+ const flatReportActions = Object.values(allReportActions ?? {})
+ .flatMap((actions) => (actions ? Object.values(actions) : []))
+ .filter(Boolean);
+ flatReportActions.forEach((action) => {
+ // Pending actions should not be counted here as a user could create a comment or some other action while offline and the server might know about
+ // messages they have not seen yet.
+ if (action.pendingAction) {
+ return;
+ }
+
+ const lastModified = action.lastModified ?? action.created;
+
+ if (lastModified < mostRecentReportActionLastModified) {
+ return;
+ }
+
+ mostRecentReportActionLastModified = lastModified;
+ });
+
+ // We might not have actions so we also look at the report objects to see if any have a lastVisibleActionLastModified that is more recent. We don't need to get
+ // any reports that have been updated before either a recently updated report or reportAction as we should be up to date on these
+ Object.values(allReports ?? {}).forEach((report) => {
+ const reportLastVisibleActionLastModified = report?.lastVisibleActionLastModified ?? report?.lastVisibleActionCreated;
+ if (!reportLastVisibleActionLastModified || reportLastVisibleActionLastModified < mostRecentReportActionLastModified) {
+ return;
+ }
+
+ mostRecentReportActionLastModified = reportLastVisibleActionLastModified;
+ });
+
+ return mostRecentReportActionLastModified;
+}
+
+/**
+ * @returns The report preview action or `null` if one couldn't be found
+ */
+function getReportPreviewAction(chatReportID: string, iouReportID: string): OnyxEntry {
+ return (
+ Object.values(allReportActions?.[chatReportID] ?? {}).find(
+ (reportAction) => reportAction && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && reportAction.originalMessage.linkedReportID === iouReportID,
+ ) ?? null
+ );
+}
+
+/**
+ * Get the iouReportID for a given report action.
+ */
+function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? reportAction.originalMessage.linkedReportID : '';
+}
+
+function isCreatedTaskReportAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !!reportAction.originalMessage?.taskReportID;
+}
+
+/**
+ * A helper method to identify if the message is deleted or not.
+ */
+function isMessageDeleted(reportAction: OnyxEntry): boolean {
+ return reportAction?.message?.[0]?.isDeletedParentAction ?? false;
+}
+
+/**
+ * Returns the number of money requests associated with a report preview
+ */
+function getNumberOfMoneyRequests(reportPreviewAction: OnyxEntry): number {
+ return reportPreviewAction?.childMoneyRequestCount ?? 0;
+}
+
+function isSplitBillAction(reportAction: OnyxEntry): boolean {
+ return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT;
+}
+
+function isTaskAction(reportAction: OnyxEntry): boolean {
+ const reportActionName = reportAction?.actionName;
+ return (
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED ||
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ||
+ reportActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED
+ );
+}
+
+function getAllReportActions(reportID: string): ReportActions {
+ return allReportActions?.[reportID] ?? {};
+}
+
+/**
+ * Check whether a report action is an attachment (a file, such as an image or a zip).
+ *
+ */
+function isReportActionAttachment(reportAction: OnyxEntry): boolean {
+ const message = reportAction?.message?.[0];
+
+ if (reportAction && 'isAttachment' in reportAction) {
+ return reportAction.isAttachment ?? false;
+ }
+
+ if (message) {
+ return isReportMessageAttachment(message);
+ }
+
+ return false;
+}
+
+// eslint-disable-next-line rulesdir/no-negated-variables
+function isNotifiableReportAction(reportAction: OnyxEntry): boolean {
+ if (!reportAction) {
+ return false;
+ }
+
+ const actions: ActionName[] = [CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT, CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE];
+
+ return actions.includes(reportAction.actionName);
+}
+
+export {
+ extractLinksFromMessageHtml,
+ getAllReportActions,
+ getIOUReportIDFromReportActionPreview,
+ getLastClosedReportAction,
+ getLastVisibleAction,
+ getLastVisibleMessage,
+ getLatestReportActionFromOnyxData,
+ getLinkedTransactionID,
+ getMostRecentIOURequestActionID,
+ getMostRecentReportActionLastModified,
+ getNumberOfMoneyRequests,
+ getParentReportAction,
+ getReportAction,
+ getReportPreviewAction,
+ getSortedReportActions,
+ getSortedReportActionsForDisplay,
+ isConsecutiveActionMadeByPreviousActor,
+ isCreatedAction,
+ isCreatedTaskReportAction,
+ isDeletedAction,
+ isDeletedParentAction,
+ isMessageDeleted,
+ isModifiedExpenseAction,
+ isMoneyRequestAction,
+ isNotifiableReportAction,
+ isPendingRemove,
+ isReversedTransaction,
+ isReportActionAttachment,
+ isReportActionDeprecated,
+ isReportPreviewAction,
+ isSentMoneyReportAction,
+ isSplitBillAction,
+ isTaskAction,
+ isThreadParentMessage,
+ isTransactionThread,
+ isWhisperAction,
+ shouldReportActionBeVisible,
+ shouldReportActionBeVisibleAsLastAction,
+};
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 8c8f203f8f18..75ee6257caab 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1,6 +1,6 @@
/* eslint-disable rulesdir/prefer-underscore-method */
import _ from 'underscore';
-import {format, parseISO} from 'date-fns';
+import {format} from 'date-fns';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import lodashIntersection from 'lodash/intersection';
@@ -660,6 +660,17 @@ function hasSingleParticipant(report) {
return report && report.participantAccountIDs && report.participantAccountIDs.length === 1;
}
+/**
+ * Checks whether all the transactions linked to the IOU report are of the Distance Request type
+ *
+ * @param {string|null} iouReportID
+ * @returns {boolean}
+ */
+function hasOnlyDistanceRequestTransactions(iouReportID) {
+ const allTransactions = TransactionUtils.getAllReportTransactions(iouReportID);
+ return _.all(allTransactions, (transaction) => TransactionUtils.isDistanceRequest(transaction));
+}
+
/**
* If the report is a thread and has a chat type set, it is a workspace chat.
*
@@ -1212,6 +1223,46 @@ function getDisplayNamesWithTooltips(personalDetailsList, isMultipleParticipantR
});
}
+/**
+ * For a deleted parent report action within a chat report,
+ * let us return the appropriate display message
+ *
+ * @param {Object} reportAction - The deleted report action of a chat report for which we need to return message.
+ * @return {String}
+ */
+function getDeletedParentActionMessageForChatReport(reportAction) {
+ // By default, let us display [Deleted message]
+ let deletedMessageText = Localize.translateLocal('parentReportAction.deletedMessage');
+ if (ReportActionsUtils.isCreatedTaskReportAction(reportAction)) {
+ // For canceled task report, let us display [Deleted task]
+ deletedMessageText = Localize.translateLocal('parentReportAction.deletedTask');
+ }
+ return deletedMessageText;
+}
+
+/**
+ * Returns the last visible message for a given report after considering the given optimistic actions
+ *
+ * @param {String} reportID - the report for which last visible message has to be fetched
+ * @param {Object} [actionsToMerge] - the optimistic merge actions that needs to be considered while fetching last visible message
+ * @return {Object}
+ */
+function getLastVisibleMessage(reportID, actionsToMerge = {}) {
+ const report = getReport(reportID);
+ const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, actionsToMerge);
+
+ // For Chat Report with deleted parent actions, let us fetch the correct message
+ if (ReportActionsUtils.isDeletedParentAction(lastVisibleAction) && isChatReport(report)) {
+ const lastMessageText = getDeletedParentActionMessageForChatReport(lastVisibleAction);
+ return {
+ lastMessageText,
+ };
+ }
+
+ // Fetch the last visible message for report represented by reportID and based on actions to merge.
+ return ReportActionsUtils.getLastVisibleMessage(reportID, actionsToMerge);
+}
+
/**
* Determines if a report has an IOU that is waiting for an action from the current user (either Pay or Add a credit bank account)
*
@@ -1283,7 +1334,7 @@ function hasNonReimbursableTransactions(iouReportID) {
* @param {Object} allReportsDict
* @returns {Number}
*/
-function getMoneyRequestTotal(report, allReportsDict = null) {
+function getMoneyRequestReimbursableTotal(report, allReportsDict = null) {
const allAvailableReports = allReportsDict || allReports;
let moneyRequestReport;
if (isMoneyRequestReport(report)) {
@@ -1294,7 +1345,6 @@ function getMoneyRequestTotal(report, allReportsDict = null) {
}
if (moneyRequestReport) {
const total = lodashGet(moneyRequestReport, 'total', 0);
-
if (total !== 0) {
// There is a possibility that if the Expense report has a negative total.
// This is because there are instances where you can get a credit back on your card,
@@ -1305,6 +1355,45 @@ function getMoneyRequestTotal(report, allReportsDict = null) {
return 0;
}
+/**
+ * @param {Object} report
+ * @param {Object} allReportsDict
+ * @returns {Object}
+ */
+function getMoneyRequestSpendBreakdown(report, allReportsDict = null) {
+ const allAvailableReports = allReportsDict || allReports;
+ let moneyRequestReport;
+ if (isMoneyRequestReport(report)) {
+ moneyRequestReport = report;
+ }
+ if (allAvailableReports && report.hasOutstandingIOU && report.iouReportID) {
+ moneyRequestReport = allAvailableReports[`${ONYXKEYS.COLLECTION.REPORT}${report.iouReportID}`];
+ }
+ if (moneyRequestReport) {
+ let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0);
+ let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0);
+
+ if (nonReimbursableSpend + reimbursableSpend !== 0) {
+ // There is a possibility that if the Expense report has a negative total.
+ // This is because there are instances where you can get a credit back on your card,
+ // or you enter a negative expense to βoffsetβ future expenses
+ nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend);
+ reimbursableSpend = isExpenseReport(moneyRequestReport) ? reimbursableSpend * -1 : Math.abs(reimbursableSpend);
+ const totalDisplaySpend = nonReimbursableSpend + reimbursableSpend;
+ return {
+ nonReimbursableSpend,
+ reimbursableSpend,
+ totalDisplaySpend,
+ };
+ }
+ }
+ return {
+ nonReimbursableSpend: 0,
+ reimbursableSpend: 0,
+ totalDisplaySpend: 0,
+ };
+}
+
/**
* Get the title for a policy expense chat which depends on the role of the policy member seeing this report
*
@@ -1337,33 +1426,34 @@ function getPolicyExpenseChatName(report, policy = undefined) {
}
/**
- * Get the title for a IOU or expense chat which will be showing the payer and the amount
+ * Get the title for an IOU or expense chat which will be showing the payer and the amount
*
* @param {Object} report
* @param {Object} [policy]
* @returns {String}
*/
function getMoneyRequestReportName(report, policy = undefined) {
- const formattedAmount = CurrencyUtils.convertToDisplayString(getMoneyRequestTotal(report), report.currency);
+ const moneyRequestTotal = getMoneyRequestReimbursableTotal(report);
+ const formattedAmount = CurrencyUtils.convertToDisplayString(moneyRequestTotal, report.currency, hasOnlyDistanceRequestTransactions(report.reportID));
const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID);
- const payerPaidAmountMesssage = Localize.translateLocal('iou.payerPaidAmount', {
+ const payerPaidAmountMessage = Localize.translateLocal('iou.payerPaidAmount', {
payer: payerName,
amount: formattedAmount,
});
if (report.isWaitingOnBankAccount) {
- return `${payerPaidAmountMesssage} β’ ${Localize.translateLocal('iou.pending')}`;
+ return `${payerPaidAmountMessage} β’ ${Localize.translateLocal('iou.pending')}`;
}
if (hasNonReimbursableTransactions(report.reportID)) {
return Localize.translateLocal('iou.payerSpentAmount', {payer: payerName, amount: formattedAmount});
}
- if (report.hasOutstandingIOU) {
+ if (report.hasOutstandingIOU || moneyRequestTotal === 0) {
return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount});
}
- return payerPaidAmountMesssage;
+ return payerPaidAmountMessage;
}
/**
@@ -1448,7 +1538,7 @@ function canEditReportAction(reportAction) {
/**
* Gets all transactions on an IOU report with a receipt
*
- * @param {Object|null} iouReportID
+ * @param {string|null} iouReportID
* @returns {[Object]}
*/
function getTransactionsWithReceipts(iouReportID) {
@@ -1514,7 +1604,7 @@ function getTransactionReportName(reportAction) {
const {amount, currency, comment} = getTransactionDetails(transaction);
return Localize.translateLocal(ReportActionsUtils.isSentMoneyReportAction(reportAction) ? 'iou.threadSentMoneyReportName' : 'iou.threadRequestReportName', {
- formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency),
+ formattedAmount: CurrencyUtils.convertToDisplayString(amount, currency, TransactionUtils.isDistanceRequest(transaction)),
comment,
});
}
@@ -1542,12 +1632,15 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip
if (_.isEmpty(linkedTransaction)) {
return reportActionMessage;
}
+ if (TransactionUtils.isReceiptBeingScanned(linkedTransaction)) {
+ return Localize.translateLocal('iou.receiptScanning');
+ }
const {amount, currency, comment} = getTransactionDetails(linkedTransaction);
const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
return Localize.translateLocal('iou.didSplitAmount', {formattedAmount, comment});
}
- const totalAmount = getMoneyRequestTotal(report);
+ const totalAmount = getMoneyRequestReimbursableTotal(report);
const payerName = isExpenseReport(report) ? getPolicyName(report) : getDisplayNameForParticipant(report.managerID, true);
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
@@ -1676,7 +1769,7 @@ function getModifiedExpenseMessage(reportAction) {
const hasModifiedCreated = _.has(reportActionOriginalMessage, 'oldCreated') && _.has(reportActionOriginalMessage, 'created');
if (hasModifiedCreated) {
// Take only the YYYY-MM-DD value as the original date includes timestamp
- let formattedOldCreated = parseISO(reportActionOriginalMessage.oldCreated);
+ let formattedOldCreated = new Date(reportActionOriginalMessage.oldCreated);
formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING);
return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.created, formattedOldCreated, Localize.translateLocal('common.date'), false);
}
@@ -2216,7 +2309,7 @@ function buildOptimisticExpenseReport(chatReportID, policyID, payeeAccountID, to
function getIOUReportActionMessage(iouReportID, type, total, comment, currency, paymentType = '', isSettlingUp = false) {
const amount =
type === CONST.IOU.REPORT_ACTION_TYPE.PAY
- ? CurrencyUtils.convertToDisplayString(getMoneyRequestTotal(getReport(iouReportID)), currency)
+ ? CurrencyUtils.convertToDisplayString(getMoneyRequestReimbursableTotal(getReport(iouReportID)), currency)
: CurrencyUtils.convertToDisplayString(total, currency);
let paymentMethodMessage;
@@ -3070,11 +3163,12 @@ function shouldReportBeInOptionList(report, currentReportId, isInGSDMode, betas,
if (
!report ||
!report.reportID ||
+ !report.type ||
report.isHidden ||
(report.participantAccountIDs &&
report.participantAccountIDs.length === 0 &&
!isChatThread(report) &&
- !isPublicRoom(report) &&
+ !isUserCreatedPolicyRoom(report) &&
!isArchivedRoom(report) &&
!isMoneyRequestReport(report) &&
!isTaskReport(report))
@@ -3432,19 +3526,23 @@ function getMoneyRequestOptions(report, reportParticipants) {
// User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option
// unless there are no participants at all (e.g. #admins room for a policy with only 1 admin)
// DM chats will have the Split Bill option only when there are at least 3 people in the chat.
- // There is no Split Bill option for Workspace chats, IOU or Expense reports which are threads
- if ((isChatRoom(report) && participants.length > 0) || (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) || isControlPolicyExpenseChat(report)) {
- return [CONST.IOU.MONEY_REQUEST_TYPE.SPLIT];
+ // There is no Split Bill option for IOU or Expense reports which are threads
+ if (
+ (isChatRoom(report) && participants.length > 0) ||
+ (hasMultipleParticipants && !isPolicyExpenseChat(report) && !isMoneyRequestReport(report)) ||
+ (isControlPolicyExpenseChat(report) && report.isOwnPolicyExpenseChat)
+ ) {
+ return [CONST.IOU.TYPE.SPLIT];
}
// DM chats that only have 2 people will see the Send / Request money options.
// IOU and open or processing expense reports should show the Request option.
// Workspace chats should only see the Request money option or Split option in case of Control policies
return [
- ...(canRequestMoney(report, participants) ? [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST] : []),
+ ...(canRequestMoney(report, participants) ? [CONST.IOU.TYPE.REQUEST] : []),
// Send money option should be visible only in DMs
- ...(isChatReport(report) && !isPolicyExpenseChat(report) && hasSingleParticipantInReport ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []),
+ ...(isChatReport(report) && !isPolicyExpenseChat(report) && hasSingleParticipantInReport ? [CONST.IOU.TYPE.SEND] : []),
];
}
@@ -3589,7 +3687,8 @@ function shouldDisableWriteActions(report) {
* @returns {String}
*/
function getOriginalReportID(reportID, reportAction) {
- return isThreadFirstChat(reportAction, reportID) ? lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, 'parentReportID']) : reportID;
+ const currentReportAction = ReportActionsUtils.getReportAction(reportID, reportAction.reportActionID);
+ return isThreadFirstChat(reportAction, reportID) && _.isEmpty(currentReportAction) ? lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, 'parentReportID']) : reportID;
}
/**
@@ -3792,29 +3891,6 @@ function getParticipantsIDs(report) {
return participants;
}
-/**
- * Get the last 3 transactions with receipts of an IOU report that will be displayed on the report preview
- *
- * @param {Object} reportPreviewAction
- * @returns {Object}
- */
-function getReportPreviewDisplayTransactions(reportPreviewAction) {
- const transactionIDs = lodashGet(reportPreviewAction, ['childRecentReceiptTransactionIDs']);
- return _.reduce(
- _.keys(transactionIDs),
- (transactions, transactionID) => {
- if (transactionIDs[transactionID] !== null) {
- const transaction = TransactionUtils.getTransaction(transactionID);
- if (TransactionUtils.hasReceipt(transaction)) {
- transactions.push(transaction);
- }
- }
- return transactions;
- },
- [],
- );
-}
-
/**
* Return iou report action display message
*
@@ -3828,7 +3904,7 @@ function getIOUReportActionDisplayMessage(reportAction) {
const {amount, currency, IOUReportID} = originalMessage;
const formattedAmount = CurrencyUtils.convertToDisplayString(amount, currency);
const iouReport = getReport(IOUReportID);
- const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID);
+ const payerName = isExpenseReport(iouReport) ? getPolicyName(iouReport) : getDisplayNameForParticipant(iouReport.managerID, true);
let translationKey;
switch (originalMessage.paymentType) {
case CONST.IOU.PAYMENT_TYPE.ELSEWHERE:
@@ -3863,6 +3939,14 @@ function isReportDraft(report) {
return isExpenseReport(report) && lodashGet(report, 'stateNum') === CONST.REPORT.STATE_NUM.OPEN && lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.OPEN;
}
+/**
+ * @param {Object} report
+ * @returns {Boolean}
+ */
+function shouldUseFullTitleToDisplay(report) {
+ return isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report);
+}
+
export {
getReportParticipantsTitle,
isReportMessageAttachment,
@@ -3895,7 +3979,8 @@ export {
hasExpensifyGuidesEmails,
isWaitingForIOUActionFromCurrentUser,
isIOUOwnedByCurrentUser,
- getMoneyRequestTotal,
+ getMoneyRequestReimbursableTotal,
+ getMoneyRequestSpendBreakdown,
canShowReportRecipientLocalTime,
formatReportLastMessageText,
chatIncludesConcierge,
@@ -3910,6 +3995,8 @@ export {
getReport,
getReportIDFromLink,
getRouteFromLink,
+ getDeletedParentActionMessageForChatReport,
+ getLastVisibleMessage,
navigateToDetailsPage,
generateReportID,
hasReportNameError,
@@ -4002,11 +4089,13 @@ export {
canEditMoneyRequest,
buildTransactionThread,
areAllRequestsBeingSmartScanned,
- getReportPreviewDisplayTransactions,
getTransactionsWithReceipts,
+ hasOnlyDistanceRequestTransactions,
hasNonReimbursableTransactions,
hasMissingSmartscanFields,
getIOUReportActionDisplayMessage,
isWaitingForTaskCompleteFromAssignee,
isReportDraft,
+ shouldUseFullTitleToDisplay,
+ parseReportRouteParams,
};
diff --git a/src/libs/Request.ts b/src/libs/Request.ts
index 903e70358da9..9c4af4aa7e18 100644
--- a/src/libs/Request.ts
+++ b/src/libs/Request.ts
@@ -3,24 +3,24 @@ import enhanceParameters from './Network/enhanceParameters';
import * as NetworkStore from './Network/NetworkStore';
import Request from '../types/onyx/Request';
import Response from '../types/onyx/Response';
-
-type Middleware = (response: Promise, request: Request, isFromSequentialQueue: boolean) => Promise;
+import Middleware from './Middleware/types';
let middlewares: Middleware[] = [];
-function makeXHR(request: Request): Promise {
+function makeXHR(request: Request): Promise {
const finalParameters = enhanceParameters(request.command, request?.data ?? {});
- return NetworkStore.hasReadRequiredDataFromStorage().then(() => {
+ return NetworkStore.hasReadRequiredDataFromStorage().then((): Promise => {
// If we're using the Supportal token and this is not a Supportal request
// let's just return a promise that will resolve itself.
if (NetworkStore.getSupportAuthToken() && !NetworkStore.isSupportRequest(request.command)) {
return new Promise((resolve) => resolve());
}
- return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
- }) as Promise;
+
+ return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise;
+ });
}
-function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
+function processWithMiddleware(request: Request, isFromSequentialQueue = false): Promise {
return middlewares.reduce((last, middleware) => middleware(last, request, isFromSequentialQueue), makeXHR(request));
}
diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js
index 7a32db660021..caa8fb384e56 100644
--- a/src/libs/SidebarUtils.js
+++ b/src/libs/SidebarUtils.js
@@ -158,7 +158,7 @@ function getOrderedReportIDs(currentReportId, allReportsDict, betas, policies, p
report.displayName = ReportUtils.getReportName(report);
// eslint-disable-next-line no-param-reassign
- report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, allReportsDict);
+ report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(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:
@@ -347,17 +347,45 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
if ((result.isChatRoom || result.isPolicyExpenseChat || result.isThread || result.isTaskReport) && !result.isArchivedRoom) {
const lastAction = visibleReportActionItems[report.reportID];
- if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
+ if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) {
const newName = lodashGet(lastAction, 'originalMessage.newName', '');
result.alternateText = Localize.translate(preferredLocale, 'newRoomPage.roomRenamedTo', {newName});
- } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) {
+ } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED) {
result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}`;
- } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) {
+ } else if (lastAction && lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) {
result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}`;
- } else if (lodashGet(lastAction, 'actionName', '') !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) {
+ } else if (
+ lastAction &&
+ _.includes(
+ [
+ CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM,
+ CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM,
+ CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM,
+ CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.REMOVE_FROM_ROOM,
+ ],
+ lastAction.actionName,
+ )
+ ) {
+ const targetAccountIDs = lodashGet(lastAction, 'originalMessage.targetAccountIDs', []);
+ const verb =
+ lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM
+ ? 'invited'
+ : 'removed';
+ const users = targetAccountIDs.length > 1 ? 'users' : 'user';
+ result.alternateText = `${verb} ${targetAccountIDs.length} ${users}`;
+
+ const roomName = lodashGet(lastAction, 'originalMessage.roomName', '');
+ if (roomName) {
+ const preposition =
+ lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || lastAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM
+ ? ' to'
+ : ' from';
+ result.alternateText += `${preposition} ${roomName}`;
+ }
+ } else if (lastAction && lastAction.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) {
result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`;
} else {
- result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
+ result.alternateText = lastAction && lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet');
}
} else {
if (!lastMessageText) {
@@ -384,7 +412,7 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale,
}
result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result);
- result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result);
+ result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result);
if (!hasMultipleParticipants) {
result.accountID = personalDetail.accountID;
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 393de4e0d6e3..44f8094ca13d 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -1,12 +1,12 @@
import Onyx, {OnyxCollection} from 'react-native-onyx';
-import {format, parseISO, isValid} from 'date-fns';
+import {format, isValid} from 'date-fns';
import CONST from '../CONST';
import ONYXKEYS from '../ONYXKEYS';
import DateUtils from './DateUtils';
import {isExpensifyCard} from './CardUtils';
import * as NumberUtils from './NumberUtils';
import {RecentWaypoint, ReportAction, Transaction} from '../types/onyx';
-import {Receipt, Comment, WaypointCollection} from '../types/onyx/Transaction';
+import {Receipt, Comment, WaypointCollection, Waypoint} from '../types/onyx/Transaction';
type AdditionalTransactionChanges = {comment?: string; waypoints?: WaypointCollection};
@@ -76,11 +76,18 @@ function buildOptimisticTransaction(
};
}
+/**
+ * Check if the transaction has an Ereceipt
+ */
+function hasEReceipt(transaction: Transaction | undefined | null): boolean {
+ return !!transaction?.hasEReceipt;
+}
+
function hasReceipt(transaction: Transaction | undefined | null): boolean {
- return !!transaction?.receipt?.state;
+ return !!transaction?.receipt?.state || hasEReceipt(transaction);
}
-function areRequiredFieldsEmpty(transaction: Transaction): boolean {
+function isMerchantMissing(transaction: Transaction) {
const isMerchantEmpty =
transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === '';
@@ -90,10 +97,19 @@ function areRequiredFieldsEmpty(transaction: Transaction): boolean {
transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ||
transaction.modifiedMerchant === '';
- const isModifiedAmountEmpty = !transaction.modifiedAmount || transaction.modifiedAmount === 0;
- const isModifiedCreatedEmpty = !transaction.modifiedCreated || transaction.modifiedCreated === '';
+ return isMerchantEmpty && isModifiedMerchantEmpty;
+}
+
+function isAmountMissing(transaction: Transaction) {
+ return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0);
+}
- return (isModifiedMerchantEmpty && isMerchantEmpty) || (isModifiedAmountEmpty && transaction.amount === 0) || (isModifiedCreatedEmpty && transaction.created === '');
+function isCreatedMissing(transaction: Transaction) {
+ return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === '');
+}
+
+function areRequiredFieldsEmpty(transaction: Transaction): boolean {
+ return isMerchantMissing(transaction) || isAmountMissing(transaction) || isCreatedMissing(transaction);
}
/**
@@ -294,7 +310,7 @@ function getTag(transaction: Transaction): string {
*/
function getCreated(transaction: Transaction, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING): string {
const created = transaction?.modifiedCreated ? transaction.modifiedCreated : transaction?.created || '';
- const createdDate = parseISO(created);
+ const createdDate = new Date(created);
if (isValid(createdDate)) {
return format(createdDate, dateFormat);
}
@@ -356,13 +372,6 @@ function hasRoute(transaction: Transaction): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
-/**
- * Check if the transaction has an Ereceipt
- */
-function hasEreceipt(transaction: Transaction): boolean {
- return !!transaction?.hasEReceipt;
-}
-
/**
* Get the transactions related to a report preview with receipts
* Get the details linked to the IOU reportAction
@@ -390,7 +399,7 @@ function getAllReportTransactions(reportID?: string): Transaction[] {
/**
* Checks if a waypoint has a valid address
*/
-function waypointHasValidAddress(waypoint: RecentWaypoint | null): boolean {
+function waypointHasValidAddress(waypoint: RecentWaypoint | Waypoint): boolean {
return !!waypoint?.address?.trim();
}
@@ -405,7 +414,9 @@ function getWaypointIndex(key: string): number {
* Filters the waypoints which are valid and returns those
*/
function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = false): WaypointCollection {
- const sortedIndexes = Object.keys(waypoints).map(getWaypointIndex).sort();
+ const sortedIndexes = Object.keys(waypoints)
+ .map(getWaypointIndex)
+ .sort((a, b) => a - b);
const waypointValues = sortedIndexes.map((index) => waypoints[`waypoint${index}`]);
// Ensure the number of waypoints is between 2 and 25
if (waypointValues.length < 2 || waypointValues.length > 25) {
@@ -414,7 +425,7 @@ function getValidWaypoints(waypoints: WaypointCollection, reArrangeIndexes = fal
let lastWaypointIndex = -1;
- return waypointValues.reduce((acc, currentWaypoint, index) => {
+ return waypointValues.reduce((acc, currentWaypoint, index) => {
const previousWaypoint = waypointValues[lastWaypointIndex];
// Check if the waypoint has a valid address
@@ -463,7 +474,7 @@ export {
getLinkedTransaction,
getAllReportTransactions,
hasReceipt,
- hasEreceipt,
+ hasEReceipt,
hasRoute,
isReceiptBeingScanned,
getValidWaypoints,
@@ -472,6 +483,9 @@ export {
isPending,
isPosted,
getWaypoints,
+ isAmountMissing,
+ isMerchantMissing,
+ isCreatedMissing,
areRequiredFieldsEmpty,
hasMissingSmartscanFields,
getWaypointIndex,
diff --git a/src/libs/UpdateMultilineInputRange/index.ios.js b/src/libs/UpdateMultilineInputRange/index.ios.js
index 85ed529a33bc..4c10f768a2a2 100644
--- a/src/libs/UpdateMultilineInputRange/index.ios.js
+++ b/src/libs/UpdateMultilineInputRange/index.ios.js
@@ -8,8 +8,9 @@
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
+ * @param {boolean} shouldAutoFocus
*/
-export default function updateMultilineInputRange(input) {
+export default function updateMultilineInputRange(input, shouldAutoFocus = true) {
if (!input) {
return;
}
@@ -19,5 +20,7 @@ export default function updateMultilineInputRange(input) {
* Issue: does not scroll multiline input when text exceeds the maximum number of lines
* For more details: https://github.com/Expensify/App/pull/27702#issuecomment-1728651132
*/
- input.focus();
+ if (shouldAutoFocus) {
+ input.focus();
+ }
}
diff --git a/src/libs/UpdateMultilineInputRange/index.js b/src/libs/UpdateMultilineInputRange/index.js
index 179d30dc611d..66fb1889be21 100644
--- a/src/libs/UpdateMultilineInputRange/index.js
+++ b/src/libs/UpdateMultilineInputRange/index.js
@@ -8,8 +8,10 @@
* See https://github.com/Expensify/App/issues/20836 for more details.
*
* @param {Object} input the input element
+ * @param {boolean} shouldAutoFocus
*/
-export default function updateMultilineInputRange(input) {
+// eslint-disable-next-line no-unused-vars
+export default function updateMultilineInputRange(input, shouldAutoFocus = true) {
if (!input) {
return;
}
diff --git a/src/libs/actions/App.js b/src/libs/actions/App.js
index a1d64154906c..75520d483f98 100644
--- a/src/libs/actions/App.js
+++ b/src/libs/actions/App.js
@@ -350,6 +350,40 @@ function createWorkspaceAndNavigateToIt(policyOwnerEmail = '', makeMeAdmin = fal
.then(endSignOnTransition);
}
+/**
+ * Create a new draft workspace and navigate to it
+ *
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {String} [policyName] Optional, custom policy name we will use for created workspace
+ * @param {Boolean} [transitionFromOldDot] Optional, if the user is transitioning from old dot
+ */
+function createWorkspaceWithPolicyDraftAndNavigateToIt(policyOwnerEmail = '', policyName = '', transitionFromOldDot = false) {
+ const policyID = Policy.generatePolicyID();
+ Policy.createDraftInitialWorkspace(policyOwnerEmail, policyName, policyID);
+
+ Navigation.isNavigationReady()
+ .then(() => {
+ if (transitionFromOldDot) {
+ // We must call goBack() to remove the /transition route from history
+ Navigation.goBack(ROUTES.HOME);
+ }
+ Navigation.navigate(ROUTES.WORKSPACE_INITIAL.getRoute(policyID));
+ })
+ .then(endSignOnTransition);
+}
+
+/**
+ * Create a new workspace and delete the draft
+ *
+ * @param {String} [policyID] the ID of the policy to use
+ * @param {String} [policyName] custom policy name we will use for created workspace
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {Boolean} [makeMeAdmin] Optional, leave the calling account as an admin on the policy
+ */
+function savePolicyDraftByNewWorkspace(policyID, policyName, policyOwnerEmail = '', makeMeAdmin = false) {
+ Policy.createWorkspace(policyOwnerEmail, makeMeAdmin, policyName, policyID);
+}
+
/**
* This action runs when the Navigator is ready and the current route changes
*
@@ -389,9 +423,6 @@ function setUpPoliciesAndNavigate(session, shouldNavigateToAdminChat) {
// Sign out the current user if we're transitioning with a different user
const isTransitioning = Str.startsWith(url.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS));
- if (isLoggingInAsNewUser && isTransitioning) {
- Session.signOut();
- }
const shouldCreateFreePolicy = !isLoggingInAsNewUser && isTransitioning && exitTo === ROUTES.WORKSPACE_NEW;
if (shouldCreateFreePolicy) {
@@ -527,4 +558,6 @@ export {
createWorkspaceAndNavigateToIt,
getMissingOnyxUpdates,
finalReconnectAppAfterActivatingReliableUpdates,
+ savePolicyDraftByNewWorkspace,
+ createWorkspaceWithPolicyDraftAndNavigateToIt,
};
diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.ts
similarity index 54%
rename from src/libs/actions/BankAccounts.js
rename to src/libs/actions/BankAccounts.ts
index 4d3c880b5983..bf4f170f1ba7 100644
--- a/src/libs/actions/BankAccounts.js
+++ b/src/libs/actions/BankAccounts.ts
@@ -7,6 +7,10 @@ import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropT
import Navigation from '../Navigation/Navigation';
import ROUTES from '../../ROUTES';
import * as ReimbursementAccount from './ReimbursementAccount';
+import type PlaidBankAccount from '../../types/onyx/PlaidBankAccount';
+import type {ACHContractStepProps, BankAccountStepProps, CompanyStepProps, OnfidoData, ReimbursementAccountProps, RequestorStepProps} from '../../types/onyx/ReimbursementAccountDraft';
+import type {OnyxData} from '../../types/onyx/Request';
+import type {BankAccountStep, BankAccountSubStep} from '../../types/onyx/ReimbursementAccount';
export {
goToWithdrawalAccountSetupStep,
@@ -23,7 +27,13 @@ export {
export {openPlaidBankAccountSelector, openPlaidBankLogin} from './Plaid';
export {openOnfidoFlow, answerQuestionsForWallet, verifyIdentity, acceptWalletTerms} from './Wallet';
-function clearPlaid() {
+type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps;
+
+type ReimbursementAccountStep = BankAccountStep | '';
+
+type ReimbursementAccountSubStep = BankAccountSubStep | '';
+
+function clearPlaid(): Promise {
Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, '');
return Onyx.set(ONYXKEYS.PLAID_DATA, PlaidDataProps.plaidDataDefaultProps);
@@ -35,9 +45,8 @@ function openPlaidView() {
/**
* Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished.
- * @param {String} exitReportID
*/
-function openPersonalBankAccountSetupView(exitReportID) {
+function openPersonalBankAccountSetupView(exitReportID: string) {
clearPlaid().then(() => {
if (exitReportID) {
Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID});
@@ -46,6 +55,13 @@ function openPersonalBankAccountSetupView(exitReportID) {
});
}
+/**
+ * Whether after adding a bank account we should continue with the KYC flow. If so, we must specify the fallback route.
+ */
+function setPersonalBankAccountContinueKYCOnSuccess(onSuccessFallbackRoute: string) {
+ Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {onSuccessFallbackRoute});
+}
+
function clearPersonalBankAccount() {
clearPlaid();
Onyx.set(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {});
@@ -57,10 +73,8 @@ function clearOnfidoToken() {
/**
* Helper method to build the Onyx data required during setup of a Verified Business Bank Account
- * @param {String | undefined} currentStep The name of the bank account setup step for which we will update the draft value when we receive the response from the API.
- * @returns {Object}
*/
-function getVBBADataForOnyx(currentStep = undefined) {
+function getVBBADataForOnyx(currentStep?: BankAccountStep): OnyxData {
return {
optimisticData: [
{
@@ -103,14 +117,20 @@ function getVBBADataForOnyx(currentStep = undefined) {
/**
* Submit Bank Account step with Plaid data so php can perform some checks.
- *
- * @param {Number} bankAccountID
- * @param {Object} selectedPlaidBankAccount
*/
-function connectBankAccountWithPlaid(bankAccountID, selectedPlaidBankAccount) {
+function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount) {
const commandName = 'ConnectBankAccountWithPlaid';
- const parameters = {
+ type ConnectBankAccountWithPlaidParams = {
+ bankAccountID: number;
+ routingNumber: string;
+ accountNumber: string;
+ bank?: string;
+ plaidAccountID: string;
+ plaidAccessToken: string;
+ };
+
+ const parameters: ConnectBankAccountWithPlaidParams = {
bankAccountID,
routingNumber: selectedPlaidBankAccount.routingNumber,
accountNumber: selectedPlaidBankAccount.accountNumber,
@@ -125,13 +145,23 @@ function connectBankAccountWithPlaid(bankAccountID, selectedPlaidBankAccount) {
/**
* Adds a bank account via Plaid
*
- * @param {Object} account
* @TODO offline pattern for this command will have to be added later once the pattern B design doc is complete
*/
-function addPersonalBankAccount(account) {
+function addPersonalBankAccount(account: PlaidBankAccount) {
const commandName = 'AddPersonalBankAccount';
- const parameters = {
+ type AddPersonalBankAccountParams = {
+ addressName: string;
+ routingNumber: string;
+ accountNumber: string;
+ isSavings: boolean;
+ setupType: string;
+ bank?: string;
+ plaidAccountID: string;
+ plaidAccessToken: string;
+ };
+
+ const parameters: AddPersonalBankAccountParams = {
addressName: account.addressName,
routingNumber: account.routingNumber,
accountNumber: account.accountNumber,
@@ -142,7 +172,7 @@ function addPersonalBankAccount(account) {
plaidAccessToken: account.plaidAccessToken,
};
- const onyxData = {
+ const onyxData: OnyxData = {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -180,107 +210,94 @@ function addPersonalBankAccount(account) {
API.write(commandName, parameters, onyxData);
}
-function deletePaymentBankAccount(bankAccountID) {
- API.write(
- 'DeletePaymentBankAccount',
- {
- bankAccountID,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
- value: {[bankAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
- },
- ],
-
- // Sometimes pusher updates aren't received when we close the App while still offline,
- // so we are setting the bankAccount to null here to ensure that it gets cleared out once we come back online.
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
- value: {[bankAccountID]: null},
- },
- ],
- },
- );
+function deletePaymentBankAccount(bankAccountID: number) {
+ type DeletePaymentBankAccountParams = {bankAccountID: number};
+
+ const parameters: DeletePaymentBankAccountParams = {bankAccountID};
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
+ value: {[bankAccountID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
+ },
+ ],
+
+ // Sometimes pusher updates aren't received when we close the App while still offline,
+ // so we are setting the bankAccount to null here to ensure that it gets cleared out once we come back online.
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.BANK_ACCOUNT_LIST}`,
+ value: {[bankAccountID]: null},
+ },
+ ],
+ };
+
+ API.write('DeletePaymentBankAccount', parameters, onyxData);
}
/**
* Update the user's personal information on the bank account in database.
*
* This action is called by the requestor step in the Verified Bank Account flow
- *
- * @param {Object} params
- *
- * @param {String} [params.dob]
- * @param {String} [params.firstName]
- * @param {String} [params.lastName]
- * @param {String} [params.requestorAddressStreet]
- * @param {String} [params.requestorAddressCity]
- * @param {String} [params.requestorAddressState]
- * @param {String} [params.requestorAddressZipCode]
- * @param {String} [params.ssnLast4]
- * @param {String} [params.isControllingOfficer]
- * @param {Object} [params.onfidoData]
- * @param {Boolean} [params.isOnfidoSetupComplete]
*/
-function updatePersonalInformationForBankAccount(params) {
+function updatePersonalInformationForBankAccount(params: RequestorStepProps) {
API.write('UpdatePersonalInformationForBankAccount', params, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR));
}
-/**
- * @param {Number} bankAccountID
- * @param {String} validateCode
- */
-function validateBankAccount(bankAccountID, validateCode) {
- API.write(
- 'ValidateBankAccountWithTransactions',
- {
- bankAccountID,
- validateCode,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: true,
- errors: null,
- },
+function validateBankAccount(bankAccountID: number, validateCode: string) {
+ type ValidateBankAccountWithTransactionsParams = {
+ bankAccountID: number;
+ validateCode: string;
+ };
+
+ const parameters: ValidateBankAccountWithTransactionsParams = {
+ bankAccountID,
+ validateCode,
+ };
+
+ const onyxData: OnyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: true,
+ errors: null,
},
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
},
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- value: {
- isLoading: false,
- },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ isLoading: false,
},
- ],
- },
- );
+ },
+ ],
+ };
+
+ API.write('ValidateBankAccountWithTransactions', parameters, onyxData);
}
function clearReimbursementAccount() {
Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null);
}
-function openReimbursementAccountPage(stepToOpen, subStep, localCurrentStep) {
- const onyxData = {
+function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep) {
+ const onyxData: OnyxData = {
optimisticData: [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -310,122 +327,104 @@ function openReimbursementAccountPage(stepToOpen, subStep, localCurrentStep) {
],
};
- const param = {
+ type OpenReimbursementAccountPageParams = {
+ stepToOpen: ReimbursementAccountStep;
+ subStep: ReimbursementAccountSubStep;
+ localCurrentStep: ReimbursementAccountStep;
+ };
+
+ const parameters: OpenReimbursementAccountPageParams = {
stepToOpen,
subStep,
localCurrentStep,
};
- return API.read('OpenReimbursementAccountPage', param, onyxData);
+ return API.read('OpenReimbursementAccountPage', parameters, onyxData);
}
/**
* Updates the bank account in the database with the company step data
- *
- * @param {Object} bankAccount
- * @param {Number} [bankAccount.bankAccountID]
- *
- * Fields from BankAccount step
- * @param {String} [bankAccount.routingNumber]
- * @param {String} [bankAccount.accountNumber]
- * @param {String} [bankAccount.bankName]
- * @param {String} [bankAccount.plaidAccountID]
- * @param {String} [bankAccount.plaidAccessToken]
- * @param {Boolean} [bankAccount.isSavings]
- *
- * Fields from Company step
- * @param {String} [bankAccount.companyName]
- * @param {String} [bankAccount.addressStreet]
- * @param {String} [bankAccount.addressCity]
- * @param {String} [bankAccount.addressState]
- * @param {String} [bankAccount.addressZipCode]
- * @param {String} [bankAccount.companyPhone]
- * @param {String} [bankAccount.website]
- * @param {String} [bankAccount.companyTaxID]
- * @param {String} [bankAccount.incorporationType]
- * @param {String} [bankAccount.incorporationState]
- * @param {String} [bankAccount.incorporationDate]
- * @param {Boolean} [bankAccount.hasNoConnectionToCannabis]
- * @param {String} policyID
*/
-function updateCompanyInformationForBankAccount(bankAccount, policyID) {
- API.write('UpdateCompanyInformationForBankAccount', {...bankAccount, policyID}, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY));
+function updateCompanyInformationForBankAccount(bankAccount: BankAccountCompanyInformation, policyID: string) {
+ type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {policyID: string};
+
+ const parameters: UpdateCompanyInformationForBankAccountParams = {...bankAccount, policyID};
+
+ API.write('UpdateCompanyInformationForBankAccount', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY));
}
/**
* Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided
- *
- * @param {Object} params
- *
- * // ACH Contract Step
- * @param {Boolean} [params.ownsMoreThan25Percent]
- * @param {Boolean} [params.hasOtherBeneficialOwners]
- * @param {Boolean} [params.acceptTermsAndConditions]
- * @param {Boolean} [params.certifyTrueInformation]
- * @param {String} [params.beneficialOwners]
*/
-function updateBeneficialOwnersForBankAccount(params) {
- API.write('UpdateBeneficialOwnersForBankAccount', {...params}, getVBBADataForOnyx());
+function updateBeneficialOwnersForBankAccount(params: ACHContractStepProps) {
+ API.write('UpdateBeneficialOwnersForBankAccount', params, getVBBADataForOnyx());
}
/**
* Create the bank account with manually entered data.
*
- * @param {number} [bankAccountID]
- * @param {String} [accountNumber]
- * @param {String} [routingNumber]
- * @param {String} [plaidMask]
- *
*/
-function connectBankAccountManually(bankAccountID, accountNumber, routingNumber, plaidMask) {
- API.write(
- 'ConnectBankAccountManually',
- {
- bankAccountID,
- accountNumber,
- routingNumber,
- plaidMask,
- },
- getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT),
- );
+function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string) {
+ type ConnectBankAccountManuallyParams = {
+ bankAccountID: number;
+ accountNumber?: string;
+ routingNumber?: string;
+ plaidMask?: string;
+ };
+
+ const parameters: ConnectBankAccountManuallyParams = {
+ bankAccountID,
+ accountNumber,
+ routingNumber,
+ plaidMask,
+ };
+
+ API.write('ConnectBankAccountManually', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT));
}
/**
* Verify the user's identity via Onfido
- *
- * @param {Number} bankAccountID
- * @param {Object} onfidoData
*/
-function verifyIdentityForBankAccount(bankAccountID, onfidoData) {
- API.write(
- 'VerifyIdentityForBankAccount',
- {
- bankAccountID,
- onfidoData: JSON.stringify(onfidoData),
- },
- getVBBADataForOnyx(),
- );
+function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoData) {
+ type VerifyIdentityForBankAccountParams = {
+ bankAccountID: number;
+ onfidoData: string;
+ };
+
+ const parameters: VerifyIdentityForBankAccountParams = {
+ bankAccountID,
+ onfidoData: JSON.stringify(onfidoData),
+ };
+
+ API.write('VerifyIdentityForBankAccount', parameters, getVBBADataForOnyx());
}
function openWorkspaceView() {
- API.read('OpenWorkspaceView');
+ API.read('OpenWorkspaceView', {}, {});
}
-function handlePlaidError(bankAccountID, error, error_description, plaidRequestID) {
- API.write('BankAccount_HandlePlaidError', {
+function handlePlaidError(bankAccountID: number, error: string, errorDescription: string, plaidRequestID: string) {
+ type BankAccountHandlePlaidErrorParams = {
+ bankAccountID: number;
+ error: string;
+ errorDescription: string;
+ plaidRequestID: string;
+ };
+
+ const parameters: BankAccountHandlePlaidErrorParams = {
bankAccountID,
error,
- error_description,
+ errorDescription,
plaidRequestID,
- });
+ };
+
+ API.write('BankAccount_HandlePlaidError', parameters);
}
/**
* Set the reimbursement account loading so that it happens right away, instead of when the API command is processed.
- *
- * @param {Boolean} isLoading
*/
-function setReimbursementAccountLoading(isLoading) {
+function setReimbursementAccountLoading(isLoading: boolean) {
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isLoading});
}
@@ -439,6 +438,7 @@ export {
connectBankAccountWithPlaid,
deletePaymentBankAccount,
handlePlaidError,
+ setPersonalBankAccountContinueKYCOnSuccess,
openPersonalBankAccountSetupView,
clearReimbursementAccount,
openReimbursementAccountPage,
diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js
index a060c1bc67fa..92b23e2103ee 100644
--- a/src/libs/actions/Card.js
+++ b/src/libs/actions/Card.js
@@ -43,6 +43,51 @@ function reportVirtualExpensifyCardFraud(cardID) {
);
}
+/**
+ * Call the API to deactivate the card and request a new one
+ * @param {String} cardId - id of the card that is going to be replaced
+ * @param {String} reason - reason for replacement ('damaged' | 'stolen')
+ */
+function requestReplacementExpensifyCard(cardId, reason) {
+ API.write(
+ 'RequestReplacementExpensifyCard',
+ {
+ cardId,
+ reason,
+ },
+ {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM,
+ value: {
+ isLoading: true,
+ errors: null,
+ },
+ },
+ ],
+ successData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM,
+ value: {
+ isLoading: false,
+ },
+ },
+ ],
+ failureData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM,
+ value: {
+ isLoading: false,
+ },
+ },
+ ],
+ },
+ );
+}
+
/**
* Activates the physical Expensify card based on the last four digits of the card number
*
@@ -101,4 +146,4 @@ function clearCardListErrors(cardID) {
Onyx.merge(ONYXKEYS.CARD_LIST, {[cardID]: {errors: null, isLoading: false}});
}
-export {reportVirtualExpensifyCardFraud, activatePhysicalExpensifyCard, clearCardListErrors};
+export {requestReplacementExpensifyCard, activatePhysicalExpensifyCard, clearCardListErrors, reportVirtualExpensifyCardFraud};
diff --git a/src/libs/actions/Chronos.ts b/src/libs/actions/Chronos.ts
index 1b46a68a1afe..ce821e524722 100644
--- a/src/libs/actions/Chronos.ts
+++ b/src/libs/actions/Chronos.ts
@@ -1,11 +1,11 @@
-import Onyx from 'react-native-onyx';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
import {ChronosOOOEvent} from '../../types/onyx/OriginalMessage';
const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => {
- const optimisticData = [
+ const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
@@ -20,7 +20,7 @@ const removeEvent = (reportID: string, reportActionID: string, eventID: string,
},
];
- const successData = [
+ const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
@@ -32,7 +32,7 @@ const removeEvent = (reportID: string, reportActionID: string, eventID: string,
},
];
- const failureData = [
+ const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
diff --git a/src/libs/actions/DemoActions.js b/src/libs/actions/DemoActions.js
new file mode 100644
index 000000000000..e7ce02d2796b
--- /dev/null
+++ b/src/libs/actions/DemoActions.js
@@ -0,0 +1,70 @@
+import Config from 'react-native-config';
+import Onyx from 'react-native-onyx';
+import lodashGet from 'lodash/get';
+import * as API from '../API';
+import * as ReportUtils from '../ReportUtils';
+import Navigation from '../Navigation/Navigation';
+import ROUTES from '../../ROUTES';
+import ONYXKEYS from '../../ONYXKEYS';
+
+let currentUserEmail;
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (val) => {
+ currentUserEmail = lodashGet(val, 'email', '');
+ },
+});
+
+function runMoney2020Demo() {
+ // Try to navigate to existing demo chat if it exists in Onyx
+ const money2020AccountID = Number(lodashGet(Config, 'EXPENSIFY_ACCOUNT_ID_MONEY2020', 15864555));
+ const existingChatReport = ReportUtils.getChatByParticipants([money2020AccountID]);
+ if (existingChatReport) {
+ // We must call goBack() to remove the demo route from nav history
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(existingChatReport.reportID));
+ return;
+ }
+
+ // We use makeRequestWithSideEffects here because we need to get the chat report ID to navigate to it after it's created
+ // eslint-disable-next-line rulesdir/no-api-side-effects-method
+ API.makeRequestWithSideEffects('CreateChatReport', {
+ emailList: `${currentUserEmail},money2020@expensify.com`,
+ activationConference: 'money2020',
+ }).then((response) => {
+ // If there's no response or no reportID in the response, navigate the user home so user doesn't get stuck.
+ if (!response || !response.reportID) {
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.HOME);
+ return;
+ }
+
+ // Get reportID & navigate to it
+ // Note: We must call goBack() to remove the demo route from history
+ const chatReportID = response.reportID;
+ Navigation.goBack();
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chatReportID));
+ });
+}
+
+/**
+ * Runs code for specific demos, based on the provided URL
+ *
+ * @param {String} url - URL user is navigating to via deep link (or regular link in web)
+ */
+function runDemoByURL(url = '') {
+ const cleanUrl = (url || '').toLowerCase();
+
+ if (cleanUrl.endsWith(ROUTES.MONEY2020)) {
+ Onyx.set(ONYXKEYS.DEMO_INFO, {
+ money2020: {
+ isBeginningDemo: true,
+ },
+ });
+ } else {
+ // No demo is being run, so clear out demo info
+ Onyx.set(ONYXKEYS.DEMO_INFO, {});
+ }
+}
+
+export {runMoney2020Demo, runDemoByURL};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index a95d69243ec8..07e814f92884 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -1,6 +1,7 @@
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import lodashGet from 'lodash/get';
+import lodashHas from 'lodash/has';
import Str from 'expensify-common/lib/str';
import {format} from 'date-fns';
import CONST from '../../CONST';
@@ -1064,6 +1065,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
let oneOnOneChatReport;
let isNewOneOnOneChatReport = false;
let shouldCreateOptimisticPersonalDetails = false;
+ const personalDetailExists = lodashHas(allPersonalDetails, accountID);
// If this is a split between two people only and the function
// wasn't provided with an existing group chat report id
@@ -1072,11 +1074,11 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
// entering code that creates optimistic personal details
if ((!hasMultipleParticipants && !existingSplitChatReportID) || isOwnPolicyExpenseChat) {
oneOnOneChatReport = splitChatReport;
- shouldCreateOptimisticPersonalDetails = !existingSplitChatReport;
+ shouldCreateOptimisticPersonalDetails = !existingSplitChatReport && !personalDetailExists;
} else {
const existingChatReport = ReportUtils.getChatByParticipants([accountID]);
isNewOneOnOneChatReport = !existingChatReport;
- shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport;
+ shouldCreateOptimisticPersonalDetails = isNewOneOnOneChatReport && !personalDetailExists;
oneOnOneChatReport = existingChatReport || ReportUtils.buildOptimisticChatReport([accountID]);
}
@@ -1104,7 +1106,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, currentUserAcco
oneOnOneIOUReport.reportID,
comment,
'',
- CONST.IOU.MONEY_REQUEST_TYPE.SPLIT,
+ CONST.IOU.TYPE.SPLIT,
splitTransaction.transactionID,
undefined,
undefined,
@@ -1303,7 +1305,18 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
const receiptObject = {state, source};
// ReportID is -2 (aka "deleted") on the group transaction
- const splitTransaction = TransactionUtils.buildOptimisticTransaction(0, CONST.CURRENCY.USD, CONST.REPORT.SPLIT_REPORTID, comment, '', '', '', '', receiptObject, filename);
+ const splitTransaction = TransactionUtils.buildOptimisticTransaction(
+ 0,
+ CONST.CURRENCY.USD,
+ CONST.REPORT.SPLIT_REPORTID,
+ comment,
+ '',
+ '',
+ '',
+ CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ receiptObject,
+ filename,
+ );
// Note: The created action must be optimistically generated before the IOU action so there's no chance that the created action appears after the IOU action in the chat
const splitChatCreatedReportAction = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit);
@@ -1419,7 +1432,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
},
[splitIOUReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateFailureMessage'),
+ errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
},
},
},
@@ -1628,7 +1641,7 @@ function completeSplitBill(chatReportID, reportAction, updatedTransaction, sessi
oneOnOneIOUReport.reportID,
updatedTransaction.comment.comment,
updatedTransaction.modifiedCreated,
- CONST.IOU.MONEY_REQUEST_TYPE.SPLIT,
+ CONST.IOU.TYPE.SPLIT,
transactionID,
updatedTransaction.modifiedMerchant,
{...updatedTransaction.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN},
@@ -1688,15 +1701,23 @@ function completeSplitBill(chatReportID, reportAction, updatedTransaction, sessi
failureData.push(...oneOnOneFailureData);
});
+ const {
+ amount: transactionAmount,
+ currency: transactionCurrency,
+ created: transactionCreated,
+ merchant: transactionMerchant,
+ comment: transactionComment,
+ } = ReportUtils.getTransactionDetails(updatedTransaction);
+
API.write(
'CompleteSplitBill',
{
transactionID,
- amount: updatedTransaction.modifiedAmount,
- currency: updatedTransaction.modifiedCurrency,
- created: updatedTransaction.modifiedCreated,
- merchant: updatedTransaction.modifiedMerchant,
- comment: updatedTransaction.comment.comment,
+ amount: transactionAmount,
+ currency: transactionCurrency,
+ created: transactionCreated,
+ merchant: transactionMerchant,
+ comment: transactionComment,
splits: JSON.stringify(splits),
},
{optimisticData, successData, failureData},
@@ -1985,7 +2006,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
}
updatedIOUReport.lastMessageText = iouReportLastMessageText;
- updatedIOUReport.lastVisibleActionCreated = lastVisibleAction.created;
+ updatedIOUReport.lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created');
updatedReportPreviewAction = {...reportPreviewAction};
const hasNonReimbursableTransactions = ReportUtils.hasNonReimbursableTransactions(iouReport);
@@ -2047,7 +2068,7 @@ function deleteMoneyRequest(transactionID, reportAction, isSingleTransactionView
hasOutstandingIOU: false,
iouReportID: null,
lastMessageText: ReportActionsUtils.getLastVisibleMessage(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}).lastMessageText,
- lastVisibleActionCreated: ReportActionsUtils.getLastVisibleAction(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}).created,
+ lastVisibleActionCreated: lodashGet(ReportActionsUtils.getLastVisibleAction(iouReport.chatReportID, {[reportPreviewAction.reportActionID]: null}), 'created'),
},
},
]
@@ -2626,7 +2647,7 @@ function submitReport(expenseReport) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
value: {
- state: CONST.REPORT.STATE.OPEN,
+ statusNum: CONST.REPORT.STATUS.OPEN,
stateNum: CONST.REPORT.STATE_NUM.OPEN,
},
},
diff --git a/src/libs/actions/PaymentMethods.js b/src/libs/actions/PaymentMethods.js
deleted file mode 100644
index 0ed6f8b036bb..000000000000
--- a/src/libs/actions/PaymentMethods.js
+++ /dev/null
@@ -1,356 +0,0 @@
-import _ from 'underscore';
-import {createRef} from 'react';
-import Onyx from 'react-native-onyx';
-import ONYXKEYS from '../../ONYXKEYS';
-import * as API from '../API';
-import CONST from '../../CONST';
-import Navigation from '../Navigation/Navigation';
-import * as CardUtils from '../CardUtils';
-import ROUTES from '../../ROUTES';
-
-/**
- * Sets up a ref to an instance of the KYC Wall component.
- */
-const kycWallRef = createRef();
-
-/**
- * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set.
- */
-function continueSetup() {
- if (!kycWallRef.current || !kycWallRef.current.continue) {
- Navigation.goBack(ROUTES.HOME);
- return;
- }
-
- // Close the screen (Add Debit Card, Add Bank Account, or Enable Payments) on success and continue with setup
- Navigation.goBack(ROUTES.HOME);
- kycWallRef.current.continue();
-}
-
-function openWalletPage() {
- const onyxData = {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: true,
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: false,
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
- value: false,
- },
- ],
- };
-
- return API.read('OpenPaymentsPage', {}, onyxData);
-}
-
-/**
- *
- * @param {Number} bankAccountID
- * @param {Number} fundID
- * @param {Object} previousPaymentMethod
- * @param {Object} currentPaymentMethod
- * @param {Boolean} isOptimisticData
- * @return {Array}
- *
- */
-function getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, isOptimisticData = true) {
- const onyxData = [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.USER_WALLET,
- value: {
- walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
- },
- },
- ];
-
- // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
- if (isOptimisticData) {
- onyxData[0].value.errors = null;
- }
-
- if (previousPaymentMethod) {
- onyxData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
- value: {
- [previousPaymentMethod.methodID]: {
- isDefault: !isOptimisticData,
- },
- },
- });
- }
-
- if (currentPaymentMethod) {
- onyxData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
- value: {
- [currentPaymentMethod.methodID]: {
- isDefault: isOptimisticData,
- },
- },
- });
- }
-
- return onyxData;
-}
-
-/**
- * Sets the default bank account or debit card for an Expensify Wallet
- *
- * @param {Number} bankAccountID
- * @param {Number} fundID
- * @param {Object} previousPaymentMethod
- * @param {Object} currentPaymentMethod
- *
- */
-function makeDefaultPaymentMethod(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod) {
- API.write(
- 'MakeDefaultPaymentMethod',
- {
- bankAccountID,
- fundID,
- },
- {
- optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true, ONYXKEYS.FUND_LIST),
- failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false, ONYXKEYS.FUND_LIST),
- },
- );
-}
-
-/**
- * Calls the API to add a new card.
- *
- * @param {Object} params
- */
-function addPaymentCard(params) {
- const cardMonth = CardUtils.getMonthFromExpirationDateString(params.expirationDate);
- const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate);
-
- API.write(
- 'AddPaymentCard',
- {
- cardNumber: params.cardNumber,
- cardYear,
- cardMonth,
- cardCVV: params.securityCode,
- addressName: params.nameOnCard,
- addressZip: params.addressZipCode,
- currency: CONST.CURRENCY.USD,
- isP2PDebitCard: true,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: true},
- },
- ],
- successData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: false},
- },
- ],
- failureData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
- value: {isLoading: false},
- },
- ],
- },
- );
-}
-
-/**
- * Resets the values for the add debit card form back to their initial states
- */
-function clearDebitCardFormErrorAndSubmit() {
- Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
- isLoading: false,
- errors: null,
- });
-}
-
-/**
- * Call the API to transfer wallet balance.
- * @param {Object} paymentMethod
- * @param {*} paymentMethod.methodID
- * @param {String} paymentMethod.accountType
- */
-function transferWalletBalance(paymentMethod) {
- const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
- const parameters = {
- [paymentMethodIDKey]: paymentMethod.methodID,
- };
-
- API.write('TransferWalletBalance', parameters, {
- optimisticData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: true,
- errors: null,
- },
- },
- ],
- successData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: false,
- shouldShowSuccess: true,
- paymentMethodType: paymentMethod.accountType,
- },
- },
- ],
- failureData: [
- {
- onyxMethod: 'merge',
- key: ONYXKEYS.WALLET_TRANSFER,
- value: {
- loading: false,
- shouldShowSuccess: false,
- },
- },
- ],
- });
-}
-
-function resetWalletTransferData() {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {
- selectedAccountType: '',
- selectedAccountID: null,
- filterPaymentMethodType: null,
- loading: false,
- shouldShowSuccess: false,
- });
-}
-
-/**
- * @param {String} selectedAccountType
- * @param {String} selectedAccountID
- */
-function saveWalletTransferAccountTypeAndID(selectedAccountType, selectedAccountID) {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {selectedAccountType, selectedAccountID});
-}
-
-/**
- * Toggles the user's selected type of payment method (bank account or debit card) on the wallet transfer balance screen.
- * @param {String} filterPaymentMethodType
- */
-function saveWalletTransferMethodType(filterPaymentMethodType) {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {filterPaymentMethodType});
-}
-
-function dismissSuccessfulTransferBalancePage() {
- Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
- Navigation.goBack(ROUTES.SETTINGS_WALLET);
-}
-
-/**
- * Looks through each payment method to see if there is an existing error
- * @param {Object} bankList
- * @param {Object} fundList
- * @returns {Boolean}
- */
-function hasPaymentMethodError(bankList, fundList) {
- const combinedPaymentMethods = {...bankList, ...fundList};
- return _.some(combinedPaymentMethods, (item) => !_.isEmpty(item.errors));
-}
-
-/**
- * Clears the error for the specified payment item
- * @param {String} paymentListKey The onyx key for the provided payment method
- * @param {String} paymentMethodID
- */
-function clearDeletePaymentMethodError(paymentListKey, paymentMethodID) {
- Onyx.merge(paymentListKey, {
- [paymentMethodID]: {
- pendingAction: null,
- errors: null,
- },
- });
-}
-
-/**
- * If there was a failure adding a payment method, clearing it removes the payment method from the list entirely
- * @param {String} paymentListKey The onyx key for the provided payment method
- * @param {String} paymentMethodID
- */
-function clearAddPaymentMethodError(paymentListKey, paymentMethodID) {
- Onyx.merge(paymentListKey, {
- [paymentMethodID]: null,
- });
-}
-
-/**
- * Clear any error(s) related to the user's wallet
- */
-function clearWalletError() {
- Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null});
-}
-
-/**
- * Clear any error(s) related to the user's wallet terms
- */
-function clearWalletTermsError() {
- Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null});
-}
-
-function deletePaymentCard(fundID) {
- API.write(
- 'DeletePaymentCard',
- {
- fundID,
- },
- {
- optimisticData: [
- {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.FUND_LIST}`,
- value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
- },
- ],
- },
- );
-}
-
-export {
- deletePaymentCard,
- addPaymentCard,
- openWalletPage,
- makeDefaultPaymentMethod,
- kycWallRef,
- continueSetup,
- clearDebitCardFormErrorAndSubmit,
- dismissSuccessfulTransferBalancePage,
- transferWalletBalance,
- resetWalletTransferData,
- saveWalletTransferAccountTypeAndID,
- saveWalletTransferMethodType,
- hasPaymentMethodError,
- clearDeletePaymentMethodError,
- clearAddPaymentMethodError,
- clearWalletError,
- clearWalletTermsError,
-};
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
new file mode 100644
index 000000000000..fe1b5ebe10e9
--- /dev/null
+++ b/src/libs/actions/PaymentMethods.ts
@@ -0,0 +1,393 @@
+import {createRef} from 'react';
+import Onyx, {OnyxUpdate} from 'react-native-onyx';
+import {ValueOf} from 'type-fest';
+import ONYXKEYS, {OnyxValues} from '../../ONYXKEYS';
+import * as API from '../API';
+import CONST from '../../CONST';
+import Navigation from '../Navigation/Navigation';
+import * as CardUtils from '../CardUtils';
+import ROUTES from '../../ROUTES';
+import {FilterMethodPaymentType} from '../../types/onyx/WalletTransfer';
+import PaymentMethod from '../../types/onyx/PaymentMethod';
+
+type KYCWallRef = {
+ continue?: () => void;
+};
+
+/**
+ * Sets up a ref to an instance of the KYC Wall component.
+ */
+const kycWallRef = createRef();
+
+/**
+ * When we successfully add a payment method or pass the KYC checks we will continue with our setup action if we have one set.
+ */
+function continueSetup(fallbackRoute = ROUTES.HOME) {
+ if (!kycWallRef.current?.continue) {
+ Navigation.goBack(fallbackRoute);
+ return;
+ }
+
+ // Close the screen (Add Debit Card, Add Bank Account, or Enable Payments) on success and continue with setup
+ Navigation.goBack(fallbackRoute);
+ kycWallRef.current.continue();
+}
+
+function openWalletPage() {
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: true,
+ },
+ ];
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: false,
+ },
+ ];
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.IS_LOADING_PAYMENT_METHODS,
+ value: false,
+ },
+ ];
+
+ return API.read(
+ 'OpenPaymentsPage',
+ {},
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
+}
+
+function getMakeDefaultPaymentOnyxData(
+ bankAccountID: number,
+ fundID: number,
+ previousPaymentMethod: PaymentMethod,
+ currentPaymentMethod: PaymentMethod,
+ isOptimisticData = true,
+): OnyxUpdate[] {
+ const onyxData: OnyxUpdate[] = [
+ isOptimisticData
+ ? {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER_WALLET,
+ value: {
+ walletLinkedAccountID: bankAccountID || fundID,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
+ errors: null,
+ },
+ }
+ : {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.USER_WALLET,
+ value: {
+ walletLinkedAccountID: bankAccountID || fundID,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ },
+ },
+ ];
+
+ if (previousPaymentMethod?.methodID) {
+ onyxData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ value: {
+ [previousPaymentMethod.methodID]: {
+ isDefault: !isOptimisticData,
+ },
+ },
+ });
+ }
+
+ if (currentPaymentMethod?.methodID) {
+ onyxData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ value: {
+ [currentPaymentMethod.methodID]: {
+ isDefault: isOptimisticData,
+ },
+ },
+ });
+ }
+
+ return onyxData;
+}
+
+/**
+ * Sets the default bank account or debit card for an Expensify Wallet
+ *
+ */
+function makeDefaultPaymentMethod(bankAccountID: number, fundID: number, previousPaymentMethod: PaymentMethod, currentPaymentMethod: PaymentMethod) {
+ type MakeDefaultPaymentMethodParams = {
+ bankAccountID: number;
+ fundID: number;
+ };
+
+ const parameters: MakeDefaultPaymentMethodParams = {
+ bankAccountID,
+ fundID,
+ };
+
+ API.write('MakeDefaultPaymentMethod', parameters, {
+ optimisticData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, true),
+ failureData: getMakeDefaultPaymentOnyxData(bankAccountID, fundID, previousPaymentMethod, currentPaymentMethod, false),
+ });
+}
+
+type PaymentCardParams = {expirationDate: string; cardNumber: string; securityCode: string; nameOnCard: string; addressZipCode: string};
+
+/**
+ * Calls the API to add a new card.
+ *
+ */
+function addPaymentCard(params: PaymentCardParams) {
+ const cardMonth = CardUtils.getMonthFromExpirationDateString(params.expirationDate);
+ const cardYear = CardUtils.getYearFromExpirationDateString(params.expirationDate);
+
+ type AddPaymentCardParams = {
+ cardNumber: string;
+ cardYear: string;
+ cardMonth: string;
+ cardCVV: string;
+ addressName: string;
+ addressZip: string;
+ currency: ValueOf;
+ isP2PDebitCard: boolean;
+ };
+
+ const parameters: AddPaymentCardParams = {
+ cardNumber: params.cardNumber,
+ cardYear,
+ cardMonth,
+ cardCVV: params.securityCode,
+ addressName: params.nameOnCard,
+ addressZip: params.addressZipCode,
+ currency: CONST.CURRENCY.USD,
+ isP2PDebitCard: true,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: true},
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
+ value: {isLoading: false},
+ },
+ ];
+
+ API.write('AddPaymentCard', parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
+/**
+ * Resets the values for the add debit card form back to their initial states
+ */
+function clearDebitCardFormErrorAndSubmit() {
+ Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, {
+ isLoading: false,
+ errors: undefined,
+ setupComplete: true,
+ });
+}
+
+/**
+ * Call the API to transfer wallet balance.
+ *
+ */
+function transferWalletBalance(paymentMethod: PaymentMethod) {
+ const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
+
+ type TransferWalletBalanceParameters = Partial, number | undefined>>;
+
+ const parameters: TransferWalletBalanceParameters = {
+ [paymentMethodIDKey]: paymentMethod.methodID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: true,
+ errors: null,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: false,
+ shouldShowSuccess: true,
+ paymentMethodType: paymentMethod.accountType,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: 'merge',
+ key: ONYXKEYS.WALLET_TRANSFER,
+ value: {
+ loading: false,
+ shouldShowSuccess: false,
+ },
+ },
+ ];
+
+ API.write('TransferWalletBalance', parameters, {
+ optimisticData,
+ successData,
+ failureData,
+ });
+}
+
+function resetWalletTransferData() {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {
+ selectedAccountType: '',
+ selectedAccountID: null,
+ filterPaymentMethodType: null,
+ loading: false,
+ shouldShowSuccess: false,
+ });
+}
+
+function saveWalletTransferAccountTypeAndID(selectedAccountType: string, selectedAccountID: string) {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {selectedAccountType, selectedAccountID});
+}
+
+/**
+ * Toggles the user's selected type of payment method (bank account or debit card) on the wallet transfer balance screen.
+ *
+ */
+function saveWalletTransferMethodType(filterPaymentMethodType?: FilterMethodPaymentType) {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {filterPaymentMethodType});
+}
+
+function dismissSuccessfulTransferBalancePage() {
+ Onyx.merge(ONYXKEYS.WALLET_TRANSFER, {shouldShowSuccess: false});
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
+}
+
+/**
+ * Looks through each payment method to see if there is an existing error
+ *
+ */
+function hasPaymentMethodError(bankList: OnyxValues[typeof ONYXKEYS.BANK_ACCOUNT_LIST], fundList: OnyxValues[typeof ONYXKEYS.FUND_LIST]): boolean {
+ const combinedPaymentMethods = {...bankList, ...fundList};
+
+ return Object.values(combinedPaymentMethods).some((item) => Object.keys(item.errors ?? {}).length);
+}
+
+type PaymentListKey = typeof ONYXKEYS.BANK_ACCOUNT_LIST | typeof ONYXKEYS.FUND_LIST;
+
+/**
+ * Clears the error for the specified payment item
+ * @param paymentListKey The onyx key for the provided payment method
+ * @param paymentMethodID
+ */
+function clearDeletePaymentMethodError(paymentListKey: PaymentListKey, paymentMethodID: string) {
+ Onyx.merge(paymentListKey, {
+ [paymentMethodID]: {
+ pendingAction: null,
+ errors: null,
+ },
+ });
+}
+
+/**
+ * If there was a failure adding a payment method, clearing it removes the payment method from the list entirely
+ * @param paymentListKey The onyx key for the provided payment method
+ * @param paymentMethodID
+ */
+function clearAddPaymentMethodError(paymentListKey: PaymentListKey, paymentMethodID: string) {
+ Onyx.merge(paymentListKey, {
+ [paymentMethodID]: null,
+ });
+}
+
+/**
+ * Clear any error(s) related to the user's wallet
+ */
+function clearWalletError() {
+ Onyx.merge(ONYXKEYS.USER_WALLET, {errors: null});
+}
+
+/**
+ * Clear any error(s) related to the user's wallet terms
+ */
+function clearWalletTermsError() {
+ Onyx.merge(ONYXKEYS.WALLET_TERMS, {errors: null});
+}
+
+function deletePaymentCard(fundID: number) {
+ type DeletePaymentCardParams = {
+ fundID: number;
+ };
+
+ const parameters: DeletePaymentCardParams = {
+ fundID,
+ };
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.FUND_LIST}`,
+ value: {[fundID]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}},
+ },
+ ];
+
+ API.write('DeletePaymentCard', parameters, {
+ optimisticData,
+ });
+}
+
+export {
+ deletePaymentCard,
+ addPaymentCard,
+ openWalletPage,
+ makeDefaultPaymentMethod,
+ kycWallRef,
+ continueSetup,
+ clearDebitCardFormErrorAndSubmit,
+ dismissSuccessfulTransferBalancePage,
+ transferWalletBalance,
+ resetWalletTransferData,
+ saveWalletTransferAccountTypeAndID,
+ saveWalletTransferMethodType,
+ hasPaymentMethodError,
+ clearDeletePaymentMethodError,
+ clearAddPaymentMethodError,
+ clearWalletError,
+ clearWalletTermsError,
+};
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 1a73b148e100..89324dd35485 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -73,6 +73,13 @@ Onyx.connect({
callback: (val) => (allRecentlyUsedCategories = val),
});
+let networkStatus = {};
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ waitForCollectionCallback: true,
+ callback: (val) => (networkStatus = val),
+});
+
/**
* Stores in Onyx the policy ID of the last workspace that was accessed by the user
* @param {String|null} policyID
@@ -766,7 +773,7 @@ function updateWorkspaceCustomUnitAndRate(policyID, currentCustomUnit, newCustom
'UpdateWorkspaceCustomUnitAndRate',
{
policyID,
- lastModified,
+ ...(!networkStatus.isOffline && {lastModified}),
customUnit: JSON.stringify(newCustomUnitParam),
customUnitRate: JSON.stringify(newCustomUnitParam.rates),
},
@@ -909,6 +916,48 @@ function buildOptimisticCustomUnits() {
};
}
+/**
+ * Optimistically creates a Policy Draft for a new workspace
+ *
+ * @param {String} [policyOwnerEmail] Optional, the email of the account to make the owner of the policy
+ * @param {String} [policyName] Optional, custom policy name we will use for created workspace
+ * @param {String} [policyID] Optional, custom policy id we will use for created workspace
+ */
+function createDraftInitialWorkspace(policyOwnerEmail = '', policyName = '', policyID = generatePolicyID()) {
+ const workspaceName = policyName || generateDefaultWorkspaceName(policyOwnerEmail);
+ const {customUnits} = buildOptimisticCustomUnits();
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: {
+ id: policyID,
+ type: CONST.POLICY.TYPE.FREE,
+ name: workspaceName,
+ role: CONST.POLICY.ROLE.ADMIN,
+ owner: sessionEmail,
+ isPolicyExpenseChatEnabled: true,
+ outputCurrency: lodashGet(allPersonalDetails, [sessionAccountID, 'localCurrencyCode'], CONST.CURRENCY.USD),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ customUnits,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: {
+ [sessionAccountID]: {
+ role: CONST.POLICY.ROLE.ADMIN,
+ errors: {},
+ },
+ },
+ },
+ ];
+
+ Onyx.update(optimisticData);
+}
+
/**
* Optimistically creates a new workspace and default workspace chats
*
@@ -1027,6 +1076,16 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseChatReportID}`,
value: expenseReportActionData,
},
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: null,
+ },
],
successData: [
{
@@ -1131,6 +1190,7 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
],
},
);
+
return adminsChatReportID;
}
@@ -1259,4 +1319,5 @@ export {
clearErrors,
openDraftWorkspaceRequest,
buildOptimisticPolicyRecentlyUsedCategories,
+ createDraftInitialWorkspace,
};
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index c56e9c567745..dc881252e4d8 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -375,8 +375,8 @@ function addActions(reportID, text = '', file) {
const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(reportID);
if (lastMessageText || lastMessageTranslationKey) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID);
- const lastVisibleActionCreated = lastVisibleAction.created;
- const lastActorAccountID = lastVisibleAction.actorAccountID;
+ const lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created');
+ const lastActorAccountID = lodashGet(lastVisibleAction, 'actorAccountID');
failureReport = {
lastMessageTranslationKey,
lastMessageText,
@@ -1054,11 +1054,11 @@ function deleteReportComment(reportID, reportAction) {
isLastMessageDeletedParentAction: true,
};
} else {
- const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportActionsUtils.getLastVisibleMessage(originalReportID, optimisticReportActions);
+ const {lastMessageText = '', lastMessageTranslationKey = ''} = ReportUtils.getLastVisibleMessage(originalReportID, optimisticReportActions);
if (lastMessageText || lastMessageTranslationKey) {
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions);
- const lastVisibleActionCreated = lastVisibleAction.created;
- const lastActorAccountID = lastVisibleAction.actorAccountID;
+ const lastVisibleActionCreated = lodashGet(lastVisibleAction, 'created');
+ const lastActorAccountID = lodashGet(lastVisibleAction, 'actorAccountID');
optimisticReport = {
lastMessageTranslationKey,
lastMessageText,
@@ -1162,17 +1162,16 @@ const removeLinksFromHtml = (html, links) => {
* This function will handle removing only links that were purposely removed by the user while editing.
*
* @param {String} newCommentText text of the comment after editing.
- * @param {String} originalHtml original html of the comment before editing.
+ * @param {String} originalCommentMarkdown original markdown of the comment before editing.
* @returns {String}
*/
-const handleUserDeletedLinksInHtml = (newCommentText, originalHtml) => {
+const handleUserDeletedLinksInHtml = (newCommentText, originalCommentMarkdown) => {
const parser = new ExpensiMark();
if (newCommentText.length > CONST.MAX_MARKUP_LENGTH) {
return newCommentText;
}
- const markdownOriginalComment = parser.htmlToMarkdown(originalHtml).trim();
const htmlForNewComment = parser.replace(newCommentText);
- const removedLinks = parser.getRemovedMarkdownLinks(markdownOriginalComment, newCommentText);
+ const removedLinks = parser.getRemovedMarkdownLinks(originalCommentMarkdown, newCommentText);
return removeLinksFromHtml(htmlForNewComment, removedLinks);
};
@@ -1191,7 +1190,14 @@ function editReportComment(reportID, originalReportAction, textForNewComment) {
// https://github.com/Expensify/App/issues/9090
// https://github.com/Expensify/App/issues/13221
const originalCommentHTML = lodashGet(originalReportAction, 'message[0].html');
- const htmlForNewComment = handleUserDeletedLinksInHtml(textForNewComment, originalCommentHTML);
+ const originalCommentMarkdown = parser.htmlToMarkdown(originalCommentHTML).trim();
+
+ // Skip the Edit if draft is not changed
+ if (originalCommentMarkdown === textForNewComment) {
+ return;
+ }
+
+ const htmlForNewComment = handleUserDeletedLinksInHtml(textForNewComment, originalCommentMarkdown);
const reportComment = parser.htmlToText(htmlForNewComment);
// For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database
@@ -1199,7 +1205,7 @@ function editReportComment(reportID, originalReportAction, textForNewComment) {
let parsedOriginalCommentHTML = originalCommentHTML;
if (textForNewComment.length <= CONST.MAX_MARKUP_LENGTH) {
const autolinkFilter = {filterRules: _.filter(_.pluck(parser.rules, 'name'), (name) => name !== 'autolink')};
- parsedOriginalCommentHTML = parser.replace(parser.htmlToMarkdown(originalCommentHTML).trim(), autolinkFilter);
+ parsedOriginalCommentHTML = parser.replace(originalCommentMarkdown, autolinkFilter);
}
// Delete the comment if it's empty
@@ -1239,7 +1245,7 @@ function editReportComment(reportID, originalReportAction, textForNewComment) {
];
const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(originalReportID, optimisticReportActions);
- if (reportActionID === lastVisibleAction.reportActionID) {
+ if (reportActionID === lodashGet(lastVisibleAction, 'reportActionID')) {
const lastMessageText = ReportUtils.formatReportLastMessageText(reportComment);
const optimisticReport = {
lastMessageTranslationKey: '',
@@ -1728,25 +1734,6 @@ function clearIOUError(reportID) {
Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {errorFields: {iou: null}});
}
-/**
- * Returns true if the accountID has reacted to the report action (with the given skin tone).
- * Uses the NEW FORMAT for "emojiReactions"
- * @param {String} accountID
- * @param {Array