diff --git a/android/app/build.gradle b/android/app/build.gradle
index e43909433367..b07c66308609 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -90,8 +90,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001039504
- versionName "1.3.95-4"
+ versionCode 1001039505
+ versionName "1.3.95-5"
}
flavorDimensions "default"
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 4d019ccacaa1..1966f3862d59 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.3.95.4
+ 1.3.95.5
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 64aaf1899c16..387687a2beaa 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.3.95.4
+ 1.3.95.5
diff --git a/package-lock.json b/package-lock.json
index 7c4ba8f2aad7..a80022853a24 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.95-4",
+ "version": "1.3.95-5",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.95-4",
+ "version": "1.3.95-5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index c18fa7d9da00..f3462a2b63bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.95-4",
+ "version": "1.3.95-5",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.ts b/src/CONST.ts
index 29bb0b83aaee..9e7c1f007335 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1312,6 +1312,7 @@ const CONST = {
TAX_ID: /^\d{9}$/,
NON_NUMERIC: /\D/g,
+ ANY_SPACE: /\s/g,
// Extract attachment's source from the data's html string
ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g,
diff --git a/src/components/DatePicker/index.android.js b/src/components/DatePicker/index.android.js
index d92869162d49..561fc700b6a5 100644
--- a/src/components/DatePicker/index.android.js
+++ b/src/components/DatePicker/index.android.js
@@ -1,5 +1,5 @@
import RNDatePicker from '@react-native-community/datetimepicker';
-import {format} from 'date-fns';
+import {format, parseISO} from 'date-fns';
import React, {forwardRef, useCallback, useImperativeHandle, useRef, useState} from 'react';
import {Keyboard} from 'react-native';
import TextInput from '@components/TextInput';
@@ -39,7 +39,7 @@ function DatePicker({value, defaultValue, label, placeholder, errorText, contain
);
const date = value || defaultValue;
- const dateAsText = date ? format(new Date(date), CONST.DATE.FNS_FORMAT_STRING) : '';
+ const dateAsText = date ? format(parseISO(date), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
diff --git a/src/components/DatePicker/index.ios.js b/src/components/DatePicker/index.ios.js
index 0f741e8db1ea..60307f70e954 100644
--- a/src/components/DatePicker/index.ios.js
+++ b/src/components/DatePicker/index.ios.js
@@ -1,5 +1,5 @@
import RNDatePicker from '@react-native-community/datetimepicker';
-import {format} from 'date-fns';
+import {format, parseISO} from 'date-fns';
import isFunction from 'lodash/isFunction';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Button, Keyboard, View} from 'react-native';
@@ -77,7 +77,7 @@ function DatePicker({value, defaultValue, innerRef, onInputChange, preferredLoca
setSelectedDate(date);
};
- const dateAsText = dateValue ? format(new Date(dateValue), CONST.DATE.FNS_FORMAT_STRING) : '';
+ const dateAsText = dateValue ? format(parseISO(dateValue), CONST.DATE.FNS_FORMAT_STRING) : '';
return (
<>
diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js
index 3bed9ca55321..33266242c5db 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -1,4 +1,4 @@
-import {format, isValid} from 'date-fns';
+import {format, isValid, parseISO} from 'date-fns';
import React, {useEffect, useRef} from 'react';
import _ from 'underscore';
import TextInput from '@components/TextInput';
@@ -29,7 +29,7 @@ function DatePicker({maxDate, minDate, onInputChange, innerRef, label, value, pl
return;
}
- const date = new Date(text);
+ const date = parseISO(text);
if (isValid(date)) {
onInputChange(format(date, CONST.DATE.FNS_FORMAT_STRING));
}
diff --git a/src/components/InlineErrorText.js b/src/components/InlineErrorText.js
deleted file mode 100644
index 80438eea8b5f..000000000000
--- a/src/components/InlineErrorText.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import _ from 'underscore';
-import styles from '@styles/styles';
-import Text from './Text';
-
-const propTypes = {
- /** Text to display */
- children: PropTypes.string.isRequired,
-
- /** Styling for inline error text */
- // eslint-disable-next-line react/forbid-prop-types
- styles: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- styles: [],
-};
-
-function InlineErrorText(props) {
- if (_.isEmpty(props.children)) {
- return null;
- }
-
- return {props.children};
-}
-
-InlineErrorText.propTypes = propTypes;
-InlineErrorText.defaultProps = defaultProps;
-InlineErrorText.displayName = 'InlineErrorText';
-export default InlineErrorText;
diff --git a/src/components/LHNOptionsList/OptionRowLHNData.js b/src/components/LHNOptionsList/OptionRowLHNData.js
index cb64a135b264..ebba2ffe0587 100644
--- a/src/components/LHNOptionsList/OptionRowLHNData.js
+++ b/src/components/LHNOptionsList/OptionRowLHNData.js
@@ -168,17 +168,14 @@ export default React.memo(
},
fullReport: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- initialValue: {},
},
reportActions: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
canEvict: false,
- initialValue: {},
},
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
selector: personalDetailsSelector,
- initialValue: {},
},
preferredLocale: {
key: ONYXKEYS.NVP_PREFERRED_LOCALE,
@@ -189,17 +186,15 @@ export default React.memo(
parentReportActions: {
key: ({fullReport}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${fullReport.parentReportID}`,
canEvict: false,
- initialValue: {},
},
policy: {
key: ({fullReport}) => `${ONYXKEYS.COLLECTION.POLICY}${fullReport.policyID}`,
- initialValue: {},
},
// Ideally, we aim to access only the last transaction for the current report by listening to changes in reportActions.
// In some scenarios, a transaction might be created after reportActions have been modified.
// This can lead to situations where `lastTransaction` doesn't update and retains the previous value.
// However, performance overhead of this is minimized by using memos inside the component.
- receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION, initialValue: {}},
+ receiptTransactions: {key: ONYXKEYS.COLLECTION.TRANSACTION},
}),
// eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
withOnyx({
diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js
index 874f9d72b276..62a919925a53 100644
--- a/src/hooks/useDebounce.js
+++ b/src/hooks/useDebounce.js
@@ -1,5 +1,5 @@
import lodashDebounce from 'lodash/debounce';
-import {useEffect, useRef} from 'react';
+import {useCallback, useEffect, useRef} from 'react';
/**
* Create and return a debounced function.
@@ -27,11 +27,13 @@ export default function useDebounce(func, wait, options) {
return debouncedFn.cancel;
}, [func, wait, leading, maxWait, trailing]);
- return (...args) => {
+ const debounceCallback = useCallback((...args) => {
const debouncedFn = debouncedFnRef.current;
if (debouncedFn) {
debouncedFn(...args);
}
- };
+ }, []);
+
+ return debounceCallback;
}
diff --git a/src/libs/Clipboard/index.native.js b/src/libs/Clipboard/index.native.js
deleted file mode 100644
index fe79e38585c4..000000000000
--- a/src/libs/Clipboard/index.native.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Clipboard from '@react-native-clipboard/clipboard';
-
-/**
- * Sets a string on the Clipboard object via @react-native-clipboard/clipboard
- *
- * @param {String} text
- */
-const setString = (text) => {
- Clipboard.setString(text);
-};
-
-export default {
- setString,
-
- // We don't want to set HTML on native platforms so noop them.
- canSetHtml: () => false,
- setHtml: () => {},
-};
diff --git a/src/libs/Clipboard/index.native.ts b/src/libs/Clipboard/index.native.ts
new file mode 100644
index 000000000000..f78c5e4ab230
--- /dev/null
+++ b/src/libs/Clipboard/index.native.ts
@@ -0,0 +1,19 @@
+import Clipboard from '@react-native-clipboard/clipboard';
+import {CanSetHtml, SetHtml, SetString} from './types';
+
+/**
+ * Sets a string on the Clipboard object via @react-native-clipboard/clipboard
+ */
+const setString: SetString = (text) => {
+ Clipboard.setString(text);
+};
+
+// We don't want to set HTML on native platforms so noop them.
+const canSetHtml: CanSetHtml = () => false;
+const setHtml: SetHtml = () => {};
+
+export default {
+ setString,
+ canSetHtml,
+ setHtml,
+};
diff --git a/src/libs/Clipboard/index.js b/src/libs/Clipboard/index.ts
similarity index 68%
rename from src/libs/Clipboard/index.js
rename to src/libs/Clipboard/index.ts
index 3fb2091c5cb1..b703b0b4d7f5 100644
--- a/src/libs/Clipboard/index.js
+++ b/src/libs/Clipboard/index.ts
@@ -1,16 +1,34 @@
import Clipboard from '@react-native-clipboard/clipboard';
-import lodashGet from 'lodash/get';
import * as Browser from '@libs/Browser';
import CONST from '@src/CONST';
+import {CanSetHtml, SetHtml, SetString} from './types';
-const canSetHtml = () => lodashGet(navigator, 'clipboard.write');
+type ComposerSelection = {
+ start: number;
+ end: number;
+ direction: 'forward' | 'backward' | 'none';
+};
+
+type AnchorSelection = {
+ anchorOffset: number;
+ focusOffset: number;
+ anchorNode: Node;
+ focusNode: Node;
+};
+
+type NullableObject = {[K in keyof T]: T[K] | null};
+
+type OriginalSelection = ComposerSelection | NullableObject;
+
+const canSetHtml: CanSetHtml =
+ () =>
+ (...args: ClipboardItems) =>
+ navigator?.clipboard?.write([...args]);
/**
* Deprecated method to write the content as HTML to clipboard.
- * @param {String} html HTML representation
- * @param {String} text Plain text representation
*/
-function setHTMLSync(html, text) {
+function setHTMLSync(html: string, text: string) {
const node = document.createElement('span');
node.textContent = html;
node.style.all = 'unset';
@@ -21,16 +39,21 @@ function setHTMLSync(html, text) {
node.addEventListener('copy', (e) => {
e.stopPropagation();
e.preventDefault();
- e.clipboardData.clearData();
- e.clipboardData.setData('text/html', html);
- e.clipboardData.setData('text/plain', text);
+ e.clipboardData?.clearData();
+ e.clipboardData?.setData('text/html', html);
+ e.clipboardData?.setData('text/plain', text);
});
document.body.appendChild(node);
- const selection = window.getSelection();
- const firstAnchorChild = selection.anchorNode && selection.anchorNode.firstChild;
+ const selection = window?.getSelection();
+
+ if (selection === null) {
+ return;
+ }
+
+ const firstAnchorChild = selection.anchorNode?.firstChild;
const isComposer = firstAnchorChild instanceof HTMLTextAreaElement;
- let originalSelection = null;
+ let originalSelection: OriginalSelection | null = null;
if (isComposer) {
originalSelection = {
start: firstAnchorChild.selectionStart,
@@ -60,12 +83,14 @@ function setHTMLSync(html, text) {
selection.removeAllRanges();
- if (isComposer) {
+ const anchorSelection = originalSelection as AnchorSelection;
+
+ if (isComposer && 'start' in originalSelection) {
firstAnchorChild.setSelectionRange(originalSelection.start, originalSelection.end, originalSelection.direction);
- } else if (originalSelection.anchorNode && originalSelection.focusNode) {
+ } else if (anchorSelection.anchorNode && anchorSelection.focusNode) {
// When copying to the clipboard here, the original values of anchorNode and focusNode will be null since there will be no user selection.
// We are adding a check to prevent null values from being passed to setBaseAndExtent, in accordance with the standards of the Selection API as outlined here: https://w3c.github.io/selection-api/#dom-selection-setbaseandextent.
- selection.setBaseAndExtent(originalSelection.anchorNode, originalSelection.anchorOffset, originalSelection.focusNode, originalSelection.focusOffset);
+ selection.setBaseAndExtent(anchorSelection.anchorNode, anchorSelection.anchorOffset, anchorSelection.focusNode, anchorSelection.focusOffset);
}
document.body.removeChild(node);
@@ -73,10 +98,8 @@ function setHTMLSync(html, text) {
/**
* Writes the content as HTML if the web client supports it.
- * @param {String} html HTML representation
- * @param {String} text Plain text representation
*/
-const setHtml = (html, text) => {
+const setHtml: SetHtml = (html: string, text: string) => {
if (!html || !text) {
return;
}
@@ -93,8 +116,8 @@ const setHtml = (html, text) => {
setHTMLSync(html, text);
} else {
navigator.clipboard.write([
- // eslint-disable-next-line no-undef
new ClipboardItem({
+ /* eslint-disable @typescript-eslint/naming-convention */
'text/html': new Blob([html], {type: 'text/html'}),
'text/plain': new Blob([text], {type: 'text/plain'}),
}),
@@ -104,10 +127,8 @@ const setHtml = (html, text) => {
/**
* Sets a string on the Clipboard object via react-native-web
- *
- * @param {String} text
*/
-const setString = (text) => {
+const setString: SetString = (text) => {
Clipboard.setString(text);
};
diff --git a/src/libs/Clipboard/types.ts b/src/libs/Clipboard/types.ts
new file mode 100644
index 000000000000..1d899144a2ba
--- /dev/null
+++ b/src/libs/Clipboard/types.ts
@@ -0,0 +1,5 @@
+type SetString = (text: string) => void;
+type SetHtml = (html: string, text: string) => void;
+type CanSetHtml = (() => (...args: ClipboardItems) => Promise) | (() => boolean);
+
+export type {SetString, CanSetHtml, SetHtml};
diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts
index 5a7da7ca08cf..58e1efa7aa65 100644
--- a/src/libs/ComposerUtils/index.ts
+++ b/src/libs/ComposerUtils/index.ts
@@ -32,7 +32,11 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo
*/
function getCommonSuffixLength(str1: string, str2: string): number {
let i = 0;
- while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
+ if (str1.length === 0 || str2.length === 0) {
+ return 0;
+ }
+ const minLen = Math.min(str1.length, str2.length);
+ while (i < minLen && str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
i++;
}
return i;
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 99853975f86a..54d09b75eff2 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -87,7 +87,6 @@ Onyx.connect({
const policyExpenseReports = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
- waitForCollectionCallback: true,
callback: (report, key) => {
if (!ReportUtils.isPolicyExpenseChat(report)) {
return;
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index e0858a356586..bddb2c586eba 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -524,12 +524,9 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions =
};
}
- let messageText = message?.text ?? '';
- if (messageText) {
- messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim();
- }
+ const messageText = message?.text ?? '';
return {
- lastMessageText: messageText,
+ lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
};
}
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index f657e902c1e2..6cf109327684 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -818,15 +818,8 @@ function isOneOnOneChat(report) {
* @returns {Object}
*/
function getReport(reportID) {
- /**
- * Using typical string concatenation here due to performance issues
- * with template literals.
- */
- if (!allReports) {
- return {};
- }
-
- return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {};
+ // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check
+ return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {};
}
/**
@@ -1499,15 +1492,18 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) {
}
if (moneyRequestReport) {
let nonReimbursableSpend = lodashGet(moneyRequestReport, 'nonReimbursableTotal', 0);
- let reimbursableSpend = lodashGet(moneyRequestReport, 'total', 0);
+ let totalSpend = lodashGet(moneyRequestReport, 'total', 0);
- if (nonReimbursableSpend + reimbursableSpend !== 0) {
+ if (nonReimbursableSpend + totalSpend !== 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;
+ totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend);
+
+ const totalDisplaySpend = totalSpend;
+ const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend;
+
return {
nonReimbursableSpend,
reimbursableSpend,
@@ -1530,25 +1526,14 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) {
* @returns {String}
*/
function getPolicyExpenseChatName(report, policy = undefined) {
- const ownerAccountID = report.ownerAccountID;
- const personalDetails = allPersonalDetails[ownerAccountID];
- const login = personalDetails ? personalDetails.login : null;
- const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName;
+ const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName;
// If the policy expense chat is owned by this user, use the name of the policy as the report name.
if (report.isOwnPolicyExpenseChat) {
return getPolicyName(report, false, policy);
}
- let policyExpenseChatRole = 'user';
- /**
- * Using typical string concatenation here due to performance issues
- * with template literals.
- */
- const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID];
- if (policyItem) {
- policyExpenseChatRole = policyItem.role || 'user';
- }
+ const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user';
// If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat
// of the account which was merged into the current user's account. Use the name of the policy as the name of the report.
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 4951432bcd03..4aa708d5882d 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -156,6 +156,18 @@ function getOrderedReportIDs(
}
}
+ // There are a few properties that need to be calculated for the report which are used when sorting reports.
+ reportsToDisplay.forEach((report) => {
+ // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params.
+ // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add
+ // the reportDisplayName property to the report object directly.
+ // eslint-disable-next-line no-param-reassign
+ report.displayName = ReportUtils.getReportName(report);
+
+ // eslint-disable-next-line no-param-reassign
+ report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports);
+ });
+
// The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
// 1. Pinned/GBR - Always sorted by reportDisplayName
// 2. Drafts - Always sorted by reportDisplayName
@@ -169,17 +181,7 @@ function getOrderedReportIDs(
const draftReports: Report[] = [];
const nonArchivedReports: Report[] = [];
const archivedReports: Report[] = [];
-
reportsToDisplay.forEach((report) => {
- // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params.
- // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add
- // the reportDisplayName property to the report object directly.
- // eslint-disable-next-line no-param-reassign
- report.displayName = ReportUtils.getReportName(report);
-
- // eslint-disable-next-line no-param-reassign
- report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports);
-
const isPinned = report.isPinned ?? false;
if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) {
pinnedAndGBRReports.push(report);
diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js
index bfa0cd911177..9af74f8313c3 100644
--- a/src/libs/UnreadIndicatorUpdater/index.js
+++ b/src/libs/UnreadIndicatorUpdater/index.js
@@ -1,4 +1,3 @@
-import {InteractionManager} from 'react-native';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import * as ReportUtils from '@libs/ReportUtils';
@@ -6,33 +5,11 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import updateUnread from './updateUnread/index';
-let previousUnreadCount = 0;
-
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (reportsFromOnyx) => {
- if (!reportsFromOnyx) {
- return;
- }
-
- /**
- * We need to wait until after interactions have finished to update the unread count because otherwise
- * the unread count will be updated while the interactions/animations are in progress and we don't want
- * to put more work on the main thread.
- *
- * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions
- * have finished.
- *
- * More info: https://reactnative.dev/docs/interactionmanager
- */
- InteractionManager.runAfterInteractions(() => {
- const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
- const unreadReportsCount = _.size(unreadReports);
- if (previousUnreadCount !== unreadReportsCount) {
- previousUnreadCount = unreadReportsCount;
- updateUnread(unreadReportsCount);
- }
- });
+ const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+ updateUnread(_.size(unreadReports));
},
});
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index e716c17de8b2..9b33ff9b086e 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -1,6 +1,7 @@
import {PUBLIC_DOMAINS} from 'expensify-common/lib/CONST';
import Str from 'expensify-common/lib/str';
import {escapeRegExp} from 'lodash';
+import filter from 'lodash/filter';
import lodashGet from 'lodash/get';
import lodashUnion from 'lodash/union';
import Onyx from 'react-native-onyx';
@@ -74,6 +75,12 @@ Onyx.connect({
callback: (val) => (allPersonalDetails = val),
});
+let reimbursementAccount;
+Onyx.connect({
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ callback: (val) => (reimbursementAccount = val),
+});
+
let allRecentlyUsedCategories = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES,
@@ -96,6 +103,36 @@ function updateLastAccessedWorkspace(policyID) {
Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID);
}
+/**
+ * Check if the user has any active free policies (aka workspaces)
+ *
+ * @param {Array} policies
+ * @returns {Boolean}
+ */
+function hasActiveFreePolicy(policies) {
+ const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN);
+
+ if (adminFreePolicies.length === 0) {
+ return false;
+ }
+
+ if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) {
+ return true;
+ }
+
+ if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) {
+ return true;
+ }
+
+ if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) {
+ return false;
+ }
+
+ // If there are no add or delete pending actions the only option left is an update
+ // pendingAction, in which case we should return true.
+ return true;
+}
+
/**
* Delete the workspace
*
@@ -104,6 +141,7 @@ function updateLastAccessedWorkspace(policyID) {
* @param {String} policyName
*/
function deleteWorkspace(policyID, reports, policyName) {
+ const filteredPolicies = filter(allPolicies, (policy) => policy.id !== policyID);
const optimisticData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -146,6 +184,18 @@ function deleteWorkspace(policyID, reports, policyName) {
value: optimisticReportActions,
};
}),
+
+ ...(!hasActiveFreePolicy(filteredPolicies)
+ ? [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ errors: null,
+ },
+ },
+ ]
+ : []),
];
// Restore the old report stateNum and statusNum
@@ -160,6 +210,13 @@ function deleteWorkspace(policyID, reports, policyName) {
oldPolicyName,
},
})),
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
+ value: {
+ errors: lodashGet(reimbursementAccount, 'errors', null),
+ },
+ },
];
// We don't need success data since the push notification will update
@@ -183,36 +240,6 @@ function isAdminOfFreePolicy(policies) {
return _.some(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN);
}
-/**
- * Check if the user has any active free policies (aka workspaces)
- *
- * @param {Array} policies
- * @returns {Boolean}
- */
-function hasActiveFreePolicy(policies) {
- const adminFreePolicies = _.filter(policies, (policy) => policy && policy.type === CONST.POLICY.TYPE.FREE && policy.role === CONST.POLICY.ROLE.ADMIN);
-
- if (adminFreePolicies.length === 0) {
- return false;
- }
-
- if (_.some(adminFreePolicies, (policy) => !policy.pendingAction)) {
- return true;
- }
-
- if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD)) {
- return true;
- }
-
- if (_.some(adminFreePolicies, (policy) => policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) {
- return false;
- }
-
- // If there are no add or delete pending actions the only option left is an update
- // pendingAction, in which case we should return true.
- return true;
-}
-
/**
* Remove the passed members from the policy employeeList
*
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 2729df061357..55545801f302 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -65,7 +65,6 @@ Onyx.connect({
const currentReportData = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
- waitForCollectionCallback: true,
callback: (data, key) => {
if (!key || !data) {
return;
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
index 3bbc2b03ff6f..6b375fb5ffa5 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -117,6 +117,7 @@ function ComposerWithSuggestions({
return draft;
});
const commentRef = useRef(value);
+ const lastTextRef = useRef(value);
const {isSmallScreenWidth} = useWindowDimensions();
const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
@@ -196,6 +197,50 @@ function ComposerWithSuggestions({
RNTextInputReset.resetKeyboardInput(findNodeHandle(textInputRef.current));
}, [textInputRef]);
+ /**
+ * Find the newly added characters between the previous text and the new text based on the selection.
+ *
+ * @param {string} prevText - The previous text.
+ * @param {string} newText - The new text.
+ * @returns {object} An object containing information about the newly added characters.
+ * @property {number} startIndex - The start index of the newly added characters in the new text.
+ * @property {number} endIndex - The end index of the newly added characters in the new text.
+ * @property {string} diff - The newly added characters.
+ */
+ const findNewlyAddedChars = useCallback(
+ (prevText, newText) => {
+ let startIndex = -1;
+ let endIndex = -1;
+ let currentIndex = 0;
+
+ // Find the first character mismatch with newText
+ while (currentIndex < newText.length && prevText.charAt(currentIndex) === newText.charAt(currentIndex) && selection.start > currentIndex) {
+ currentIndex++;
+ }
+
+ if (currentIndex < newText.length) {
+ startIndex = currentIndex;
+
+ // if text is getting pasted over find length of common suffix and subtract it from new text length
+ if (selection.end - selection.start > 0) {
+ const commonSuffixLength = ComposerUtils.getCommonSuffixLength(prevText, newText);
+ endIndex = newText.length - commonSuffixLength;
+ } else {
+ endIndex = currentIndex + (newText.length - prevText.length);
+ }
+ }
+
+ return {
+ startIndex,
+ endIndex,
+ diff: newText.substring(startIndex, endIndex),
+ };
+ },
+ [selection.end, selection.start],
+ );
+
+ const insertWhiteSpace = (text, index) => `${text.slice(0, index)} ${text.slice(index)}`;
+
const debouncedSaveReportComment = useMemo(
() =>
_.debounce((selectedReportID, newComment) => {
@@ -213,7 +258,14 @@ function ComposerWithSuggestions({
const updateComment = useCallback(
(commentValue, shouldDebounceSaveComment) => {
raiseIsScrollLikelyLayoutTriggered();
- const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale);
+ const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue);
+ const isEmojiInserted = diff.length && endIndex > startIndex && EmojiUtils.containsOnlyEmojis(diff);
+ const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(
+ isEmojiInserted ? insertWhiteSpace(commentValue, endIndex) : commentValue,
+ preferredSkinTone,
+ preferredLocale,
+ );
+
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
if (!_.isEmpty(newEmojis)) {
@@ -226,14 +278,8 @@ function ComposerWithSuggestions({
}
}
const newCommentConverted = convertToLTRForComposer(newComment);
- const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/);
- const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/);
-
- /** Only update isCommentEmpty state if it's different from previous one */
- if (isNewCommentEmpty !== isPrevCommentEmpty) {
- setIsCommentEmpty(isNewCommentEmpty);
- }
emojisPresentBefore.current = emojis;
+ setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/));
setValue(newCommentConverted);
if (commentValue !== newComment) {
const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
@@ -264,13 +310,14 @@ function ComposerWithSuggestions({
}
},
[
- debouncedUpdateFrequentlyUsedEmojis,
- preferredLocale,
+ raiseIsScrollLikelyLayoutTriggered,
+ findNewlyAddedChars,
preferredSkinTone,
- reportID,
+ preferredLocale,
setIsCommentEmpty,
+ debouncedUpdateFrequentlyUsedEmojis,
suggestionsRef,
- raiseIsScrollLikelyLayoutTriggered,
+ reportID,
debouncedSaveReportComment,
],
);
@@ -321,14 +368,8 @@ function ComposerWithSuggestions({
* @param {Boolean} shouldAddTrailSpace
*/
const replaceSelectionWithText = useCallback(
- (text, shouldAddTrailSpace = true) => {
- const updatedText = shouldAddTrailSpace ? `${text} ` : text;
- const selectionSpaceLength = shouldAddTrailSpace ? CONST.SPACE_LENGTH : 0;
- updateComment(ComposerUtils.insertText(commentRef.current, selection, updatedText));
- setSelection((prevSelection) => ({
- start: prevSelection.start + text.length + selectionSpaceLength,
- end: prevSelection.start + text.length + selectionSpaceLength,
- }));
+ (text) => {
+ updateComment(ComposerUtils.insertText(commentRef.current, selection, text));
},
[selection, updateComment],
);
@@ -452,7 +493,12 @@ function ComposerWithSuggestions({
}
focus();
- replaceSelectionWithText(e.key, false);
+ // Reset cursor to last known location
+ setSelection((prevSelection) => ({
+ start: prevSelection.start + 1,
+ end: prevSelection.end + 1,
+ }));
+ replaceSelectionWithText(e.key);
},
[checkComposerVisibility, focus, replaceSelectionWithText],
);
@@ -510,10 +556,16 @@ function ComposerWithSuggestions({
if (value.length === 0) {
return;
}
+
Report.setReportWithDraft(reportID, true);
+
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ useEffect(() => {
+ lastTextRef.current = value;
+ }, [value]);
+
useImperativeHandle(
forwardedRef,
() => ({
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
index baf93da6ccc4..2ea2dd334528 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
@@ -1,17 +1,15 @@
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
-import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import * as Expensicons from '@components/Icon/Expensicons';
import MentionSuggestions from '@components/MentionSuggestions';
+import {usePersonalDetails} from '@components/OnyxProvider';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import * as UserUtils from '@libs/UserUtils';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import * as SuggestionProps from './suggestionProps';
/**
@@ -29,9 +27,6 @@ const defaultSuggestionsValues = {
};
const propTypes = {
- /** Personal details of all users */
- personalDetails: PropTypes.objectOf(personalDetailsPropType),
-
/** A ref to this component */
forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
@@ -39,7 +34,6 @@ const propTypes = {
};
const defaultProps = {
- personalDetails: {},
forwardedRef: null,
};
@@ -49,7 +43,6 @@ function SuggestionMention({
selection,
setSelection,
isComposerFullSize,
- personalDetails,
updateComment,
composerHeight,
forwardedRef,
@@ -57,6 +50,7 @@ function SuggestionMention({
measureParentContainer,
isComposerFocused,
}) {
+ const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const {translate} = useLocalize();
const previousValue = usePrevious(value);
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
@@ -316,8 +310,4 @@ const SuggestionMentionWithRef = React.forwardRef((props, ref) => (
SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef';
-export default withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
-})(SuggestionMentionWithRef);
+export default SuggestionMentionWithRef;
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index 1e5e11fd9fcb..293dc3f5cd9d 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -198,28 +198,23 @@ export default compose(
chatReports: {
key: ONYXKEYS.COLLECTION.REPORT,
selector: chatReportSelector,
- initialValue: {},
},
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
priorityMode: {
key: ONYXKEYS.NVP_PRIORITY_MODE,
- initialValue: CONST.PRIORITY_MODE.DEFAULT,
},
betas: {
key: ONYXKEYS.BETAS,
- initialValue: [],
},
allReportActions: {
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
selector: reportActionsSelector,
- initialValue: {},
},
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
selector: policySelector,
- initialValue: {},
},
}),
)(SidebarLinksData);
diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js
index c7b5885865df..20344a08a2c8 100644
--- a/src/pages/iou/IOUCurrencySelection.js
+++ b/src/pages/iou/IOUCurrencySelection.js
@@ -126,8 +126,12 @@ function IOUCurrencySelection(props) {
};
});
- const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim()), 'i');
- const filteredCurrencies = _.filter(currencyOptions, (currencyOption) => searchRegex.test(currencyOption.text) || searchRegex.test(currencyOption.currencyName));
+ const searchRegex = new RegExp(Str.escapeForRegExp(searchValue.trim().replace(CONST.REGEX.ANY_SPACE, ' ')), 'i');
+ const filteredCurrencies = _.filter(
+ currencyOptions,
+ (currencyOption) =>
+ searchRegex.test(currencyOption.text.replace(CONST.REGEX.ANY_SPACE, ' ')) || searchRegex.test(currencyOption.currencyName.replace(CONST.REGEX.ANY_SPACE, ' ')),
+ );
const isEmpty = searchValue.trim() && !filteredCurrencies.length;
return {
diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js
index 9d1000179291..d913ae26c170 100644
--- a/src/pages/workspace/WorkspaceSettingsPage.js
+++ b/src/pages/workspace/WorkspaceSettingsPage.js
@@ -101,8 +101,7 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) {
-
+
;
+ static setString(text: string): boolean;
+ }
+
+ export {Clipboard};
+}