diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2528e23ebfe4..9a930427ecdf 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -98,8 +98,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001044408
- versionName "1.4.44-8"
+ versionCode 1001044501
+ versionName "1.4.45-1"
}
flavorDimensions "default"
diff --git a/assets/images/workspace-profile-light.png b/assets/images/workspace-profile-light.png
new file mode 100644
index 000000000000..7e82c98656d2
Binary files /dev/null and b/assets/images/workspace-profile-light.png differ
diff --git a/assets/images/workspace-profile.png b/assets/images/workspace-profile.png
index 72112566e35f..df1f6f9fd645 100644
Binary files a/assets/images/workspace-profile.png and b/assets/images/workspace-profile.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index b8cbf2e78f99..cf7cc7cc3caa 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.4.44
+ 1.4.45
CFBundleSignature
????
CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.44.8
+ 1.4.45.1
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 48b003f4185e..22bc17b167f6 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 1.4.44
+ 1.4.45
CFBundleSignature
????
CFBundleVersion
- 1.4.44.8
+ 1.4.45.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 45b77311fb36..8055f08b8d7c 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 1.4.44
+ 1.4.45
CFBundleVersion
- 1.4.44.8
+ 1.4.45.1
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index dc8eb94eeb3f..12c0c99c0d9a 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1432,7 +1432,7 @@ PODS:
- React-Core
- RNReactNativeHapticFeedback (2.2.0):
- React-Core
- - RNReanimated (3.6.1):
+ - RNReanimated (3.7.1):
- glog
- RCT-Folly (= 2022.05.16.00)
- React-Core
@@ -1986,7 +1986,7 @@ SPEC CHECKSUMS:
rnmapbox-maps: fcf7f1cbdc8bd7569c267d07284e8a5c7bee06ed
RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa
RNReactNativeHapticFeedback: ec56a5f81c3941206fd85625fa669ffc7b4545f9
- RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0
+ RNReanimated: beb07f7f900543928467da8107c175d1e57a1049
RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2
RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852
RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a
diff --git a/package-lock.json b/package-lock.json
index 42795645cbe4..066c140d915f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.44-8",
+ "version": "1.4.45-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.44-8",
+ "version": "1.4.45-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -106,7 +106,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "^3.6.1",
+ "react-native-reanimated": "^3.7.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
@@ -44630,9 +44630,9 @@
}
},
"node_modules/react-native-reanimated": {
- "version": "3.6.1",
- "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.6.1.tgz",
- "integrity": "sha512-F4vG9Yf9PKmE3GaWtVGUpzj3SM6YY2cx1yRHCwiMd1uY7W0gU017LfcVUorboJnj0y5QZqEriEK1Usq2Y8YZqg==",
+ "version": "3.7.1",
+ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.7.1.tgz",
+ "integrity": "sha512-bapCxhnS58+GZynQmA/f5U8vRlmhXlI/WhYg0dqnNAGXHNIc+38ahRWcG8iK8e0R2v9M8Ky2ZWObEC6bmweofg==",
"dependencies": {
"@babel/plugin-transform-object-assign": "^7.16.7",
"@babel/preset-typescript": "^7.16.7",
diff --git a/package.json b/package.json
index 7df7dcfcb93f..10b4f107aa9d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.44-8",
+ "version": "1.4.45-1",
"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.",
@@ -154,7 +154,7 @@
"react-native-plaid-link-sdk": "10.8.0",
"react-native-qrcode-svg": "^6.2.0",
"react-native-quick-sqlite": "^8.0.0-beta.2",
- "react-native-reanimated": "^3.6.1",
+ "react-native-reanimated": "^3.7.1",
"react-native-render-html": "6.3.1",
"react-native-safe-area-context": "4.8.2",
"react-native-screens": "3.29.0",
diff --git a/patches/react-native-reanimated+3.6.1+001+fix-boost-dependency.patch b/patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch
similarity index 100%
rename from patches/react-native-reanimated+3.6.1+001+fix-boost-dependency.patch
rename to patches/react-native-reanimated+3.7.1+001+fix-boost-dependency.patch
diff --git a/patches/react-native-reanimated+3.6.1.patch b/patches/react-native-reanimated+3.7.1.patch
similarity index 100%
rename from patches/react-native-reanimated+3.6.1.patch
rename to patches/react-native-reanimated+3.7.1.patch
diff --git a/src/CONST.ts b/src/CONST.ts
index 8abd4c087b16..3bce14516b80 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -178,6 +178,7 @@ const CONST = {
DATE: {
SQL_DATE_TIME: 'YYYY-MM-DD HH:mm:ss',
FNS_FORMAT_STRING: 'yyyy-MM-dd',
+ FNS_DATE_TIME_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss',
LOCAL_TIME_FORMAT: 'h:mm a',
YEAR_MONTH_FORMAT: 'yyyyMM',
MONTH_FORMAT: 'MMMM',
@@ -3319,6 +3320,10 @@ const CONST = {
PREFER_CLASSIC: 'preferClassic',
},
},
+
+ SESSION_STORAGE_KEYS: {
+ INITIAL_URL: 'INITIAL_URL',
+ },
} as const;
type Country = keyof typeof CONST.ALL_COUNTRIES;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 78f0e61e72a9..d4a0b8a21d66 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -272,6 +272,9 @@ const ONYXKEYS = {
/** Indicates whether we should store logs or not */
SHOULD_STORE_LOGS: 'shouldStoreLogs',
+ // Paths of PDF file that has been cached during one session
+ CACHED_PDF_PATHS: 'cachedPDFPaths',
+
/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
@@ -564,6 +567,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.PLAID_CURRENT_EVENT]: string;
[ONYXKEYS.LOGS]: Record;
[ONYXKEYS.SHOULD_STORE_LOGS]: boolean;
+ [ONYXKEYS.CACHED_PDF_PATHS]: Record;
};
type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping;
diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx
index ab39e5379230..7f0178863fc9 100755
--- a/src/components/AttachmentModal.tsx
+++ b/src/components/AttachmentModal.tsx
@@ -89,7 +89,7 @@ type AttachmentModalProps = AttachmentModalOnyxProps & {
source?: AvatarSource;
/** Optional callback to fire when we want to preview an image and approve it for use. */
- onConfirm?: ((file: Partial) => void) | null;
+ onConfirm?: ((file: FileObject) => void) | null;
/** Whether the modal should be open by default */
defaultOpen?: boolean;
@@ -264,7 +264,7 @@ function AttachmentModal({
}
if (onConfirm) {
- onConfirm(Object.assign(file ?? {}, {source: sourceState}));
+ onConfirm(Object.assign(file ?? {}, {source: sourceState} as FileObject));
}
setIsModalOpen(false);
@@ -318,7 +318,7 @@ function AttachmentModal({
const validateAndDisplayFileToUpload = useCallback(
(data: FileObject) => {
- if (!isDirectoryCheck(data)) {
+ if (!data || !isDirectoryCheck(data)) {
return;
}
let fileObject = data;
@@ -617,4 +617,4 @@ export default withOnyx({
},
})(memo(AttachmentModal));
-export type {Attachment};
+export type {Attachment, FileObject};
diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
index edc8ab11fd27..b2c9fed64467 100644
--- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js
+++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js
@@ -105,6 +105,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}) {
isAuthTokenRequired={item.isAuthTokenRequired}
onPress={onPress}
transactionID={item.transactionID}
+ reportActionID={item.reportActionID}
isHovered={isModalHovered}
isFocused={isFocused}
optionalVideoDuration={item.duration}
diff --git a/src/components/Attachments/AttachmentView/index.js b/src/components/Attachments/AttachmentView/index.js
index c871628f65e7..56425f64a51c 100755
--- a/src/components/Attachments/AttachmentView/index.js
+++ b/src/components/Attachments/AttachmentView/index.js
@@ -17,6 +17,7 @@ import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as CachedPDFPaths from '@libs/actions/CachedPDFPaths';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import compose from '@libs/compose';
import * as TransactionUtils from '@libs/TransactionUtils';
@@ -57,6 +58,9 @@ const propTypes = {
// eslint-disable-next-line react/no-unused-prop-types
transactionID: PropTypes.string,
+ /** The id of the report action related to the attachment */
+ reportActionID: PropTypes.string,
+
isHovered: PropTypes.bool,
optionalVideoDuration: PropTypes.number,
@@ -71,6 +75,7 @@ const defaultProps = {
isWorkspaceAvatar: false,
maybeIcon: false,
transactionID: '',
+ reportActionID: '',
isHovered: false,
optionalVideoDuration: 0,
};
@@ -92,6 +97,7 @@ function AttachmentView({
maybeIcon,
fallbackSource,
transaction,
+ reportActionID,
isHovered,
optionalVideoDuration,
}) {
@@ -153,6 +159,15 @@ function AttachmentView({
if ((_.isString(source) && Str.isPDF(source)) || (file && Str.isPDF(file.name || translate('attachmentView.unknownFilename')))) {
const encryptedSourceUrl = isAuthTokenRequired ? addEncryptedAuthTokenToURL(source) : source;
+ const onPDFLoadComplete = (path) => {
+ if (path && (transaction.transactionID || reportActionID)) {
+ CachedPDFPaths.add(transaction.transactionID || reportActionID, path);
+ }
+ if (!loadComplete) {
+ setLoadComplete(true);
+ }
+ };
+
// We need the following View component on android native
// So that the event will propagate properly and
// the Password protected preview will be shown for pdf attachement we are about to send.
@@ -166,7 +181,7 @@ function AttachmentView({
encryptedSourceUrl={encryptedSourceUrl}
onPress={onPress}
onToggleKeyboard={onToggleKeyboard}
- onLoadComplete={() => !loadComplete && setLoadComplete(true)}
+ onLoadComplete={onPDFLoadComplete}
errorLabelStyles={isUsedInAttachmentModal ? [styles.textLabel, styles.textLarge] : [styles.cursorAuto]}
style={isUsedInAttachmentModal ? styles.imageModalPDF : styles.flex1}
isUsedInCarousel={isUsedInCarousel}
diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx
index fa8a6d71516f..4388ebb8f815 100644
--- a/src/components/AvatarWithImagePicker.tsx
+++ b/src/components/AvatarWithImagePicker.tsx
@@ -220,7 +220,7 @@ function AvatarWithImagePicker({
setError(null, {});
setIsMenuVisible(false);
setImageData({
- uri: image.uri,
+ uri: image.uri ?? '',
name: image.name,
type: image.type,
});
diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu.tsx
index 9466da601825..8aa3a5f0b9f0 100644
--- a/src/components/ButtonWithDropdownMenu.tsx
+++ b/src/components/ButtonWithDropdownMenu.tsx
@@ -10,33 +10,30 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import type {AnchorPosition} from '@styles/index';
import CONST from '@src/CONST';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
-import type DeepValueOf from '@src/types/utils/DeepValueOf';
import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import PopoverMenu from './PopoverMenu';
-type PaymentType = DeepValueOf;
-
-type DropdownOption = {
- value: PaymentType;
+type DropdownOption = {
+ value: T;
text: string;
- icon: IconAsset;
+ icon?: IconAsset;
iconWidth?: number;
iconHeight?: number;
iconDescription?: string;
};
-type ButtonWithDropdownMenuProps = {
+type ButtonWithDropdownMenuProps = {
/** Text to display for the menu header */
menuHeaderText?: string;
/** Callback to execute when the main button is pressed */
- onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: PaymentType) => void;
+ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: T) => void;
/** Callback to execute when a dropdown option is selected */
- onOptionSelected?: (option: DropdownOption) => void;
+ onOptionSelected?: (option: DropdownOption) => void;
/** Call the onPress function on main button when Enter key is pressed */
pressOnEnter?: boolean;
@@ -55,19 +52,19 @@ type ButtonWithDropdownMenuProps = {
/** Menu options to display */
/** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
- options: DropdownOption[];
+ options: Array>;
/** The anchor alignment of the popover menu */
anchorAlignment?: AnchorAlignment;
/* ref for the button */
- buttonRef: RefObject;
+ buttonRef?: RefObject;
/** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */
enterKeyEventListenerPriority?: number;
};
-function ButtonWithDropdownMenu({
+function ButtonWithDropdownMenu({
isLoading = false,
isDisabled = false,
pressOnEnter = false,
@@ -83,7 +80,7 @@ function ButtonWithDropdownMenu({
options,
onOptionSelected,
enterKeyEventListenerPriority = 0,
-}: ButtonWithDropdownMenuProps) {
+}: ButtonWithDropdownMenuProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
index 2374fc9e5d0c..89312a7ca614 100644
--- a/src/components/CategoryPicker/index.js
+++ b/src/components/CategoryPicker/index.js
@@ -70,6 +70,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
onSelectRow={onSubmit}
ListItem={RadioListItem}
initiallyFocusedOptionKey={selectedOptionKey}
+ isRowMultilineSupported
/>
);
}
diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx
index 516de55c73ba..b6443f3ca385 100755
--- a/src/components/Composer/index.tsx
+++ b/src/components/Composer/index.tsx
@@ -75,7 +75,7 @@ function Composer(
shouldContainScroll = false,
...props
}: ComposerProps,
- ref: ForwardedRef,
+ ref: ForwardedRef,
) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -278,6 +278,7 @@ function Composer(
if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) {
return;
}
+
onKeyPress(e);
},
[onKeyPress],
diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts
index d8d88970ea78..6bc44aba69cd 100644
--- a/src/components/Composer/types.ts
+++ b/src/components/Composer/types.ts
@@ -1,11 +1,11 @@
-import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native';
+import type {NativeSyntheticEvent, StyleProp, TextInputProps, TextInputSelectionChangeEventData, TextStyle} from 'react-native';
type TextSelection = {
start: number;
end?: number;
};
-type ComposerProps = {
+type ComposerProps = TextInputProps & {
/** identify id in the text input */
id?: string;
@@ -31,7 +31,7 @@ type ComposerProps = {
onNumberOfLinesChange?: (numberOfLines: number) => void;
/** Callback method to handle pasting a file */
- onPasteFile?: (file?: File) => void;
+ onPasteFile?: (file: File) => void;
/** General styles to apply to the text input */
// eslint-disable-next-line react/forbid-prop-types
@@ -74,12 +74,6 @@ type ComposerProps = {
/** Whether the sull composer is open */
isComposerFullSize?: boolean;
- onKeyPress?: (event: NativeSyntheticEvent) => void;
-
- onFocus?: (event: NativeSyntheticEvent) => void;
-
- onBlur?: (event: NativeSyntheticEvent) => void;
-
/** Should make the input only scroll inside the element avoid scroll out to parent */
shouldContainScroll?: boolean;
};
diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx
index 7f05b45bca30..da8c0ed86a84 100644
--- a/src/components/ConfirmedRoute.tsx
+++ b/src/components/ConfirmedRoute.tsx
@@ -25,13 +25,13 @@ type ConfirmedRoutePropsOnyxProps = {
type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & {
/** Transaction that stores the distance request data */
- transaction: Transaction;
+ transaction: Transaction | undefined;
};
function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) {
const {isOffline} = useNetwork();
- const {route0: route} = transaction.routes ?? {};
- const waypoints = transaction.comment?.waypoints ?? {};
+ const {route0: route} = transaction?.routes ?? {};
+ const waypoints = transaction?.comment?.waypoints ?? {};
const coordinates = route?.geometry?.coordinates ?? [];
const theme = useTheme();
const styles = useThemeStyles();
diff --git a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
index ed91b51a2a44..154689df4ce8 100644
--- a/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
+++ b/src/components/DeeplinkWrapper/DeeplinkRedirectLoadingIndicator.tsx
@@ -12,7 +12,6 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
type DeeplinkRedirectLoadingIndicatorOnyxProps = {
@@ -45,7 +44,7 @@ function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: Deeplink
{translate('deeplinkWrapper.loggedInAs', {email: session?.email ?? ''})}
{translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')}
- {translate('deeplinkWrapper.or')} Navigation.navigate(ROUTES.HOME)}>{translate('deeplinkWrapper.continueInWeb')}.
+ {translate('deeplinkWrapper.or')} Navigation.goBack()}>{translate('deeplinkWrapper.continueInWeb')}.
diff --git a/src/components/DistanceEReceipt.tsx b/src/components/DistanceEReceipt.tsx
index 9846cfa04257..941d63c1bf94 100644
--- a/src/components/DistanceEReceipt.tsx
+++ b/src/components/DistanceEReceipt.tsx
@@ -75,8 +75,6 @@ function DistanceEReceipt({transaction}: DistanceEReceiptProps) {
let descriptionKey: TranslationPaths = 'distance.waypointDescription.stop';
if (index === 0) {
descriptionKey = 'distance.waypointDescription.start';
- } else if (index === Object.keys(waypoints).length - 1) {
- descriptionKey = 'distance.waypointDescription.finish';
}
return (
diff --git a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx
index a1f3efbf0291..57e4fb0b530e 100644
--- a/src/components/DistanceRequest/DistanceRequestRenderItem.tsx
+++ b/src/components/DistanceRequest/DistanceRequestRenderItem.tsx
@@ -42,7 +42,7 @@ function DistanceRequestRenderItem({waypoints, item = '', onSecondaryInteraction
descriptionKey += 'start';
waypointIcon = Expensicons.DotIndicatorUnfilled;
} else if (index === lastWaypointIndex) {
- descriptionKey += 'finish';
+ descriptionKey += 'stop';
waypointIcon = Expensicons.Location;
} else {
descriptionKey += 'stop';
diff --git a/src/components/FormElement.tsx b/src/components/FormElement/index.native.tsx
similarity index 80%
rename from src/components/FormElement.tsx
rename to src/components/FormElement/index.native.tsx
index da98d4dc565a..d0413c5244c1 100644
--- a/src/components/FormElement.tsx
+++ b/src/components/FormElement/index.native.tsx
@@ -2,12 +2,10 @@ import type {ForwardedRef} from 'react';
import React, {forwardRef} from 'react';
import type {ViewProps} from 'react-native';
import {View} from 'react-native';
-import * as ComponentUtils from '@libs/ComponentUtils';
function FormElement(props: ViewProps, ref: ForwardedRef) {
return (
{
+ // When Enter is pressed, the form is submitted to the action URL (POST /).
+ // As we are using a controlled component, we need to disable this behavior here.
+ event.preventDefault();
+};
+
+function FormElement(props: ViewProps, outerRef: ForwardedRef) {
+ const formRef = useRef(null);
+ const mergedRef = mergeRefs(formRef, outerRef);
+
+ useEffect(() => {
+ const formCurrent = formRef.current;
+
+ if (!formCurrent) {
+ return;
+ }
+
+ // Prevent the browser from applying its own validation, which affects the email input
+ formCurrent.setAttribute('novalidate', '');
+
+ // Password Managers need these attributes to be able to identify the form elements properly.
+ formCurrent.setAttribute('method', 'post');
+ formCurrent.setAttribute('action', '/');
+ formCurrent.addEventListener('submit', preventFormDefault);
+
+ return () => {
+ formCurrent.removeEventListener('submit', preventFormDefault);
+ };
+ }, []);
+
+ return (
+
+ );
+}
+
+FormElement.displayName = 'FormElement';
+
+export default forwardRef(FormElement);
diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx
index 7313bb4aa7bb..25b468181b87 100644
--- a/src/components/LocaleContextProvider.tsx
+++ b/src/components/LocaleContextProvider.tsx
@@ -132,4 +132,4 @@ Provider.displayName = 'withOnyx(LocaleContextProvider)';
export {Provider as LocaleContextProvider, LocaleContext};
-export type {LocaleContextProps};
+export type {LocaleContextProps, Locale};
diff --git a/src/components/MentionSuggestions.tsx b/src/components/MentionSuggestions.tsx
index 459131ecc434..23040a242807 100644
--- a/src/components/MentionSuggestions.tsx
+++ b/src/components/MentionSuggestions.tsx
@@ -1,4 +1,5 @@
import React, {useCallback} from 'react';
+import type {MeasureInWindowOnSuccessCallback} from 'react-native';
import {View} from 'react-native';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -18,7 +19,7 @@ type Mention = {
alternateText: string;
/** Email/phone number of the user */
- login: string;
+ login?: string;
/** Array of icons of the user. We use the first element of this array */
icons: Icon[];
@@ -32,7 +33,7 @@ type MentionSuggestionsProps = {
mentions: Mention[];
/** Fired when the user selects a mention */
- onSelect: () => void;
+ onSelect: (highlightedMentionIndex: number) => void;
/** Mention prefix that follows the @ sign */
prefix: string;
@@ -43,7 +44,7 @@ type MentionSuggestionsProps = {
isMentionPickerLarge: boolean;
/** Measures the parent container's position and dimensions. */
- measureParentContainer: () => void;
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
};
/**
@@ -142,3 +143,5 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe
MentionSuggestions.displayName = 'MentionSuggestions';
export default MentionSuggestions;
+
+export type {Mention};
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
deleted file mode 100755
index df2781d3ea89..000000000000
--- a/src/components/MoneyRequestConfirmationList.js
+++ /dev/null
@@ -1,898 +0,0 @@
-import {useIsFocused} from '@react-navigation/native';
-import {format} from 'date-fns';
-import {isEmpty} from 'lodash';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import useLocalize from '@hooks/useLocalize';
-import usePermissions from '@hooks/usePermissions';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import compose from '@libs/compose';
-import * as CurrencyUtils from '@libs/CurrencyUtils';
-import DistanceRequestUtils from '@libs/DistanceRequestUtils';
-import * as IOUUtils from '@libs/IOUUtils';
-import Log from '@libs/Log';
-import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import * as OptionsListUtils from '@libs/OptionsListUtils';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import * as ReceiptUtils from '@libs/ReceiptUtils';
-import * as ReportUtils from '@libs/ReportUtils';
-import * as TransactionUtils from '@libs/TransactionUtils';
-import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes';
-import {policyPropTypes} from '@pages/workspace/withPolicy';
-import * as IOU from '@userActions/IOU';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
-import categoryPropTypes from './categoryPropTypes';
-import ConfirmedRoute from './ConfirmedRoute';
-import FormHelpMessage from './FormHelpMessage';
-import Image from './Image';
-import MenuItemWithTopDescription from './MenuItemWithTopDescription';
-import optionPropTypes from './optionPropTypes';
-import OptionsSelector from './OptionsSelector';
-import ReceiptEmptyState from './ReceiptEmptyState';
-import SettlementButton from './SettlementButton';
-import ShowMoreButton from './ShowMoreButton';
-import Switch from './Switch';
-import tagPropTypes from './tagPropTypes';
-import Text from './Text';
-import transactionPropTypes from './transactionPropTypes';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from './withCurrentUserPersonalDetails';
-
-const propTypes = {
- /** Callback to inform parent modal of success */
- onConfirm: PropTypes.func,
-
- /** Callback to parent modal to send money */
- onSendMoney: PropTypes.func,
-
- /** Callback to inform a participant is selected */
- onSelectParticipant: PropTypes.func,
-
- /** Should we request a single or multiple participant selection from user */
- hasMultipleParticipants: PropTypes.bool.isRequired,
-
- /** IOU amount */
- iouAmount: PropTypes.number.isRequired,
-
- /** IOU comment */
- iouComment: PropTypes.string,
-
- /** IOU currency */
- iouCurrencyCode: PropTypes.string,
-
- /** IOU type */
- iouType: PropTypes.string,
-
- /** IOU date */
- iouCreated: PropTypes.string,
-
- /** IOU merchant */
- iouMerchant: PropTypes.string,
-
- /** IOU Category */
- iouCategory: PropTypes.string,
-
- /** IOU Tag */
- iouTag: PropTypes.string,
-
- /** IOU isBillable */
- iouIsBillable: PropTypes.bool,
-
- /** Callback to toggle the billable state */
- onToggleBillable: PropTypes.func,
-
- /** Selected participants from MoneyRequestModal with login / accountID */
- selectedParticipants: PropTypes.arrayOf(optionPropTypes).isRequired,
-
- /** Payee of the money request with login */
- payeePersonalDetails: optionPropTypes,
-
- /** Can the participants be modified or not */
- canModifyParticipants: PropTypes.bool,
-
- /** Should the list be read only, and not editable? */
- isReadOnly: PropTypes.bool,
-
- /** Depending on expense report or personal IOU report, respective bank account route */
- bankAccountRoute: PropTypes.string,
-
- ...withCurrentUserPersonalDetailsPropTypes,
-
- /** Current user session */
- session: PropTypes.shape({
- email: PropTypes.string.isRequired,
- }),
-
- /** The policyID of the request */
- policyID: PropTypes.string,
-
- /** The reportID of the request */
- reportID: PropTypes.string,
-
- /** File path of the receipt */
- receiptPath: PropTypes.string,
-
- /** File name of the receipt */
- receiptFilename: PropTypes.string,
-
- /** List styles for OptionsSelector */
- listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
-
- /** ID of the transaction that represents the money request */
- transactionID: PropTypes.string,
-
- /** Transaction that represents the money request */
- transaction: transactionPropTypes,
-
- /** Unit and rate used for if the money request is a distance request */
- mileageRate: PropTypes.shape({
- /** Unit used to represent distance */
- unit: PropTypes.oneOf([CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]),
-
- /** Rate used to calculate the distance request amount */
- rate: PropTypes.number,
-
- /** The currency of the rate */
- currency: PropTypes.string,
- }),
-
- /** Whether the money request is a distance request */
- isDistanceRequest: PropTypes.bool,
-
- /** Whether the money request is a scan request */
- isScanRequest: PropTypes.bool,
-
- /** Whether we're editing a split bill */
- isEditingSplitBill: PropTypes.bool,
-
- /** Whether we should show the amount, date, and merchant fields. */
- shouldShowSmartScanFields: PropTypes.bool,
-
- /** A flag for verifying that the current report is a sub-report of a workspace chat */
- isPolicyExpenseChat: PropTypes.bool,
-
- /* Onyx Props */
- /** Collection of categories attached to a policy */
- policyCategories: PropTypes.objectOf(categoryPropTypes),
-
- /** Collection of tags attached to a policy */
- policyTags: tagPropTypes,
-
- /* Onyx Props */
- /** The policy of the report */
- policy: policyPropTypes.policy,
-
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: iouPropTypes,
-};
-
-const defaultProps = {
- onConfirm: () => {},
- onSendMoney: () => {},
- onSelectParticipant: () => {},
- iouType: CONST.IOU.TYPE.REQUEST,
- iouCategory: '',
- iouTag: '',
- iouIsBillable: false,
- onToggleBillable: () => {},
- payeePersonalDetails: null,
- canModifyParticipants: false,
- isReadOnly: false,
- bankAccountRoute: '',
- session: {
- email: null,
- },
- policyID: '',
- reportID: '',
- ...withCurrentUserPersonalDetailsDefaultProps,
- receiptPath: '',
- receiptFilename: '',
- listStyles: [],
- policy: {},
- policyCategories: {},
- policyTags: {},
- transactionID: '',
- transaction: {},
- mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'},
- isDistanceRequest: false,
- isScanRequest: false,
- shouldShowSmartScanFields: true,
- isPolicyExpenseChat: false,
- iou: iouDefaultProps,
-};
-
-function MoneyRequestConfirmationList(props) {
- const theme = useTheme();
- const styles = useThemeStyles();
- // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks.
- // Prop functions pass props itself as a "this" value to the function which means they change every time props change.
- const {onSendMoney, onConfirm, onSelectParticipant} = props;
- const {translate, toLocaleDigit} = useLocalize();
- const transaction = props.transaction;
- const {canUseViolations} = usePermissions();
-
- const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST;
- const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT;
- const isTypeSend = props.iouType === CONST.IOU.TYPE.SEND;
-
- const isSplitWithScan = isSplitBill && props.isScanRequest;
-
- const {unit, rate, currency} = props.mileageRate;
- const distance = lodashGet(transaction, 'routes.route0.distance', 0);
- const shouldCalculateDistanceAmount = props.isDistanceRequest && props.iouAmount === 0;
- const taxRates = lodashGet(props.policy, 'taxRates', {});
-
- // A flag for showing the categories field
- const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)));
- // A flag and a toggler for showing the rest of the form fields
- const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
-
- // Do not hide fields in case of send money request
- const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill;
-
- // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item
- const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan;
- const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !props.isDistanceRequest && !isSplitWithScan;
-
- const policyTagLists = useMemo(() => PolicyUtils.getTagLists(props.policyTags), [props.policyTags]);
-
- // A flag for showing the tags field
- const shouldShowTags = props.isPolicyExpenseChat && (props.iouTag || OptionsListUtils.hasEnabledTags(policyTagLists));
-
- // A flag for showing tax fields - tax rate and tax amount
- const shouldShowTax = props.isPolicyExpenseChat && lodashGet(props.policy, 'tax.trackingEnabled', props.policy.isTaxTrackingEnabled);
-
- // A flag for showing the billable field
- const shouldShowBillable = !lodashGet(props.policy, 'disabledFields.defaultBillable', true);
-
- const hasRoute = TransactionUtils.hasRoute(transaction);
- const isDistanceRequestWithPendingRoute = props.isDistanceRequest && (!hasRoute || !rate);
- const formattedAmount = isDistanceRequestWithPendingRoute
- ? ''
- : CurrencyUtils.convertToDisplayString(
- shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate) : props.iouAmount,
- props.isDistanceRequest ? currency : props.iouCurrencyCode,
- );
- const formattedTaxAmount = CurrencyUtils.convertToDisplayString(props.transaction.taxAmount, props.iouCurrencyCode);
-
- const defaultTaxKey = taxRates.defaultExternalID;
- const defaultTaxName = (defaultTaxKey && `${taxRates.taxes[defaultTaxKey].name} (${taxRates.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) || '';
- const taxRateTitle = (props.transaction.taxRate && props.transaction.taxRate.text) || defaultTaxName;
-
- const isFocused = useIsFocused();
- const [formError, setFormError] = useState('');
-
- const [didConfirm, setDidConfirm] = useState(false);
- const [didConfirmSplit, setDidConfirmSplit] = useState(false);
-
- const shouldDisplayFieldError = useMemo(() => {
- if (!props.isEditingSplitBill) {
- return false;
- }
-
- return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction));
- }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]);
-
- const isMerchantEmpty = !props.iouMerchant || props.iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
- const shouldDisplayMerchantError = props.isPolicyExpenseChat && !props.isScanRequest && isMerchantEmpty;
-
- useEffect(() => {
- if (shouldDisplayFieldError && didConfirmSplit) {
- setFormError('iou.error.genericSmartscanFailureMessage');
- return;
- }
- if (shouldDisplayFieldError && props.hasSmartScanFailed) {
- setFormError('iou.receiptScanningFailed');
- return;
- }
- // reset the form error whenever the screen gains or loses focus
- setFormError('');
- }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]);
-
- useEffect(() => {
- if (!shouldCalculateDistanceAmount) {
- return;
- }
-
- const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate);
- IOU.setMoneyRequestAmount(amount);
- }, [shouldCalculateDistanceAmount, distance, rate, unit]);
-
- /**
- * Returns the participants with amount
- * @param {Array} participants
- * @returns {Array}
- */
- const getParticipantsWithAmount = useCallback(
- (participantsList) => {
- const iouAmount = IOUUtils.calculateAmount(participantsList.length, props.iouAmount, props.iouCurrencyCode);
- return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(
- participantsList,
- props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(iouAmount, props.iouCurrencyCode) : '',
- );
- },
- [props.iouAmount, props.iouCurrencyCode],
- );
-
- // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again
- if (props.isEditingSplitBill && didConfirm) {
- setDidConfirm(false);
- }
-
- const splitOrRequestOptions = useMemo(() => {
- let text;
- if (isSplitBill && props.iouAmount === 0) {
- text = translate('iou.split');
- } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
- text = translate('iou.request');
- if (props.iouAmount !== 0) {
- text = translate('iou.requestAmount', {amount: formattedAmount});
- }
- } else {
- const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount';
- text = translate(translationKey, {amount: formattedAmount});
- }
- return [
- {
- text: text[0].toUpperCase() + text.slice(1),
- value: props.iouType,
- },
- ];
- }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]);
-
- const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]);
- const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]);
- const canModifyParticipants = !props.isReadOnly && props.canModifyParticipants && props.hasMultipleParticipants;
- const shouldDisablePaidBySection = canModifyParticipants;
-
- const optionSelectorSections = useMemo(() => {
- const sections = [];
- const unselectedParticipants = _.filter(props.selectedParticipants, (participant) => !participant.selected);
- if (props.hasMultipleParticipants) {
- const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipants);
- let formattedParticipantsList = _.union(formattedSelectedParticipants, unselectedParticipants);
-
- if (!canModifyParticipants) {
- formattedParticipantsList = _.map(formattedParticipantsList, (participant) => ({
- ...participant,
- isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID),
- }));
- }
-
- const myIOUAmount = IOUUtils.calculateAmount(selectedParticipants.length, props.iouAmount, props.iouCurrencyCode, true);
- const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
- payeePersonalDetails,
- props.iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, props.iouCurrencyCode) : '',
- );
-
- sections.push(
- {
- title: translate('moneyRequestConfirmationList.paidBy'),
- data: [formattedPayeeOption],
- shouldShow: true,
- indexOffset: 0,
- isDisabled: shouldDisablePaidBySection,
- },
- {
- title: translate('moneyRequestConfirmationList.splitWith'),
- data: formattedParticipantsList,
- shouldShow: true,
- indexOffset: 1,
- },
- );
- } else {
- const formattedSelectedParticipants = _.map(props.selectedParticipants, (participant) => ({
- ...participant,
- isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID),
- }));
- sections.push({
- title: translate('common.to'),
- data: formattedSelectedParticipants,
- shouldShow: true,
- indexOffset: 0,
- });
- }
- return sections;
- }, [
- props.selectedParticipants,
- props.hasMultipleParticipants,
- props.iouAmount,
- props.iouCurrencyCode,
- getParticipantsWithAmount,
- selectedParticipants,
- payeePersonalDetails,
- translate,
- shouldDisablePaidBySection,
- canModifyParticipants,
- ]);
-
- const selectedOptions = useMemo(() => {
- if (!props.hasMultipleParticipants) {
- return [];
- }
- return [...selectedParticipants, OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetails)];
- }, [selectedParticipants, props.hasMultipleParticipants, payeePersonalDetails]);
-
- useEffect(() => {
- if (!props.isDistanceRequest) {
- return;
- }
-
- /*
- Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
- When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
- In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
- */
- IOU.setMoneyRequestPendingFields(props.transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});
-
- const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(hasRoute, distance, unit, rate, currency, translate, toLocaleDigit);
- IOU.setMoneyRequestMerchant(props.transactionID, distanceMerchant, false);
- }, [isDistanceRequestWithPendingRoute, hasRoute, distance, unit, rate, currency, translate, toLocaleDigit, props.isDistanceRequest, props.transactionID]);
-
- /**
- * @param {Object} option
- */
- const selectParticipant = useCallback(
- (option) => {
- // Return early if selected option is currently logged in user.
- if (option.accountID === props.session.accountID) {
- return;
- }
- onSelectParticipant(option);
- },
- [props.session.accountID, onSelectParticipant],
- );
-
- /**
- * Navigate to report details or profile of selected user
- * @param {Object} option
- */
- const navigateToReportOrUserDetail = (option) => {
- if (option.accountID) {
- const activeRoute = Navigation.getActiveRouteWithoutParams();
-
- Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
- } else if (option.reportID) {
- Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID));
- }
- };
-
- /**
- * @param {String} paymentMethod
- */
- const confirm = useCallback(
- (paymentMethod) => {
- if (_.isEmpty(selectedParticipants)) {
- return;
- }
- if (props.iouCategory && props.iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) {
- setFormError('iou.error.invalidCategoryLength');
- return;
- }
- if (props.iouType === CONST.IOU.TYPE.SEND) {
- if (!paymentMethod) {
- return;
- }
-
- setDidConfirm(true);
-
- Log.info(`[IOU] Sending money via: ${paymentMethod}`);
- onSendMoney(paymentMethod);
- } else {
- // validate the amount for distance requests
- const decimals = CurrencyUtils.getCurrencyDecimals(props.iouCurrencyCode);
- if (props.isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(props.iouAmount), decimals)) {
- setFormError('common.error.invalidAmount');
- return;
- }
-
- if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) {
- setDidConfirmSplit(true);
- return;
- }
-
- setDidConfirm(true);
- onConfirm(selectedParticipants);
- }
- },
- [
- selectedParticipants,
- onSendMoney,
- onConfirm,
- props.isEditingSplitBill,
- props.iouType,
- props.isDistanceRequest,
- props.iouCategory,
- isDistanceRequestWithPendingRoute,
- props.iouCurrencyCode,
- props.iouAmount,
- transaction,
- ],
- );
-
- const footerContent = useMemo(() => {
- if (props.isReadOnly) {
- return;
- }
-
- const shouldShowSettlementButton = props.iouType === CONST.IOU.TYPE.SEND;
- const shouldDisableButton = selectedParticipants.length === 0 || shouldDisplayMerchantError;
-
- const button = shouldShowSettlementButton ? (
-
- ) : (
- confirm(value)}
- options={splitOrRequestOptions}
- buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
- enterKeyEventListenerPriority={1}
- />
- );
-
- return (
- <>
- {!_.isEmpty(formError) && (
-
- )}
- {button}
- >
- );
- }, [
- props.isReadOnly,
- props.iouType,
- props.bankAccountRoute,
- props.iouCurrencyCode,
- props.policyID,
- selectedParticipants.length,
- shouldDisplayMerchantError,
- confirm,
- splitOrRequestOptions,
- formError,
- styles.ph1,
- styles.mb2,
- ]);
-
- const {image: receiptImage, thumbnail: receiptThumbnail} =
- props.receiptPath && props.receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, props.receiptPath, props.receiptFilename) : {};
- return (
-
- {props.isDistanceRequest && (
-
-
-
- )}
- {receiptImage || receiptThumbnail ? (
-
- ) : (
- // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate")
- PolicyUtils.isPaidGroupPolicy(props.policy) &&
- !props.isDistanceRequest &&
- props.iouType === CONST.IOU.TYPE.REQUEST && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
- CONST.IOU.ACTION.CREATE,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- )
- }
- />
- )
- )}
- {props.shouldShowSmartScanFields && (
- {
- if (props.isDistanceRequest) {
- return;
- }
- if (props.isEditingSplitBill) {
- Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT));
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID));
- }}
- style={[styles.moneyRequestMenuItem, styles.mt2]}
- titleStyle={styles.moneyRequestConfirmationAmount}
- disabled={didConfirm}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={shouldDisplayFieldError && TransactionUtils.isAmountMissing(transaction) ? translate('common.error.enterAmount') : ''}
- />
- )}
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={[styles.moneyRequestMenuItem]}
- titleStyle={styles.flex1}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- numberOfLinesTitle={2}
- />
- {!shouldShowAllFields && (
-
- )}
- {shouldShowAllFields && (
- <>
- {shouldShowDate && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''}
- />
- )}
- {props.isDistanceRequest && (
- Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))}
- disabled={didConfirm || !isTypeRequest}
- interactive={!props.isReadOnly}
- />
- )}
- {shouldShowMerchant && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- brickRoadIndicator={
- props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''
- }
- error={
- shouldDisplayMerchantError || (props.isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction))
- ? translate('common.error.enterMerchant')
- : ''
- }
- />
- )}
- {shouldShowCategories && (
- {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
- CONST.IOU.ACTION.EDIT,
- props.iouType,
- props.transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- }}
- style={[styles.moneyRequestMenuItem]}
- titleStyle={styles.flex1}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''}
- />
- )}
- {shouldShowTags &&
- _.map(policyTagLists, ({name}, index) => (
- {
- if (props.isEditingSplitBill) {
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
- CONST.IOU.ACTION.EDIT,
- CONST.IOU.TYPE.SPLIT,
- index,
- props.transaction.transactionID,
- props.reportID,
- Navigation.getActiveRouteWithoutParams(),
- ),
- );
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID));
- }}
- style={[styles.moneyRequestMenuItem]}
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''}
- />
- ))}
-
- {shouldShowTax && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()),
- )
- }
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- />
- )}
-
- {shouldShowTax && (
-
- Navigation.navigate(
- ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(props.iouType, props.transaction.transactionID, props.reportID, Navigation.getActiveRouteWithoutParams()),
- )
- }
- disabled={didConfirm}
- interactive={!props.isReadOnly}
- />
- )}
-
- {shouldShowBillable && (
-
- {translate('common.billable')}
-
-
- )}
- >
- )}
-
- );
-}
-
-MoneyRequestConfirmationList.propTypes = propTypes;
-MoneyRequestConfirmationList.defaultProps = defaultProps;
-MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
-
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
- policyCategories: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
- },
- policyTags: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
- },
- mileageRate: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- selector: DistanceRequestUtils.getDefaultMileageRate,
- },
- splitTransactionDraft: {
- key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`,
- },
- policy: {
- key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
- },
- iou: {
- key: ONYXKEYS.IOU,
- },
- }),
-)(MoneyRequestConfirmationList);
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
new file mode 100755
index 000000000000..773e98b6462e
--- /dev/null
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -0,0 +1,904 @@
+import {useIsFocused} from '@react-navigation/native';
+import {format} from 'date-fns';
+import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {withOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import * as CurrencyUtils from '@libs/CurrencyUtils';
+import DistanceRequestUtils from '@libs/DistanceRequestUtils';
+import * as IOUUtils from '@libs/IOUUtils';
+import Log from '@libs/Log';
+import * as MoneyRequestUtils from '@libs/MoneyRequestUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as OptionsListUtils from '@libs/OptionsListUtils';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as ReceiptUtils from '@libs/ReceiptUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
+import * as IOU from '@userActions/IOU';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import ROUTES from '@src/ROUTES';
+import type * as OnyxTypes from '@src/types/onyx';
+import type {Participant} from '@src/types/onyx/IOU';
+import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
+import type {MileageRate} from '@src/types/onyx/Policy';
+import type DeepValueOf from '@src/types/utils/DeepValueOf';
+import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
+import ConfirmedRoute from './ConfirmedRoute';
+import FormHelpMessage from './FormHelpMessage';
+import Image from './Image';
+import MenuItemWithTopDescription from './MenuItemWithTopDescription';
+import OptionsSelector from './OptionsSelector';
+import ReceiptEmptyState from './ReceiptEmptyState';
+import SettlementButton from './SettlementButton';
+import ShowMoreButton from './ShowMoreButton';
+import Switch from './Switch';
+import Text from './Text';
+import type {WithCurrentUserPersonalDetailsProps} from './withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails';
+
+type DropdownOption = {
+ text: string;
+ value: DeepValueOf;
+};
+
+type Option = Partial;
+
+type CategorySection = {
+ title: string | undefined;
+ shouldShow: boolean;
+ indexOffset: number;
+ data: Option[];
+};
+
+type MoneyRequestConfirmationListOnyxProps = {
+ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
+ iou: OnyxEntry;
+
+ /** Unit and rate used for if the money request is a distance request */
+ mileageRate: OnyxEntry;
+
+ /** Collection of categories attached to a policy */
+ policyCategories: OnyxEntry;
+
+ /** Collection of tags attached to a policy */
+ policyTags: OnyxEntry;
+
+ /** The policy of root parent report */
+ policy: OnyxEntry;
+
+ /** The session of the logged in user */
+ session: OnyxEntry;
+};
+
+type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps &
+ WithCurrentUserPersonalDetailsProps & {
+ /** Callback to inform parent modal of success */
+ onConfirm?: (selectedParticipants: Participant[]) => void;
+
+ /** Callback to parent modal to send money */
+ onSendMoney?: (paymentMethod: PaymentMethodType) => void;
+
+ /** Callback to inform a participant is selected */
+ onSelectParticipant?: (option: Participant) => void;
+
+ /** Should we request a single or multiple participant selection from user */
+ hasMultipleParticipants: boolean;
+
+ /** IOU amount */
+ iouAmount: number;
+
+ /** IOU comment */
+ iouComment?: string;
+
+ /** IOU currency */
+ iouCurrencyCode?: string;
+
+ /** IOU type */
+ iouType?: ValueOf;
+
+ /** IOU date */
+ iouCreated?: string;
+
+ /** IOU merchant */
+ iouMerchant?: string;
+
+ /** IOU Category */
+ iouCategory?: string;
+
+ /** IOU Tag */
+ iouTag?: string;
+
+ /** IOU isBillable */
+ iouIsBillable?: boolean;
+
+ /** Callback to toggle the billable state */
+ onToggleBillable?: () => void;
+
+ /** Selected participants from MoneyRequestModal with login / accountID */
+ selectedParticipants: Participant[];
+
+ /** Payee of the money request with login */
+ payeePersonalDetails?: OnyxEntry;
+
+ /** Can the participants be modified or not */
+ canModifyParticipants?: boolean;
+
+ /** Should the list be read only, and not editable? */
+ isReadOnly?: boolean;
+
+ /** Depending on expense report or personal IOU report, respective bank account route */
+ bankAccountRoute?: Route;
+
+ /** The policyID of the request */
+ policyID?: string;
+
+ /** The reportID of the request */
+ reportID?: string;
+
+ /** File path of the receipt */
+ receiptPath?: string;
+
+ /** File name of the receipt */
+ receiptFilename?: string;
+
+ /** List styles for OptionsSelector */
+ listStyles?: StyleProp;
+
+ /** ID of the transaction that represents the money request */
+ transactionID?: string;
+
+ /** Whether the money request is a distance request */
+ isDistanceRequest?: boolean;
+
+ /** Whether the money request is a scan request */
+ isScanRequest?: boolean;
+
+ /** Whether we're editing a split bill */
+ isEditingSplitBill?: boolean;
+
+ /** Whether we should show the amount, date, and merchant fields. */
+ shouldShowSmartScanFields?: boolean;
+
+ /** A flag for verifying that the current report is a sub-report of a workspace chat */
+ isPolicyExpenseChat?: boolean;
+
+ /** Whether there is smartscan failed */
+ hasSmartScanFailed?: boolean;
+
+ /** ID of the report action */
+ reportActionID?: string;
+
+ /** Transaction object */
+ transaction?: OnyxTypes.Transaction;
+ };
+
+function MoneyRequestConfirmationList({
+ onConfirm = () => {},
+ onSendMoney = () => {},
+ onSelectParticipant = () => {},
+ iouType = CONST.IOU.TYPE.REQUEST,
+ iouCategory = '',
+ iouTag = '',
+ iouIsBillable = false,
+ onToggleBillable = () => {},
+ payeePersonalDetails,
+ canModifyParticipants = false,
+ isReadOnly = false,
+ bankAccountRoute,
+ policyID,
+ reportID,
+ receiptPath,
+ receiptFilename,
+ transactionID,
+ mileageRate,
+ isDistanceRequest = false,
+ isScanRequest = false,
+ shouldShowSmartScanFields = true,
+ isPolicyExpenseChat = false,
+ transaction,
+ iouAmount,
+ policyTags,
+ policyCategories,
+ policy,
+ iouCurrencyCode,
+ isEditingSplitBill,
+ hasSmartScanFailed,
+ iouMerchant,
+ currentUserPersonalDetails,
+ hasMultipleParticipants,
+ selectedParticipants,
+ session,
+ iou,
+ reportActionID,
+ iouCreated,
+ listStyles,
+ iouComment,
+}: MoneyRequestConfirmationListProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate, toLocaleDigit} = useLocalize();
+ const {canUseViolations} = usePermissions();
+
+ const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST;
+ const isSplitBill = iouType === CONST.IOU.TYPE.SPLIT;
+ const isTypeSend = iouType === CONST.IOU.TYPE.SEND;
+
+ const isSplitWithScan = isSplitBill && isScanRequest;
+
+ const distance = transaction?.routes?.route0.distance ?? 0;
+ const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0;
+ const taxRates = policy?.taxRates;
+
+ // A flag for showing the categories field
+ const shouldShowCategories = isPolicyExpenseChat && (iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {})));
+
+ // A flag and a toggler for showing the rest of the form fields
+ const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
+
+ // Do not hide fields in case of send money request
+ const shouldShowAllFields = isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend || isEditingSplitBill;
+
+ // In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited. For distance requests, don't show the merchant as there's already another "Distance" menu item
+ const shouldShowDate = shouldShowAllFields && !isTypeSend && !isSplitWithScan;
+ const shouldShowMerchant = shouldShowAllFields && !isTypeSend && !isDistanceRequest && !isSplitWithScan;
+ const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]);
+ // A flag for showing the tags field
+ const shouldShowTags = isPolicyExpenseChat && (iouTag || OptionsListUtils.hasEnabledTags(policyTagLists));
+
+ // A flag for showing tax fields - tax rate and tax amount
+ const shouldShowTax = isPolicyExpenseChat && (policy?.tax?.trackingEnabled ?? policy?.isTaxTrackingEnabled);
+
+ // A flag for showing the billable field
+ const shouldShowBillable = !policy?.disabledFields?.defaultBillable ?? true;
+
+ const hasRoute = TransactionUtils.hasRoute(transaction ?? null);
+ const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !mileageRate?.rate);
+ const formattedAmount = isDistanceRequestWithPendingRoute
+ ? ''
+ : CurrencyUtils.convertToDisplayString(
+ shouldCalculateDistanceAmount
+ ? DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0)
+ : iouAmount,
+ isDistanceRequest ? mileageRate?.currency : iouCurrencyCode,
+ );
+ const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode);
+
+ const defaultTaxKey = taxRates?.defaultExternalID;
+ const defaultTaxName = (defaultTaxKey && `${taxRates?.taxes[defaultTaxKey].name} (${taxRates?.taxes[defaultTaxKey].value}) • ${translate('common.default')}`) ?? '';
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const taxRateTitle = transaction?.taxRate?.text || defaultTaxName;
+
+ const isFocused = useIsFocused();
+ const [formError, setFormError] = useState(null);
+
+ const [didConfirm, setDidConfirm] = useState(false);
+ const [didConfirmSplit, setDidConfirmSplit] = useState(false);
+
+ const shouldDisplayFieldError = useMemo(() => {
+ if (!isEditingSplitBill) {
+ return false;
+ }
+
+ return (!!hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction ?? null)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null));
+ }, [isEditingSplitBill, hasSmartScanFailed, transaction, didConfirmSplit]);
+
+ const isMerchantEmpty = !iouMerchant || iouMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
+ const shouldDisplayMerchantError = isPolicyExpenseChat && !isScanRequest && isMerchantEmpty;
+
+ useEffect(() => {
+ if (shouldDisplayFieldError && didConfirmSplit) {
+ setFormError('iou.error.genericSmartscanFailureMessage');
+ return;
+ }
+ if (shouldDisplayFieldError && hasSmartScanFailed) {
+ setFormError('iou.receiptScanningFailed');
+ return;
+ }
+ // reset the form error whenever the screen gains or loses focus
+ setFormError(null);
+ }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]);
+
+ useEffect(() => {
+ if (!shouldCalculateDistanceAmount) {
+ return;
+ }
+
+ const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, mileageRate?.rate ?? 0);
+ IOU.setMoneyRequestAmount(amount);
+ }, [shouldCalculateDistanceAmount, distance, mileageRate?.rate, mileageRate?.unit]);
+
+ /**
+ * Returns the participants with amount
+ */
+ const getParticipantsWithAmount = useCallback(
+ (participantsList: Participant[]) => {
+ const calculatedIouAmount = IOUUtils.calculateAmount(participantsList.length, iouAmount, iouCurrencyCode ?? '');
+ return OptionsListUtils.getIOUConfirmationOptionsFromParticipants(
+ participantsList,
+ calculatedIouAmount > 0 ? CurrencyUtils.convertToDisplayString(calculatedIouAmount, iouCurrencyCode) : '',
+ );
+ },
+ [iouAmount, iouCurrencyCode],
+ );
+
+ // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again
+ if (isEditingSplitBill && didConfirm) {
+ setDidConfirm(false);
+ }
+
+ const splitOrRequestOptions: DropdownOption[] = useMemo(() => {
+ let text;
+ if (isSplitBill && iouAmount === 0) {
+ text = translate('iou.split');
+ } else if (!!(receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) {
+ text = translate('iou.request');
+ if (iouAmount !== 0) {
+ text = translate('iou.requestAmount', {amount: formattedAmount});
+ }
+ } else {
+ const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount';
+ text = translate(translationKey, {amount: formattedAmount});
+ }
+ return [
+ {
+ text: text[0].toUpperCase() + text.slice(1),
+ value: iouType,
+ },
+ ];
+ }, [isSplitBill, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]);
+
+ const selectedParticipantsMemo = useMemo(() => selectedParticipants.filter((participant) => participant.selected), [selectedParticipants]);
+ const payeePersonalDetailsMemo = useMemo(() => payeePersonalDetails ?? currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]);
+ const canModifyParticipantsValue = !isReadOnly && canModifyParticipants && hasMultipleParticipants;
+
+ const optionSelectorSections: CategorySection[] = useMemo(() => {
+ const sections = [];
+ const unselectedParticipants = selectedParticipants.filter((participant) => !participant.selected);
+ if (hasMultipleParticipants) {
+ const formattedSelectedParticipants = getParticipantsWithAmount(selectedParticipantsMemo);
+ let formattedParticipantsList = [...new Set([...formattedSelectedParticipants, ...unselectedParticipants])];
+
+ if (!canModifyParticipantsValue) {
+ formattedParticipantsList = formattedParticipantsList.map((participant) => ({
+ ...participant,
+ isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1),
+ }));
+ }
+
+ const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true);
+ const formattedPayeeOption = OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(
+ payeePersonalDetailsMemo,
+ iouAmount > 0 ? CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode) : '',
+ );
+
+ sections.push(
+ {
+ title: translate('moneyRequestConfirmationList.paidBy'),
+ data: [formattedPayeeOption],
+ shouldShow: true,
+ indexOffset: 0,
+ isDisabled: canModifyParticipantsValue,
+ },
+ {
+ title: translate('moneyRequestConfirmationList.splitWith'),
+ data: formattedParticipantsList,
+ shouldShow: true,
+ indexOffset: 1,
+ },
+ );
+ } else {
+ const formattedSelectedParticipants = selectedParticipants.map((participant) => ({
+ ...participant,
+ isDisabled: ReportUtils.isOptimisticPersonalDetail(participant.accountID ?? -1),
+ }));
+ sections.push({
+ title: translate('common.to'),
+ data: formattedSelectedParticipants,
+ shouldShow: true,
+ indexOffset: 0,
+ });
+ }
+ return sections;
+ }, [
+ selectedParticipants,
+ hasMultipleParticipants,
+ iouAmount,
+ iouCurrencyCode,
+ getParticipantsWithAmount,
+ payeePersonalDetailsMemo,
+ translate,
+ canModifyParticipantsValue,
+ selectedParticipantsMemo,
+ ]);
+
+ const selectedOptions = useMemo(() => {
+ if (!hasMultipleParticipants) {
+ return [];
+ }
+ const myIOUAmount = IOUUtils.calculateAmount(selectedParticipantsMemo.length, iouAmount, iouCurrencyCode ?? '', true);
+ return [
+ ...selectedParticipantsMemo,
+ OptionsListUtils.getIOUConfirmationOptionsFromPayeePersonalDetail(payeePersonalDetailsMemo, CurrencyUtils.convertToDisplayString(myIOUAmount, iouCurrencyCode)),
+ ];
+ }, [hasMultipleParticipants, selectedParticipantsMemo, iouAmount, iouCurrencyCode, payeePersonalDetailsMemo]);
+
+ useEffect(() => {
+ if (!isDistanceRequest) {
+ return;
+ }
+ /*
+ Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
+ When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
+ In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
+ */
+ IOU.setMoneyRequestPendingFields(transactionID ?? '', {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});
+ const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(
+ hasRoute,
+ distance,
+ mileageRate?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES,
+ mileageRate?.rate ?? 0,
+ mileageRate?.currency ?? 'USD',
+ translate,
+ toLocaleDigit,
+ );
+ IOU.setMoneyRequestMerchant(transactionID ?? '', distanceMerchant, false);
+ }, [hasRoute, distance, mileageRate?.unit, mileageRate?.rate, mileageRate?.currency, translate, toLocaleDigit, isDistanceRequest, transactionID, isDistanceRequestWithPendingRoute]);
+
+ const selectParticipant = useCallback(
+ (option: Participant) => {
+ // Return early if selected option is currently logged in user.
+ if (option.accountID === session?.accountID) {
+ return;
+ }
+ onSelectParticipant(option);
+ },
+ [session?.accountID, onSelectParticipant],
+ );
+
+ /**
+ * Navigate to report details or profile of selected user
+ */
+ const navigateToReportOrUserDetail = (option: Participant | OnyxTypes.Report) => {
+ if ('accountID' in option && option.accountID) {
+ const activeRoute = Navigation.getActiveRouteWithoutParams();
+
+ Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute));
+ } else if ('reportID' in option && option.reportID) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(option.reportID));
+ }
+ };
+
+ const confirm = useCallback(
+ (paymentMethod: PaymentMethodType | undefined) => {
+ if (selectedParticipantsMemo.length === 0) {
+ return;
+ }
+ if (iouCategory && iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) {
+ setFormError('iou.error.invalidCategoryLength');
+ return;
+ }
+ if (iouType === CONST.IOU.TYPE.SEND) {
+ if (!paymentMethod) {
+ return;
+ }
+
+ setDidConfirm(true);
+
+ Log.info(`[IOU] Sending money via: ${paymentMethod}`);
+ onSendMoney(paymentMethod);
+ } else {
+ // validate the amount for distance requests
+ const decimals = CurrencyUtils.getCurrencyDecimals(iouCurrencyCode);
+ if (isDistanceRequest && !isDistanceRequestWithPendingRoute && !MoneyRequestUtils.validateAmount(String(iouAmount), decimals)) {
+ setFormError('common.error.invalidAmount');
+ return;
+ }
+
+ if (isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction ?? null)) {
+ setDidConfirmSplit(true);
+ return;
+ }
+
+ setDidConfirm(true);
+ onConfirm(selectedParticipantsMemo);
+ }
+ },
+ [
+ selectedParticipantsMemo,
+ iouCategory,
+ iouType,
+ onSendMoney,
+ iouCurrencyCode,
+ isDistanceRequest,
+ isDistanceRequestWithPendingRoute,
+ iouAmount,
+ isEditingSplitBill,
+ transaction,
+ onConfirm,
+ ],
+ );
+
+ const footerContent = useMemo(() => {
+ if (isReadOnly) {
+ return;
+ }
+
+ const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND;
+ const shouldDisableButton = selectedParticipantsMemo.length === 0 || shouldDisplayMerchantError;
+
+ const button = shouldShowSettlementButton ? (
+
+ ) : (
+ confirm(value as PaymentMethodType)}
+ options={splitOrRequestOptions}
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
+ enterKeyEventListenerPriority={1}
+ />
+ );
+
+ return (
+ <>
+ {!!formError && (
+
+ )}
+ {button}
+ >
+ );
+ }, [
+ isReadOnly,
+ iouType,
+ selectedParticipantsMemo.length,
+ shouldDisplayMerchantError,
+ confirm,
+ bankAccountRoute,
+ iouCurrencyCode,
+ policyID,
+ splitOrRequestOptions,
+ formError,
+ styles.ph1,
+ styles.mb2,
+ ]);
+
+ const receiptData = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction ?? null, receiptPath, receiptFilename) : null;
+ return (
+ // @ts-expect-error TODO: Remove this once OptionsSelector (https://github.com/Expensify/App/issues/25125) is migrated to TypeScript.
+
+ {isDistanceRequest && (
+
+
+
+ )}
+ {receiptData?.image ?? receiptData?.thumbnail ? (
+
+ ) : (
+ // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate")
+ PolicyUtils.isPaidGroupPolicy(policy) &&
+ !isDistanceRequest &&
+ iouType === CONST.IOU.TYPE.REQUEST && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
+ CONST.IOU.ACTION.CREATE,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ )
+ }
+ />
+ )
+ )}
+ {shouldShowSmartScanFields && (
+ {
+ if (isDistanceRequest) {
+ return;
+ }
+ if (isEditingSplitBill) {
+ Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID ?? '', reportActionID ?? '', CONST.EDIT_REQUEST_FIELD.AMOUNT));
+ return;
+ }
+ Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(iouType, reportID));
+ }}
+ style={[styles.moneyRequestMenuItem, styles.mt2]}
+ titleStyle={styles.moneyRequestConfirmationAmount}
+ disabled={didConfirm}
+ brickRoadIndicator={
+ isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined
+ }
+ error={
+ shouldDisplayMerchantError || (isPolicyExpenseChat && shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))
+ ? translate('common.error.enterMerchant')
+ : ''
+ }
+ />
+ )}
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={styles.moneyRequestMenuItem}
+ titleStyle={styles.flex1}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ numberOfLinesTitle={2}
+ />
+ {!shouldShowAllFields && (
+
+ )}
+ {shouldShowAllFields && (
+ <>
+ {shouldShowDate && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction ?? null) ? translate('common.error.enterDate') : ''}
+ />
+ )}
+ {isDistanceRequest && (
+ Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(iouType, reportID))}
+ disabled={didConfirm || !isTypeRequest}
+ interactive={!isReadOnly}
+ />
+ )}
+ {shouldShowMerchant && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
+ error={
+ shouldDisplayMerchantError || (shouldDisplayFieldError && TransactionUtils.isMerchantMissing(transaction ?? null))
+ ? translate('common.error.enterMerchant')
+ : ''
+ }
+ />
+ )}
+ {shouldShowCategories && (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ iouType,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={styles.moneyRequestMenuItem}
+ titleStyle={styles.flex1}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ rightLabel={canUseViolations && Boolean(policy?.requiresCategory) ? translate('common.required') : ''}
+ />
+ )}
+ {shouldShowTags &&
+ policyTagLists.map(({name}, index) => (
+ {
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ CONST.IOU.TYPE.SPLIT,
+ index,
+ transaction?.transactionID ?? '',
+ reportID ?? '',
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
+ }}
+ style={styles.moneyRequestMenuItem}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ rightLabel={canUseViolations && !!policy?.requiresTag ? translate('common.required') : ''}
+ />
+ ))}
+
+ {shouldShowTax && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()),
+ )
+ }
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ />
+ )}
+
+ {shouldShowTax && (
+
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(iouType, transaction?.transactionID ?? '', reportID ?? '', Navigation.getActiveRouteWithoutParams()),
+ )
+ }
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ />
+ )}
+
+ {shouldShowBillable && (
+
+ {translate('common.billable')}
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+MoneyRequestConfirmationList.displayName = 'MoneyRequestConfirmationList';
+
+export default withCurrentUserPersonalDetails(
+ withOnyx({
+ policyCategories: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`,
+ },
+ policyTags: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`,
+ },
+ mileageRate: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ selector: DistanceRequestUtils.getDefaultMileageRate,
+ },
+ policy: {
+ key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ },
+ iou: {
+ key: ONYXKEYS.IOU,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ })(MoneyRequestConfirmationList),
+);
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index b95de8844ee0..8b606bd4429d 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -5,6 +5,7 @@ import type {ValueOf} from 'type-fest';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as ReportUtils from '@libs/ReportUtils';
import type {AvatarSource} from '@libs/UserUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -106,7 +107,8 @@ function MultipleAvatars({
let avatarContainerStyles = StyleUtils.getContainerStyles(size, isInReportAction);
const {singleAvatarStyle, secondAvatarStyles} = useMemo(() => avatarSizeToStylesMap[size as AvatarSizeToStyles] ?? avatarSizeToStylesMap.default, [size, avatarSizeToStylesMap]);
- const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => icon.name) : ['']), [shouldShowTooltip, icons]);
+ const tooltipTexts = useMemo(() => (shouldShowTooltip ? icons.map((icon) => ReportUtils.getUserDetailTooltipText(Number(icon.id), icon.name)) : ['']), [shouldShowTooltip, icons]);
+
const avatarSize = useMemo(() => {
if (isFocusMode) {
return CONST.AVATAR_SIZE.MID_SUBSCRIPT;
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index 1fa63f181dd6..c5c7c3ec50b0 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -1,8 +1,6 @@
-import {useIsFocused} from '@react-navigation/native';
import lodashGet from 'lodash/get';
-import lodashIsEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
-import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import React, {Component} from 'react';
import {ScrollView, View} from 'react-native';
import _ from 'underscore';
import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager';
@@ -13,10 +11,11 @@ import OptionsList from '@components/OptionsList';
import ReferralProgramCTA from '@components/ReferralProgramCTA';
import ShowMoreButton from '@components/ShowMoreButton';
import TextInput from '@components/TextInput';
-import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
-import useLocalize from '@hooks/useLocalize';
-import usePrevious from '@hooks/usePrevious';
-import useThemeStyles from '@hooks/useThemeStyles';
+import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import withNavigationFocus from '@components/withNavigationFocus';
+import withTheme, {withThemePropTypes} from '@components/withTheme';
+import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles';
+import compose from '@libs/compose';
import getPlatform from '@libs/getPlatform';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import setSelection from '@libs/setSelection';
@@ -36,6 +35,9 @@ const propTypes = {
/** List styles for OptionsList */
listStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ /** Whether navigation is focused */
+ isFocused: PropTypes.bool.isRequired,
+
/** Whether referral CTA should be displayed */
shouldShowReferralCTA: PropTypes.bool,
@@ -43,9 +45,13 @@ const propTypes = {
referralContentType: PropTypes.string,
...optionsSelectorPropTypes,
+ ...withLocalizePropTypes,
+ ...withThemeStylesPropTypes,
+ ...withThemePropTypes,
};
const defaultProps = {
+ shouldDelayFocus: false,
shouldShowReferralCTA: false,
referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND,
safeAreaPaddingBottomStyle: {},
@@ -55,657 +61,622 @@ const defaultProps = {
...optionsSelectorDefaultProps,
};
-function BaseOptionsSelector(props) {
- const isFocused = useIsFocused();
- const {translate} = useLocalize();
- const themeStyles = useThemeStyles();
-
- const getInitiallyFocusedIndex = useCallback(
- (allOptions) => {
- let defaultIndex;
- if (props.shouldTextInputAppearBelowOptions) {
- defaultIndex = allOptions.length;
- } else if (props.focusedIndex >= 0) {
- defaultIndex = props.focusedIndex;
- } else {
- defaultIndex = props.selectedOptions.length;
+class BaseOptionsSelector extends Component {
+ constructor(props) {
+ super(props);
+
+ this.updateFocusedIndex = this.updateFocusedIndex.bind(this);
+ this.scrollToIndex = this.scrollToIndex.bind(this);
+ this.selectRow = this.selectRow.bind(this);
+ this.selectFocusedOption = this.selectFocusedOption.bind(this);
+ this.addToSelection = this.addToSelection.bind(this);
+ this.updateSearchValue = this.updateSearchValue.bind(this);
+ this.incrementPage = this.incrementPage.bind(this);
+ this.sliceSections = this.sliceSections.bind(this);
+ this.calculateAllVisibleOptionsCount = this.calculateAllVisibleOptionsCount.bind(this);
+ this.handleFocusIn = this.handleFocusIn.bind(this);
+ this.handleFocusOut = this.handleFocusOut.bind(this);
+ this.debouncedUpdateSearchValue = _.debounce(this.updateSearchValue, CONST.TIMING.SEARCH_OPTION_LIST_DEBOUNCE_TIME);
+ this.relatedTarget = null;
+ this.accessibilityRoles = _.values(CONST.ROLE);
+ this.isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform());
+
+ const allOptions = this.flattenSections();
+ const sections = this.sliceSections();
+ const focusedIndex = this.getInitiallyFocusedIndex(allOptions);
+ this.focusedOption = allOptions[focusedIndex];
+
+ this.state = {
+ sections,
+ allOptions,
+ focusedIndex,
+ shouldDisableRowSelection: false,
+ errorMessage: '',
+ paginationPage: 1,
+ disableEnterShortCut: false,
+ value: '',
+ };
+ }
+
+ componentDidMount() {
+ this.subscribeToEnterShortcut();
+ this.subscribeToCtrlEnterShortcut();
+ this.subscribeActiveElement();
+
+ if (this.props.isFocused && this.props.autoFocus && this.textInput) {
+ this.focusTimeout = setTimeout(() => {
+ this.textInput.focus();
+ }, CONST.ANIMATED_TRANSITION);
+ }
+
+ this.scrollToIndex(this.props.selectedOptions.length ? 0 : this.state.focusedIndex, false);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (prevState.disableEnterShortCut !== this.state.disableEnterShortCut) {
+ // Unregister the shortcut before registering a new one to avoid lingering shortcut listener
+ this.unsubscribeEnter();
+ if (!this.state.disableEnterShortCut) {
+ this.subscribeToEnterShortcut();
}
- if (_.isUndefined(props.initiallyFocusedOptionKey)) {
- return defaultIndex;
+ }
+
+ if (prevProps.isFocused !== this.props.isFocused) {
+ // Unregister the shortcut before registering a new one to avoid lingering shortcut listener
+ this.unSubscribeFromKeyboardShortcut();
+ if (this.props.isFocused) {
+ this.subscribeToEnterShortcut();
+ this.subscribeToCtrlEnterShortcut();
}
+ }
- const indexOfInitiallyFocusedOption = _.findIndex(allOptions, (option) => option.keyForList === props.initiallyFocusedOptionKey);
-
- return indexOfInitiallyFocusedOption;
- },
- [props.shouldTextInputAppearBelowOptions, props.initiallyFocusedOptionKey, props.selectedOptions.length, props.focusedIndex],
- );
-
- const isWebOrDesktop = [CONST.PLATFORM.DESKTOP, CONST.PLATFORM.WEB].includes(getPlatform());
- const accessibilityRoles = _.values(CONST.ROLE);
-
- const [disabledOptionsIndexes, setDisabledOptionsIndexes] = useState([]);
- const [shouldDisableRowSelection, setShouldDisableRowSelection] = useState(false);
- const [errorMessage, setErrorMessage] = useState('');
- const [value, setValue] = useState('');
- const [paginationPage, setPaginationPage] = useState(1);
- const [disableEnterShortCut, setDisableEnterShortCut] = useState(false);
-
- const relatedTarget = useRef(null);
- const listRef = useRef();
- const textInputRef = useRef();
- const enterSubscription = useRef();
- const CTRLEnterSubscription = useRef();
- const focusTimeout = useRef();
- const prevLocale = useRef(props.preferredLocale);
- const prevPaginationPage = useRef(paginationPage);
- const prevSelectedOptions = useRef(props.selectedOptions);
- const prevValue = useRef(value);
- const previousSections = usePrevious(props.sections);
-
- useImperativeHandle(props.forwardedRef, () => textInputRef.current);
+ // Screen coming back into focus, for example
+ // when doing Cmd+Shift+K, then Cmd+K, then Cmd+Shift+K.
+ // Only applies to platforms that support keyboard shortcuts
+ if (this.isWebOrDesktop && !prevProps.isFocused && this.props.isFocused && this.props.autoFocus && this.textInput) {
+ setTimeout(() => {
+ this.textInput.focus();
+ }, CONST.ANIMATED_TRANSITION);
+ }
- /**
- * Flattens the sections into a single array of options.
- * Each object in this array is enhanced to have:
- *
- * 1. A `sectionIndex`, which represents the index of the section it came from
- * 2. An `index`, which represents the index of the option within the section it came from.
- *
- * @returns {Array
- {props.shouldShowReferralCTA && (
-
-
-
- )}
-
- {shouldShowFooter && (
-
- {shouldShowDefaultConfirmButton && (
-
- )}
- {props.footerContent}
-
- )}
-
- );
+
+ );
+ }
}
BaseOptionsSelector.defaultProps = defaultProps;
BaseOptionsSelector.propTypes = propTypes;
-const BaseOptionsSelectorWithRef = forwardRef((props, ref) => (
-
-));
-
-BaseOptionsSelectorWithRef.displayName = 'BaseOptionsSelectorWithRef';
-
-export default BaseOptionsSelectorWithRef;
+export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(BaseOptionsSelector);
diff --git a/src/components/OptionsSelector/index.android.js b/src/components/OptionsSelector/index.android.js
index 9f7c924e427f..ace5a5614ffb 100644
--- a/src/components/OptionsSelector/index.android.js
+++ b/src/components/OptionsSelector/index.android.js
@@ -6,6 +6,7 @@ const OptionsSelector = forwardRef((props, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={ref}
+ shouldDelayFocus
/>
));
diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js
index 7339718e7073..558f6636a325 100644
--- a/src/components/PDFView/index.native.js
+++ b/src/components/PDFView/index.native.js
@@ -104,12 +104,14 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused
/**
* After the PDF is successfully loaded hide PDFPasswordForm and the loading
* indicator.
+ * @param {Number} numberOfPages
+ * @param {Number} path - Path to cache location
*/
- const finishPDFLoad = () => {
+ const finishPDFLoad = (numberOfPages, path) => {
setShouldRequestPassword(false);
setShouldShowLoadingIndicator(false);
setSuccessToLoadPDF(true);
- onLoadComplete();
+ onLoadComplete(path);
};
function renderPDFView() {
@@ -137,7 +139,7 @@ function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused
fitPolicy={0}
trustAllCerts={false}
renderActivityIndicator={() => }
- source={{uri: sourceURL}}
+ source={{uri: sourceURL, cache: true, expiration: 864000}}
style={pdfStyles}
onError={handleFailureToLoadPDF}
password={password}
diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx
index 2816715dae2c..3109453ca6b0 100644
--- a/src/components/ParentNavigationSubtitle.tsx
+++ b/src/components/ParentNavigationSubtitle.tsx
@@ -21,7 +21,7 @@ type ParentNavigationSubtitleProps = {
function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID = '', pressableStyles}: ParentNavigationSubtitleProps) {
const styles = useThemeStyles();
- const {workspaceName, rootReportName} = parentNavigationSubtitleData;
+ const {workspaceName, reportName} = parentNavigationSubtitleData;
const {translate} = useLocalize();
@@ -30,7 +30,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID
onPress={() => {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID));
}}
- accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})}
+ accessibilityLabel={translate('threads.parentNavigationSummary', {reportName, workspaceName})}
role={CONST.ROLE.LINK}
style={pressableStyles}
>
@@ -39,7 +39,7 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID
numberOfLines={1}
>
{`${translate('threads.from')} `}
- {rootReportName}
+ {reportName}
{Boolean(workspaceName) && {` ${translate('threads.in')} ${workspaceName}`}}
diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx
index a391ff061baa..83da817da858 100644
--- a/src/components/PopoverMenu.tsx
+++ b/src/components/PopoverMenu.tsx
@@ -18,7 +18,7 @@ import Text from './Text';
type PopoverMenuItem = {
/** An icon element displayed on the left side */
- icon: IconAsset;
+ icon?: IconAsset;
/** Text label */
text: string;
diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
index 1df56093d6a6..907aa2f8ae01 100644
--- a/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
+++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.tsx
@@ -109,20 +109,27 @@ function GenericPressable(
if (ref && 'current' in ref) {
ref.current?.blur();
}
- onPress(event);
-
+ const onPressResult = onPress(event);
Accessibility.moveAccessibilityFocus(nextFocusRef);
+ return onPressResult;
},
[shouldUseHapticsOnPress, onPress, nextFocusRef, ref, isDisabled],
);
+ const onKeyboardShortcutPressHandler = useCallback(
+ (event?: GestureResponderEvent | KeyboardEvent) => {
+ onPressHandler(event);
+ },
+ [onPressHandler],
+ );
+
useEffect(() => {
if (!keyboardShortcut) {
return () => {};
}
const {shortcutKey, descriptionKey, modifiers} = keyboardShortcut;
- return KeyboardShortcut.subscribe(shortcutKey, onPressHandler, descriptionKey, modifiers, true, false, 0, false);
- }, [keyboardShortcut, onPressHandler]);
+ return KeyboardShortcut.subscribe(shortcutKey, onKeyboardShortcutPressHandler, descriptionKey, modifiers, true, false, 0, false);
+ }, [keyboardShortcut, onKeyboardShortcutPressHandler]);
return (
{}, value}: RadioButtonsProps, ref: ForwardedRef) {
const styles = useThemeStyles();
const [checkedValue, setCheckedValue] = useState(defaultCheckedValue);
+
useEffect(() => {
- if (value === checkedValue) {
+ if (value === checkedValue || value === undefined) {
return;
}
setCheckedValue(value ?? '');
diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.tsx
similarity index 75%
rename from src/components/ReimbursementAccountLoadingIndicator.js
rename to src/components/ReimbursementAccountLoadingIndicator.tsx
index 141e056afd93..cc9beb513002 100644
--- a/src/components/ReimbursementAccountLoadingIndicator.js
+++ b/src/components/ReimbursementAccountLoadingIndicator.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
@@ -10,14 +9,15 @@ import LottieAnimations from './LottieAnimations';
import ScreenWrapper from './ScreenWrapper';
import Text from './Text';
-const propTypes = {
+type ReimbursementAccountLoadingIndicatorProps = {
/** Method to trigger when pressing back button of the header */
- onBackButtonPress: PropTypes.func.isRequired,
+ onBackButtonPress: () => void;
};
-function ReimbursementAccountLoadingIndicator(props) {
+function ReimbursementAccountLoadingIndicator({onBackButtonPress}: ReimbursementAccountLoadingIndicatorProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+
return (
-
+
-
- {translate('reimbursementAccountLoadingAnimation.explanationLine')}
+
+ {translate('reimbursementAccountLoadingAnimation.explanationLine')}
@@ -46,7 +46,6 @@ function ReimbursementAccountLoadingIndicator(props) {
);
}
-ReimbursementAccountLoadingIndicator.propTypes = propTypes;
ReimbursementAccountLoadingIndicator.displayName = 'ReimbursementAccountLoadingIndicator';
export default ReimbursementAccountLoadingIndicator;
diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx
index 198b47cb4259..7c9219c16286 100644
--- a/src/components/ScreenWrapper.tsx
+++ b/src/components/ScreenWrapper.tsx
@@ -83,6 +83,9 @@ type ScreenWrapperProps = {
/** Whether to avoid scroll on virtual viewport */
shouldAvoidScrollOnVirtualViewport?: boolean;
+ /** Whether to use cached virtual viewport height */
+ shouldUseCachedViewportHeight?: boolean;
+
/**
* The navigation prop is passed by the navigator. It is used to trigger the onEntryTransitionEnd callback
* when the screen transition ends.
@@ -115,6 +118,7 @@ function ScreenWrapper(
navigation: navigationProp,
shouldAvoidScrollOnVirtualViewport = true,
shouldShowOfflineIndicatorInWideScreen = false,
+ shouldUseCachedViewportHeight = false,
}: ScreenWrapperProps,
ref: ForwardedRef,
) {
@@ -127,7 +131,7 @@ function ScreenWrapper(
*/
const navigationFallback = useNavigation>();
const navigation = navigationProp ?? navigationFallback;
- const {windowHeight, isSmallScreenWidth} = useWindowDimensions(shouldEnableMaxHeight);
+ const {windowHeight, isSmallScreenWidth} = useWindowDimensions(shouldUseCachedViewportHeight);
const {initialHeight} = useInitialDimensions();
const styles = useThemeStyles();
const keyboardState = useKeyboardState();
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index 2f853dc55839..98b1999625ee 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -19,6 +19,7 @@ function BaseListItem({
shouldPreventDefaultFocusOnSelectRow = false,
canSelectMultiple = false,
onSelectRow,
+ onCheckboxPress,
onDismissError = () => {},
rightHandSideComponent,
keyForList,
@@ -43,6 +44,14 @@ function BaseListItem({
return rightHandSideComponent;
};
+ const handleCheckboxPress = () => {
+ if (onCheckboxPress) {
+ onCheckboxPress(item);
+ } else {
+ onSelectRow(item);
+ }
+ };
+
return (
onDismissError(item)}
@@ -66,8 +75,10 @@ function BaseListItem({
<>
{canSelectMultiple && (
-
@@ -80,7 +91,7 @@ function BaseListItem({
/>
)}
-
+
)}
{typeof children === 'function' ? children(hovered) : children}
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 1c69d00b3910..9cd37709552b 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -8,7 +8,6 @@ import Button from '@components/Button';
import Checkbox from '@components/Checkbox';
import FixedFooter from '@components/FixedFooter';
import OptionsListSkeletonView from '@components/OptionsListSkeletonView';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import SafeAreaConsumer from '@components/SafeAreaConsumer';
import SectionList from '@components/SectionList';
import Text from '@components/Text';
@@ -30,6 +29,7 @@ function BaseSelectionList(
ListItem,
canSelectMultiple = false,
onSelectRow,
+ onCheckboxPress,
onSelectAll,
onDismissError,
textInputLabel = '',
@@ -63,6 +63,7 @@ function BaseSelectionList(
onLayout,
customListHeader,
listHeaderWrapperStyle,
+ isRowMultilineSupported = false,
}: BaseSelectionListProps,
inputRef: ForwardedRef,
) {
@@ -289,10 +290,12 @@ function BaseSelectionList(
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={() => selectRow(item)}
+ onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined}
onDismissError={() => onDismissError?.(item)}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
rightHandSideComponent={rightHandSideComponent}
keyForList={item.keyForList}
+ isMultilineSupported={isRowMultilineSupported}
/>
);
};
@@ -429,28 +432,19 @@ function BaseSelectionList(
) : (
<>
{!headerMessage && canSelectMultiple && shouldShowSelectAll && (
- e.preventDefault() : undefined}
- >
+
{customListHeader ?? (
{translate('workspace.people.selectAll')}
)}
-
+
)}
{!headerMessage && !canSelectMultiple && customListHeader}
{!!item.alternateText && (
diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx
index 922937c72219..e5f230065455 100644
--- a/src/components/SelectionList/TableListItem.tsx
+++ b/src/components/SelectionList/TableListItem.tsx
@@ -15,6 +15,7 @@ function TableListItem({
isDisabled,
canSelectMultiple,
onSelectRow,
+ onCheckboxPress,
onDismissError,
shouldPreventDefaultFocusOnSelectRow,
rightHandSideComponent,
@@ -29,14 +30,15 @@ function TableListItem({
return (
= {
/** Callback to fire when the item is pressed */
onSelectRow: (item: TItem) => void;
+ /** Callback to fire when a checkbox is pressed */
+ onCheckboxPress?: (item: TItem) => void;
+
/** Callback to fire when an error is dismissed */
onDismissError?: (item: TItem) => void;
@@ -37,6 +40,9 @@ type CommonListItemProps = {
/** Styles for the checkbox wrapper view if select multiple option is on */
selectMultipleStyle?: StyleProp;
+
+ /** Whether to wrap long text up to 2 lines */
+ isMultilineSupported?: boolean;
};
type ListItem = {
@@ -83,6 +89,9 @@ type ListItem = {
/** Whether this option should show subscript */
shouldShowSubscript?: boolean;
+
+ /** Whether to wrap long text up to 2 lines */
+ isMultilineSupported?: boolean;
};
type ListItemProps = CommonListItemProps & {
@@ -157,6 +166,9 @@ type BaseSelectionListProps = Partial & {
/** Callback to fire when a row is pressed */
onSelectRow: (item: TItem) => void;
+ /** Optional callback function triggered upon pressing a checkbox. If undefined and the list displays checkboxes, checkbox interactions are managed by onSelectRow, allowing for pressing anywhere on the list. */
+ onCheckboxPress?: (item: TItem) => void;
+
/** Callback to fire when "Select All" checkbox is pressed. Only use along with `canSelectMultiple` */
onSelectAll?: () => void;
@@ -258,6 +270,9 @@ type BaseSelectionListProps = Partial & {
/** Styles for the list header wrapper */
listHeaderWrapperStyle?: StyleProp;
+
+ /** Whether to wrap long text up to 2 lines */
+ isRowMultilineSupported?: boolean;
};
type ItemLayout = {
diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx
index 50bfcd4cc8be..6ca13a61933c 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -228,10 +228,10 @@ function SettlementButton({
buttonRef={buttonRef}
isDisabled={isDisabled}
isLoading={isLoading}
- onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType, triggerKYCFlow)}
+ onPress={(event, iouPaymentType) => selectPaymentType(event, iouPaymentType as PaymentMethodType, triggerKYCFlow)}
pressOnEnter={pressOnEnter}
options={paymentButtonOptions}
- onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value)}
+ onOptionSelected={(option) => savePreferredPaymentMethod(policyID, option.value as PaymentMethodType)}
style={style}
buttonSize={buttonSize}
anchorAlignment={paymentMethodDropdownAnchorAlignment}
diff --git a/src/components/SignInPageForm/index.native.tsx b/src/components/SignInPageForm/index.native.tsx
deleted file mode 100644
index 0d00c754d45a..000000000000
--- a/src/components/SignInPageForm/index.native.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from 'react';
-import FormElement from '@components/FormElement';
-import type SignInPageFormProps from './types';
-
-function SignInPageForm(props: SignInPageFormProps) {
- // eslint-disable-next-line react/jsx-props-no-spreading
- return ;
-}
-
-SignInPageForm.displayName = 'SignInPageForm';
-
-export default SignInPageForm;
diff --git a/src/components/SignInPageForm/index.tsx b/src/components/SignInPageForm/index.tsx
deleted file mode 100644
index b9f0fe202dd1..000000000000
--- a/src/components/SignInPageForm/index.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React, {useEffect, useRef} from 'react';
-import type {View} from 'react-native';
-import FormElement from '@components/FormElement';
-import type SignInPageFormProps from './types';
-
-const preventFormDefault = (event: SubmitEvent) => {
- // When enter is pressed form is submitted to action url (POST /).
- // As we are using controlled component, we need to disable it here.
- event.preventDefault();
-};
-
-function SignInPageForm(props: SignInPageFormProps) {
- const form = useRef(null);
-
- useEffect(() => {
- const formCurrent = form.current;
-
- if (!formCurrent) {
- return;
- }
-
- // Prevent the browser from applying its own validation, which affects the email input
- formCurrent.setAttribute('novalidate', '');
-
- // Password Managers need these attributes to be able to identify the form elements properly.
- formCurrent.setAttribute('method', 'post');
- formCurrent.setAttribute('action', '/');
- formCurrent.addEventListener('submit', preventFormDefault);
-
- return () => {
- if (!formCurrent) {
- return;
- }
- formCurrent.removeEventListener('submit', preventFormDefault);
- };
- }, []);
-
- return (
-
- );
-}
-
-SignInPageForm.displayName = 'SignInPageForm';
-
-export default SignInPageForm;
diff --git a/src/components/SignInPageForm/types.ts b/src/components/SignInPageForm/types.ts
deleted file mode 100644
index c7f71a3f7151..000000000000
--- a/src/components/SignInPageForm/types.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import type {ViewProps} from 'react-native';
-
-type SignInPageFormProps = ViewProps;
-
-export default SignInPageFormProps;
diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.native.tsx
index f8013ae00e4c..3780df6362be 100644
--- a/src/components/TextWithTooltip/index.native.tsx
+++ b/src/components/TextWithTooltip/index.native.tsx
@@ -2,11 +2,11 @@ import React from 'react';
import Text from '@components/Text';
import type TextWithTooltipProps from './types';
-function TextWithTooltip({text, textStyles}: TextWithTooltipProps) {
+function TextWithTooltip({text, textStyles, numberOfLines = 1}: TextWithTooltipProps) {
return (
{text}
diff --git a/src/components/TextWithTooltip/index.tsx b/src/components/TextWithTooltip/index.tsx
index fd202db8de64..96721488c6db 100644
--- a/src/components/TextWithTooltip/index.tsx
+++ b/src/components/TextWithTooltip/index.tsx
@@ -7,7 +7,7 @@ type LayoutChangeEvent = {
target: HTMLElement;
};
-function TextWithTooltip({text, shouldShowTooltip, textStyles}: TextWithTooltipProps) {
+function TextWithTooltip({text, shouldShowTooltip, textStyles, numberOfLines = 1}: TextWithTooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
return (
@@ -17,7 +17,7 @@ function TextWithTooltip({text, shouldShowTooltip, textStyles}: TextWithTooltipP
>
{
const target = (e.nativeEvent as unknown as LayoutChangeEvent).target;
if (!shouldShowTooltip) {
diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts
index 80517b0b2acf..19c0b0dca6ed 100644
--- a/src/components/TextWithTooltip/types.ts
+++ b/src/components/TextWithTooltip/types.ts
@@ -1,9 +1,17 @@
import type {StyleProp, TextStyle} from 'react-native';
type TextWithTooltipProps = {
+ /** The text to display */
text: string;
+
+ /** Whether to show the toolip text */
shouldShowTooltip: boolean;
+
+ /** Additional text styles */
textStyles?: StyleProp;
+
+ /** Custom number of lines for text wrapping */
+ numberOfLines?: number;
};
export default TextWithTooltipProps;
diff --git a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
index c4015c74abd5..592cec3beca5 100644
--- a/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
+++ b/src/components/UserDetailsTooltip/BaseUserDetailsTooltip/index.tsx
@@ -19,7 +19,7 @@ function BaseUserDetailsTooltip({accountID, fallbackUserDetails, icon, delegateA
const personalDetails = usePersonalDetails();
const userDetails = personalDetails?.[accountID] ?? fallbackUserDetails ?? {};
- let userDisplayName = ReportUtils.getDisplayNameForParticipant(accountID) || (userDetails.displayName ? userDetails.displayName.trim() : '');
+ let userDisplayName = ReportUtils.getUserDetailTooltipText(accountID, userDetails.displayName ? userDetails.displayName.trim() : '');
let userLogin = userDetails.login?.trim() && userDetails.login !== userDetails.displayName ? Str.removeSMSDomain(userDetails.login) : '';
let userAvatar = userDetails.avatar;
@@ -29,7 +29,7 @@ function BaseUserDetailsTooltip({accountID, fallbackUserDetails, icon, delegateA
// the Copilot feature is implemented.
if (delegateAccountID) {
const delegateUserDetails = personalDetails?.[delegateAccountID];
- const delegateUserDisplayName = ReportUtils.getDisplayNameForParticipant(delegateAccountID);
+ const delegateUserDisplayName = ReportUtils.getUserDetailTooltipText(delegateAccountID);
userDisplayName = `${delegateUserDisplayName} (${translate('reportAction.asCopilot')} ${userDisplayName})`;
userLogin = delegateUserDetails?.login ?? '';
userAvatar = delegateUserDetails?.avatar;
diff --git a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
index 1c1d042d3893..45f47eb87c36 100644
--- a/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
+++ b/src/components/VideoPlayer/VideoPlayerControls/VolumeButton/index.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, {memo, useState} from 'react';
+import React, {memo, useCallback, useState} from 'react';
import {View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {runOnJS, useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
@@ -9,6 +9,7 @@ import IconButton from '@components/VideoPlayer/IconButton';
import {useVolumeContext} from '@components/VideoPlayerContexts/VolumeContext';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
+import * as NumberUtils from '@libs/NumberUtils';
import stylePropTypes from '@styles/stylePropTypes';
const propTypes = {
@@ -38,27 +39,35 @@ function VolumeButton({style, small}) {
const [volumeIcon, setVolumeIcon] = useState({icon: getVolumeIcon(volume.value)});
const [isSliderBeingUsed, setIsSliderBeingUsed] = useState(false);
- const onSliderLayout = (e) => {
+ const onSliderLayout = useCallback((e) => {
setSliderHeight(e.nativeEvent.layout.height);
- };
+ }, []);
+
+ const changeVolumeOnPan = useCallback(
+ (event) => {
+ const val = NumberUtils.roundToTwoDecimalPlaces(1 - event.y / sliderHeight);
+ volume.value = NumberUtils.clamp(val, 0, 1);
+ },
+ [sliderHeight, volume],
+ );
const pan = Gesture.Pan()
- .onBegin(() => {
+ .onBegin((event) => {
runOnJS(setIsSliderBeingUsed)(true);
+ changeVolumeOnPan(event);
})
.onChange((event) => {
- const val = Math.floor((1 - event.y / sliderHeight) * 100) / 100;
- volume.value = Math.min(Math.max(val, 0), 1);
+ changeVolumeOnPan(event);
})
- .onEnd(() => {
+ .onFinalize(() => {
runOnJS(setIsSliderBeingUsed)(false);
});
const progressBarStyle = useAnimatedStyle(() => ({height: `${volume.value * 100}%`}));
- const updateIcon = (vol) => {
+ const updateIcon = useCallback((vol) => {
setVolumeIcon({icon: getVolumeIcon(vol)});
- };
+ }, []);
useDerivedValue(() => {
runOnJS(updateVolume)(volume.value);
diff --git a/src/components/VideoPlayerContexts/VolumeContext.js b/src/components/VideoPlayerContexts/VolumeContext.js
index 2df463654075..a0b972d37a0d 100644
--- a/src/components/VideoPlayerContexts/VolumeContext.js
+++ b/src/components/VideoPlayerContexts/VolumeContext.js
@@ -14,7 +14,7 @@ function VolumeContextProvider({children}) {
if (!currentVideoPlayerRef.current) {
return;
}
- currentVideoPlayerRef.current.setStatusAsync({volume: newVolume});
+ currentVideoPlayerRef.current.setStatusAsync({volume: newVolume, isMuted: newVolume === 0});
volume.value = newVolume;
},
[currentVideoPlayerRef, volume],
diff --git a/src/languages/en.ts b/src/languages/en.ts
index e4e0804e11c9..caf441fec8e9 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2173,7 +2173,7 @@ export default {
reply: 'Reply',
from: 'From',
in: 'in',
- parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `From ${rootReportName}${workspaceName ? ` in ${workspaceName}` : ''}`,
+ parentNavigationSummary: ({reportName, workspaceName}: ParentNavigationSummaryParams) => `From ${reportName}${workspaceName ? ` in ${workspaceName}` : ''}`,
},
qrCodes: {
copy: 'Copy URL',
@@ -2240,7 +2240,6 @@ export default {
address: 'Address',
waypointDescription: {
start: 'Start',
- finish: 'Finish',
stop: 'Stop',
},
mapPending: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 20dcb9b0e2df..4af15fa77b35 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2661,7 +2661,7 @@ export default {
reply: 'Respuesta',
from: 'De',
in: 'en',
- parentNavigationSummary: ({rootReportName, workspaceName}: ParentNavigationSummaryParams) => `De ${rootReportName}${workspaceName ? ` en ${workspaceName}` : ''}`,
+ parentNavigationSummary: ({reportName, workspaceName}: ParentNavigationSummaryParams) => `De ${reportName}${workspaceName ? ` en ${workspaceName}` : ''}`,
},
qrCodes: {
copy: 'Copiar URL',
@@ -2729,7 +2729,6 @@ export default {
address: 'Dirección',
waypointDescription: {
start: 'Comienzo',
- finish: 'Final',
stop: 'Parada',
},
mapPending: {
diff --git a/src/languages/types.ts b/src/languages/types.ts
index 4129b35432c9..5e9e8fcae3fe 100644
--- a/src/languages/types.ts
+++ b/src/languages/types.ts
@@ -110,7 +110,7 @@ type RequestAmountParams = {amount: string};
type RequestedAmountMessageParams = {formattedAmount: string; comment?: string};
-type SplitAmountParams = {amount: number};
+type SplitAmountParams = {amount: string | number};
type DidSplitAmountMessageParams = {formattedAmount: string; comment: string};
@@ -196,7 +196,7 @@ type OOOEventSummaryFullDayParams = {summary: string; dayCount: number; date: st
type OOOEventSummaryPartialDayParams = {summary: string; timePeriod: string; date: string};
-type ParentNavigationSummaryParams = {rootReportName?: string; workspaceName?: string};
+type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string};
type SetTheRequestParams = {valueName: string; newValueToDisplay: string};
diff --git a/src/libs/API/parameters/AddCommentOrAttachementParams.ts b/src/libs/API/parameters/AddCommentOrAttachementParams.ts
index 58faf9fdfc9c..a705c92f7f27 100644
--- a/src/libs/API/parameters/AddCommentOrAttachementParams.ts
+++ b/src/libs/API/parameters/AddCommentOrAttachementParams.ts
@@ -1,9 +1,11 @@
+import type {FileObject} from '@components/AttachmentModal';
+
type AddCommentOrAttachementParams = {
reportID: string;
reportActionID?: string;
commentReportActionID?: string | null;
reportComment?: string;
- file?: File;
+ file?: FileObject;
timezone?: string;
shouldAllowActionableMentionWhispers?: boolean;
clientCreatedTime?: string;
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index d3514a110314..6da5c8af1ff2 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -265,7 +265,7 @@ function formatToLongDateWithWeekday(datetime: string | Date): string {
*
* @returns Sunday
*/
-function formatToDayOfWeek(datetime: string): string {
+function formatToDayOfWeek(datetime: Date): string {
return format(new Date(datetime), CONST.DATE.WEEKDAY_TIME_FORMAT);
}
diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts
index a42cb6a8f756..155a167f322e 100644
--- a/src/libs/DistanceRequestUtils.ts
+++ b/src/libs/DistanceRequestUtils.ts
@@ -1,17 +1,11 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
-import type {Unit} from '@src/types/onyx/Policy';
+import type {MileageRate, Unit} from '@src/types/onyx/Policy';
import type Policy from '@src/types/onyx/Policy';
import * as CurrencyUtils from './CurrencyUtils';
import * as PolicyUtils from './PolicyUtils';
-type DefaultMileageRate = {
- rate?: number;
- currency?: string;
- unit: Unit;
-};
-
/**
* Retrieves the default mileage rate based on a given policy.
*
@@ -22,7 +16,7 @@ type DefaultMileageRate = {
* @returns [currency] - The currency associated with the rate.
* @returns [unit] - The unit of measurement for the distance.
*/
-function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate | null {
+function getDefaultMileageRate(policy: OnyxEntry): MileageRate | null {
if (!policy?.customUnits) {
return null;
}
@@ -39,7 +33,7 @@ function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate |
return {
rate: distanceRate.rate,
- currency: distanceRate.currency,
+ currency: distanceRate.currency ?? 'USD',
unit: distanceUnit.attributes.unit,
};
}
diff --git a/src/libs/E2E/client.ts b/src/libs/E2E/client.ts
index 265c55c4a230..f76bdf2ed9a5 100644
--- a/src/libs/E2E/client.ts
+++ b/src/libs/E2E/client.ts
@@ -3,10 +3,19 @@ import Routes from '../../../tests/e2e/server/routes';
import type {NetworkCacheMap, TestConfig} from './types';
type TestResult = {
+ /** Name of the test */
name: string;
+
+ /** The branch where test were running */
branch?: string;
+
+ /** Duration in milliseconds */
duration?: number;
+
+ /** Optional, if set indicates that the test run failed and has no valid results. */
error?: string;
+
+ /** Render count */
renderCount?: number;
};
@@ -113,3 +122,4 @@ export default {
updateNetworkCache,
getNetworkCache,
};
+export type {TestResult, NativeCommand};
diff --git a/src/libs/E2E/tests/chatOpeningTest.e2e.ts b/src/libs/E2E/tests/chatOpeningTest.e2e.ts
index ef380f847c3f..17d9dfa1cb4d 100644
--- a/src/libs/E2E/tests/chatOpeningTest.e2e.ts
+++ b/src/libs/E2E/tests/chatOpeningTest.e2e.ts
@@ -1,14 +1,14 @@
+import type {NativeConfig} from 'react-native-config';
import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import E2EClient from '@libs/E2E/client';
-import type {TestConfig} from '@libs/E2E/types';
import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
-const test = (config: TestConfig) => {
+const test = (config: NativeConfig) => {
// check for login (if already logged in the action will simply resolve)
console.debug('[E2E] Logging in for chat opening');
diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts
index 4e0678aeb020..817bda941611 100644
--- a/src/libs/E2E/tests/reportTypingTest.e2e.ts
+++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts
@@ -1,9 +1,9 @@
+import type {NativeConfig} from 'react-native-config';
import Config from 'react-native-config';
import E2ELogin from '@libs/E2E/actions/e2eLogin';
import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded';
import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard';
import E2EClient from '@libs/E2E/client';
-import type {TestConfig} from '@libs/E2E/types';
import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow';
import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
@@ -12,7 +12,7 @@ import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction';
-const test = (config: TestConfig) => {
+const test = (config: NativeConfig) => {
// check for login (if already logged in the action will simply resolve)
console.debug('[E2E] Logging in for typing');
diff --git a/src/libs/E2E/types.ts b/src/libs/E2E/types.ts
index 2d48813fa115..93640fbb4ce8 100644
--- a/src/libs/E2E/types.ts
+++ b/src/libs/E2E/types.ts
@@ -20,7 +20,7 @@ type NetworkCacheMap = Record<
type TestConfig = {
name: string;
- [key: string]: string;
+ [key: string]: string | {autoFocus: boolean};
};
export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 2b9e4c6fcd8a..cab0f48d75fd 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -10,7 +10,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx';
import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportActionReactions';
import type IconAsset from '@src/types/utils/IconAsset';
-import type {SupportedLanguage} from './EmojiTrie';
type HeaderIndice = {code: string; index: number; icon: IconAsset};
type EmojiSpacer = {code: string; spacer: boolean};
@@ -384,7 +383,7 @@ function replaceAndExtractEmojis(text: string, preferredSkinTone: number = CONST
* Suggest emojis when typing emojis prefix after colon
* @param [limit] - matching emojis limit
*/
-function suggestEmojis(text: string, lang: SupportedLanguage, limit: number = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined {
+function suggestEmojis(text: string, lang: Locale, limit: number = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS): Emoji[] | undefined {
// emojisTrie is importing the emoji JSON file on the app starting and we want to avoid it
const emojisTrie = require('./EmojiTrie').default;
diff --git a/src/libs/Navigation/dismissModal.ts b/src/libs/Navigation/dismissModal.ts
index 484008d2e070..1f46f5c37193 100644
--- a/src/libs/Navigation/dismissModal.ts
+++ b/src/libs/Navigation/dismissModal.ts
@@ -25,6 +25,7 @@ function dismissModal(navigationRef: NavigationContainerRef)
case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
case SCREENS.NOT_FOUND:
case SCREENS.REPORT_ATTACHMENTS:
+ case SCREENS.CONCIERGE:
navigationRef.dispatch({...StackActions.pop(), target: state.key});
break;
default: {
diff --git a/src/libs/Navigation/dismissModalWithReport.ts b/src/libs/Navigation/dismissModalWithReport.ts
index b708819cdf9c..4fc82f76df86 100644
--- a/src/libs/Navigation/dismissModalWithReport.ts
+++ b/src/libs/Navigation/dismissModalWithReport.ts
@@ -38,6 +38,7 @@ function dismissModalWithReport(targetReport: Report | EmptyObject, navigationRe
case NAVIGATORS.RIGHT_MODAL_NAVIGATOR:
case SCREENS.NOT_FOUND:
case SCREENS.REPORT_ATTACHMENTS:
+ case SCREENS.CONCIERGE:
// If we are not in the target report, we need to navigate to it after dismissing the modal
if (targetReport.reportID !== getTopmostReportId(state)) {
const reportState = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReport.reportID));
diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts
index f03c34b1696e..3c003ab03590 100644
--- a/src/libs/NextStepUtils.ts
+++ b/src/libs/NextStepUtils.ts
@@ -1,5 +1,6 @@
import {format, lastDayOfMonth, setDate} from 'date-fns';
import Str from 'expensify-common/lib/str';
+import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import CONST from '@src/CONST';
@@ -63,7 +64,7 @@ type BuildNextStepParameters = {
* @returns nextStep
*/
function buildNextStep(
- report: Report | EmptyObject,
+ report: OnyxEntry | EmptyObject,
predictedNextStatus: ValueOf,
{isPaidWithExpensify}: BuildNextStepParameters = {},
): ReportNextStep | null {
@@ -71,13 +72,13 @@ function buildNextStep(
return null;
}
- const {policyID = '', ownerAccountID = -1, managerID = -1} = report;
+ const {policyID = '', ownerAccountID = -1, managerID = -1} = report ?? {};
const policy = ReportUtils.getPolicy(policyID);
const {submitsTo, harvesting, isPreventSelfApprovalEnabled, preventSelfApprovalEnabled, autoReportingFrequency, autoReportingOffset} = policy;
const isOwner = currentUserAccountID === ownerAccountID;
const isManager = currentUserAccountID === managerID;
const isSelfApproval = currentUserAccountID === submitsTo;
- const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([ownerAccountID])[0] ?? '';
+ const ownerLogin = PersonalDetailsUtils.getLoginsByAccountIDs([report?.ownerAccountID ?? -1])[0] ?? '';
const managerDisplayName = isSelfApproval ? 'you' : ReportUtils.getDisplayNameForParticipant(submitsTo) ?? '';
const type: ReportNextStep['type'] = 'neutral';
let optimisticNextStep: ReportNextStep | null;
diff --git a/src/libs/NumberUtils.ts b/src/libs/NumberUtils.ts
index 60e5246f5ed2..62d6fa00906a 100644
--- a/src/libs/NumberUtils.ts
+++ b/src/libs/NumberUtils.ts
@@ -76,4 +76,20 @@ function roundDownToLargestMultiple(p: number, q: number) {
return Math.floor(p / q) * q;
}
-export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple};
+/**
+ * Rounds a number to two decimal places.
+ * @returns the rounded value
+ */
+function roundToTwoDecimalPlaces(value: number): number {
+ return Math.round(value * 100) / 100;
+}
+
+/**
+ * Clamps a value between a minimum and maximum value.
+ * @returns the clamped value
+ */
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+export {rand64, generateHexadecimalValue, generateRandomInt, parseFloatAnyLocale, roundDownToLargestMultiple, roundToTwoDecimalPlaces, clamp};
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 9121eebb3367..0bf55edd260d 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -31,6 +31,7 @@ import type {
import type {Participant} from '@src/types/onyx/IOU';
import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
+import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
import Timing from './actions/Timing';
@@ -1743,7 +1744,7 @@ function getShareLogOptions(reports: OnyxCollection, personalDetails: On
/**
* Build the IOUConfirmation options for showing the payee personalDetail
*/
-function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails, amountText: string): PayeePersonalDetails {
+function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails | EmptyObject, amountText: string): PayeePersonalDetails {
const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? '');
return {
text: PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, formattedLogin),
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 0f8656adfa51..541a2130bcfa 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -1,3 +1,4 @@
+import {format} from 'date-fns';
import ExpensiMark from 'expensify-common/lib/ExpensiMark';
import Str from 'expensify-common/lib/str';
import {isEmpty} from 'lodash';
@@ -8,6 +9,7 @@ import lodashIsEqual from 'lodash/isEqual';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import * as Expensicons from '@components/Icon/Expensicons';
import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvatars';
import CONST from '@src/CONST';
@@ -98,11 +100,6 @@ type SpendBreakdown = {
type ParticipantDetails = [number, string, UserUtils.AvatarSource, UserUtils.AvatarSource];
-type ReportAndWorkspaceName = {
- rootReportName: string;
- workspaceName?: string;
-};
-
type OptimisticAddCommentReportAction = Pick<
ReportAction,
| 'reportActionID'
@@ -1323,7 +1320,7 @@ function getRoomWelcomeMessage(report: OnyxEntry, isUserPolicyAdmin: boo
/**
* Returns true if Concierge is one of the chat participants (1:1 as well as group chats)
*/
-function chatIncludesConcierge(report: OnyxEntry): boolean {
+function chatIncludesConcierge(report: Partial>): boolean {
return Boolean(report?.participantAccountIDs?.length && report?.participantAccountIDs?.includes(CONST.ACCOUNT_ID.CONCIERGE));
}
@@ -1696,6 +1693,14 @@ function getDisplayNamesWithTooltips(
});
}
+/**
+ * Returns the the display names of the given user accountIDs
+ */
+function getUserDetailTooltipText(accountID: number, fallbackUserDisplayName = ''): string {
+ const displayNameForParticipant = getDisplayNameForParticipant(accountID);
+ return displayNameForParticipant || fallbackUserDisplayName;
+}
+
/**
* For a deleted parent report action within a chat report,
* let us return the appropriate display message
@@ -1977,6 +1982,13 @@ function getFormulaTypeReportField(reportFields: PolicyReportFields) {
return Object.values(reportFields).find((field) => field.type === 'formula');
}
+/**
+ * Given a set of report fields, return the field that refers to title
+ */
+function getTitleReportField(reportFields: PolicyReportFields) {
+ return Object.values(reportFields).find((field) => isReportFieldOfTypeTitle(field));
+}
+
/**
* Get the report fields attached to the policy given policyID
*/
@@ -2557,39 +2569,6 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu
return participantsWithoutCurrentUser.map((accountID) => getDisplayNameForParticipant(accountID, isMultipleParticipantReport)).join(', ');
}
-/**
- * Recursively navigates through thread parents to get the root report and workspace name.
- * The recursion stops when we find a non thread or money request report, whichever comes first.
- */
-function getRootReportAndWorkspaceName(report: OnyxEntry): ReportAndWorkspaceName {
- if (!report) {
- return {
- rootReportName: '',
- };
- }
- if (isChildReport(report) && !isMoneyRequestReport(report) && !isTaskReport(report)) {
- const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null;
- return getRootReportAndWorkspaceName(parentReport);
- }
-
- if (isIOURequest(report)) {
- return {
- rootReportName: getReportName(report),
- };
- }
- if (isExpenseRequest(report)) {
- return {
- rootReportName: getReportName(report),
- workspaceName: isIOUReport(report) ? CONST.POLICY.OWNER_EMAIL_FAKE : getPolicyName(report, true),
- };
- }
-
- return {
- rootReportName: getReportName(report),
- workspaceName: getPolicyName(report, true),
- };
-}
-
/**
* Get either the policyName or domainName the chat is tied to
*/
@@ -2617,16 +2596,15 @@ function getChatRoomSubtitle(report: OnyxEntry): string | undefined {
* Gets the parent navigation subtitle for the report
*/
function getParentNavigationSubtitle(report: OnyxEntry): ParentNavigationSummaryParams {
- if (isThread(report)) {
- const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null;
- const {rootReportName, workspaceName} = getRootReportAndWorkspaceName(parentReport);
- if (!rootReportName) {
- return {};
- }
-
- return {rootReportName, workspaceName};
+ const parentReport = getParentReport(report);
+ if (isEmptyObject(parentReport)) {
+ return {};
}
- return {};
+
+ return {
+ reportName: getReportName(parentReport),
+ workspaceName: getPolicyName(parentReport, true),
+ };
}
/**
@@ -2698,7 +2676,7 @@ function getPolicyDescriptionText(policy: Policy): string {
return parser.htmlToText(policy.description);
}
-function buildOptimisticAddCommentReportAction(text?: string, file?: File, actorAccountID?: number): OptimisticReportAction {
+function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number): OptimisticReportAction {
const parser = new ExpensiMark();
const commentText = getParsedComment(text ?? '');
const isAttachment = !text && file !== undefined;
@@ -2884,6 +2862,38 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number
};
}
+function getHumanReadableStatus(statusNum: number): string {
+ const status = Object.keys(CONST.REPORT.STATUS_NUM).find((key) => CONST.REPORT.STATUS_NUM[key as keyof typeof CONST.REPORT.STATUS_NUM] === statusNum);
+ return status ? `${status.charAt(0)}${status.slice(1).toLowerCase()}` : '';
+}
+
+/**
+ * Populates the report field formula with the values from the report and policy.
+ * Currently, this only supports optimistic expense reports.
+ * Each formula field is either replaced with a value, or removed.
+ * If after all replacements the formula is empty, the original formula is returned.
+ * See {@link https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates}
+ */
+function populateOptimisticReportFormula(formula: string, report: OptimisticExpenseReport, policy: Policy | EmptyObject): string {
+ const createdDate = report.lastVisibleActionCreated ? new Date(report.lastVisibleActionCreated) : undefined;
+ const result = formula
+ .replaceAll('{report:id}', report.reportID)
+ // We don't translate because the server response is always in English
+ .replaceAll('{report:type}', 'Expense Report')
+ .replaceAll('{report:startdate}', createdDate ? format(createdDate, CONST.DATE.FNS_FORMAT_STRING) : '')
+ .replaceAll('{report:total}', report.total?.toString() ?? '')
+ .replaceAll('{report:currency}', report.currency ?? '')
+ .replaceAll('{report:policyname}', policy.name ?? '')
+ .replaceAll('{report:created}', createdDate ? format(createdDate, CONST.DATE.FNS_DATE_TIME_FORMAT_STRING) : '')
+ .replaceAll('{report:created:yyyy-MM-dd}', createdDate ? format(createdDate, CONST.DATE.FNS_FORMAT_STRING) : '')
+ .replaceAll('{report:status}', report.statusNum !== undefined ? getHumanReadableStatus(report.statusNum) : '')
+ .replaceAll('{user:email}', currentUserEmail ?? '')
+ .replaceAll('{user:email|frontPart}', currentUserEmail ? currentUserEmail.split('@')[0] : '')
+ .replaceAll(/\{report:(.+)}/g, '');
+
+ return result.trim().length ? result : formula;
+}
+
/**
* Builds an optimistic Expense report with a randomly generated reportID
*
@@ -2893,7 +2903,6 @@ function buildOptimisticIOUReport(payeeAccountID: number, payerAccountID: number
* @param total - Amount in cents
* @param currency
*/
-
function buildOptimisticExpenseReport(chatReportID: string, policyID: string, payeeAccountID: number, total: number, currency: string): OptimisticExpenseReport {
// The amount for Expense reports are stored as negative value in the database
const storedTotal = total * -1;
@@ -2914,7 +2923,6 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa
type: CONST.REPORT.TYPE.EXPENSE,
ownerAccountID: payeeAccountID,
currency,
-
// We don't translate reportName because the server response is always in English
reportName: `${policyName} owes ${formattedTotal}`,
stateNum,
@@ -2930,6 +2938,11 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa
expenseReport.managerID = policy.submitsTo;
}
+ const titleReportField = getTitleReportField(getReportFieldsByPolicyID(policyID) ?? {});
+ if (!!titleReportField && reportFieldsEnabled(expenseReport)) {
+ expenseReport.reportName = populateOptimisticReportFormula(titleReportField.defaultValue, expenseReport, policy);
+ }
+
return expenseReport;
}
@@ -5192,6 +5205,7 @@ export {
buildOptimisticUnHoldReportAction,
shouldDisplayThreadReplies,
shouldDisableThread,
+ getUserDetailTooltipText,
doesReportBelongToWorkspace,
getChildReportNotificationPreference,
getAllAncestorReportActions,
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 67e31c610369..2e1a283bc8b8 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -140,11 +140,11 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean {
return !!transaction?.receipt?.state || hasEReceipt(transaction);
}
-function isMerchantMissing(transaction: Transaction) {
- if (transaction.modifiedMerchant && transaction.modifiedMerchant !== '') {
+function isMerchantMissing(transaction: OnyxEntry) {
+ if (transaction?.modifiedMerchant && transaction?.modifiedMerchant !== '') {
return transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}
- const isMerchantEmpty = transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === '';
+ const isMerchantEmpty = transaction?.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction?.merchant === '';
return isMerchantEmpty;
}
@@ -156,15 +156,15 @@ function isPartialMerchant(merchant: string): boolean {
return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}
-function isAmountMissing(transaction: Transaction) {
- return transaction.amount === 0 && (!transaction.modifiedAmount || transaction.modifiedAmount === 0);
+function isAmountMissing(transaction: OnyxEntry) {
+ return transaction?.amount === 0 && (!transaction?.modifiedAmount || transaction?.modifiedAmount === 0);
}
-function isCreatedMissing(transaction: Transaction) {
- return transaction.created === '' && (!transaction.created || transaction.modifiedCreated === '');
+function isCreatedMissing(transaction: OnyxEntry) {
+ return transaction?.created === '' && (!transaction?.created || transaction?.modifiedCreated === '');
}
-function areRequiredFieldsEmpty(transaction: Transaction): boolean {
+function areRequiredFieldsEmpty(transaction: OnyxEntry): boolean {
const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`] ?? null;
const isFromExpenseReport = parentReport?.type === CONST.REPORT.TYPE.EXPENSE;
return (isFromExpenseReport && isMerchantMissing(transaction)) || isAmountMissing(transaction) || isCreatedMissing(transaction);
@@ -487,7 +487,7 @@ function hasMissingSmartscanFields(transaction: OnyxEntry): boolean
/**
* Check if the transaction has a defined route
*/
-function hasRoute(transaction: Transaction): boolean {
+function hasRoute(transaction: OnyxEntry): boolean {
return !!transaction?.routes?.route0?.geometry?.coordinates;
}
diff --git a/src/libs/actions/CachedPDFPaths/index.native.ts b/src/libs/actions/CachedPDFPaths/index.native.ts
new file mode 100644
index 000000000000..09203995e9a1
--- /dev/null
+++ b/src/libs/actions/CachedPDFPaths/index.native.ts
@@ -0,0 +1,47 @@
+import {exists, unlink} from 'react-native-fs';
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Add, Clear, ClearAll, ClearByKey} from './types';
+
+/*
+ * We need to save the paths of PDF files so we can delete them later.
+ * This is to remove the cached PDFs when an attachment is deleted or the user logs out.
+ */
+let pdfPaths: Record = {};
+Onyx.connect({
+ key: ONYXKEYS.CACHED_PDF_PATHS,
+ callback: (val) => {
+ pdfPaths = val ?? {};
+ },
+});
+
+const add: Add = (id: string, path: string) => {
+ if (pdfPaths[id]) {
+ return Promise.resolve();
+ }
+ return Onyx.merge(ONYXKEYS.CACHED_PDF_PATHS, {[id]: path});
+};
+
+const clear: Clear = (path: string) => {
+ if (!path) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve) => {
+ exists(path).then((exist) => {
+ if (!exist) {
+ resolve();
+ }
+ return unlink(path);
+ });
+ });
+};
+
+const clearByKey: ClearByKey = (id: string) => {
+ clear(pdfPaths[id] ?? '').then(() => Onyx.merge(ONYXKEYS.CACHED_PDF_PATHS, {[id]: null}));
+};
+
+const clearAll: ClearAll = () => {
+ Promise.all(Object.values(pdfPaths).map(clear)).then(() => Onyx.merge(ONYXKEYS.CACHED_PDF_PATHS, {}));
+};
+
+export {add, clearByKey, clearAll};
diff --git a/src/libs/actions/CachedPDFPaths/index.ts b/src/libs/actions/CachedPDFPaths/index.ts
new file mode 100644
index 000000000000..3cac21bf3c25
--- /dev/null
+++ b/src/libs/actions/CachedPDFPaths/index.ts
@@ -0,0 +1,9 @@
+import type {Add, ClearAll, ClearByKey} from './types';
+
+const add: Add = () => Promise.resolve();
+
+const clearByKey: ClearByKey = () => {};
+
+const clearAll: ClearAll = () => {};
+
+export {add, clearByKey, clearAll};
diff --git a/src/libs/actions/CachedPDFPaths/types.ts b/src/libs/actions/CachedPDFPaths/types.ts
new file mode 100644
index 000000000000..98b768c4645e
--- /dev/null
+++ b/src/libs/actions/CachedPDFPaths/types.ts
@@ -0,0 +1,6 @@
+type Add = (id: string, path: string) => Promise;
+type Clear = (path: string) => Promise;
+type ClearAll = () => void;
+type ClearByKey = (id: string) => void;
+
+export type {Add, Clear, ClearAll, ClearByKey};
diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts
index 0fc21713c608..c63e1c13d29a 100644
--- a/src/libs/actions/EmojiPickerAction.ts
+++ b/src/libs/actions/EmojiPickerAction.ts
@@ -68,7 +68,7 @@ function showEmojiPicker(
/**
* Hide the Emoji Picker modal.
*/
-function hideEmojiPicker(isNavigating: boolean) {
+function hideEmojiPicker(isNavigating?: boolean) {
if (!emojiPickerRef.current) {
return;
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 2528add01bbb..ff7fa1d1d352 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -58,6 +58,7 @@ import type {OnyxData} from '@src/types/onyx/Request';
import type {Comment, Receipt, ReceiptSource, TaxRate, TransactionChanges, WaypointCollection} from '@src/types/onyx/Transaction';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import * as CachedPDFPaths from './CachedPDFPaths';
import * as Policy from './Policy';
import * as Report from './Report';
@@ -384,9 +385,25 @@ function getReceiptError(receipt?: Receipt, filename?: string, isScanRequest = t
: ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source?.toString() ?? '', filename: filename ?? ''});
}
-/** Return the object to update hasOutstandingChildRequest */
-function getOutstandingChildRequest(needsToBeManuallySubmitted: boolean, policy: OnyxEntry | EmptyObject = null): OutstandingChildRequest {
- if (!needsToBeManuallySubmitted) {
+function needsToBeManuallySubmitted(iouReport: OnyxTypes.Report) {
+ const isPolicyExpenseChat = ReportUtils.isExpenseReport(iouReport);
+
+ if (isPolicyExpenseChat) {
+ const policy = ReportUtils.getPolicy(iouReport.policyID);
+ const isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy);
+
+ // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN
+ return isFromPaidPolicy && !policy.harvesting?.enabled;
+ }
+
+ return true;
+}
+
+/**
+ * Return the object to update hasOutstandingChildRequest
+ */
+function getOutstandingChildRequest(policy: OnyxEntry | EmptyObject, iouReport: OnyxTypes.Report): OutstandingChildRequest {
+ if (!needsToBeManuallySubmitted(iouReport)) {
return {
hasOutstandingChildRequest: false,
};
@@ -398,8 +415,9 @@ function getOutstandingChildRequest(needsToBeManuallySubmitted: boolean, policy:
};
}
- // We don't need to update hasOutstandingChildRequest in this case
- return {};
+ return {
+ hasOutstandingChildRequest: iouReport.managerID === userAccountID && iouReport.total !== 0,
+ };
}
/** Builds the Onyx data for a money request */
@@ -422,10 +440,9 @@ function buildOnyxDataForMoneyRequest(
policyTagList?: OnyxEntry,
policyCategories?: OnyxEntry,
optimisticNextStep?: OnyxTypes.ReportNextStep | null,
- needsToBeManuallySubmitted = true,
): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] {
const isScanRequest = TransactionUtils.isScanRequest(transaction);
- const outstandingChildRequest = getOutstandingChildRequest(needsToBeManuallySubmitted, policy);
+ const outstandingChildRequest = getOutstandingChildRequest(policy ?? {}, iouReport);
const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null]));
const optimisticData: OnyxUpdate[] = [];
@@ -818,15 +835,10 @@ function getMoneyRequestInformation(
iouReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReport.iouReportID}`] ?? null;
}
- // Check if the Scheduled Submit is enabled in case of expense report
- let needsToBeManuallySubmitted = true;
let isFromPaidPolicy = false;
if (isPolicyExpenseChat) {
isFromPaidPolicy = PolicyUtils.isPaidGroupPolicy(policy ?? null);
- // If the scheduled submit is turned off on the policy, user needs to manually submit the report which is indicated by GBR in LHN
- needsToBeManuallySubmitted = isFromPaidPolicy && !policy?.harvesting?.enabled;
-
// If the linked expense report on paid policy is not draft and not instantly submitted, we need to create a new draft expense report
if (iouReport && isFromPaidPolicy && !ReportUtils.isDraftExpenseReport(iouReport) && !ReportUtils.isExpenseReportWithInstantSubmittedState(iouReport)) {
iouReport = null;
@@ -966,7 +978,6 @@ function getMoneyRequestInformation(
policyTagList,
policyCategories,
optimisticNextStep,
- needsToBeManuallySubmitted,
);
return {
@@ -1233,11 +1244,21 @@ function getUpdateMoneyRequestParams(
}
updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency);
- optimisticData.push({
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
- value: updatedMoneyRequestReport,
- });
+ optimisticData.push(
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
+ value: updatedMoneyRequestReport,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`,
+ value: {
+ hasOutstandingChildRequest:
+ iouReport && needsToBeManuallySubmitted(iouReport) && updatedMoneyRequestReport.managerID === userAccountID && updatedMoneyRequestReport.total !== 0,
+ },
+ },
+ );
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`,
@@ -3018,6 +3039,13 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
[reportPreviewAction?.reportActionID ?? '']: updatedReportPreviewAction,
},
},
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`,
+ value: {
+ hasOutstandingChildRequest: iouReport && needsToBeManuallySubmitted(iouReport) && updatedIOUReport?.managerID === userAccountID && updatedIOUReport.total !== 0,
+ },
+ },
);
if (!shouldDeleteIOUReport && updatedReportPreviewAction.childMoneyRequestCount === 0) {
@@ -3162,6 +3190,7 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor
// STEP 6: Make the API request
API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData});
+ CachedPDFPaths.clearByKey(transactionID);
// STEP 7: Navigate the user depending on which page they are on and which resources were deleted
if (iouReport && isSingleTransactionView && shouldDeleteTransactionThread && !shouldDeleteIOUReport) {
@@ -3644,14 +3673,14 @@ function sendMoneyWithWallet(report: OnyxTypes.Report, amount: number, currency:
Report.notifyNewAction(params.chatReportID, managerID);
}
-function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
- const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null;
- const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport.total ?? 0, expenseReport.currency ?? '', expenseReport.reportID);
+function approveMoneyRequest(expenseReport: OnyxEntry | EmptyObject) {
+ const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`] ?? null;
+ const optimisticApprovedReportAction = ReportUtils.buildOptimisticApprovedReportAction(expenseReport?.total ?? 0, expenseReport?.currency ?? '', expenseReport?.reportID ?? '');
const optimisticNextStep = NextStepUtils.buildNextStep(expenseReport, CONST.REPORT.STATUS_NUM.APPROVED);
const optimisticReportActionsData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
value: {
[optimisticApprovedReportAction.reportActionID]: {
...(optimisticApprovedReportAction as OnyxTypes.ReportAction),
@@ -3661,7 +3690,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
};
const optimisticIOUReportData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${expenseReport?.reportID}`,
value: {
...expenseReport,
lastMessageText: optimisticApprovedReportAction.message?.[0].text,
@@ -3672,7 +3701,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
};
const optimisticNextStepData: OnyxUpdate = {
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`,
value: optimisticNextStep,
};
const optimisticData: OnyxUpdate[] = [optimisticIOUReportData, optimisticReportActionsData, optimisticNextStepData];
@@ -3680,7 +3709,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
const successData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
value: {
[optimisticApprovedReportAction.reportActionID]: {
pendingAction: null,
@@ -3692,22 +3721,22 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject) {
const failureData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReport?.reportID}`,
value: {
- [expenseReport.reportActionID ?? '']: {
+ [expenseReport?.reportActionID ?? '']: {
errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'),
},
},
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport?.reportID}`,
value: currentNextStep,
},
];
const parameters: ApproveMoneyRequestParams = {
- reportID: expenseReport.reportID,
+ reportID: expenseReport?.reportID ?? '',
approvedReportActionID: optimisticApprovedReportAction.reportActionID,
};
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index b9a2e8535b62..41288ce5eecd 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -392,6 +392,9 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
autoReporting: enabled,
+ harvesting: {
+ enabled: true,
+ },
pendingFields: {isAutoApprovalEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
},
},
@@ -403,7 +406,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
autoReporting: !enabled,
- pendingFields: {isAutoApprovalEnabled: null},
+ pendingFields: {isAutoApprovalEnabled: null, harvesting: null},
},
},
];
@@ -413,7 +416,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) {
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
value: {
- pendingFields: {isAutoApprovalEnabled: null},
+ pendingFields: {isAutoApprovalEnabled: null, harvesting: null},
},
},
];
@@ -1399,6 +1402,12 @@ function createWorkspace(policyOwnerEmail = '', makeMeAdmin = false, policyName
outputCurrency,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
customUnits,
+ areCategoriesEnabled: true,
+ areTagsEnabled: false,
+ areDistanceRatesEnabled: false,
+ areWorkflowsEnabled: false,
+ areReportFieldsEnabled: false,
+ areConnectionsEnabled: false,
},
},
{
@@ -1800,6 +1809,12 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string
outputCurrency: CONST.CURRENCY.USD,
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
customUnits,
+ areCategoriesEnabled: true,
+ areTagsEnabled: false,
+ areDistanceRatesEnabled: false,
+ areWorkflowsEnabled: false,
+ areReportFieldsEnabled: false,
+ areConnectionsEnabled: false,
};
const optimisticData: OnyxUpdate[] = [
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index f29f8a4fbaab..c363f49e7e3d 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -7,6 +7,7 @@ import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-nat
import Onyx from 'react-native-onyx';
import type {PartialDeep, ValueOf} from 'type-fest';
import type {Emoji} from '@assets/emojis/types';
+import type {FileObject} from '@components/AttachmentModal';
import * as ActiveClientManager from '@libs/ActiveClientManager';
import * as API from '@libs/API';
import type {
@@ -47,6 +48,7 @@ import DateUtils from '@libs/DateUtils';
import * as EmojiUtils from '@libs/EmojiUtils';
import * as Environment from '@libs/Environment/Environment';
import * as ErrorUtils from '@libs/ErrorUtils';
+import getPlatform from '@libs/getPlatform';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import LocalNotification from '@libs/Notification/LocalNotification';
@@ -76,6 +78,7 @@ import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/Rep
import type ReportAction from '@src/types/onyx/ReportAction';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import * as CachedPDFPaths from './CachedPDFPaths';
import * as Modal from './Modal';
import * as Session from './Session';
import * as Welcome from './Welcome';
@@ -175,10 +178,27 @@ const typingWatchTimers: Record = {};
let reportIDDeeplinkedFromOldDot: string | undefined;
Linking.getInitialURL().then((url) => {
- const params = new URLSearchParams(url ?? '');
- const exitToRoute = params.get('exitTo') ?? '';
- const {reportID} = ReportUtils.parseReportRouteParams(exitToRoute);
- reportIDDeeplinkedFromOldDot = reportID;
+ const isWeb = ([CONST.PLATFORM.WEB] as unknown as string).includes(getPlatform());
+ const currentParams = new URLSearchParams(url ?? '');
+ const currentExitToRoute = currentParams.get('exitTo') ?? '';
+ const {reportID: currentReportID} = ReportUtils.parseReportRouteParams(currentExitToRoute);
+
+ if (!isWeb) {
+ reportIDDeeplinkedFromOldDot = currentReportID;
+
+ return;
+ }
+
+ const prevUrl = sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.INITIAL_URL);
+ const prevParams = new URLSearchParams(prevUrl ?? '');
+ const prevExitToRoute = prevParams.get('exitTo') ?? '';
+ const {reportID: prevReportID} = ReportUtils.parseReportRouteParams(prevExitToRoute);
+
+ reportIDDeeplinkedFromOldDot = currentReportID || prevReportID;
+
+ if (currentReportID && url) {
+ sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.INITIAL_URL, url);
+ }
});
let lastVisitedPath: string | undefined;
@@ -355,7 +375,7 @@ function notifyNewAction(reportID: string, accountID?: number, reportActionID?:
* - Adding one attachment
* - Add both a comment and attachment simultaneously
*/
-function addActions(reportID: string, text = '', file?: File) {
+function addActions(reportID: string, text = '', file?: FileObject) {
let reportCommentText = '';
let reportCommentAction: OptimisticAddCommentReportAction | undefined;
let attachmentAction: OptimisticAddCommentReportAction | undefined;
@@ -514,7 +534,7 @@ function addActions(reportID: string, text = '', file?: File) {
}
/** Add an attachment and optional comment. */
-function addAttachment(reportID: string, file: File, text = '') {
+function addAttachment(reportID: string, file: FileObject, text = '') {
addActions(reportID, text, file);
}
@@ -1223,6 +1243,7 @@ function deleteReportComment(reportID: string, reportAction: ReportAction) {
reportActionID,
};
+ CachedPDFPaths.clearByKey(reportActionID);
API.write(WRITE_COMMANDS.DELETE_COMMENT, parameters, {optimisticData, successData, failureData});
}
@@ -1719,7 +1740,7 @@ function updateWriteCapabilityAndNavigate(report: Report, newValue: WriteCapabil
/**
* Navigates to the 1:1 report with Concierge
*/
-function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScreen = false, checkIfCurrentPageActive = () => true) {
+function navigateToConciergeChat(shouldDismissModal = false, checkIfCurrentPageActive = () => true) {
// If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID.
// Otherwise, we would find the concierge chat and navigate to it.
if (!conciergeChatReportID) {
@@ -1730,17 +1751,11 @@ function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScr
if (!checkIfCurrentPageActive()) {
return;
}
- if (shouldPopCurrentScreen && !shouldDismissModal) {
- Navigation.goBack();
- }
navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal);
});
} else if (shouldDismissModal) {
Navigation.dismissModal(conciergeChatReportID);
} else {
- if (shouldPopCurrentScreen) {
- Navigation.goBack();
- }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID));
}
}
@@ -2254,6 +2269,7 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal
if (!report) {
return;
}
+ const isChatThread = ReportUtils.isChatThread(report);
// Pusher's leavingStatus should be sent earlier.
// Place the broadcast before calling the LeaveRoom API to prevent a race condition
@@ -2262,20 +2278,22 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal
// If a workspace member is leaving a workspace room, they don't actually lose the room from Onyx.
// Instead, their notification preference just gets set to "hidden".
+ // Same applies for chat threads too
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: isWorkspaceMemberLeavingWorkspaceRoom
- ? {
- notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
- }
- : {
- reportID: null,
- stateNum: CONST.REPORT.STATE_NUM.APPROVED,
- statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
- notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
- },
+ value:
+ isWorkspaceMemberLeavingWorkspaceRoom || isChatThread
+ ? {
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
+ }
+ : {
+ reportID: null,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ statusNum: CONST.REPORT.STATUS_NUM.CLOSED,
+ notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
+ },
},
];
@@ -2283,12 +2301,13 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: isWorkspaceMemberLeavingWorkspaceRoom
- ? {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}
- : Object.keys(report).reduce>((acc, key) => {
- acc[key] = null;
- return acc;
- }, {}),
+ value:
+ isWorkspaceMemberLeavingWorkspaceRoom || isChatThread
+ ? {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN}
+ : Object.keys(report).reduce>((acc, key) => {
+ acc[key] = null;
+ return acc;
+ }, {}),
},
];
@@ -2344,15 +2363,19 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal
const lastAccessedReportID = filteredReportsByLastRead.at(-1)?.reportID;
if (lastAccessedReportID) {
- // We should call Navigation.goBack to pop the current route first before navigating to Concierge.
- Navigation.goBack();
+ // If it is not a chat thread we should call Navigation.goBack to pop the current route first before navigating to last accessed report.
+ if (!isChatThread) {
+ Navigation.goBack();
+ }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID));
} else {
const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE]);
const chat = ReportUtils.getChatByParticipants(participantAccountIDs);
if (chat?.reportID) {
- // We should call Navigation.goBack to pop the current route first before navigating to Concierge.
- Navigation.goBack();
+ // If it is not a chat thread we should call Navigation.goBack to pop the current route first before navigating to Concierge.
+ if (!isChatThread) {
+ Navigation.goBack();
+ }
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chat.reportID));
}
}
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index d7cef2aca546..5d089ed6e393 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -33,7 +33,7 @@ import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {FrequentlyUsedEmoji} from '@src/types/onyx';
+import type {BlockedFromConcierge, FrequentlyUsedEmoji} from '@src/types/onyx';
import type Login from '@src/types/onyx/Login';
import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer';
import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails';
@@ -47,8 +47,6 @@ import * as OnyxUpdates from './OnyxUpdates';
import * as Report from './Report';
import * as Session from './Session';
-type BlockedFromConciergeNVP = {expiresAt: number};
-
let currentUserAccountID = -1;
let currentEmail = '';
Onyx.connect({
@@ -447,7 +445,7 @@ function validateSecondaryLogin(contactMethod: string, validateCode: string) {
* and if so whether the expiresAt date of a user's ban is before right now
*
*/
-function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry): boolean {
+function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry): boolean {
if (isEmptyObject(blockedFromConciergeNVP)) {
return false;
}
diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx
index 4abf8f0d2033..10040a6c3146 100644
--- a/src/pages/ConciergePage.tsx
+++ b/src/pages/ConciergePage.tsx
@@ -40,7 +40,7 @@ function ConciergePage({session}: ConciergePageProps) {
if (isUnmounted.current) {
return;
}
- Report.navigateToConciergeChat(undefined, true, () => !isUnmounted.current);
+ Report.navigateToConciergeChat(true, () => !isUnmounted.current);
});
} else {
Navigation.navigate();
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index 054f229ac16a..72393e89ae1a 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -273,6 +273,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF
textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
isLoadingNewOptions={isSearchingForReports}
+ autoFocus={false}
/>
{isSmallScreenWidth && }
diff --git a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx
index 025dcafd9740..9ad19482a4a7 100644
--- a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx
+++ b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx
@@ -90,7 +90,7 @@ function PurposeForUsingExpensifyModal() {
}
Report.completeEngagementModal(message, choice);
- Report.navigateToConciergeChat(false, true);
+ Report.navigateToConciergeChat(true);
}, []);
const menuItems: MenuItemProps[] = useMemo(
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index c505758e80f8..0b986adf1c6f 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -7,13 +7,11 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import AutoUpdateTime from '@components/AutoUpdateTime';
import Avatar from '@components/Avatar';
-import BlockingView from '@components/BlockingViews/BlockingView';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import CommunicationsLink from '@components/CommunicationsLink';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
-import * as Illustrations from '@components/Icon/Illustrations';
import MenuItem from '@components/MenuItem';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
@@ -30,7 +28,6 @@ import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
-import variables from '@styles/variables';
import * as PersonalDetails from '@userActions/PersonalDetails';
import * as Report from '@userActions/Report';
import * as Session from '@userActions/Session';
@@ -145,7 +142,7 @@ function ProfilePage(props) {
return (
-
+
Navigation.goBack(navigateBackTo)}
@@ -251,16 +248,6 @@ function ProfilePage(props) {
)}
{!hasMinimumDetails && isLoading && }
- {shouldShowBlockingView && (
-
- )}
diff --git a/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx b/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx
index 1682cb66f7c8..078c216d836c 100644
--- a/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx
+++ b/src/pages/ReimbursementAccount/ConnectBankAccount/ConnectBankAccount.tsx
@@ -39,7 +39,7 @@ function ConnectBankAccount({reimbursementAccount, onBackButtonPress, account, p
const styles = useThemeStyles();
const {translate} = useLocalize();
- const handleNavigateToConciergeChat = () => Report.navigateToConciergeChat(false, true);
+ const handleNavigateToConciergeChat = () => Report.navigateToConciergeChat(true);
const bankAccountState = reimbursementAccount.achData?.state ?? '';
// If a user tries to navigate directly to the validate page we'll show them the EnableStep
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 7fba188dcedd..da5a8e4aae27 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -561,6 +561,7 @@ function ReportScreen({
getParticipantLocalTime(participant, preferredLocale));
useEffect(() => {
@@ -44,7 +43,8 @@ function ParticipantLocalTime(props) {
};
}, [participant, preferredLocale]);
- const reportRecipientDisplayName = lodashGet(props, 'participant.firstName') || lodashGet(props, 'participant.displayName');
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null
+ const reportRecipientDisplayName = participant.firstName || participant.displayName;
if (!reportRecipientDisplayName) {
return null;
@@ -65,7 +65,6 @@ function ParticipantLocalTime(props) {
);
}
-ParticipantLocalTime.propTypes = propTypes;
ParticipantLocalTime.displayName = 'ParticipantLocalTime';
-export default withLocalize(ParticipantLocalTime);
+export default ParticipantLocalTime;
diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
similarity index 81%
rename from src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
rename to src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
index 72727168cad6..68c7f0883683 100644
--- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js
+++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx
@@ -1,112 +1,94 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import {useIsFocused} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo} from 'react';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import AttachmentPicker from '@components/AttachmentPicker';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
+import type {PopoverMenuItem} from '@components/PopoverMenu';
import PopoverMenu from '@components/PopoverMenu';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
-import withNavigationFocus from '@components/withNavigationFocus';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
-import compose from '@libs/compose';
import * as ReportUtils from '@libs/ReportUtils';
import * as IOU from '@userActions/IOU';
import * as Report from '@userActions/Report';
import * as Task from '@userActions/Task';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
-const propTypes = {
- /** The report currently being looked at */
- report: PropTypes.shape({
- /** ID of the report */
- reportID: PropTypes.string,
-
- /** Whether or not the report is in the process of being created */
- loading: PropTypes.bool,
- }).isRequired,
+type MoneyRequestOptions = Record, PopoverMenuItem>;
+type AttachmentPickerWithMenuItemsOnyxProps = {
/** The policy tied to the report */
- policy: PropTypes.shape({
- /** Type of the policy */
- type: PropTypes.string,
- }),
+ policy: OnyxEntry;
+};
- /** The personal details of everyone in the report */
- reportParticipantIDs: PropTypes.arrayOf(PropTypes.number),
+type AttachmentPickerWithMenuItemsProps = AttachmentPickerWithMenuItemsOnyxProps & {
+ /** The report currently being looked at */
+ report: OnyxEntry;
/** Callback to open the file in the modal */
- displayFileInModal: PropTypes.func.isRequired,
+ displayFileInModal: (url: FileObject) => void;
/** Whether or not the full size composer is available */
- isFullComposerAvailable: PropTypes.bool.isRequired,
+ isFullComposerAvailable: boolean;
/** Whether or not the composer is full size */
- isComposerFullSize: PropTypes.bool.isRequired,
+ isComposerFullSize: boolean;
/** Whether or not the user is blocked from concierge */
- isBlockedFromConcierge: PropTypes.bool.isRequired,
+ isBlockedFromConcierge: boolean;
/** Whether or not the attachment picker is disabled */
- disabled: PropTypes.bool,
+ disabled?: boolean;
/** Sets the menu visibility */
- setMenuVisibility: PropTypes.func.isRequired,
+ setMenuVisibility: (isVisible: boolean) => void;
/** Whether or not the menu is visible */
- isMenuVisible: PropTypes.bool.isRequired,
+ isMenuVisible: boolean;
/** Report ID */
- reportID: PropTypes.string.isRequired,
+ reportID: string;
/** Called when opening the attachment picker */
- onTriggerAttachmentPicker: PropTypes.func.isRequired,
+ onTriggerAttachmentPicker: () => void;
/** Called when cancelling the attachment picker */
- onCanceledAttachmentPicker: PropTypes.func.isRequired,
+ onCanceledAttachmentPicker: () => void;
/** Called when the menu with the items is closed after it was open */
- onMenuClosed: PropTypes.func.isRequired,
+ onMenuClosed: () => void;
/** Called when the add action button is pressed */
- onAddActionPressed: PropTypes.func.isRequired,
+ onAddActionPressed: () => void;
/** Called when the menu item is selected */
- onItemSelected: PropTypes.func.isRequired,
+ onItemSelected: () => void;
/** A ref for the add action button */
- actionButtonRef: PropTypes.shape({
- // eslint-disable-next-line react/forbid-prop-types
- current: PropTypes.object,
- }).isRequired,
-
- /** Whether or not the screen is focused */
- isFocused: PropTypes.bool.isRequired,
+ actionButtonRef: React.RefObject;
/** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */
- raiseIsScrollLikelyLayoutTriggered: PropTypes.func.isRequired,
-};
+ raiseIsScrollLikelyLayoutTriggered: () => void;
-const defaultProps = {
- reportParticipantIDs: [],
- disabled: false,
- policy: {},
+ /** The personal details of everyone in the report */
+ reportParticipantIDs?: number[];
};
/**
* This includes the popover of options you see when pressing the + button in the composer.
* It also contains the attachment picker, as the menu items need to be able to open it.
- *
- * @returns {React.Component}
*/
function AttachmentPickerWithMenuItems({
report,
@@ -126,9 +108,9 @@ function AttachmentPickerWithMenuItems({
onAddActionPressed,
onItemSelected,
actionButtonRef,
- isFocused,
raiseIsScrollLikelyLayoutTriggered,
-}) {
+}: AttachmentPickerWithMenuItemsProps) {
+ const isFocused = useIsFocused();
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -136,37 +118,35 @@ function AttachmentPickerWithMenuItems({
/**
* Returns the list of IOU Options
- * @returns {Array}
*/
const moneyRequestOptions = useMemo(() => {
- const options = {
+ const options: MoneyRequestOptions = {
[CONST.IOU.TYPE.SPLIT]: {
icon: Expensicons.Receipt,
text: translate('iou.splitBill'),
- onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report.reportID),
+ onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report?.reportID ?? ''),
},
[CONST.IOU.TYPE.REQUEST]: {
icon: Expensicons.MoneyCircle,
text: translate('iou.requestMoney'),
- onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report.reportID),
+ onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report?.reportID ?? ''),
},
[CONST.IOU.TYPE.SEND]: {
icon: Expensicons.Send,
text: translate('iou.sendMoney'),
- onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report.reportID),
+ onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID),
},
};
- return _.map(ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs), (option) => ({
+ return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? []).map((option) => ({
...options[option],
}));
}, [report, policy, reportParticipantIDs, translate]);
/**
* Determines if we can show the task option
- * @returns {Boolean}
*/
- const taskOption = useMemo(() => {
+ const taskOption: PopoverMenuItem[] = useMemo(() => {
if (!ReportUtils.canCreateTaskInReport(report)) {
return [];
}
@@ -205,6 +185,7 @@ function AttachmentPickerWithMenuItems({
return (
+ {/* @ts-expect-error TODO: Remove this once AttachmentPicker (https://github.com/Expensify/App/issues/25134) is migrated to TypeScript. */}
{({openPicker}) => {
const triggerAttachmentPicker = () => {
onTriggerAttachmentPicker();
@@ -234,7 +215,7 @@ function AttachmentPickerWithMenuItems({
{
- e.preventDefault();
+ e?.preventDefault();
raiseIsScrollLikelyLayoutTriggered();
Report.setIsComposerFullSize(reportID, false);
}}
@@ -256,7 +237,7 @@ function AttachmentPickerWithMenuItems({
{
- e.preventDefault();
+ e?.preventDefault();
raiseIsScrollLikelyLayoutTriggered();
Report.setIsComposerFullSize(reportID, true);
}}
@@ -278,14 +259,14 @@ function AttachmentPickerWithMenuItems({
{
- e.preventDefault();
+ e?.preventDefault();
if (!isFocused) {
return;
}
onAddActionPressed();
// Drop focus to avoid blue focus ring.
- actionButtonRef.current.blur();
+ actionButtonRef.current?.blur();
setMenuVisibility(!isMenuVisible);
}}
style={styles.composerSizeButton}
@@ -328,15 +309,10 @@ function AttachmentPickerWithMenuItems({
);
}
-AttachmentPickerWithMenuItems.propTypes = propTypes;
-AttachmentPickerWithMenuItems.defaultProps = defaultProps;
AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems';
-export default compose(
- withNavigationFocus,
- withOnyx({
- policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${lodashGet(report, 'policyID')}`,
- },
- }),
-)(AttachmentPickerWithMenuItems);
+export default withOnyx({
+ policy: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`,
+ },
+})(AttachmentPickerWithMenuItems);
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
similarity index 65%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
index 026df340040e..af2d0b9eab56 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx
@@ -1,11 +1,24 @@
import {useIsFocused, useNavigation} from '@react-navigation/native';
-import lodashGet from 'lodash/get';
-import React, {memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import lodashDebounce from 'lodash/debounce';
+import type {ForwardedRef, MutableRefObject, RefAttributes, RefObject} from 'react';
+import React, {forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import type {
+ LayoutChangeEvent,
+ MeasureInWindowOnSuccessCallback,
+ NativeSyntheticEvent,
+ TextInput,
+ TextInputFocusEventData,
+ TextInputKeyPressEventData,
+ TextInputSelectionChangeEventData,
+} from 'react-native';
import {findNodeHandle, InteractionManager, NativeModules, View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {useAnimatedRef} from 'react-native-reanimated';
+import type {Emoji} from '@assets/emojis/types';
+import type {FileObject} from '@components/AttachmentModal';
import Composer from '@components/Composer';
-import withKeyboardState from '@components/withKeyboardState';
+import useKeyboardState from '@hooks/useKeyboardState';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -14,7 +27,6 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
-import compose from '@libs/compose';
import * as ComposerUtils from '@libs/ComposerUtils';
import getDraftComment from '@libs/ComposerUtils/getDraftComment';
import convertToLTRForComposer from '@libs/convertToLTRForComposer';
@@ -28,6 +40,7 @@ import * as ReportUtils from '@libs/ReportUtils';
import * as SuggestionUtils from '@libs/SuggestionUtils';
import updateMultilineInputRange from '@libs/updateMultilineInputRange';
import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside';
+import type {ComposerRef, SuggestionsRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
import SilentCommentUpdater from '@pages/home/report/ReportActionCompose/SilentCommentUpdater';
import Suggestions from '@pages/home/report/ReportActionCompose/Suggestions';
import * as EmojiPickerActions from '@userActions/EmojiPickerAction';
@@ -36,7 +49,128 @@ import * as Report from '@userActions/Report';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import {defaultProps, propTypes} from './composerWithSuggestionsProps';
+import type * as OnyxTypes from '@src/types/onyx';
+import type ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type SyncSelection = {
+ position: number;
+ value: string;
+};
+
+type AnimatedRef = ReturnType;
+
+type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string};
+
+type ComposerWithSuggestionsOnyxProps = {
+ /** The number of lines the comment should take up */
+ numberOfLines: OnyxEntry;
+
+ /** The parent report actions for the report */
+ parentReportActions: OnyxEntry;
+
+ /** The modal state */
+ modal: OnyxEntry;
+
+ /** The preferred skin tone of the user */
+ preferredSkinTone: number;
+
+ /** Whether the input is focused */
+ editFocused: OnyxEntry;
+};
+
+type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps &
+ Partial & {
+ /** Report ID */
+ reportID: string;
+
+ /** Callback to focus composer */
+ onFocus: () => void;
+
+ /** Callback to blur composer */
+ onBlur: (event: NativeSyntheticEvent) => void;
+
+ /** Callback to update the value of the composer */
+ onValueChange: (value: string) => void;
+
+ /** Whether the composer is full size */
+ isComposerFullSize: boolean;
+
+ /** Whether the menu is visible */
+ isMenuVisible: boolean;
+
+ /** The placeholder for the input */
+ inputPlaceholder: string;
+
+ /** Function to display a file in a modal */
+ displayFileInModal: (file: FileObject) => void;
+
+ /** Whether the text input should clear */
+ textInputShouldClear: boolean;
+
+ /** Function to set the text input should clear */
+ setTextInputShouldClear: (shouldClear: boolean) => void;
+
+ /** Whether the user is blocked from concierge */
+ isBlockedFromConcierge: boolean;
+
+ /** Whether the input is disabled */
+ disabled: boolean;
+
+ /** Whether the full composer is available */
+ isFullComposerAvailable: boolean;
+
+ /** Function to set whether the full composer is available */
+ setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void;
+
+ /** Function to set whether the comment is empty */
+ setIsCommentEmpty: (isCommentEmpty: boolean) => void;
+
+ /** Function to handle sending a message */
+ handleSendMessage: () => void;
+
+ /** Whether the compose input should show */
+ shouldShowComposeInput: OnyxEntry;
+
+ /** Function to measure the parent container */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+
+ /** The height of the list */
+ listHeight: number;
+
+ /** Whether the scroll is likely to trigger a layout */
+ isScrollLikelyLayoutTriggered: RefObject;
+
+ /** Function to raise the scroll is likely layout triggered */
+ raiseIsScrollLikelyLayoutTriggered: () => void;
+
+ /** The ref to the suggestions */
+ suggestionsRef: React.RefObject;
+
+ /** The ref to the animated input */
+ animatedRef: AnimatedRef;
+
+ /** The ref to the next modal will open */
+ isNextModalWillOpenRef: MutableRefObject;
+
+ /** Whether the edit is focused */
+ editFocused: boolean;
+
+ /** Wheater chat is empty */
+ isEmptyChat?: boolean;
+
+ /** The last report action */
+ lastReportAction?: OnyxTypes.ReportAction;
+
+ /** Whether to include chronos */
+ includeChronos?: boolean;
+
+ /** The parent report action ID */
+ parentReportActionID?: string;
+
+ /** The parent report ID */
+ // eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
+ parentReportID: string | undefined;
+ };
const {RNTextInputReset} = NativeModules;
@@ -44,9 +178,8 @@ const isIOSNative = getPlatform() === CONST.PLATFORM.IOS;
/**
* Broadcast that the user is typing. Debounced to limit how often we publish client events.
- * @param {String} reportID
*/
-const debouncedBroadcastUserIsTyping = _.debounce((reportID) => {
+const debouncedBroadcastUserIsTyping = lodashDebounce((reportID: string) => {
Report.broadcastUserIsTyping(reportID);
}, 100);
@@ -61,63 +194,66 @@ const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
* If a component really needs access to these state values it should be put here.
* However, double check if the component really needs access, as it will re-render
* on every key press.
- * @param {Object} props
- * @returns {React.Component}
*/
-function ComposerWithSuggestions({
- // Onyx
- modal,
- preferredSkinTone,
- parentReportActions,
- numberOfLines,
- // HOCs
- isKeyboardShown,
- // Props: Report
- reportID,
- includeChronos,
- isEmptyChat,
- lastReportAction,
- parentReportActionID,
- // Focus
- onFocus,
- onBlur,
- onValueChange,
- // Composer
- isComposerFullSize,
- isMenuVisible,
- inputPlaceholder,
- displayFileInModal,
- textInputShouldClear,
- setTextInputShouldClear,
- isBlockedFromConcierge,
- disabled,
- isFullComposerAvailable,
- setIsFullComposerAvailable,
- setIsCommentEmpty,
- handleSendMessage,
- shouldShowComposeInput,
- measureParentContainer,
- listHeight,
- isScrollLikelyLayoutTriggered,
- raiseIsScrollLikelyLayoutTriggered,
- // Refs
- suggestionsRef,
- animatedRef,
- forwardedRef,
- isNextModalWillOpenRef,
- editFocused,
- // For testing
- children,
-}) {
+function ComposerWithSuggestions(
+ {
+ // Onyx
+ modal,
+ preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
+ parentReportActions,
+ numberOfLines,
+
+ // Props: Report
+ reportID,
+ includeChronos,
+ isEmptyChat,
+ lastReportAction,
+ parentReportActionID,
+
+ // Focus
+ onFocus,
+ onBlur,
+ onValueChange,
+
+ // Composer
+ isComposerFullSize,
+ isMenuVisible,
+ inputPlaceholder,
+ displayFileInModal,
+ textInputShouldClear,
+ setTextInputShouldClear,
+ isBlockedFromConcierge,
+ disabled,
+ isFullComposerAvailable,
+ setIsFullComposerAvailable,
+ setIsCommentEmpty,
+ handleSendMessage,
+ shouldShowComposeInput,
+ measureParentContainer = () => {},
+ listHeight,
+ isScrollLikelyLayoutTriggered,
+ raiseIsScrollLikelyLayoutTriggered,
+
+ // Refs
+ suggestionsRef,
+ animatedRef,
+ isNextModalWillOpenRef,
+ editFocused,
+
+ // For testing
+ children,
+ }: ComposerWithSuggestionsProps,
+ ref: ForwardedRef,
+) {
+ const {isKeyboardShown} = useKeyboardState();
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {preferredLocale} = useLocalize();
const isFocused = useIsFocused();
const navigation = useNavigation();
- const emojisPresentBefore = useRef([]);
-
- const draftComment = getDraftComment(reportID) || '';
+ const emojisPresentBefore = useRef([]);
+ const draftComment = getDraftComment(reportID) ?? '';
const [value, setValue] = useState(() => {
if (draftComment) {
emojisPresentBefore.current = EmojiUtils.extractEmojis(draftComment);
@@ -130,9 +266,9 @@ function ComposerWithSuggestions({
const {isSmallScreenWidth} = useWindowDimensions();
const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES;
- const parentReportAction = lodashGet(parentReportActions, [parentReportActionID]);
+ const parentReportAction = parentReportActions?.[parentReportActionID ?? ''] ?? null;
const shouldAutoFocus =
- !modal.isVisible && isFocused && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput;
+ !modal?.isVisible && isFocused && (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput;
const valueRef = useRef(value);
valueRef.current = value;
@@ -141,14 +277,14 @@ function ComposerWithSuggestions({
const [composerHeight, setComposerHeight] = useState(0);
- const textInputRef = useRef(null);
- const insertedEmojisRef = useRef([]);
+ const textInputRef = useRef(null);
+ const insertedEmojisRef = useRef([]);
- const syncSelectionWithOnChangeTextRef = useRef(null);
+ const syncSelectionWithOnChangeTextRef = useRef(null);
- const suggestions = lodashGet(suggestionsRef, 'current.getSuggestions', () => [])();
+ const suggestions = suggestionsRef.current?.getSuggestions() ?? [];
- const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions.length);
+ const hasEnoughSpaceForLargeSuggestion = SuggestionUtils.hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, suggestions?.length ?? 0);
const isAutoSuggestionPickerLarge = !isSmallScreenWidth || (isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion);
@@ -163,15 +299,13 @@ function ComposerWithSuggestions({
/**
* Set the TextInput Ref
- *
- * @param {Element} el
- * @memberof ReportActionCompose
*/
const setTextInputRef = useCallback(
- (el) => {
+ (el: TextInput) => {
+ // @ts-expect-error need to reassign this ref
ReportActionComposeFocusManager.composerRef.current = el;
textInputRef.current = el;
- if (_.isFunction(animatedRef)) {
+ if (typeof animatedRef === 'function') {
animatedRef(el);
}
},
@@ -187,7 +321,7 @@ function ComposerWithSuggestions({
const debouncedSaveReportComment = useMemo(
() =>
- _.debounce((selectedReportID, newComment) => {
+ lodashDebounce((selectedReportID, newComment) => {
Report.saveReportComment(selectedReportID, newComment || '');
}, 1000),
[],
@@ -196,15 +330,15 @@ function ComposerWithSuggestions({
/**
* 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.
+ * @param prevText - The previous text.
+ * @param newText - The new text.
+ * @returns An object containing information about the newly added characters.
+ * @property startIndex - The start index of the newly added characters in the new text.
+ * @property endIndex - The end index of the newly added characters in the new text.
+ * @property diff - The newly added characters.
*/
const findNewlyAddedChars = useCallback(
- (prevText, newText) => {
+ (prevText: string, newText: string): NewlyAddedChars => {
let startIndex = -1;
let endIndex = -1;
let currentIndex = 0;
@@ -224,7 +358,6 @@ function ComposerWithSuggestions({
endIndex = currentIndex + newText.length;
}
}
-
return {
startIndex,
endIndex,
@@ -236,12 +369,9 @@ function ComposerWithSuggestions({
/**
* Update the value of the comment in Onyx
- *
- * @param {String} comment
- * @param {Boolean} shouldDebounceSaveComment
*/
const updateComment = useCallback(
- (commentValue, shouldDebounceSaveComment) => {
+ (commentValue: string, shouldDebounceSaveComment?: boolean) => {
raiseIsScrollLikelyLayoutTriggered();
const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue);
const isEmojiInserted = diff.length && endIndex > startIndex && diff.trim() === diff && EmojiUtils.containsOnlyEmojis(diff);
@@ -250,9 +380,9 @@ function ComposerWithSuggestions({
emojis,
cursorPosition,
} = EmojiUtils.replaceAndExtractEmojis(isEmojiInserted ? ComposerUtils.insertWhiteSpaceAtIndex(commentValue, endIndex) : commentValue, preferredSkinTone, preferredLocale);
- if (!_.isEmpty(emojis)) {
+ if (emojis.length) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
- if (!_.isEmpty(newEmojis)) {
+ if (newEmojis.length) {
// Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
@@ -272,7 +402,7 @@ function ComposerWithSuggestions({
emojisPresentBefore.current = emojis;
setValue(newCommentConverted);
if (commentValue !== newComment) {
- const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0);
+ const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition ?? 0);
if (isIOSNative) {
syncSelectionWithOnChangeTextRef.current = {position, value: newComment};
@@ -320,10 +450,9 @@ function ComposerWithSuggestions({
/**
* Update the number of lines for a comment in Onyx
- * @param {Number} numberOfLines
*/
const updateNumberOfLines = useCallback(
- (newNumberOfLines) => {
+ (newNumberOfLines: number) => {
if (newNumberOfLines === numberOfLines) {
return;
}
@@ -332,10 +461,7 @@ function ComposerWithSuggestions({
[reportID, numberOfLines],
);
- /**
- * @returns {String}
- */
- const prepareCommentAndResetComposer = useCallback(() => {
+ const prepareCommentAndResetComposer = useCallback((): string => {
const trimmedComment = commentRef.current.trim();
const commentLength = ReportUtils.getCommentLength(trimmedComment);
@@ -360,37 +486,45 @@ function ComposerWithSuggestions({
/**
* Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker)
- * @param {String} text
*/
const replaceSelectionWithText = useCallback(
- (text) => {
+ (text: string) => {
updateComment(ComposerUtils.insertText(commentRef.current, selection, text));
},
[selection, updateComment],
);
const triggerHotkeyActions = useCallback(
- (e) => {
- if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
+ (event: NativeSyntheticEvent) => {
+ const webEvent = event as unknown as KeyboardEvent;
+ if (!webEvent || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
return;
}
- if (suggestionsRef.current.triggerHotkeyActions(e)) {
+ if (suggestionsRef.current?.triggerHotkeyActions(webEvent)) {
return;
}
// Submit the form when Enter is pressed
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) {
- e.preventDefault();
+ if (webEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !webEvent.shiftKey) {
+ webEvent.preventDefault();
handleSendMessage();
}
// Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants
const valueLength = valueRef.current.length;
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && textInputRef.current.selectionStart === 0 && valueLength === 0 && !includeChronos) {
- e.preventDefault();
+ if (
+ 'key' in event &&
+ event.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey &&
+ textInputRef.current &&
+ 'selectionStart' in textInputRef.current &&
+ textInputRef.current?.selectionStart === 0 &&
+ valueLength === 0 &&
+ !includeChronos
+ ) {
+ event.preventDefault();
if (lastReportAction) {
- Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html);
+ Report.saveReportActionDraft(reportID, lastReportAction, lastReportAction.message?.at(-1)?.html ?? '');
}
}
},
@@ -398,7 +532,7 @@ function ComposerWithSuggestions({
);
const onChangeText = useCallback(
- (commentValue) => {
+ (commentValue: string) => {
updateComment(commentValue, true);
if (isIOSNative && syncSelectionWithOnChangeTextRef.current) {
@@ -409,7 +543,7 @@ function ComposerWithSuggestions({
InteractionManager.runAfterInteractions(() => {
// note: this implementation is only available on non-web RN, thus the wrapping
// 'if' block contains a redundant (since the ref is only used on iOS) platform check
- textInputRef.current.setSelection(positionSnapshot, positionSnapshot);
+ textInputRef.current?.setSelection(positionSnapshot, positionSnapshot);
});
}
},
@@ -417,8 +551,8 @@ function ComposerWithSuggestions({
);
const onSelectionChange = useCallback(
- (e) => {
- if (textInputRef.current && textInputRef.current.isFocused() && suggestionsRef.current.onSelectionChange(e)) {
+ (e: NativeSyntheticEvent) => {
+ if (textInputRef.current?.isFocused() && suggestionsRef.current?.onSelectionChange?.(e)) {
return;
}
@@ -444,8 +578,7 @@ function ComposerWithSuggestions({
/**
* Focus the composer text input
- * @param {Boolean} [shouldDelay=false] Impose delay before focusing the composer
- * @memberof ReportActionCompose
+ * @param [shouldDelay=false] Impose delay before focusing the composer
*/
const focus = useCallback((shouldDelay = false) => {
focusComposerWithDelay(textInputRef.current)(shouldDelay);
@@ -468,12 +601,12 @@ function ComposerWithSuggestions({
*/
const checkComposerVisibility = useCallback(() => {
// Checking whether the screen is focused or not, helps avoid `modal.isVisible` false when popups are closed, even if the modal is opened.
- const isComposerCoveredUp = !isFocused || EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || modal.isVisible || modal.willAlertModalBecomeVisible;
+ const isComposerCoveredUp = !isFocused || EmojiPickerActions.isEmojiPickerVisible() || isMenuVisible || !!modal?.isVisible || modal?.willAlertModalBecomeVisible;
return !isComposerCoveredUp;
}, [isMenuVisible, modal, isFocused]);
const focusComposerOnKeyPress = useCallback(
- (e) => {
+ (e: KeyboardEvent) => {
const isComposerVisible = checkComposerVisibility();
if (!isComposerVisible) {
return;
@@ -484,7 +617,7 @@ function ComposerWithSuggestions({
}
// if we're typing on another input/text area, do not focus
- if (['INPUT', 'TEXTAREA'].includes(e.target.nodeName)) {
+ if (['INPUT', 'TEXTAREA'].includes((e.target as Element | null)?.nodeName ?? '')) {
return;
}
@@ -519,17 +652,17 @@ function ComposerWithSuggestions({
};
}, [focusComposerOnKeyPress, navigation, setUpComposeFocusManager]);
- const prevIsModalVisible = usePrevious(modal.isVisible);
+ const prevIsModalVisible = usePrevious(modal?.isVisible);
const prevIsFocused = usePrevious(isFocused);
useEffect(() => {
- if (modal.isVisible && !prevIsModalVisible) {
+ if (modal?.isVisible && !prevIsModalVisible) {
// eslint-disable-next-line no-param-reassign
isNextModalWillOpenRef.current = false;
}
// We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused.
// We avoid doing this on native platforms since the software keyboard popping
// open creates a jarring and broken UX.
- if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal.isVisible && isFocused && (prevIsModalVisible || !prevIsFocused))) {
+ if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !modal?.isVisible && isFocused && (!!prevIsModalVisible || !prevIsFocused))) {
return;
}
@@ -538,11 +671,11 @@ function ComposerWithSuggestions({
return;
}
focus(true);
- }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef, shouldAutoFocus]);
+ }, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal?.isVisible, isNextModalWillOpenRef, shouldAutoFocus]);
useEffect(() => {
// Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
- updateMultilineInputRange(textInputRef.current, shouldAutoFocus);
+ updateMultilineInputRange(textInputRef.current, !!shouldAutoFocus);
if (value.length === 0) {
return;
@@ -552,15 +685,14 @@ function ComposerWithSuggestions({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
-
useImperativeHandle(
- forwardedRef,
+ ref,
() => ({
blur,
focus,
replaceSelectionWithText,
prepareCommentAndResetComposer,
- isFocused: () => textInputRef.current.isFocused(),
+ isFocused: () => !!textInputRef.current?.isFocused(),
}),
[blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText],
);
@@ -574,7 +706,7 @@ function ComposerWithSuggestions({
}, [onValueChange, value]);
const onLayout = useCallback(
- (e) => {
+ (e: LayoutChangeEvent) => {
const composerLayoutHeight = e.nativeEvent.layout.height;
if (composerHeight === composerLayoutHeight) {
return;
@@ -594,7 +726,7 @@ function ComposerWithSuggestions({
(
-
-));
-
-ComposerWithSuggestionsWithRef.displayName = 'ComposerWithSuggestionsWithRef';
-
-export default compose(
- withKeyboardState,
- withOnyx({
- numberOfLines: {
- key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`,
- // We might not have number of lines in onyx yet, for which the composer would be rendered as null
- // during the first render, which we want to avoid:
- initWithStoredValues: false,
- },
- modal: {
- key: ONYXKEYS.MODAL,
- },
- preferredSkinTone: {
- key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
- selector: EmojiUtils.getPreferredSkinToneIndex,
- },
- editFocused: {
- key: ONYXKEYS.INPUT_FOCUSED,
- },
- parentReportActions: {
- key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
- canEvict: false,
- initWithStoredValues: false,
- },
- }),
-)(memo(ComposerWithSuggestionsWithRef));
+const ComposerWithSuggestionsWithRef = forwardRef(ComposerWithSuggestions);
+
+export default withOnyx, ComposerWithSuggestionsOnyxProps>({
+ numberOfLines: {
+ key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT_NUMBER_OF_LINES}${reportID}`,
+ // We might not have number of lines in onyx yet, for which the composer would be rendered as null
+ // during the first render, which we want to avoid:
+ initWithStoredValues: false,
+ },
+ modal: {
+ key: ONYXKEYS.MODAL,
+ },
+ preferredSkinTone: {
+ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ selector: EmojiUtils.getPreferredSkinToneIndex,
+ },
+ editFocused: {
+ key: ONYXKEYS.INPUT_FOCUSED,
+ },
+ parentReportActions: {
+ key: ({parentReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`,
+ canEvict: false,
+ initWithStoredValues: false,
+ },
+})(memo(ComposerWithSuggestionsWithRef));
+
+export type {ComposerWithSuggestionsProps};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js
deleted file mode 100644
index 9d05db572949..000000000000
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/composerWithSuggestionsProps.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import PropTypes from 'prop-types';
-import CONST from '@src/CONST';
-
-const propTypes = {
- /** Details about any modals being used */
- modal: PropTypes.shape({
- /** Indicates if there is a modal currently visible or not */
- isVisible: PropTypes.bool,
- }),
-
- /** User's preferred skin tone color */
- preferredSkinTone: PropTypes.number,
-
- /** Number of lines for the composer */
- numberOfLines: PropTypes.number,
-
- /** Whether the keyboard is open or not */
- isKeyboardShown: PropTypes.bool.isRequired,
-
- /** The ID of the report */
- reportID: PropTypes.string.isRequired,
-
- /** Callback when the input is focused */
- onFocus: PropTypes.func.isRequired,
-
- /** Callback when the input is blurred */
- onBlur: PropTypes.func.isRequired,
-
- /** Whether the composer is full size or not */
- isComposerFullSize: PropTypes.bool.isRequired,
-
- /** Whether the menu is visible or not */
- isMenuVisible: PropTypes.bool.isRequired,
-
- /** Placeholder text for the input */
- inputPlaceholder: PropTypes.string.isRequired,
-
- /** Function to display a file in the modal */
- displayFileInModal: PropTypes.func.isRequired,
-
- /** Whether the text input should be cleared or not */
- textInputShouldClear: PropTypes.bool.isRequired,
-
- /** Function to set whether the text input should be cleared or not */
- setTextInputShouldClear: PropTypes.func.isRequired,
-
- /** Whether the user is blocked from concierge or not */
- isBlockedFromConcierge: PropTypes.bool.isRequired,
-
- /** Whether the input is disabled or not */
- disabled: PropTypes.bool,
-
- /** Whether the full composer is available or not */
- isFullComposerAvailable: PropTypes.bool.isRequired,
-
- /** Function to set whether the full composer is available or not */
- setIsFullComposerAvailable: PropTypes.func.isRequired,
-
- /** Function to set whether the comment is empty or not */
- setIsCommentEmpty: PropTypes.func.isRequired,
-
- /** A method to call when the form is submitted */
- handleSendMessage: PropTypes.func.isRequired,
-
- /** Whether the compose input is shown or not */
- shouldShowComposeInput: PropTypes.bool.isRequired,
-
- /** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func,
-
- /** Ref for the suggestions component */
- suggestionsRef: PropTypes.shape({
- current: PropTypes.shape({
- /** Update the shouldShowSuggestionMenuToFalse prop */
- updateShouldShowSuggestionMenuToFalse: PropTypes.func.isRequired,
-
- /** Trigger hotkey actions */
- triggerHotkeyActions: PropTypes.func.isRequired,
-
- /** Check if suggestion calculation should be blocked */
- setShouldBlockSuggestionCalc: PropTypes.func.isRequired,
-
- /** Callback when the selection changes */
- onSelectionChange: PropTypes.func.isRequired,
- }),
- }).isRequired,
-
- /** Ref for the animated view (text input) */
- animatedRef: PropTypes.func.isRequired,
-
- /** Ref for the composer */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- /** Ref for the isNextModalWillOpen */
- isNextModalWillOpenRef: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired,
-
- /** A flag to indicate whether the onScroll callback is likely triggered by a layout change (caused by text change) or not */
- isScrollLikelyLayoutTriggered: PropTypes.shape({current: PropTypes.bool.isRequired}).isRequired,
-
- /** A function that toggles isScrollLikelyLayoutTriggered flag for a certain period of time */
- raiseIsScrollLikelyLayoutTriggered: PropTypes.func.isRequired,
-};
-
-const defaultProps = {
- modal: {},
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- numberOfLines: undefined,
- parentReportActions: {},
- reportActions: [],
- forwardedRef: null,
- measureParentContainer: () => {},
- disabled: false,
-};
-
-export {propTypes, defaultProps};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
similarity index 61%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
index cbbd1758c9cb..7f169ef15918 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx
@@ -1,6 +1,8 @@
-import _ from 'lodash';
-import React, {useEffect} from 'react';
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useEffect} from 'react';
import E2EClient from '@libs/E2E/client';
+import type {ComposerRef} from '@pages/home/report/ReportActionCompose/ReportActionCompose';
+import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions';
import ComposerWithSuggestions from './ComposerWithSuggestions';
let rerenderCount = 0;
@@ -14,20 +16,21 @@ function IncrementRenderCount() {
return null;
}
-const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => {
+function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: ForwardedRef) {
// Eventually Auto focus on e2e tests
useEffect(() => {
- if (_.get(E2EClient.getCurrentActiveTestConfig(), 'reportScreen.autoFocus', false) === false) {
+ const testConfig = E2EClient.getCurrentActiveTestConfig();
+ if (testConfig?.reportScreen && typeof testConfig.reportScreen !== 'string' && !testConfig?.reportScreen.autoFocus) {
return;
}
// We need to wait for the component to be mounted before focusing
setTimeout(() => {
- if (!ref || !ref.current) {
+ if (!(ref && 'current' in ref)) {
return;
}
- ref.current.focus(true);
+ ref.current?.focus(true);
}, 1);
}, [ref]);
@@ -44,9 +47,9 @@ const ComposerWithSuggestionsE2e = React.forwardRef((props, ref) => {
);
-});
+}
ComposerWithSuggestionsE2e.displayName = 'ComposerWithSuggestionsE2e';
-export default ComposerWithSuggestionsE2e;
+export default forwardRef(ComposerWithSuggestionsE2e);
export {getRerenderCount, resetRerenderCount};
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.tsx
similarity index 100%
rename from src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.js
rename to src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.tsx
diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
similarity index 77%
rename from src/pages/home/report/ReportActionCompose/ReportActionCompose.js
rename to src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
index 4bbf3d393213..1e0e322be258 100644
--- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx
@@ -1,25 +1,29 @@
import {PortalHost} from '@gorhom/portal';
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
+import type {SyntheticEvent} from 'react';
import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native';
import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated';
-import _ from 'underscore';
+import type {Emoji} from '@assets/emojis/types';
+import type {FileObject} from '@components/AttachmentModal';
import AttachmentModal from '@components/AttachmentModal';
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
import ExceededCommentLength from '@components/ExceededCommentLength';
+import type {Mention} from '@components/MentionSuggestions';
import OfflineIndicator from '@components/OfflineIndicator';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
-import {usePersonalDetails, withNetwork} from '@components/OnyxProvider';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
+import {usePersonalDetails} from '@components/OnyxProvider';
+import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
+import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import useDebounce from '@hooks/useDebounce';
import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength';
import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
-import compose from '@libs/compose';
import getDraftComment from '@libs/ComposerUtils/getDraftComment';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import getModalState from '@libs/getModalState';
@@ -29,63 +33,59 @@ import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutsi
import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime';
import ReportDropUI from '@pages/home/report/ReportDropUI';
import ReportTypingIndicator from '@pages/home/report/ReportTypingIndicator';
-import reportPropTypes from '@pages/reportPropTypes';
import * as EmojiPickerActions from '@userActions/EmojiPickerAction';
import * as Report from '@userActions/Report';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
+import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
import AttachmentPickerWithMenuItems from './AttachmentPickerWithMenuItems';
import ComposerWithSuggestions from './ComposerWithSuggestions';
+import type {ComposerWithSuggestionsProps} from './ComposerWithSuggestions/ComposerWithSuggestions';
import SendButton from './SendButton';
-const propTypes = {
- /** A method to call when the form is submitted */
- onSubmit: PropTypes.func.isRequired,
-
- /** The ID of the report actions will be created for */
- reportID: PropTypes.string.isRequired,
-
- /** The report currently being looked at */
- report: reportPropTypes,
-
- /** Is composer full size */
- isComposerFullSize: PropTypes.bool,
-
- /** Whether user interactions should be disabled */
- disabled: PropTypes.bool,
+type ComposerRef = {
+ blur: () => void;
+ focus: (shouldDelay?: boolean) => void;
+ replaceSelectionWithText: (text: string, shouldAddTrailSpace: boolean) => void;
+ prepareCommentAndResetComposer: () => string;
+ isFocused: () => boolean;
+};
- /** Height of the list which the composer is part of */
- listHeight: PropTypes.number,
+type SuggestionsRef = {
+ resetSuggestions: () => void;
+ onSelectionChange?: (event: NativeSyntheticEvent) => void;
+ triggerHotkeyActions: (event: KeyboardEvent) => boolean | undefined;
+ updateShouldShowSuggestionMenuToFalse: (shouldShowSuggestionMenu?: boolean) => void;
+ setShouldBlockSuggestionCalc: (shouldBlock: boolean) => void;
+ getSuggestions: () => Mention[] | Emoji[];
+};
- // The NVP describing a user's block status
- blockedFromConcierge: PropTypes.shape({
- // The date that the user will be unblocked
- expiresAt: PropTypes.string,
- }),
+type ReportActionComposeOnyxProps = {
+ /** The NVP describing a user's block status */
+ blockedFromConcierge: OnyxEntry;
/** Whether the composer input should be shown */
- shouldShowComposeInput: PropTypes.bool,
+ shouldShowComposeInput: OnyxEntry;
+};
- /** The type of action that's pending */
- pendingAction: PropTypes.oneOf(['add', 'update', 'delete']),
+type ReportActionComposeProps = ReportActionComposeOnyxProps &
+ WithCurrentUserPersonalDetailsProps &
+ Pick & {
+ /** A method to call when the form is submitted */
+ onSubmit: (newComment: string | undefined) => void;
- /** /** Whetjer the report is ready for display */
- isReportReadyForDisplay: PropTypes.bool,
- ...withCurrentUserPersonalDetailsPropTypes,
-};
+ /** The report currently being looked at */
+ report: OnyxEntry;
-const defaultProps = {
- report: {},
- blockedFromConcierge: {},
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- isComposerFullSize: false,
- pendingAction: null,
- shouldShowComposeInput: true,
- listHeight: 0,
- isReportReadyForDisplay: true,
- ...withCurrentUserPersonalDetailsDefaultProps,
-};
+ /** The type of action that's pending */
+ pendingAction?: OnyxCommon.PendingAction;
+
+ /** Whether the report is ready for display */
+ isReportReadyForDisplay?: boolean;
+ };
// We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
// prevent auto focus on existing chat for mobile device
@@ -95,25 +95,25 @@ const willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutsideFunc();
function ReportActionCompose({
blockedFromConcierge,
- currentUserPersonalDetails,
+ currentUserPersonalDetails = {},
disabled,
- isComposerFullSize,
- network,
+ isComposerFullSize = false,
onSubmit,
pendingAction,
report,
reportID,
+ listHeight = 0,
+ shouldShowComposeInput = true,
+ isReportReadyForDisplay = true,
isEmptyChat,
lastReportAction,
- listHeight,
- shouldShowComposeInput,
- isReportReadyForDisplay,
-}) {
+}: ReportActionComposeProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions();
+ const {isOffline} = useNetwork();
const animatedRef = useAnimatedRef();
- const actionButtonRef = useRef(null);
+ const actionButtonRef = useRef(null);
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
/**
@@ -121,7 +121,7 @@ function ReportActionCompose({
*/
const [isFocused, setIsFocused] = useState(() => {
const initialModalState = getModalState();
- return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState.isVisible && !initialModalState.willAlertModalBecomeVisible;
+ return shouldFocusInputOnScreenFocus && shouldShowComposeInput && !initialModalState?.isVisible && !initialModalState?.willAlertModalBecomeVisible;
});
const [isFullComposerAvailable, setIsFullComposerAvailable] = useState(isComposerFullSize);
@@ -166,11 +166,11 @@ function ReportActionCompose({
*/
const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength();
- const suggestionsRef = useRef(null);
- const composerRef = useRef(null);
+ const suggestionsRef = useRef(null);
+ const composerRef = useRef(null);
const reportParticipantIDs = useMemo(
- () => _.without(lodashGet(report, 'participantAccountIDs', []), currentUserPersonalDetails.accountID),
+ () => report?.participantAccountIDs?.filter((accountID) => accountID !== currentUserPersonalDetails.accountID),
[currentUserPersonalDetails.accountID, report],
);
@@ -179,13 +179,13 @@ function ReportActionCompose({
[personalDetails, report, currentUserPersonalDetails.accountID, isComposerFullSize],
);
- const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report.participantAccountIDs}), [report.participantAccountIDs]);
+ const includesConcierge = useMemo(() => ReportUtils.chatIncludesConcierge({participantAccountIDs: report?.participantAccountIDs}), [report?.participantAccountIDs]);
const userBlockedFromConcierge = useMemo(() => User.isBlockedFromConcierge(blockedFromConcierge), [blockedFromConcierge]);
const isBlockedFromConcierge = useMemo(() => includesConcierge && userBlockedFromConcierge, [includesConcierge, userBlockedFromConcierge]);
// If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions
const conciergePlaceholderRandomIndex = useMemo(
- () => _.random(translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1)),
+ () => Math.floor(Math.random() * (translate('reportActionCompose.conciergePlaceholderOptions').length - (isSmallScreenWidth ? 4 : 1) + 1)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
@@ -204,7 +204,7 @@ function ReportActionCompose({
}, [includesConcierge, translate, userBlockedFromConcierge, conciergePlaceholderRandomIndex]);
const focus = () => {
- if (composerRef === null || composerRef.current === null) {
+ if (composerRef.current === null) {
return;
}
composerRef.current.focus(true);
@@ -220,9 +220,9 @@ function ReportActionCompose({
isKeyboardVisibleWhenShowingModalRef.current = false;
}, []);
- const containerRef = useRef(null);
+ const containerRef = useRef(null);
const measureContainer = useCallback(
- (callback) => {
+ (callback: MeasureInWindowOnSuccessCallback) => {
if (!containerRef.current) {
return;
}
@@ -235,9 +235,9 @@ function ReportActionCompose({
const onAddActionPressed = useCallback(() => {
if (!willBlurTextInputOnTapOutside) {
- isKeyboardVisibleWhenShowingModalRef.current = composerRef.current.isFocused();
+ isKeyboardVisibleWhenShowingModalRef.current = !!composerRef.current?.isFocused();
}
- composerRef.current.blur();
+ composerRef.current?.blur();
}, []);
const onItemSelected = useCallback(() => {
@@ -251,13 +251,10 @@ function ReportActionCompose({
suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false);
}, []);
- /**
- * @param {Object} file
- */
const addAttachment = useCallback(
- (file) => {
+ (file: FileObject) => {
playSound(SOUNDS.DONE);
- const newComment = composerRef.current.prepareCommentAndResetComposer();
+ const newComment = composerRef?.current?.prepareCommentAndResetComposer();
Report.addAttachment(reportID, file, newComment);
setTextInputShouldClear(false);
},
@@ -275,16 +272,12 @@ function ReportActionCompose({
/**
* Add a new comment to this chat
- *
- * @param {SyntheticEvent} [e]
*/
const submitForm = useCallback(
- (e) => {
- if (e) {
- e.preventDefault();
- }
+ (event?: SyntheticEvent) => {
+ event?.preventDefault();
- const newComment = composerRef.current.prepareCommentAndResetComposer();
+ const newComment = composerRef.current?.prepareCommentAndResetComposer();
if (!newComment) {
return;
}
@@ -300,12 +293,13 @@ function ReportActionCompose({
isKeyboardVisibleWhenShowingModalRef.current = true;
}, []);
- const onBlur = useCallback((e) => {
+ const onBlur = useCallback((event: NativeSyntheticEvent) => {
+ const webEvent = event as unknown as FocusEvent;
setIsFocused(false);
if (suggestionsRef.current) {
suggestionsRef.current.resetSuggestions();
}
- if (e.relatedTarget && e.relatedTarget === actionButtonRef.current) {
+ if (webEvent.relatedTarget && webEvent.relatedTarget === actionButtonRef.current) {
isKeyboardVisibleWhenShowingModalRef.current = true;
}
}, []);
@@ -326,7 +320,7 @@ function ReportActionCompose({
// We are returning a callback here as we want to incoke the method on unmount only
useEffect(
() => () => {
- if (!EmojiPickerActions.isActive(report.reportID)) {
+ if (!EmojiPickerActions.isActive(report?.reportID ?? '')) {
return;
}
EmojiPickerActions.hideEmojiPicker();
@@ -339,9 +333,9 @@ function ReportActionCompose({
const reportRecipient = personalDetails[reportRecipientAcountIDs[0]];
const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused;
- const hasReportRecipient = _.isObject(reportRecipient) && !_.isEmpty(reportRecipient);
+ const hasReportRecipient = !isEmptyObject(reportRecipient);
- const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || disabled || hasExceededMaxCommentLength;
+ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength;
const handleSendMessage = useCallback(() => {
'worklet';
@@ -365,7 +359,7 @@ function ReportActionCompose({
}, [styles]);
return (
-
+
{shouldShowReportRecipientLocalTime && hasReportRecipient && }
@@ -402,7 +396,7 @@ function ReportActionCompose({
isFullComposerAvailable={isFullComposerAvailable}
isComposerFullSize={isComposerFullSize}
isBlockedFromConcierge={isBlockedFromConcierge}
- disabled={disabled}
+ disabled={!!disabled}
setMenuVisibility={setMenuVisibility}
isMenuVisible={isMenuVisible}
onTriggerAttachmentPicker={onTriggerAttachmentPicker}
@@ -424,9 +418,9 @@ function ReportActionCompose({
isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered}
raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered}
reportID={reportID}
- parentReportID={report.parentReportID}
- parentReportActionID={report.parentReportActionID}
- includesChronos={ReportUtils.chatIncludesChronos(report)}
+ parentReportID={report?.parentReportID}
+ parentReportActionID={report?.parentReportActionID}
+ includeChronos={ReportUtils.chatIncludesChronos(report)}
isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
isMenuVisible={isMenuVisible}
@@ -436,7 +430,7 @@ function ReportActionCompose({
textInputShouldClear={textInputShouldClear}
setTextInputShouldClear={setTextInputShouldClear}
isBlockedFromConcierge={isBlockedFromConcierge}
- disabled={disabled}
+ disabled={!!disabled}
isFullComposerAvailable={isFullComposerAvailable}
setIsFullComposerAvailable={setIsFullComposerAvailable}
setIsCommentEmpty={setIsCommentEmpty}
@@ -454,12 +448,12 @@ function ReportActionCompose({
}}
/>
{
+ onDrop={(event: DragEvent) => {
if (isAttachmentPreviewActive) {
return;
}
- const data = lodashGet(e, ['dataTransfer', 'items', 0]);
- displayFileInModal(data);
+ const data = event.dataTransfer?.items[0];
+ displayFileInModal(data as unknown as FileObject);
}}
/>
>
@@ -469,8 +463,9 @@ function ReportActionCompose({
composerRef.current.replaceSelectionWithText(...args)}
- emojiPickerID={report.reportID}
+ // @ts-expect-error TODO: Remove this once EmojiPickerButton (https://github.com/Expensify/App/issues/25155) is migrated to TypeScript.
+ onEmojiSelected={(...args) => composerRef.current?.replaceSelectionWithText(...args)}
+ emojiPickerID={report?.reportID}
shiftVertical={emojiShiftVertical}
/>
)}
@@ -484,7 +479,7 @@ function ReportActionCompose({
styles.flexRow,
styles.justifyContentBetween,
styles.alignItemsCenter,
- (!isSmallScreenWidth || (isSmallScreenWidth && !network.isOffline)) && styles.chatItemComposeSecondaryRow,
+ (!isSmallScreenWidth || (isSmallScreenWidth && !isOffline)) && styles.chatItemComposeSecondaryRow,
]}
>
{!isSmallScreenWidth && }
@@ -497,19 +492,17 @@ function ReportActionCompose({
);
}
-ReportActionCompose.propTypes = propTypes;
-ReportActionCompose.defaultProps = defaultProps;
ReportActionCompose.displayName = 'ReportActionCompose';
-export default compose(
- withNetwork(),
- withCurrentUserPersonalDetails,
- withOnyx({
+export default withCurrentUserPersonalDetails(
+ withOnyx({
blockedFromConcierge: {
key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
},
shouldShowComposeInput: {
key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT,
},
- }),
-)(memo(ReportActionCompose));
+ })(memo(ReportActionCompose)),
+);
+
+export type {SuggestionsRef, ComposerRef};
diff --git a/src/pages/home/report/ReportActionCompose/SendButton.js b/src/pages/home/report/ReportActionCompose/SendButton.tsx
similarity index 87%
rename from src/pages/home/report/ReportActionCompose/SendButton.js
rename to src/pages/home/report/ReportActionCompose/SendButton.tsx
index e9e3ef244f9c..c505eb0e32e7 100644
--- a/src/pages/home/report/ReportActionCompose/SendButton.js
+++ b/src/pages/home/report/ReportActionCompose/SendButton.tsx
@@ -1,4 +1,3 @@
-import PropTypes from 'prop-types';
import React, {memo} from 'react';
import {View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
@@ -11,24 +10,22 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
-const propTypes = {
+type SendButtonProps = {
/** Whether the button is disabled */
- isDisabled: PropTypes.bool.isRequired,
+ isDisabled: boolean;
/** Handle clicking on send button */
- handleSendMessage: PropTypes.func.isRequired,
+ handleSendMessage: () => void;
};
-function SendButton({isDisabled: isDisabledProp, handleSendMessage}) {
+function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
const {translate} = useLocalize();
- const Tap = Gesture.Tap()
- .enabled()
- .onEnd(() => {
- handleSendMessage();
- });
+ const Tap = Gesture.Tap().onEnd(() => {
+ handleSendMessage();
+ });
return (
{
- updateComment(comment);
+ updateComment(comment ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount
}, []);
return null;
}
-SilentCommentUpdater.propTypes = propTypes;
-SilentCommentUpdater.defaultProps = defaultProps;
SilentCommentUpdater.displayName = 'SilentCommentUpdater';
-export default withOnyx({
+export default withOnyx({
comment: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
- initialValue: '',
},
})(SilentCommentUpdater);
diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
similarity index 72%
rename from src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js
rename to src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
index 23d69ec7defc..1abc6567bc7b 100644
--- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.js
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx
@@ -1,47 +1,23 @@
-import PropTypes from 'prop-types';
import {useEffect} from 'react';
import {withOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import ONYXKEYS from '@src/ONYXKEYS';
-
-const propTypes = {
- /** The comment of the report */
- comment: PropTypes.string,
-
- /** The value of the comment */
- value: PropTypes.string.isRequired,
-
- /** The ref of the comment */
- commentRef: PropTypes.shape({
- /** The current value of the comment */
- current: PropTypes.string,
- }).isRequired,
-
- /** Updates the comment */
- updateComment: PropTypes.func.isRequired,
-
- reportID: PropTypes.string.isRequired,
-};
-
-const defaultProps = {
- comment: '',
-};
+import type {SilentCommentUpdaterOnyxProps, SilentCommentUpdaterProps} from './types';
/**
* This component doesn't render anything. It runs a side effect to update the comment of a report under certain conditions.
* It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid
* re-rendering a UI component for that. That's why the side effect was moved down to a separate component.
- * @returns {null}
*/
-function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}) {
+function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment}: SilentCommentUpdaterProps) {
const prevCommentProp = usePrevious(comment);
const prevReportId = usePrevious(reportID);
const {preferredLocale} = useLocalize();
const prevPreferredLocale = usePrevious(preferredLocale);
useEffect(() => {
- updateComment(comment);
+ updateComment(comment ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps -- We need to run this on mount
}, []);
@@ -56,17 +32,15 @@ function SilentCommentUpdater({comment, commentRef, reportID, value, updateComme
return;
}
- updateComment(comment);
+ updateComment(comment ?? '');
}, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef]);
return null;
}
-SilentCommentUpdater.propTypes = propTypes;
-SilentCommentUpdater.defaultProps = defaultProps;
SilentCommentUpdater.displayName = 'SilentCommentUpdater';
-export default withOnyx({
+export default withOnyx({
comment: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
initialValue: '',
diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts
new file mode 100644
index 000000000000..dbc23b0279c3
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts
@@ -0,0 +1,22 @@
+import type {OnyxEntry} from 'react-native-onyx';
+
+type SilentCommentUpdaterOnyxProps = {
+ /** The comment of the report */
+ comment: OnyxEntry;
+};
+
+type SilentCommentUpdaterProps = SilentCommentUpdaterOnyxProps & {
+ /** Updates the comment */
+ updateComment: (comment: string) => void;
+
+ /** The ID of the report associated with the comment */
+ reportID: string;
+
+ /** The value of the comment */
+ value: string;
+
+ /** The ref of the comment */
+ commentRef: React.RefObject;
+};
+
+export type {SilentCommentUpdaterProps, SilentCommentUpdaterOnyxProps};
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
similarity index 72%
rename from src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
rename to src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
index b075740a3f4f..0ae45d2d705d 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx
@@ -1,7 +1,8 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
+import type {ForwardedRef, RefAttributes} from 'react';
+import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
+import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {Emoji} from '@assets/emojis/types';
import EmojiSuggestions from '@components/EmojiSuggestions';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useLocalize from '@hooks/useLocalize';
@@ -9,61 +10,59 @@ import * as EmojiUtils from '@libs/EmojiUtils';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import * as SuggestionProps from './suggestionProps';
+import {isEmptyObject} from '@src/types/utils/EmptyObject';
+import type {SuggestionsRef} from './ReportActionCompose';
+import type {SuggestionProps} from './Suggestions';
+
+type SuggestionsValue = {
+ suggestedEmojis: Emoji[];
+ colonIndex: number;
+ shouldShowSuggestionMenu: boolean;
+};
+
+type SuggestionEmojiOnyxProps = {
+ /** Preferred skin tone */
+ preferredSkinTone: number;
+};
+
+type SuggestionEmojiProps = SuggestionProps &
+ SuggestionEmojiOnyxProps & {
+ /** Function to clear the input */
+ resetKeyboardInput?: () => void;
+ };
/**
* Check if this piece of string looks like an emoji
- * @param {String} str
- * @param {Number} pos
- * @returns {Boolean}
*/
-const isEmojiCode = (str, pos) => {
+const isEmojiCode = (str: string, pos: number): boolean => {
const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI);
- const leftWord = _.last(leftWords);
+ const leftWord = leftWords.at(-1) ?? '';
return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2;
};
-const defaultSuggestionsValues = {
+const defaultSuggestionsValues: SuggestionsValue = {
suggestedEmojis: [],
- colonSignIndex: -1,
+ colonIndex: -1,
shouldShowSuggestionMenu: false,
};
-const propTypes = {
- /** Preferred skin tone */
- preferredSkinTone: PropTypes.number,
-
- /** A ref to this component */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- /** Function to clear the input */
- resetKeyboardInput: PropTypes.func.isRequired,
-
- ...SuggestionProps.baseProps,
-};
-
-const defaultProps = {
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
- forwardedRef: null,
-};
-
-function SuggestionEmoji({
- preferredSkinTone,
- value,
- setValue,
- selection,
- setSelection,
- updateComment,
- isComposerFullSize,
- isAutoSuggestionPickerLarge,
- forwardedRef,
- resetKeyboardInput,
- measureParentContainer,
- isComposerFocused,
-}) {
+function SuggestionEmoji(
+ {
+ preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE,
+ value,
+ selection,
+ setSelection,
+ updateComment,
+ isAutoSuggestionPickerLarge,
+ resetKeyboardInput,
+ measureParentContainer,
+ isComposerFocused,
+ }: SuggestionEmojiProps,
+ ref: ForwardedRef,
+) {
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
- const isEmojiSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedEmojis) && suggestionValues.shouldShowSuggestionMenu;
+ const isEmojiSuggestionsMenuVisible = suggestionValues.suggestedEmojis.length > 0 && suggestionValues.shouldShowSuggestionMenu;
const [highlightedEmojiIndex, setHighlightedEmojiIndex] = useArrowKeyFocusManager({
isActive: isEmojiSuggestionsMenuVisible,
@@ -81,10 +80,10 @@ function SuggestionEmoji({
* @param {Number} selectedEmoji
*/
const insertSelectedEmoji = useCallback(
- (highlightedEmojiIndexInner) => {
+ (highlightedEmojiIndexInner: number) => {
const commentBeforeColon = value.slice(0, suggestionValues.colonIndex);
const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner];
- const emojiCode = emojiObject.types && emojiObject.types[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code;
+ const emojiCode = emojiObject.types?.[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code;
const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end);
updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true);
@@ -92,7 +91,7 @@ function SuggestionEmoji({
// In some Android phones keyboard, the text to search for the emoji is not cleared
// will be added after the user starts typing again on the keyboard. This package is
// a workaround to reset the keyboard natively.
- resetKeyboardInput();
+ resetKeyboardInput?.();
setSelection({
start: suggestionValues.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
@@ -121,11 +120,9 @@ function SuggestionEmoji({
/**
* Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
*/
const triggerHotkeyActions = useCallback(
- (e) => {
+ (e: KeyboardEvent) => {
const suggestionsExist = suggestionValues.suggestedEmojis.length > 0;
if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
@@ -153,7 +150,7 @@ function SuggestionEmoji({
* Calculates and cares about the content of an Emoji Suggester
*/
const calculateEmojiSuggestion = useCallback(
- (selectionEnd) => {
+ (selectionEnd: number) => {
if (shouldBlockCalc.current || !value) {
shouldBlockCalc.current = false;
resetSuggestions();
@@ -163,16 +160,16 @@ function SuggestionEmoji({
const colonIndex = leftString.lastIndexOf(':');
const isCurrentlyShowingEmojiSuggestion = isEmojiCode(value, selectionEnd);
- const nextState = {
+ const nextState: SuggestionsValue = {
suggestedEmojis: [],
colonIndex,
shouldShowSuggestionMenu: false,
};
const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString, preferredLocale);
- if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) {
+ if (newSuggestedEmojis?.length && isCurrentlyShowingEmojiSuggestion) {
nextState.suggestedEmojis = newSuggestedEmojis;
- nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis);
+ nextState.shouldShowSuggestionMenu = !isEmptyObject(newSuggestedEmojis);
}
setSuggestionValues((prevState) => ({...prevState, ...nextState}));
@@ -189,7 +186,7 @@ function SuggestionEmoji({
}, [selection, calculateEmojiSuggestion, isComposerFocused]);
const onSelectionChange = useCallback(
- (e) => {
+ (e: NativeSyntheticEvent) => {
/**
* we pass here e.nativeEvent.selection.end directly to calculateEmojiSuggestion
* because in other case calculateEmojiSuggestion will have an old calculation value
@@ -201,7 +198,7 @@ function SuggestionEmoji({
);
const setShouldBlockSuggestionCalc = useCallback(
- (shouldBlockSuggestionCalc) => {
+ (shouldBlockSuggestionCalc: boolean) => {
shouldBlockCalc.current = shouldBlockSuggestionCalc;
},
[shouldBlockCalc],
@@ -209,12 +206,8 @@ function SuggestionEmoji({
const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues]);
- const resetEmojiSuggestions = useCallback(() => {
- setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}));
- }, []);
-
useImperativeHandle(
- forwardedRef,
+ ref,
() => ({
resetSuggestions,
onSelectionChange,
@@ -232,39 +225,22 @@ function SuggestionEmoji({
return (
);
}
-SuggestionEmoji.propTypes = propTypes;
-SuggestionEmoji.defaultProps = defaultProps;
SuggestionEmoji.displayName = 'SuggestionEmoji';
-const SuggestionEmojiWithRef = React.forwardRef((props, ref) => (
-
-));
-
-SuggestionEmojiWithRef.displayName = 'SuggestionEmojiWithRef';
-
-export default withOnyx({
+export default withOnyx, SuggestionEmojiOnyxProps>({
preferredSkinTone: {
key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
selector: EmojiUtils.getPreferredSkinToneIndex,
},
-})(SuggestionEmojiWithRef);
+})(forwardRef(SuggestionEmoji));
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
similarity index 73%
rename from src/pages/home/report/ReportActionCompose/SuggestionMention.js
rename to src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
index 6345ebf89185..ac52c06ee084 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
@@ -1,7 +1,8 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
-import _ from 'underscore';
+import lodashSortBy from 'lodash/sortBy';
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import * as Expensicons from '@components/Icon/Expensicons';
+import type {Mention} from '@components/MentionSuggestions';
import MentionSuggestions from '@components/MentionSuggestions';
import {usePersonalDetails} from '@components/OnyxProvider';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
@@ -11,52 +12,39 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import * as UserUtils from '@libs/UserUtils';
import CONST from '@src/CONST';
-import * as SuggestionProps from './suggestionProps';
+import type {PersonalDetailsList} from '@src/types/onyx';
+import type {SuggestionsRef} from './ReportActionCompose';
+import type {SuggestionProps} from './Suggestions';
+
+type SuggestionValues = {
+ suggestedMentions: Mention[];
+ atSignIndex: number;
+ shouldShowSuggestionMenu: boolean;
+ mentionPrefix: string;
+};
/**
* Check if this piece of string looks like a mention
- * @param {String} str
- * @returns {Boolean}
*/
-const isMentionCode = (str) => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str);
+const isMentionCode = (str: string): boolean => CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str);
-const defaultSuggestionsValues = {
+const defaultSuggestionsValues: SuggestionValues = {
suggestedMentions: [],
atSignIndex: -1,
shouldShowSuggestionMenu: false,
mentionPrefix: '',
};
-const propTypes = {
- /** A ref to this component */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- ...SuggestionProps.implementationBaseProps,
-};
-
-const defaultProps = {
- forwardedRef: null,
-};
-
-function SuggestionMention({
- value,
- setValue,
- selection,
- setSelection,
- isComposerFullSize,
- updateComment,
- composerHeight,
- forwardedRef,
- isAutoSuggestionPickerLarge,
- measureParentContainer,
- isComposerFocused,
-}) {
- const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
+function SuggestionMention(
+ {value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainer, isComposerFocused}: SuggestionProps,
+ ref: ForwardedRef,
+) {
+ const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
const {translate, formatPhoneNumber} = useLocalize();
const previousValue = usePrevious(value);
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
- const isMentionSuggestionsMenuVisible = !_.isEmpty(suggestionValues.suggestedMentions) && suggestionValues.shouldShowSuggestionMenu;
+ const isMentionSuggestionsMenuVisible = !!suggestionValues.suggestedMentions.length && suggestionValues.shouldShowSuggestionMenu;
const [highlightedMentionIndex, setHighlightedMentionIndex] = useArrowKeyFocusManager({
isActive: isMentionSuggestionsMenuVisible,
@@ -69,10 +57,9 @@ function SuggestionMention({
/**
* Replace the code of mention and update selection
- * @param {Number} highlightedMentionIndex
*/
const insertSelectedMention = useCallback(
- (highlightedMentionIndexInner) => {
+ (highlightedMentionIndexInner: number) => {
const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner];
const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.login}`;
@@ -100,23 +87,21 @@ function SuggestionMention({
/**
* Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
*/
const triggerHotkeyActions = useCallback(
- (e) => {
+ (event: KeyboardEvent) => {
const suggestionsExist = suggestionValues.suggestedMentions.length > 0;
- if (((!e.shiftKey && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
- e.preventDefault();
+ if (((!event.shiftKey && event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) || event.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) {
+ event.preventDefault();
if (suggestionValues.suggestedMentions.length > 0) {
insertSelectedMention(highlightedMentionIndex);
return true;
}
}
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
- e.preventDefault();
+ if (event.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
+ event.preventDefault();
if (suggestionsExist) {
resetSuggestions();
@@ -129,7 +114,7 @@ function SuggestionMention({
);
const getMentionOptions = useCallback(
- (personalDetailsParam, searchValue = '') => {
+ (personalDetailsParam: PersonalDetailsList, searchValue = ''): Mention[] => {
const suggestions = [];
if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue.toLowerCase())) {
@@ -139,15 +124,15 @@ function SuggestionMention({
icons: [
{
source: Expensicons.Megaphone,
- type: 'avatar',
+ type: CONST.ICON_TYPE_AVATAR,
},
],
});
}
- const filteredPersonalDetails = _.filter(_.values(personalDetailsParam), (detail) => {
+ const filteredPersonalDetails = Object.values(personalDetailsParam ?? {}).filter((detail) => {
// If we don't have user's primary login, that member is not known to the current user and hence we do not allow them to be mentioned
- if (!detail.login || detail.isOptimisticPersonalDetail) {
+ if (!detail?.login || detail.isOptimisticPersonalDetail) {
return false;
}
// We don't want to mention system emails like notifications@expensify.com
@@ -162,18 +147,19 @@ function SuggestionMention({
return true;
});
- const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login);
- _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length), (detail) => {
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing cannot be used if left side can be empty string
+ const sortedPersonalDetails = lodashSortBy(filteredPersonalDetails, (detail) => detail?.displayName || detail?.login);
+ sortedPersonalDetails.slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS - suggestions.length).forEach((detail) => {
suggestions.push({
text: PersonalDetailsUtils.getDisplayNameOrDefault(detail),
- alternateText: formatPhoneNumber(detail.login),
- login: detail.login,
+ alternateText: formatPhoneNumber(detail?.login ?? ''),
+ login: detail?.login,
icons: [
{
- name: detail.login,
- source: UserUtils.getAvatar(detail.avatar, detail.accountID),
- type: 'avatar',
- fallbackIcon: detail.fallbackIcon,
+ name: detail?.login,
+ source: UserUtils.getAvatar(detail?.avatar, detail?.accountID),
+ type: CONST.ICON_TYPE_AVATAR,
+ fallbackIcon: detail?.fallbackIcon,
},
],
});
@@ -185,7 +171,7 @@ function SuggestionMention({
);
const calculateMentionSuggestion = useCallback(
- (selectionEnd) => {
+ (selectionEnd: number) => {
if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) {
shouldBlockCalc.current = false;
resetSuggestions();
@@ -206,12 +192,12 @@ function SuggestionMention({
const afterLastBreakLineIndex = value.lastIndexOf('\n', selectionEnd - 1) + 1;
const leftString = value.substring(afterLastBreakLineIndex, suggestionEndIndex);
const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI);
- const lastWord = _.last(words);
+ const lastWord: string = words.at(-1) ?? '';
const secondToLastWord = words[words.length - 3];
- let atSignIndex;
- let suggestionWord;
- let prefix;
+ let atSignIndex: number | undefined;
+ let suggestionWord = '';
+ let prefix: string;
// Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it)
if (lastWord.startsWith('@')) {
@@ -228,7 +214,7 @@ function SuggestionMention({
prefix = lastWord.substring(1);
}
- const nextState = {
+ const nextState: Partial = {
suggestedMentions: [],
atSignIndex,
mentionPrefix: prefix,
@@ -240,7 +226,7 @@ function SuggestionMention({
const suggestions = getMentionOptions(personalDetails, prefix);
nextState.suggestedMentions = suggestions;
- nextState.shouldShowSuggestionMenu = !_.isEmpty(suggestions);
+ nextState.shouldShowSuggestionMenu = !!suggestions.length;
}
setSuggestionValues((prevState) => ({
@@ -273,20 +259,16 @@ function SuggestionMention({
}, []);
const setShouldBlockSuggestionCalc = useCallback(
- (shouldBlockSuggestionCalc) => {
+ (shouldBlockSuggestionCalc: boolean) => {
shouldBlockCalc.current = shouldBlockSuggestionCalc;
},
[shouldBlockCalc],
);
- const onClose = useCallback(() => {
- setSuggestionValues((prevState) => ({...prevState, suggestedMentions: []}));
- }, []);
-
const getSuggestions = useCallback(() => suggestionValues.suggestedMentions, [suggestionValues]);
useImperativeHandle(
- forwardedRef,
+ ref,
() => ({
resetSuggestions,
triggerHotkeyActions,
@@ -303,34 +285,16 @@ function SuggestionMention({
return (
);
}
-SuggestionMention.propTypes = propTypes;
-SuggestionMention.defaultProps = defaultProps;
SuggestionMention.displayName = 'SuggestionMention';
-const SuggestionMentionWithRef = React.forwardRef((props, ref) => (
-
-));
-
-SuggestionMentionWithRef.displayName = 'SuggestionMentionWithRef';
-
-export default SuggestionMentionWithRef;
+export default forwardRef(SuggestionMention);
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.js b/src/pages/home/report/ReportActionCompose/Suggestions.js
deleted file mode 100644
index 5dc71fec6419..000000000000
--- a/src/pages/home/report/ReportActionCompose/Suggestions.js
+++ /dev/null
@@ -1,169 +0,0 @@
-import PropTypes from 'prop-types';
-import React, {useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react';
-import {View} from 'react-native';
-import {DragAndDropContext} from '@components/DragAndDrop/Provider';
-import usePrevious from '@hooks/usePrevious';
-import SuggestionEmoji from './SuggestionEmoji';
-import SuggestionMention from './SuggestionMention';
-import * as SuggestionProps from './suggestionProps';
-
-const propTypes = {
- /** A ref to this component */
- forwardedRef: PropTypes.shape({current: PropTypes.shape({})}),
-
- /** Function to clear the input */
- resetKeyboardInput: PropTypes.func.isRequired,
-
- /** Is auto suggestion picker large */
- isAutoSuggestionPickerLarge: PropTypes.bool,
-
- ...SuggestionProps.baseProps,
-};
-
-const defaultProps = {
- forwardedRef: null,
- isAutoSuggestionPickerLarge: true,
-};
-
-/**
- * This component contains the individual suggestion components.
- * If you want to add a new suggestion type, add it here.
- *
- * @returns {React.Component}
- */
-function Suggestions({
- isComposerFullSize,
- value,
- setValue,
- selection,
- setSelection,
- updateComment,
- composerHeight,
- forwardedRef,
- resetKeyboardInput,
- measureParentContainer,
- isAutoSuggestionPickerLarge,
- isComposerFocused,
-}) {
- const suggestionEmojiRef = useRef(null);
- const suggestionMentionRef = useRef(null);
- const {isDraggingOver} = useContext(DragAndDropContext);
- const prevIsDraggingOver = usePrevious(isDraggingOver);
-
- const getSuggestions = useCallback(() => {
- if (suggestionEmojiRef.current && suggestionEmojiRef.current.getSuggestions) {
- const emojiSuggestions = suggestionEmojiRef.current.getSuggestions();
- if (emojiSuggestions.length > 0) {
- return emojiSuggestions;
- }
- }
-
- if (suggestionMentionRef.current && suggestionMentionRef.current.getSuggestions) {
- const mentionSuggestions = suggestionMentionRef.current.getSuggestions();
- if (mentionSuggestions.length > 0) {
- return mentionSuggestions;
- }
- }
-
- return [];
- }, []);
-
- /**
- * Clean data related to EmojiSuggestions
- */
- const resetSuggestions = useCallback(() => {
- suggestionEmojiRef.current.resetSuggestions();
- suggestionMentionRef.current.resetSuggestions();
- }, []);
-
- /**
- * Listens for keyboard shortcuts and applies the action
- *
- * @param {Object} e
- */
- const triggerHotkeyActions = useCallback((e) => {
- const emojiHandler = suggestionEmojiRef.current.triggerHotkeyActions(e);
- const mentionHandler = suggestionMentionRef.current.triggerHotkeyActions(e);
- return emojiHandler || mentionHandler;
- }, []);
-
- const onSelectionChange = useCallback((e) => {
- const emojiHandler = suggestionEmojiRef.current.onSelectionChange(e);
- return emojiHandler;
- }, []);
-
- const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
- suggestionEmojiRef.current.updateShouldShowSuggestionMenuToFalse();
- suggestionMentionRef.current.updateShouldShowSuggestionMenuToFalse();
- }, []);
-
- const setShouldBlockSuggestionCalc = useCallback((shouldBlock) => {
- suggestionEmojiRef.current.setShouldBlockSuggestionCalc(shouldBlock);
- suggestionMentionRef.current.setShouldBlockSuggestionCalc(shouldBlock);
- }, []);
-
- useImperativeHandle(
- forwardedRef,
- () => ({
- resetSuggestions,
- onSelectionChange,
- triggerHotkeyActions,
- updateShouldShowSuggestionMenuToFalse,
- setShouldBlockSuggestionCalc,
- getSuggestions,
- }),
- [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
- );
-
- useEffect(() => {
- if (!(!prevIsDraggingOver && isDraggingOver)) {
- return;
- }
- updateShouldShowSuggestionMenuToFalse();
- }, [isDraggingOver, prevIsDraggingOver, updateShouldShowSuggestionMenuToFalse]);
-
- const baseProps = {
- value,
- setValue,
- setSelection,
- selection,
- isComposerFullSize,
- updateComment,
- composerHeight,
- isAutoSuggestionPickerLarge,
- measureParentContainer,
- isComposerFocused,
- };
-
- return (
-
-
-
-
- );
-}
-
-Suggestions.propTypes = propTypes;
-Suggestions.defaultProps = defaultProps;
-Suggestions.displayName = 'Suggestions';
-
-const SuggestionsWithRef = React.forwardRef((props, ref) => (
-
-));
-
-SuggestionsWithRef.displayName = 'SuggestionsWithRef';
-
-export default SuggestionsWithRef;
diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx
new file mode 100644
index 000000000000..61026a792919
--- /dev/null
+++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx
@@ -0,0 +1,181 @@
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef} from 'react';
+import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native';
+import {View} from 'react-native';
+import {DragAndDropContext} from '@components/DragAndDrop/Provider';
+import usePrevious from '@hooks/usePrevious';
+import type {SuggestionsRef} from './ReportActionCompose';
+import SuggestionEmoji from './SuggestionEmoji';
+import SuggestionMention from './SuggestionMention';
+
+type Selection = {
+ start: number;
+ end: number;
+};
+
+type SuggestionProps = {
+ /** The current input value */
+ value: string;
+
+ /** Callback to update the current input value */
+ setValue: (newValue: string) => void;
+
+ /** The current selection value */
+ selection: Selection;
+
+ /** Callback to update the current selection */
+ setSelection: (newSelection: Selection) => void;
+
+ /** Callback to update the comment draft */
+ updateComment: (newComment: string, shouldDebounceSaveComment?: boolean) => void;
+
+ /** Meaures the parent container's position and dimensions. */
+ measureParentContainer: (callback: MeasureInWindowOnSuccessCallback) => void;
+
+ /** Whether the composer is expanded */
+ isComposerFullSize: boolean;
+
+ /** Report composer focus state */
+ isComposerFocused?: boolean;
+
+ /** Callback to reset the keyboard input */
+ resetKeyboardInput?: () => void;
+
+ /** Whether the auto suggestion picker is large */
+ isAutoSuggestionPickerLarge?: boolean;
+
+ /** The height of the composer */
+ composerHeight?: number;
+};
+
+/**
+ * This component contains the individual suggestion components.
+ * If you want to add a new suggestion type, add it here.
+ *
+ */
+function Suggestions(
+ {
+ isComposerFullSize,
+ value,
+ setValue,
+ selection,
+ setSelection,
+ updateComment,
+ composerHeight,
+ resetKeyboardInput,
+ measureParentContainer,
+ isAutoSuggestionPickerLarge = true,
+ isComposerFocused,
+ }: SuggestionProps,
+ ref: ForwardedRef,
+) {
+ const suggestionEmojiRef = useRef(null);
+ const suggestionMentionRef = useRef(null);
+ const {isDraggingOver} = useContext(DragAndDropContext);
+ const prevIsDraggingOver = usePrevious(isDraggingOver);
+
+ const getSuggestions = useCallback(() => {
+ if (suggestionEmojiRef.current?.getSuggestions) {
+ const emojiSuggestions = suggestionEmojiRef.current.getSuggestions();
+ if (emojiSuggestions.length > 0) {
+ return emojiSuggestions;
+ }
+ }
+
+ if (suggestionMentionRef.current?.getSuggestions) {
+ const mentionSuggestions = suggestionMentionRef.current.getSuggestions();
+ if (mentionSuggestions.length > 0) {
+ return mentionSuggestions;
+ }
+ }
+
+ return [];
+ }, []);
+
+ /**
+ * Clean data related to EmojiSuggestions
+ */
+ const resetSuggestions = useCallback(() => {
+ suggestionEmojiRef.current?.resetSuggestions();
+ suggestionMentionRef.current?.resetSuggestions();
+ }, []);
+
+ /**
+ * Listens for keyboard shortcuts and applies the action
+ */
+ const triggerHotkeyActions = useCallback((e: KeyboardEvent) => {
+ const emojiHandler = suggestionEmojiRef.current?.triggerHotkeyActions(e);
+ const mentionHandler = suggestionMentionRef.current?.triggerHotkeyActions(e);
+ return emojiHandler ?? mentionHandler;
+ }, []);
+
+ const onSelectionChange = useCallback((e: NativeSyntheticEvent) => {
+ const emojiHandler = suggestionEmojiRef.current?.onSelectionChange?.(e);
+ return emojiHandler;
+ }, []);
+
+ const updateShouldShowSuggestionMenuToFalse = useCallback(() => {
+ suggestionEmojiRef.current?.updateShouldShowSuggestionMenuToFalse();
+ suggestionMentionRef.current?.updateShouldShowSuggestionMenuToFalse();
+ }, []);
+
+ const setShouldBlockSuggestionCalc = useCallback((shouldBlock: boolean) => {
+ suggestionEmojiRef.current?.setShouldBlockSuggestionCalc(shouldBlock);
+ suggestionMentionRef.current?.setShouldBlockSuggestionCalc(shouldBlock);
+ }, []);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ resetSuggestions,
+ onSelectionChange,
+ triggerHotkeyActions,
+ updateShouldShowSuggestionMenuToFalse,
+ setShouldBlockSuggestionCalc,
+ getSuggestions,
+ }),
+ [onSelectionChange, resetSuggestions, setShouldBlockSuggestionCalc, triggerHotkeyActions, updateShouldShowSuggestionMenuToFalse, getSuggestions],
+ );
+
+ useEffect(() => {
+ if (!(!prevIsDraggingOver && isDraggingOver)) {
+ return;
+ }
+ updateShouldShowSuggestionMenuToFalse();
+ }, [isDraggingOver, prevIsDraggingOver, updateShouldShowSuggestionMenuToFalse]);
+
+ const baseProps = {
+ value,
+ setValue,
+ setSelection,
+ selection,
+ isComposerFullSize,
+ updateComment,
+ composerHeight,
+ isAutoSuggestionPickerLarge,
+ measureParentContainer,
+ isComposerFocused,
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+Suggestions.displayName = 'Suggestions';
+
+export default forwardRef(Suggestions);
+
+export type {SuggestionProps};
diff --git a/src/pages/home/report/ReportActionCompose/suggestionProps.js b/src/pages/home/report/ReportActionCompose/suggestionProps.js
deleted file mode 100644
index 62c29f3d418e..000000000000
--- a/src/pages/home/report/ReportActionCompose/suggestionProps.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import PropTypes from 'prop-types';
-
-const baseProps = {
- /** The current input value */
- value: PropTypes.string.isRequired,
-
- /** Callback to update the current input value */
- setValue: PropTypes.func.isRequired,
-
- /** The current selection value */
- selection: PropTypes.shape({
- start: PropTypes.number.isRequired,
- end: PropTypes.number.isRequired,
- }).isRequired,
-
- /** Callback to update the current selection */
- setSelection: PropTypes.func.isRequired,
-
- /** Whether the composer is expanded */
- isComposerFullSize: PropTypes.bool.isRequired,
-
- /** Callback to update the comment draft */
- updateComment: PropTypes.func.isRequired,
-
- /** Meaures the parent container's position and dimensions. */
- measureParentContainer: PropTypes.func.isRequired,
-
- /** Report composer focus state */
- isComposerFocused: PropTypes.bool,
-};
-
-const implementationBaseProps = {
- /** Whether to use the small or the big suggestion picker */
- isAutoSuggestionPickerLarge: PropTypes.bool.isRequired,
-
- ...baseProps,
-};
-
-export {baseProps, implementationBaseProps};
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index 1da806b9c269..3ace2ebeb436 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -31,6 +31,9 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes.isRequired,
+ /** The report's parentReportAction */
+ parentReportAction: PropTypes.shape(reportActionPropTypes),
+
/** Sorted actions prepared for display */
sortedReportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)).isRequired,
@@ -79,6 +82,7 @@ const defaultProps = {
isLoadingNewerReportActions: false,
...withCurrentUserPersonalDetailsDefaultProps,
policy: {},
+ parentReportAction: {},
};
const VERTICAL_OFFSET_THRESHOLD = 200;
@@ -123,6 +127,7 @@ function isMessageUnread(message, lastReadTime) {
function ReportActionsList({
report,
+ parentReportAction,
isLoadingInitialReportActions,
isLoadingOlderReportActions,
isLoadingNewerReportActions,
@@ -412,6 +417,7 @@ function ReportActionsList({
({item: reportAction, index}) => (
),
- [report, linkedReportActionID, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker],
+ [report, linkedReportActionID, sortedReportActions, mostRecentIOUReportActionID, shouldHideThreadDividerLine, shouldDisplayNewMarker, parentReportAction],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
diff --git a/src/pages/home/report/ReportActionsListItemRenderer.js b/src/pages/home/report/ReportActionsListItemRenderer.js
index 3fd6ddcef750..bc8e6a94359f 100644
--- a/src/pages/home/report/ReportActionsListItemRenderer.js
+++ b/src/pages/home/report/ReportActionsListItemRenderer.js
@@ -13,6 +13,9 @@ const propTypes = {
/** All the data of the action item */
reportAction: PropTypes.shape(reportActionPropTypes).isRequired,
+ /** The report's parentReportAction */
+ parentReportAction: PropTypes.shape(reportActionPropTypes),
+
/** Position index of the report action in the overall report FlatList view */
index: PropTypes.number.isRequired,
@@ -38,10 +41,12 @@ const propTypes = {
const defaultProps = {
mostRecentIOUReportActionID: '',
linkedReportActionID: '',
+ parentReportAction: {},
};
function ReportActionsListItemRenderer({
reportAction,
+ parentReportAction,
index,
report,
displayAsGroup,
@@ -51,9 +56,7 @@ function ReportActionsListItemRenderer({
linkedReportActionID,
}) {
const shouldDisplayParentAction =
- reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED &&
- ReportUtils.isChatThread(report) &&
- !ReportActionsUtils.isTransactionThread(ReportActionsUtils.getParentReportAction(report));
+ reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED && ReportUtils.isChatThread(report) && !ReportActionsUtils.isTransactionThread(parentReportAction);
/**
* Create a lightweight ReportAction so as to keep the re-rendering as light as possible by
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index 064187855b57..91a8810e91ff 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -34,6 +34,9 @@ const propTypes = {
/** Array of report actions for this report */
reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
+ /** The report's parentReportAction */
+ parentReportAction: PropTypes.shape(reportActionPropTypes),
+
/** The report metadata loading states */
isLoadingInitialReportActions: PropTypes.bool,
@@ -78,6 +81,7 @@ const defaultProps = {
session: {
authTokenType: '',
},
+ parentReportAction: {},
};
function ReportActionsView(props) {
@@ -254,6 +258,7 @@ function ReportActionsView(props) {
<>
void;
};
-
-function ReportDropUI({onDrop}) {
+function ReportDropUI({onDrop}: ReportDropUIProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
return (
@@ -33,6 +31,5 @@ function ReportDropUI({onDrop}) {
}
ReportDropUI.displayName = 'ReportDropUI';
-ReportDropUI.propTypes = propTypes;
export default ReportDropUI;
diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.tsx
similarity index 70%
rename from src/pages/home/report/ReportTypingIndicator.js
rename to src/pages/home/report/ReportTypingIndicator.tsx
index 41471eaa50de..3ff8f2b0eb8e 100755
--- a/src/pages/home/report/ReportTypingIndicator.js
+++ b/src/pages/home/report/ReportTypingIndicator.tsx
@@ -1,7 +1,6 @@
-import PropTypes from 'prop-types';
import React, {memo, useMemo} from 'react';
+import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import Text from '@components/Text';
import TextWithEllipsis from '@components/TextWithEllipsis';
import useLocalize from '@hooks/useLocalize';
@@ -9,28 +8,30 @@ import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import * as ReportUtils from '@libs/ReportUtils';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {ReportUserIsTyping} from '@src/types/onyx';
-const propTypes = {
+type ReportTypingIndicatorOnyxProps = {
/** Key-value pairs of user accountIDs/logins and whether or not they are typing. Keys are accountIDs or logins. */
- userTypingStatuses: PropTypes.objectOf(PropTypes.bool),
+ userTypingStatuses: OnyxEntry;
};
-const defaultProps = {
- userTypingStatuses: {},
+type ReportTypingIndicatorProps = ReportTypingIndicatorOnyxProps & {
+ // eslint-disable-next-line react/no-unused-prop-types -- This is used by withOnyx
+ reportID: string;
};
-function ReportTypingIndicator({userTypingStatuses}) {
+function ReportTypingIndicator({userTypingStatuses}: ReportTypingIndicatorProps) {
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const styles = useThemeStyles();
- const usersTyping = useMemo(() => _.filter(_.keys(userTypingStatuses), (loginOrAccountID) => userTypingStatuses[loginOrAccountID]), [userTypingStatuses]);
+ const usersTyping = useMemo(() => Object.keys(userTypingStatuses ?? {}).filter((loginOrAccountID) => userTypingStatuses?.[loginOrAccountID]), [userTypingStatuses]);
const firstUserTyping = usersTyping[0];
const isUserTypingADisplayName = Number.isNaN(Number(firstUserTyping));
// If we are offline, the user typing statuses are not up-to-date so do not show them
- if (isOffline || !firstUserTyping) {
+ if (!!isOffline || !firstUserTyping) {
return null;
}
@@ -40,6 +41,7 @@ function ReportTypingIndicator({userTypingStatuses}) {
if (usersTyping.length === 1) {
return (
({
userTypingStatuses: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
- initialValue: {},
},
})(memo(ReportTypingIndicator));
diff --git a/src/pages/iou/request/step/IOURequestStepCategory.js b/src/pages/iou/request/step/IOURequestStepCategory.js
index 3e0feec02854..1945edbc24c4 100644
--- a/src/pages/iou/request/step/IOURequestStepCategory.js
+++ b/src/pages/iou/request/step/IOURequestStepCategory.js
@@ -1,4 +1,5 @@
import lodashGet from 'lodash/get';
+import lodashIsEmpty from 'lodash/isEmpty';
import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
@@ -72,7 +73,7 @@ function IOURequestStepCategory({
const {translate} = useLocalize();
const isEditing = action === CONST.IOU.ACTION.EDIT;
const isEditingSplitBill = isEditing && iouType === CONST.IOU.TYPE.SPLIT;
- const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill ? splitDraftTransaction : transaction);
+ const {category: transactionCategory} = ReportUtils.getTransactionDetails(isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction);
const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report);
// eslint-disable-next-line rulesdir/no-negated-variables
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
index 181d8edc22f2..338444d473c6 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
@@ -238,7 +238,7 @@ function IOURequestStepScan({
return;
}
- camera.current
+ return camera.current
.takePhoto({
qualityPrioritization: 'speed',
flash: flash ? 'on' : 'off',
diff --git a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
index eee6da9e87ef..5a68c85546e6 100644
--- a/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
+++ b/src/pages/iou/request/step/IOURequestStepWaypoint.tsx
@@ -82,12 +82,10 @@ function IOURequestStepWaypoint({
switch (parsedWaypointIndex) {
case 0:
return 'distance.waypointDescription.start';
- case waypointCount - 1:
- return 'distance.waypointDescription.finish';
default:
return 'distance.waypointDescription.stop';
}
- }, [parsedWaypointIndex, waypointCount]);
+ }, [parsedWaypointIndex]);
const locationBias = useLocationBias(allWaypoints, userLocation);
const waypointAddress = currentWaypoint.address ?? '';
diff --git a/src/pages/settings/AboutPage/TroubleshootPage.tsx b/src/pages/settings/AboutPage/TroubleshootPage.tsx
index ade6932c4b2c..9bc756df03cb 100644
--- a/src/pages/settings/AboutPage/TroubleshootPage.tsx
+++ b/src/pages/settings/AboutPage/TroubleshootPage.tsx
@@ -40,6 +40,7 @@ const keysToPreserve: OnyxKey[] = [
ONYXKEYS.NVP_TRY_FOCUS_MODE,
ONYXKEYS.PREFERRED_THEME,
ONYXKEYS.NVP_PREFERRED_LOCALE,
+ ONYXKEYS.CREDENTIALS,
];
type BaseMenuItem = {
diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx
index 9193074f96c8..556634269f5c 100644
--- a/src/pages/settings/Wallet/PaymentMethodList.tsx
+++ b/src/pages/settings/Wallet/PaymentMethodList.tsx
@@ -36,7 +36,7 @@ import type {Errors} from '@src/types/onyx/OnyxCommon';
import type PaymentMethod from '@src/types/onyx/PaymentMethod';
import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
-import type IconAsset from '@src/types/utils/IconAsset';
+import type {FormattedSelectedPaymentMethodIcon} from './WalletPage/types';
type PaymentMethodListOnyxProps = {
/** List of bank accounts */
@@ -99,7 +99,14 @@ type PaymentMethodListProps = PaymentMethodListOnyxProps & {
shouldShowEmptyListMessage?: boolean;
/** What to do when a menu item is pressed */
- onPress: (event?: GestureResponderEvent | KeyboardEvent, accountType?: string, accountData?: AccountData, icon?: IconAsset, isDefault?: boolean, methodID?: number) => void;
+ onPress: (
+ event?: GestureResponderEvent | KeyboardEvent,
+ accountType?: string,
+ accountData?: AccountData,
+ icon?: FormattedSelectedPaymentMethodIcon,
+ isDefault?: boolean,
+ methodID?: number,
+ ) => void;
};
type PaymentMethodItem = PaymentMethod & {
@@ -236,12 +243,25 @@ function PaymentMethodList({
(paymentMethod) => paymentMethod.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(paymentMethod.errors),
);
}
-
combinedPaymentMethods = combinedPaymentMethods.map((paymentMethod) => {
const isMethodActive = isPaymentMethodActive(actionPaymentMethodType, activePaymentMethodID, paymentMethod);
return {
...paymentMethod,
- onPress: (e: GestureResponderEvent) => onPress(e, paymentMethod.accountType, paymentMethod.accountData, paymentMethod.icon, paymentMethod.isDefault, paymentMethod.methodID),
+ onPress: (e: GestureResponderEvent) =>
+ onPress(
+ e,
+ paymentMethod.accountType,
+ paymentMethod.accountData,
+ {
+ icon: paymentMethod.icon,
+ iconHeight: paymentMethod?.iconHeight,
+ iconWidth: paymentMethod?.iconWidth,
+ iconStyles: paymentMethod?.iconStyles,
+ iconSize: paymentMethod?.iconSize,
+ },
+ paymentMethod.isDefault,
+ paymentMethod.methodID,
+ ),
wrapperStyle: isMethodActive ? [StyleUtils.getButtonBackgroundColorStyle(CONST.BUTTON_STATES.PRESSED)] : null,
disabled: paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
isMethodActive,
diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx
index 13e2877d3ec7..b9f49049d51a 100644
--- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx
+++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx
@@ -37,12 +37,11 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {AccountData} from '@src/types/onyx';
-import type IconAsset from '@src/types/utils/IconAsset';
-import type {WalletPageOnyxProps, WalletPageProps} from './types';
+import type {FormattedSelectedPaymentMethodIcon, WalletPageOnyxProps, WalletPageProps} from './types';
type FormattedSelectedPaymentMethod = {
title: string;
- icon?: IconAsset;
+ icon?: FormattedSelectedPaymentMethodIcon;
description?: string;
type?: string;
};
@@ -151,7 +150,7 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi
nativeEvent?: GestureResponderEvent | KeyboardEvent,
accountType?: string,
account?: AccountData,
- icon?: IconAsset,
+ icon?: FormattedSelectedPaymentMethodIcon,
isDefault?: boolean,
methodID?: string | number,
) => {
@@ -538,10 +537,14 @@ function WalletPage({bankAccountList = {}, cardList = {}, fundList = {}, isLoadi
{isPopoverBottomMount && (
)}
{shouldShowMakeDefaultButton && (
diff --git a/src/pages/settings/Wallet/WalletPage/types.ts b/src/pages/settings/Wallet/WalletPage/types.ts
index 0c3db1a2df65..ffee0c677c33 100644
--- a/src/pages/settings/Wallet/WalletPage/types.ts
+++ b/src/pages/settings/Wallet/WalletPage/types.ts
@@ -1,5 +1,7 @@
+import type {ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {BankAccountList, CardList, FundList, UserWallet, WalletTerms, WalletTransfer} from '@src/types/onyx';
+import type IconAsset from '@src/types/utils/IconAsset';
type WalletPageOnyxProps = {
/** Wallet balance transfer props */
@@ -28,4 +30,12 @@ type WalletPageProps = WalletPageOnyxProps & {
shouldListenForResize?: boolean;
};
-export type {WalletPageOnyxProps, WalletPageProps};
+type FormattedSelectedPaymentMethodIcon = {
+ icon: IconAsset;
+ iconHeight?: number;
+ iconWidth?: number;
+ iconStyles?: ViewStyle[];
+ iconSize?: number;
+};
+
+export type {WalletPageOnyxProps, WalletPageProps, FormattedSelectedPaymentMethodIcon};
diff --git a/src/pages/signin/SignInPageLayout/SignInPageContent.tsx b/src/pages/signin/SignInPageLayout/SignInPageContent.tsx
index 770eafa199b7..9e304bc9114d 100755
--- a/src/pages/signin/SignInPageLayout/SignInPageContent.tsx
+++ b/src/pages/signin/SignInPageLayout/SignInPageContent.tsx
@@ -1,8 +1,8 @@
import React from 'react';
import {View} from 'react-native';
import ExpensifyWordmark from '@components/ExpensifyWordmark';
+import FormElement from '@components/FormElement';
import OfflineIndicator from '@components/OfflineIndicator';
-import SignInPageForm from '@components/SignInPageForm';
import Text from '@components/Text';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -27,7 +27,7 @@ function SignInPageContent({shouldShowWelcomeHeader, welcomeHeader, welcomeText,
{/* This empty view creates margin on the top of the sign in form which will shrink and grow depending on if the keyboard is open or not */}
-
+
@@ -51,7 +51,7 @@ function SignInPageContent({shouldShowWelcomeHeader, welcomeHeader, welcomeText,
) : null}
{children}
-
+
diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js
index ea22f388f404..b62440b22967 100644
--- a/src/pages/tasks/TaskShareDestinationSelectorModal.js
+++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js
@@ -1,22 +1,22 @@
-/* eslint-disable es/no-optional-chaining */
+import keys from 'lodash/keys';
+import reduce from 'lodash/reduce';
import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import React, {useEffect, useMemo} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import OptionsSelector from '@components/OptionsSelector';
+import {usePersonalDetails} from '@components/OnyxProvider';
import ScreenWrapper from '@components/ScreenWrapper';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import useAutoFocusInput from '@hooks/useAutoFocusInput';
+import SelectionList from '@components/SelectionList';
+import UserListItem from '@components/SelectionList/UserListItem';
+import useDebouncedState from '@hooks/useDebouncedState';
+import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import * as Report from '@libs/actions/Report';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as ReportUtils from '@libs/ReportUtils';
-import personalDetailsPropType from '@pages/personalDetailsPropType';
import reportPropTypes from '@pages/reportPropTypes';
import * as Task from '@userActions/Task';
import CONST from '@src/CONST';
@@ -24,132 +24,88 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
const propTypes = {
- /* Onyx Props */
-
- /** Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string),
-
- /** All of the personal details for everyone */
- personalDetails: PropTypes.objectOf(personalDetailsPropType),
-
/** All reports shared with the user */
reports: PropTypes.objectOf(reportPropTypes),
-
- /** Whether we are searching for reports in the server */
+ /** Whether or not we are searching for reports on the server */
isSearchingForReports: PropTypes.bool,
-
- ...withLocalizePropTypes,
};
const defaultProps = {
- betas: [],
- personalDetails: {},
reports: {},
isSearchingForReports: false,
};
-function TaskShareDestinationSelectorModal(props) {
- const styles = useThemeStyles();
- const [searchValue, setSearchValue] = useState('');
- const [headerMessage, setHeaderMessage] = useState('');
- const [filteredRecentReports, setFilteredRecentReports] = useState([]);
+const selectReportHandler = (option) => {
+ if (!option || !option.reportID) {
+ return;
+ }
- const {inputCallbackRef} = useAutoFocusInput();
- const {isSearchingForReports} = props;
- const {isOffline} = useNetwork();
+ Task.setShareDestinationValue(option.reportID);
+ Navigation.goBack(ROUTES.NEW_TASK);
+};
- const filteredReports = useMemo(() => {
- const reports = {};
- _.keys(props.reports).forEach((reportKey) => {
- if (
- !ReportUtils.canUserPerformWriteAction(props.reports[reportKey]) ||
- !ReportUtils.canCreateTaskInReport(props.reports[reportKey]) ||
- ReportUtils.isCanceledTaskReport(props.reports[reportKey])
- ) {
- return;
+const reportFilter = (reports) =>
+ reduce(
+ keys(reports),
+ (filtered, reportKey) => {
+ const report = reports[reportKey];
+ if (ReportUtils.canUserPerformWriteAction(report) && ReportUtils.canCreateTaskInReport(report) && !ReportUtils.isCanceledTaskReport(report)) {
+ return {...filtered, [reportKey]: report};
}
- reports[reportKey] = props.reports[reportKey];
- });
- return reports;
- }, [props.reports]);
- const updateOptions = useCallback(() => {
- const {recentReports} = OptionsListUtils.getShareDestinationOptions(filteredReports, props.personalDetails, props.betas, searchValue.trim(), [], CONST.EXPENSIFY_EMAILS, true);
+ return filtered;
+ },
+ {},
+ );
- setHeaderMessage(OptionsListUtils.getHeaderMessage(recentReports?.length !== 0, false, searchValue));
+function TaskShareDestinationSelectorModal({reports, isSearchingForReports}) {
+ const styles = useThemeStyles();
+ const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState('');
+ const {translate} = useLocalize();
+ const personalDetails = usePersonalDetails();
+ const {isOffline} = useNetwork();
+
+ const textInputHint = useMemo(() => (isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''), [isOffline, translate]);
+
+ const options = useMemo(() => {
+ const filteredReports = reportFilter(reports);
+
+ const {recentReports} = OptionsListUtils.getShareDestinationOptions(filteredReports, personalDetails, [], debouncedSearchValue.trim(), [], CONST.EXPENSIFY_EMAILS, true);
+
+ const headerMessage = OptionsListUtils.getHeaderMessage(recentReports && recentReports.length !== 0, false, debouncedSearchValue);
- setFilteredRecentReports(recentReports);
- }, [props, searchValue, filteredReports]);
+ const sections = recentReports && recentReports.length > 0 ? [{data: recentReports, shouldShow: true}] : [];
+
+ return {sections, headerMessage};
+ }, [personalDetails, reports, debouncedSearchValue]);
useEffect(() => {
- const debouncedSearch = _.debounce(updateOptions, 150);
- debouncedSearch();
- return () => {
- debouncedSearch.cancel();
- };
- }, [updateOptions]);
-
- const getSections = () => {
- const sections = [];
- let indexOffset = 0;
-
- if (filteredRecentReports?.length > 0) {
- sections.push({
- data: filteredRecentReports,
- shouldShow: true,
- indexOffset,
- });
- indexOffset += filteredRecentReports.length;
- }
-
- return sections;
- };
-
- const selectReport = (option) => {
- if (!option) {
- return;
- }
-
- if (option.reportID) {
- Task.setShareDestinationValue(option.reportID);
- Navigation.goBack(ROUTES.NEW_TASK);
- }
- };
-
- // When search term updates we will fetch any reports
- const setSearchTermAndSearchInServer = useCallback((text = '') => {
- Report.searchInServer(text);
- setSearchValue(text);
- }, []);
-
- const sections = getSections();
+ Report.searchInServer(debouncedSearchValue);
+ }, [debouncedSearchValue]);
return (
{({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
Navigation.goBack(ROUTES.NEW_TASK)}
/>
-
>
@@ -162,21 +118,12 @@ TaskShareDestinationSelectorModal.displayName = 'TaskShareDestinationSelectorMod
TaskShareDestinationSelectorModal.propTypes = propTypes;
TaskShareDestinationSelectorModal.defaultProps = defaultProps;
-export default compose(
- withLocalize,
- withOnyx({
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- betas: {
- key: ONYXKEYS.BETAS,
- },
- isSearchingForReports: {
- key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS,
- initWithStoredValues: false,
- },
- }),
-)(TaskShareDestinationSelectorModal);
+export default withOnyx({
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ },
+ isSearchingForReports: {
+ key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS,
+ initWithStoredValues: false,
+ },
+})(TaskShareDestinationSelectorModal);
diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx
index 3c1b009aac70..ef8629e386d8 100644
--- a/src/pages/workspace/WorkspaceInvitePage.tsx
+++ b/src/pages/workspace/WorkspaceInvitePage.tsx
@@ -285,6 +285,7 @@ function WorkspaceInvitePage({
return (
= isSmallScreenWidth ? [styles.mhv12, styles.mhn5] : [styles.mhv8, styles.mhn8];
+ const imageStyle: StyleProp = isSmallScreenWidth ? [styles.mhv12, styles.mhn5, styles.mbn5] : [styles.mhv8, styles.mhn8, styles.mbn5];
const DefaultAvatar = useCallback(
() => (
Policy.updateWorkspaceAvatar(policy?.id ?? '', file as File)}
onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')}
diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
index 7cd9972a6f57..721341073d72 100644
--- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
+++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx
@@ -129,7 +129,8 @@ function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesP
smallEditIcon: {
alignItems: 'center',
backgroundColor: theme.buttonDefaultBG,
- borderColor: theme.appBG,
+ borderColor: theme.cardBG,
borderRadius: 20,
borderWidth: 3,
color: theme.textReversed,
@@ -4650,9 +4650,8 @@ const styles = (theme: ThemeColors) =>
},
workspaceTitleStyle: {
- fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD,
- fontWeight: '500',
- fontSize: variables.workspaceProfileName,
+ ...headlineFont,
+ fontSize: variables.fontSizeXLarge,
},
} satisfies Styles);
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index 3f59b08fc447..31716d75dd05 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -95,7 +95,7 @@ const darkTheme = {
// The screen name (see SCREENS.ts) is the name of the screen as far as react-navigation is concerned, and the linkingConfig maps screen names to URLs
PAGE_THEMES: {
[SCREENS.HOME]: {
- backgroundColor: colors.productDark200,
+ backgroundColor: colors.productDark100,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.REPORT]: {
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index c0b4a5fe3182..fecd8749aebb 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -95,7 +95,7 @@ const lightTheme = {
// The screen name (see SCREENS.ts) is the name of the screen as far as react-navigation is concerned, and the linkingConfig maps screen names to URLs
PAGE_THEMES: {
[SCREENS.HOME]: {
- backgroundColor: colors.productLight200,
+ backgroundColor: colors.productLight100,
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.REPORT]: {
diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts
index ec2e99430831..4a5b72a44bc5 100644
--- a/src/styles/utils/spacing.ts
+++ b/src/styles/utils/spacing.ts
@@ -319,6 +319,10 @@ export default {
marginBottom: -4,
},
+ mbn5: {
+ marginBottom: -20,
+ },
+
p0: {
padding: 0,
paddingHorizontal: 0,
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index e318c3d10ecf..63611eccb199 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -218,7 +218,6 @@ export default {
updateAnimationH: 240,
updateTextViewContainerWidth: 310,
updateViewHeaderHeight: 70,
- workspaceProfileName: 20,
mushroomTopHatWidth: 138,
mushroomTopHatHeight: 128,
diff --git a/src/types/modules/pusher.d.ts b/src/types/modules/pusher.d.ts
index 676d7a7ee2fc..e9aa50085e8d 100644
--- a/src/types/modules/pusher.d.ts
+++ b/src/types/modules/pusher.d.ts
@@ -9,6 +9,6 @@ declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface File {
source: string;
- uri: string;
+ uri?: string;
}
}
diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts
index 6300d416035a..21f1d620b14f 100644
--- a/src/types/modules/react-native.d.ts
+++ b/src/types/modules/react-native.d.ts
@@ -283,14 +283,9 @@ declare module 'react-native' {
* Extracted from react-native-web, packages/react-native-web/src/exports/TextInput/types.js
*/
interface WebTextInputProps extends WebSharedProps {
- dir?: 'auto' | 'ltr' | 'rtl';
disabled?: boolean;
- enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send';
- readOnly?: boolean;
}
interface TextInputProps extends WebTextInputProps {
- // TODO: remove once the app is updated to RN 0.73
- smartInsertDelete?: boolean;
isFullComposerAvailable?: boolean;
}
diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts
index 1c5d46610286..8b96a89a2a1b 100644
--- a/src/types/onyx/OnyxCommon.ts
+++ b/src/types/onyx/OnyxCommon.ts
@@ -31,7 +31,7 @@ type Icon = {
type: AvatarType;
/** Owner of the avatar. If user, displayName. If workspace, policy name */
- name: string;
+ name?: string;
/** Avatar id */
id?: number | string;
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 06c2d2e6abce..198c606cf9f2 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -3,7 +3,6 @@ import type CONST from '@src/CONST';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
type PaymentMethodType = DeepValueOf;
-
type ActionName = DeepValueOf;
type OriginalMessageActionName =
| 'ADDCOMMENT'
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index 1662a76c02df..1eece2d3a1e0 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -17,6 +17,12 @@ type Attributes = {
unit: Unit;
};
+type MileageRate = {
+ unit: Unit;
+ rate?: number;
+ currency: string;
+};
+
type CustomUnit = OnyxCommon.OnyxValueWithOfflineFeedback<{
name: string;
customUnitID: string;
@@ -223,10 +229,28 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback<
/** All the integration connections attached to the policy */
connections?: Record;
+
+ /** Whether the Categories feature is enabled */
+ areCategoriesEnabled?: boolean;
+
+ /** Whether the Tags feature is enabled */
+ areTagsEnabled?: boolean;
+
+ /** Whether the Distance Rates feature is enabled */
+ areDistanceRatesEnabled?: boolean;
+
+ /** Whether the workflows feature is enabled */
+ areWorkflowsEnabled?: boolean;
+
+ /** Whether the Report Fields feature is enabled */
+ areReportFieldsEnabled?: boolean;
+
+ /** Whether the Connections feature is enabled */
+ areConnectionsEnabled?: boolean;
},
'generalSettings' | 'addWorkspaceRoom'
>;
export default Policy;
-export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault};
+export type {Unit, CustomUnit, Attributes, Rate, TaxRate, TaxRates, TaxRatesWithDefault, MileageRate};
diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts
index 6f1da93f7f14..bb5bf50ec6cf 100644
--- a/src/types/onyx/ReportAction.ts
+++ b/src/types/onyx/ReportAction.ts
@@ -1,4 +1,5 @@
import type {ValueOf} from 'type-fest';
+import type {FileObject} from '@components/AttachmentModal';
import type {AvatarSource} from '@libs/UserUtils';
import type CONST from '@src/CONST';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
@@ -184,7 +185,7 @@ type ReportActionBase = OnyxCommon.OnyxValueWithOfflineFeedback<{
isFirstItem?: boolean;
/** Informations about attachments of report action */
- attachmentInfo?: File | EmptyObject;
+ attachmentInfo?: FileObject | EmptyObject;
/** Receipt tied to report action */
receipt?: Receipt;
diff --git a/tests/e2e/compare/output/markdownTable.js b/tests/e2e/compare/output/markdownTable.js
deleted file mode 100644
index 198ae17daba5..000000000000
--- a/tests/e2e/compare/output/markdownTable.js
+++ /dev/null
@@ -1,368 +0,0 @@
-/* eslint-disable */
-// copied from https://raw.githubusercontent.com/wooorm/markdown-table/main/index.js, turned into cmjs
-
-/**
- * @typedef Options
- * Configuration (optional).
- * @property {string|null|Array} [align]
- * One style for all columns, or styles for their respective columns.
- * Each style is either `'l'` (left), `'r'` (right), or `'c'` (center).
- * Other values are treated as `''`, which doesn’t place the colon in the
- * alignment row but does align left.
- * *Only the lowercased first character is used, so `Right` is fine.*
- * @property {boolean} [padding=true]
- * Whether to add a space of padding between delimiters and cells.
- *
- * When `true`, there is padding:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * When `false`, there is no padding:
- *
- * ```markdown
- * |Alpha|B |
- * |-----|-----|
- * |C |Delta|
- * ```
- * @property {boolean} [delimiterStart=true]
- * Whether to begin each row with the delimiter.
- *
- * > 👉 **Note**: please don’t use this: it could create fragile structures
- * > that aren’t understandable to some markdown parsers.
- *
- * When `true`, there are starting delimiters:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * When `false`, there are no starting delimiters:
- *
- * ```markdown
- * Alpha | B |
- * ----- | ----- |
- * C | Delta |
- * ```
- * @property {boolean} [delimiterEnd=true]
- * Whether to end each row with the delimiter.
- *
- * > 👉 **Note**: please don’t use this: it could create fragile structures
- * > that aren’t understandable to some markdown parsers.
- *
- * When `true`, there are ending delimiters:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * When `false`, there are no ending delimiters:
- *
- * ```markdown
- * | Alpha | B
- * | ----- | -----
- * | C | Delta
- * ```
- * @property {boolean} [alignDelimiters=true]
- * Whether to align the delimiters.
- * By default, they are aligned:
- *
- * ```markdown
- * | Alpha | B |
- * | ----- | ----- |
- * | C | Delta |
- * ```
- *
- * Pass `false` to make them staggered:
- *
- * ```markdown
- * | Alpha | B |
- * | - | - |
- * | C | Delta |
- * ```
- * @property {(value: string) => number} [stringLength]
- * Function to detect the length of table cell content.
- * This is used when aligning the delimiters (`|`) between table cells.
- * Full-width characters and emoji mess up delimiter alignment when viewing
- * the markdown source.
- * To fix this, you can pass this function, which receives the cell content
- * and returns its “visible” size.
- * Note that what is and isn’t visible depends on where the text is displayed.
- *
- * Without such a function, the following:
- *
- * ```js
- * markdownTable([
- * ['Alpha', 'Bravo'],
- * ['中文', 'Charlie'],
- * ['👩❤️👩', 'Delta']
- * ])
- * ```
- *
- * Yields:
- *
- * ```markdown
- * | Alpha | Bravo |
- * | - | - |
- * | 中文 | Charlie |
- * | 👩❤️👩 | Delta |
- * ```
- *
- * With [`string-width`](https://github.com/sindresorhus/string-width):
- *
- * ```js
- * import stringWidth from 'string-width'
- *
- * markdownTable(
- * [
- * ['Alpha', 'Bravo'],
- * ['中文', 'Charlie'],
- * ['👩❤️👩', 'Delta']
- * ],
- * {stringLength: stringWidth}
- * )
- * ```
- *
- * Yields:
- *
- * ```markdown
- * | Alpha | Bravo |
- * | ----- | ------- |
- * | 中文 | Charlie |
- * | 👩❤️👩 | Delta |
- * ```
- */
-
-/**
- * @typedef {Options} MarkdownTableOptions
- * @todo
- * Remove next major.
- */
-
-/**
- * Generate a markdown ([GFM](https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables)) table..
- *
- * @param {Array>} table
- * Table data (matrix of strings).
- * @param {Options} [options]
- * Configuration (optional).
- * @returns {string}
- */
-function markdownTable(table, options = {}) {
- const align = (options.align || []).concat();
- const stringLength = options.stringLength || defaultStringLength;
- /** @type {Array} Character codes as symbols for alignment per column. */
- const alignments = [];
- /** @type {Array>} Cells per row. */
- const cellMatrix = [];
- /** @type {Array>} Sizes of each cell per row. */
- const sizeMatrix = [];
- /** @type {Array} */
- const longestCellByColumn = [];
- let mostCellsPerRow = 0;
- let rowIndex = -1;
-
- // This is a superfluous loop if we don’t align delimiters, but otherwise we’d
- // do superfluous work when aligning, so optimize for aligning.
- while (++rowIndex < table.length) {
- /** @type {Array} */
- const row = [];
- /** @type {Array} */
- const sizes = [];
- let columnIndex = -1;
-
- if (table[rowIndex].length > mostCellsPerRow) {
- mostCellsPerRow = table[rowIndex].length;
- }
-
- while (++columnIndex < table[rowIndex].length) {
- const cell = serialize(table[rowIndex][columnIndex]);
-
- if (options.alignDelimiters !== false) {
- const size = stringLength(cell);
- sizes[columnIndex] = size;
-
- if (longestCellByColumn[columnIndex] === undefined || size > longestCellByColumn[columnIndex]) {
- longestCellByColumn[columnIndex] = size;
- }
- }
-
- row.push(cell);
- }
-
- cellMatrix[rowIndex] = row;
- sizeMatrix[rowIndex] = sizes;
- }
-
- // Figure out which alignments to use.
- let columnIndex = -1;
-
- if (typeof align === 'object' && 'length' in align) {
- while (++columnIndex < mostCellsPerRow) {
- alignments[columnIndex] = toAlignment(align[columnIndex]);
- }
- } else {
- const code = toAlignment(align);
-
- while (++columnIndex < mostCellsPerRow) {
- alignments[columnIndex] = code;
- }
- }
-
- // Inject the alignment row.
- columnIndex = -1;
- /** @type {Array} */
- const row = [];
- /** @type {Array} */
- const sizes = [];
-
- while (++columnIndex < mostCellsPerRow) {
- const code = alignments[columnIndex];
- let before = '';
- let after = '';
-
- if (code === 99 /* `c` */) {
- before = ':';
- after = ':';
- } else if (code === 108 /* `l` */) {
- before = ':';
- } else if (code === 114 /* `r` */) {
- after = ':';
- }
-
- // There *must* be at least one hyphen-minus in each alignment cell.
- let size = options.alignDelimiters === false ? 1 : Math.max(1, longestCellByColumn[columnIndex] - before.length - after.length);
-
- const cell = before + '-'.repeat(size) + after;
-
- if (options.alignDelimiters !== false) {
- size = before.length + size + after.length;
-
- if (size > longestCellByColumn[columnIndex]) {
- longestCellByColumn[columnIndex] = size;
- }
-
- sizes[columnIndex] = size;
- }
-
- row[columnIndex] = cell;
- }
-
- // Inject the alignment row.
- cellMatrix.splice(1, 0, row);
- sizeMatrix.splice(1, 0, sizes);
-
- rowIndex = -1;
- /** @type {Array} */
- const lines = [];
-
- while (++rowIndex < cellMatrix.length) {
- const row = cellMatrix[rowIndex];
- const sizes = sizeMatrix[rowIndex];
- columnIndex = -1;
- /** @type {Array} */
- const line = [];
-
- while (++columnIndex < mostCellsPerRow) {
- const cell = row[columnIndex] || '';
- let before = '';
- let after = '';
-
- if (options.alignDelimiters !== false) {
- const size = longestCellByColumn[columnIndex] - (sizes[columnIndex] || 0);
- const code = alignments[columnIndex];
-
- if (code === 114 /* `r` */) {
- before = ' '.repeat(size);
- } else if (code === 99 /* `c` */) {
- if (size % 2) {
- before = ' '.repeat(size / 2 + 0.5);
- after = ' '.repeat(size / 2 - 0.5);
- } else {
- before = ' '.repeat(size / 2);
- after = before;
- }
- } else {
- after = ' '.repeat(size);
- }
- }
-
- if (options.delimiterStart !== false && !columnIndex) {
- line.push('|');
- }
-
- if (
- options.padding !== false &&
- // Don’t add the opening space if we’re not aligning and the cell is
- // empty: there will be a closing space.
- !(options.alignDelimiters === false && cell === '') &&
- (options.delimiterStart !== false || columnIndex)
- ) {
- line.push(' ');
- }
-
- if (options.alignDelimiters !== false) {
- line.push(before);
- }
-
- line.push(cell);
-
- if (options.alignDelimiters !== false) {
- line.push(after);
- }
-
- if (options.padding !== false) {
- line.push(' ');
- }
-
- if (options.delimiterEnd !== false || columnIndex !== mostCellsPerRow - 1) {
- line.push('|');
- }
- }
-
- lines.push(options.delimiterEnd === false ? line.join('').replace(/ +$/, '') : line.join(''));
- }
-
- return lines.join('\n');
-}
-
-/**
- * @param {string|null|undefined} [value]
- * @returns {string}
- */
-function serialize(value) {
- return value === null || value === undefined ? '' : String(value);
-}
-
-/**
- * @param {string} value
- * @returns {number}
- */
-function defaultStringLength(value) {
- return value.length;
-}
-
-/**
- * @param {string|null|undefined} value
- * @returns {number}
- */
-function toAlignment(value) {
- const code = typeof value === 'string' ? value.codePointAt(0) : 0;
-
- return code === 67 /* `C` */ || code === 99 /* `c` */
- ? 99 /* `c` */
- : code === 76 /* `L` */ || code === 108 /* `l` */
- ? 108 /* `l` */
- : code === 82 /* `R` */ || code === 114 /* `r` */
- ? 114 /* `r` */
- : 0;
-}
-
-export default markdownTable;
diff --git a/tests/e2e/compare/output/markdownTable.ts b/tests/e2e/compare/output/markdownTable.ts
new file mode 100644
index 000000000000..51f0beeb6979
--- /dev/null
+++ b/tests/e2e/compare/output/markdownTable.ts
@@ -0,0 +1,354 @@
+// copied from https://raw.githubusercontent.com/wooorm/markdown-table/main/index.js, turned into cmjs
+
+type MarkdownTableOptions = {
+ /**
+ * One style for all columns, or styles for their respective columns.
+ * Each style is either `'l'` (left), `'r'` (right), or `'c'` (center).
+ * Other values are treated as `''`, which doesn’t place the colon in the
+ * alignment row but does align left.
+ * *Only the lowercased first character is used, so `Right` is fine.*
+ */
+ align?: string | null | Array;
+
+ /**
+ * Whether to add a space of padding between delimiters and cells.
+ *
+ * When `true`, there is padding:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * When `false`, there is no padding:
+ *
+ * ```markdown
+ * |Alpha|B |
+ * |-----|-----|
+ * |C |Delta|
+ * ```
+ */
+ padding?: boolean;
+
+ /**
+ * Whether to begin each row with the delimiter.
+ *
+ * > 👉 **Note**: please don’t use this: it could create fragile structures
+ * > that aren’t understandable to some markdown parsers.
+ *
+ * When `true`, there are starting delimiters:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * When `false`, there are no starting delimiters:
+ *
+ * ```markdown
+ * Alpha | B |
+ * ----- | ----- |
+ * C | Delta |
+ * ```
+ */
+ delimiterStart?: boolean;
+ /**
+ * Whether to end each row with the delimiter.
+ *
+ * > 👉 **Note**: please don’t use this: it could create fragile structures
+ * > that aren’t understandable to some markdown parsers.
+ *
+ * When `true`, there are ending delimiters:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * When `false`, there are no ending delimiters:
+ *
+ * ```markdown
+ * | Alpha | B
+ * | ----- | -----
+ * | C | Delta
+ * ```
+ */
+ delimiterEnd?: boolean;
+
+ /**
+ * Whether to align the delimiters.
+ * By default, they are aligned:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | ----- | ----- |
+ * | C | Delta |
+ * ```
+ *
+ * Pass `false` to make them staggered:
+ *
+ * ```markdown
+ * | Alpha | B |
+ * | - | - |
+ * | C | Delta |
+ * ```
+ */
+ alignDelimiters?: boolean;
+
+ /**
+ * Function to detect the length of table cell content.
+ * This is used when aligning the delimiters (`|`) between table cells.
+ * Full-width characters and emoji mess up delimiter alignment when viewing
+ * the markdown source.
+ * To fix this, you can pass this function, which receives the cell content
+ * and returns its “visible” size.
+ * Note that what is and isn’t visible depends on where the text is displayed.
+ *
+ * Without such a function, the following:
+ *
+ * ```js
+ * markdownTable([
+ * ['Alpha', 'Bravo'],
+ * ['中文', 'Charlie'],
+ * ['👩❤️👩', 'Delta']
+ * ])
+ * ```
+ *
+ * Yields:
+ *
+ * ```markdown
+ * | Alpha | Bravo |
+ * | - | - |
+ * | 中文 | Charlie |
+ * | 👩❤️👩 | Delta |
+ * ```
+ *
+ * With [`string-width`](https://github.com/sindresorhus/string-width):
+ *
+ * ```js
+ * import stringWidth from 'string-width'
+ *
+ * markdownTable(
+ * [
+ * ['Alpha', 'Bravo'],
+ * ['中文', 'Charlie'],
+ * ['👩❤️👩', 'Delta']
+ * ],
+ * {stringLength: stringWidth}
+ * )
+ * ```
+ *
+ * Yields:
+ *
+ * ```markdown
+ * | Alpha | Bravo |
+ * | ----- | ------- |
+ * | 中文 | Charlie |
+ * | 👩❤️👩 | Delta |
+ * ```
+ */
+ stringLength?: (value: string) => number;
+};
+
+function serialize(value: string | null | undefined): string {
+ return value === null || value === undefined ? '' : String(value);
+}
+
+function defaultStringLength(value: string): number {
+ return value.length;
+}
+
+function toAlignment(value: string | null | undefined): number {
+ const code = typeof value === 'string' ? value.codePointAt(0) : 0;
+
+ if (code === 67 /* `C` */ || code === 99 /* `c` */) {
+ return 99; /* `c` */
+ }
+
+ if (code === 76 /* `L` */ || code === 108 /* `l` */) {
+ return 108; /* `l` */
+ }
+
+ if (code === 82 /* `R` */ || code === 114 /* `r` */) {
+ return 114; /* `r` */
+ }
+
+ return 0;
+}
+
+/** Generate a markdown ([GFM](https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables)) table.. */
+function markdownTable(table: Array>, options: MarkdownTableOptions = {}) {
+ const align = (options.align ?? []).concat();
+ const stringLength = options.stringLength ?? defaultStringLength;
+ /** Character codes as symbols for alignment per column. */
+ const alignments: number[] = [];
+ /** Cells per row. */
+ const cellMatrix: string[][] = [];
+ /** Sizes of each cell per row. */
+ const sizeMatrix: number[][] = [];
+ const longestCellByColumn: number[] = [];
+ let mostCellsPerRow = 0;
+ let rowIndex = -1;
+
+ // This is a superfluous loop if we don’t align delimiters, but otherwise we’d
+ // do superfluous work when aligning, so optimize for aligning.
+ while (++rowIndex < table.length) {
+ const row: string[] = [];
+ const sizes: number[] = [];
+ let columnIndex = -1;
+
+ if (table[rowIndex].length > mostCellsPerRow) {
+ mostCellsPerRow = table[rowIndex].length;
+ }
+
+ while (++columnIndex < table[rowIndex].length) {
+ const cell = serialize(table[rowIndex][columnIndex]);
+
+ if (options.alignDelimiters !== false) {
+ const size = stringLength(cell);
+ sizes[columnIndex] = size;
+
+ if (longestCellByColumn[columnIndex] === undefined || size > longestCellByColumn[columnIndex]) {
+ longestCellByColumn[columnIndex] = size;
+ }
+ }
+
+ row.push(cell);
+ }
+
+ cellMatrix[rowIndex] = row;
+ sizeMatrix[rowIndex] = sizes;
+ }
+
+ // Figure out which alignments to use.
+ let columnIndex = -1;
+
+ if (typeof align === 'object' && 'length' in align) {
+ while (++columnIndex < mostCellsPerRow) {
+ alignments[columnIndex] = toAlignment(align[columnIndex]);
+ }
+ } else {
+ const code = toAlignment(align);
+
+ while (++columnIndex < mostCellsPerRow) {
+ alignments[columnIndex] = code;
+ }
+ }
+
+ // Inject the alignment row.
+ columnIndex = -1;
+ const row: string[] = [];
+ const sizes: number[] = [];
+
+ while (++columnIndex < mostCellsPerRow) {
+ const code = alignments[columnIndex];
+ let before = '';
+ let after = '';
+
+ if (code === 99 /* `c` */) {
+ before = ':';
+ after = ':';
+ } else if (code === 108 /* `l` */) {
+ before = ':';
+ } else if (code === 114 /* `r` */) {
+ after = ':';
+ }
+
+ // There *must* be at least one hyphen-minus in each alignment cell.
+ let size = options.alignDelimiters === false ? 1 : Math.max(1, longestCellByColumn[columnIndex] - before.length - after.length);
+
+ const cell = before + '-'.repeat(size) + after;
+
+ if (options.alignDelimiters !== false) {
+ size = before.length + size + after.length;
+
+ if (size > longestCellByColumn[columnIndex]) {
+ longestCellByColumn[columnIndex] = size;
+ }
+
+ sizes[columnIndex] = size;
+ }
+
+ row[columnIndex] = cell;
+ }
+
+ // Inject the alignment row.
+ cellMatrix.splice(1, 0, row);
+ sizeMatrix.splice(1, 0, sizes);
+
+ rowIndex = -1;
+ const lines: string[] = [];
+
+ while (++rowIndex < cellMatrix.length) {
+ const matrixRow = cellMatrix[rowIndex];
+ const matrixSizes = sizeMatrix[rowIndex];
+ columnIndex = -1;
+ const line: string[] = [];
+
+ while (++columnIndex < mostCellsPerRow) {
+ const cell = matrixRow[columnIndex] || '';
+ let before = '';
+ let after = '';
+
+ if (options.alignDelimiters !== false) {
+ const size = longestCellByColumn[columnIndex] - (matrixSizes[columnIndex] || 0);
+ const code = alignments[columnIndex];
+
+ if (code === 114 /* `r` */) {
+ before = ' '.repeat(size);
+ } else if (code === 99 /* `c` */) {
+ if (size % 2) {
+ before = ' '.repeat(size / 2 + 0.5);
+ after = ' '.repeat(size / 2 - 0.5);
+ } else {
+ before = ' '.repeat(size / 2);
+ after = before;
+ }
+ } else {
+ after = ' '.repeat(size);
+ }
+ }
+
+ if (options.delimiterStart !== false && !columnIndex) {
+ line.push('|');
+ }
+
+ if (
+ options.padding !== false &&
+ // Don’t add the opening space if we’re not aligning and the cell is
+ // empty: there will be a closing space.
+ !(options.alignDelimiters === false && cell === '') &&
+ (options.delimiterStart !== false || columnIndex)
+ ) {
+ line.push(' ');
+ }
+
+ if (options.alignDelimiters !== false) {
+ line.push(before);
+ }
+
+ line.push(cell);
+
+ if (options.alignDelimiters !== false) {
+ line.push(after);
+ }
+
+ if (options.padding !== false) {
+ line.push(' ');
+ }
+
+ if (options.delimiterEnd !== false || columnIndex !== mostCellsPerRow - 1) {
+ line.push('|');
+ }
+ }
+
+ lines.push(options.delimiterEnd === false ? line.join('').replace(/ +$/, '') : line.join(''));
+ }
+
+ return lines.join('\n');
+}
+
+export default markdownTable;
diff --git a/tests/e2e/server/index.js b/tests/e2e/server/index.ts
similarity index 62%
rename from tests/e2e/server/index.js
rename to tests/e2e/server/index.ts
index 82d9f48e0269..7e7c34959655 100644
--- a/tests/e2e/server/index.js
+++ b/tests/e2e/server/index.ts
@@ -1,15 +1,42 @@
import {createServer} from 'http';
+import type {IncomingMessage, ServerResponse} from 'http';
+import type {NativeCommand, TestResult} from '@libs/E2E/client';
+import type {NetworkCacheMap, TestConfig} from '@libs/E2E/types';
import config from '../config';
import * as nativeCommands from '../nativeCommands';
import * as Logger from '../utils/logger';
import Routes from './routes';
-const PORT = process.env.PORT || config.SERVER_PORT;
+type NetworkCache = {
+ appInstanceId: string;
+ cache: NetworkCacheMap;
+};
+
+type RequestData = TestResult | NativeCommand | NetworkCache;
+
+type TestStartedListener = (testConfig?: TestConfig) => void;
+
+type TestDoneListener = () => void;
+
+type TestResultListener = (testResult: TestResult) => void;
+
+type AddListener = (listener: TListener) => void;
+
+type ServerInstance = {
+ setTestConfig: (testConfig: TestConfig) => void;
+ addTestStartedListener: AddListener;
+ addTestResultListener: AddListener;
+ addTestDoneListener: AddListener;
+ start: () => Promise;
+ stop: () => Promise;
+};
+
+const PORT = process.env.PORT ?? config.SERVER_PORT;
// Gets the request data as a string
-const getReqData = (req) => {
+const getReqData = (req: IncomingMessage): Promise => {
let data = '';
- req.on('data', (chunk) => {
+ req.on('data', (chunk: string) => {
data += chunk;
});
@@ -21,16 +48,16 @@ const getReqData = (req) => {
};
// Expects a POST request with JSON data. Returns parsed JSON data.
-const getPostJSONRequestData = (req, res) => {
+const getPostJSONRequestData = (req: IncomingMessage, res: ServerResponse): Promise | undefined => {
if (req.method !== 'POST') {
res.statusCode = 400;
res.end('Unsupported method');
return;
}
- return getReqData(req).then((data) => {
+ return getReqData(req).then((data): TRequestData | undefined => {
try {
- return JSON.parse(data);
+ return JSON.parse(data) as TRequestData;
} catch (e) {
Logger.info('❌ Failed to parse request data', data);
res.statusCode = 400;
@@ -39,9 +66,9 @@ const getPostJSONRequestData = (req, res) => {
});
};
-const createListenerState = () => {
- const listeners = [];
- const addListener = (listener) => {
+const createListenerState = (): [TListener[], AddListener] => {
+ const listeners: TListener[] = [];
+ const addListener = (listener: TListener) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
@@ -54,20 +81,6 @@ const createListenerState = () => {
return [listeners, addListener];
};
-/**
- * The test result object that a client might submit to the server.
- * @typedef TestResult
- * @property {string} name
- * @property {number} duration Milliseconds
- * @property {string} [error] Optional, if set indicates that the test run failed and has no valid results.
- */
-
-/**
- * @callback listener
- * @param {TestResult} testResult
- */
-
-// eslint-disable-next-line valid-jsdoc
/**
* Creates a new http server.
* The server just has two endpoints:
@@ -78,35 +91,32 @@ const createListenerState = () => {
*
* It returns an instance to which you can add listeners for the test results, and test done events.
*/
-const createServerInstance = () => {
- const [testStartedListeners, addTestStartedListener] = createListenerState();
- const [testResultListeners, addTestResultListener] = createListenerState();
- const [testDoneListeners, addTestDoneListener] = createListenerState();
-
- let activeTestConfig;
- const networkCache = {};
-
- /**
- * @param {TestConfig} testConfig
- */
- const setTestConfig = (testConfig) => {
+const createServerInstance = (): ServerInstance => {
+ const [testStartedListeners, addTestStartedListener] = createListenerState();
+ const [testResultListeners, addTestResultListener] = createListenerState();
+ const [testDoneListeners, addTestDoneListener] = createListenerState();
+
+ let activeTestConfig: TestConfig | undefined;
+ const networkCache: Record = {};
+
+ const setTestConfig = (testConfig: TestConfig) => {
activeTestConfig = testConfig;
};
- const server = createServer((req, res) => {
+ const server = createServer((req, res): ServerResponse | void => {
res.statusCode = 200;
switch (req.url) {
case Routes.testConfig: {
testStartedListeners.forEach((listener) => listener(activeTestConfig));
- if (activeTestConfig == null) {
+ if (!activeTestConfig) {
throw new Error('No test config set');
}
return res.end(JSON.stringify(activeTestConfig));
}
case Routes.testResults: {
- getPostJSONRequestData(req, res).then((data) => {
- if (data == null) {
+ getPostJSONRequestData(req, res)?.then((data) => {
+ if (!data) {
// The getPostJSONRequestData function already handled the response
return;
}
@@ -128,9 +138,9 @@ const createServerInstance = () => {
}
case Routes.testNativeCommand: {
- getPostJSONRequestData(req, res)
- .then((data) =>
- nativeCommands.executeFromPayload(data.actionName, data.payload).then((status) => {
+ getPostJSONRequestData(req, res)
+ ?.then((data) =>
+ nativeCommands.executeFromPayload(data?.actionName, data?.payload).then((status) => {
if (status) {
res.end('ok');
return;
@@ -148,8 +158,8 @@ const createServerInstance = () => {
}
case Routes.testGetNetworkCache: {
- getPostJSONRequestData(req, res).then((data) => {
- const appInstanceId = data && data.appInstanceId;
+ getPostJSONRequestData(req, res)?.then((data) => {
+ const appInstanceId = data?.appInstanceId;
if (!appInstanceId) {
res.statusCode = 400;
res.end('Invalid request missing appInstanceId');
@@ -164,9 +174,9 @@ const createServerInstance = () => {
}
case Routes.testUpdateNetworkCache: {
- getPostJSONRequestData(req, res).then((data) => {
- const appInstanceId = data && data.appInstanceId;
- const cache = data && data.cache;
+ getPostJSONRequestData(req, res)?.then((data) => {
+ const appInstanceId = data?.appInstanceId;
+ const cache = data?.cache;
if (!appInstanceId || !cache) {
res.statusCode = 400;
res.end('Invalid request missing appInstanceId or cache');
@@ -192,11 +202,11 @@ const createServerInstance = () => {
addTestResultListener,
addTestDoneListener,
start: () =>
- new Promise((resolve) => {
+ new Promise((resolve) => {
server.listen(PORT, resolve);
}),
stop: () =>
- new Promise((resolve) => {
+ new Promise((resolve) => {
server.close(resolve);
}),
};
diff --git a/tests/e2e/utils/getCurrentBranchName.js b/tests/e2e/utils/getCurrentBranchName.ts
similarity index 82%
rename from tests/e2e/utils/getCurrentBranchName.js
rename to tests/e2e/utils/getCurrentBranchName.ts
index 55df11010214..7ae958b08e13 100644
--- a/tests/e2e/utils/getCurrentBranchName.js
+++ b/tests/e2e/utils/getCurrentBranchName.ts
@@ -1,6 +1,6 @@
import {execSync} from 'child_process';
-const getCurrentBranchName = () => {
+const getCurrentBranchName = (): string => {
const stdout = execSync('git rev-parse --abbrev-ref HEAD', {
encoding: 'utf8',
});
diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.js
index 260a9da06c6b..6104ded05c6a 100644
--- a/tests/perf-test/OptionsSelector.perf-test.js
+++ b/tests/perf-test/OptionsSelector.perf-test.js
@@ -5,20 +5,6 @@ import _ from 'underscore';
import OptionsSelector from '@src/components/OptionsSelector';
import variables from '@src/styles/variables';
-jest.mock('@react-navigation/native', () => {
- const actualNav = jest.requireActual('@react-navigation/native');
- return {
- ...actualNav,
- useNavigation: () => ({
- navigate: jest.fn(),
- addListener: () => jest.fn(),
- }),
- useIsFocused: () => ({
- navigate: jest.fn(),
- }),
- };
-});
-
jest.mock('../../src/components/withLocalize', () => (Component) => {
function WrappedComponent(props) {
return (
diff --git a/tests/unit/loginTest.js b/tests/unit/loginTest.tsx
similarity index 84%
rename from tests/unit/loginTest.js
rename to tests/unit/loginTest.tsx
index b63564ff2812..d5084299bb08 100644
--- a/tests/unit/loginTest.js
+++ b/tests/unit/loginTest.tsx
@@ -1,14 +1,12 @@
-/**
- * @format
- */
import React from 'react';
import 'react-native';
// Note: `react-test-renderer` renderer must be required after react-native.
import renderer from 'react-test-renderer';
-import App from '../../src/App';
+import App from '@src/App';
// Needed for: https://stackoverflow.com/questions/76903168/mocking-libraries-in-jest
jest.mock('react-native/Libraries/LogBox/LogBox', () => ({
+ // eslint-disable-next-line @typescript-eslint/naming-convention
__esModule: true,
default: {
ignoreLogs: jest.fn(),