diff --git a/android/app/build.gradle b/android/app/build.gradle index 68d8b2be74f2..47f19acfe6ae 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 1001042508 - versionName "1.4.25-8" + versionCode 1001042601 + versionName "1.4.26-1" } flavorDimensions "default" diff --git a/contributingGuides/CONTRIBUTING.md b/contributingGuides/CONTRIBUTING.md index 186d7def3423..9eb16099f535 100644 --- a/contributingGuides/CONTRIBUTING.md +++ b/contributingGuides/CONTRIBUTING.md @@ -5,7 +5,7 @@ Welcome! Thanks for checking out the New Expensify app and taking the time to co If you would like to become an Expensify contributor, the first step is to read this document in its **entirety**. The second step is to review the README guidelines [here](https://github.com/Expensify/App/blob/main/README.md) to understand our coding philosophy and for a general overview of the code repository (i.e. how to run the app locally, testing, storage, our app philosophy, etc). Please read both documents before asking questions, as it may be covered within the documentation. #### Test Accounts -You can create as many accounts as needed in order to test your changes directly from [the app](https://new.expensify.com/). An initial account can be created when logging in for the first time, and additional accounts can be created by opening the "New Chat" or "Group Chat" pages via the Global Create menu, inputting a valid email or phone number, and tapping the user's avatar. Do use Expensify employee or customer accounts for testing. +You can create as many accounts as needed in order to test your changes directly from [the app](https://new.expensify.com/). An initial account can be created when logging in for the first time, and additional accounts can be created by opening the "New Chat" or "Group Chat" pages via the Global Create menu, inputting a valid email or phone number, and tapping the user's avatar. Do not use Expensify employee or customer accounts for testing. **Notes**: diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index dae0f631f480..bb67f5840fad 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.25 + 1.4.26 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.25.8 + 1.4.26.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 5da9c1a2410f..8d5fb7867c37 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.25 + 1.4.26 CFBundleSignature ???? CFBundleVersion - 1.4.25.8 + 1.4.26.1 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 96c65a348167..85f148305fde 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -3,9 +3,9 @@ CFBundleShortVersionString - 1.4.25 + 1.4.26 CFBundleVersion - 1.4.25.8 + 1.4.26.1 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 3487ecdc4111..ab98b21fca69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.25-8", + "version": "1.4.26-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.25-8", + "version": "1.4.26-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -94,7 +94,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.126", + "react-native-onyx": "1.0.118", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -47034,17 +47034,17 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.126", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.126.tgz", - "integrity": "sha512-tUJI1mQaWXLfyBFYQQWM6mm9GiCqIXGvjbqJkH1fLY3OqbGW6DyH4CxC+qJrqfi4bKZgZHp5xlBHhkPV4pKK2A==", + "version": "1.0.118", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.118.tgz", + "integrity": "sha512-w54jO+Bpu1ElHsrxZXIIpcBqNkrUvuVCQmwWdfOW5LvO4UwsPSwmMxzExbUZ4ip+7CROmm10IgXFaAoyfeYSVQ==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.6" }, "engines": { - "node": "20.9.0", - "npm": "10.1.0" + "node": ">=16.15.1 <=20.9.0", + "npm": ">=8.11.0 <=10.1.0" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -89702,9 +89702,9 @@ } }, "react-native-onyx": { - "version": "1.0.126", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.126.tgz", - "integrity": "sha512-tUJI1mQaWXLfyBFYQQWM6mm9GiCqIXGvjbqJkH1fLY3OqbGW6DyH4CxC+qJrqfi4bKZgZHp5xlBHhkPV4pKK2A==", + "version": "1.0.118", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.118.tgz", + "integrity": "sha512-w54jO+Bpu1ElHsrxZXIIpcBqNkrUvuVCQmwWdfOW5LvO4UwsPSwmMxzExbUZ4ip+7CROmm10IgXFaAoyfeYSVQ==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index 70d3eecc7f93..7d792cae8cc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.25-8", + "version": "1.4.26-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.", @@ -142,7 +142,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.126", + "react-native-onyx": "1.0.118", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", diff --git a/src/components/AddressSearch/CurrentLocationButton.js b/src/components/AddressSearch/CurrentLocationButton.tsx similarity index 74% rename from src/components/AddressSearch/CurrentLocationButton.js rename to src/components/AddressSearch/CurrentLocationButton.tsx index fc88aaa03fe5..11bd0a64eba5 100644 --- a/src/components/AddressSearch/CurrentLocationButton.js +++ b/src/components/AddressSearch/CurrentLocationButton.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -9,21 +8,9 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; import colors from '@styles/theme/colors'; +import type {CurrentLocationButtonProps} from './types'; -const propTypes = { - /** Callback that runs when location button is clicked */ - onPress: PropTypes.func, - - /** Boolean to indicate if the button is clickable */ - isDisabled: PropTypes.bool, -}; - -const defaultProps = { - isDisabled: false, - onPress: () => {}, -}; - -function CurrentLocationButton({onPress, isDisabled}) { +function CurrentLocationButton({onPress, isDisabled = false}: CurrentLocationButtonProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -32,7 +19,7 @@ function CurrentLocationButton({onPress, isDisabled}) { onPress?.()} accessibilityLabel={translate('location.useCurrent')} disabled={isDisabled} onMouseDown={(e) => e.preventDefault()} @@ -48,7 +35,5 @@ function CurrentLocationButton({onPress, isDisabled}) { } CurrentLocationButton.displayName = 'CurrentLocationButton'; -CurrentLocationButton.propTypes = propTypes; -CurrentLocationButton.defaultProps = defaultProps; export default CurrentLocationButton; diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.tsx similarity index 63% rename from src/components/AddressSearch/index.js rename to src/components/AddressSearch/index.tsx index 08760dc5a771..89e87eeebe54 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.tsx @@ -1,185 +1,79 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {ActivityIndicator, Keyboard, LogBox, ScrollView, View} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; -import _ from 'underscore'; +import type {GooglePlaceData, GooglePlaceDetail} from 'react-native-google-places-autocomplete'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import LocationErrorMessage from '@components/LocationErrorMessage'; -import networkPropTypes from '@components/networkPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ApiUtils from '@libs/ApiUtils'; -import compose from '@libs/compose'; import getCurrentPosition from '@libs/getCurrentPosition'; +import type {GeolocationErrorCodeType} from '@libs/getCurrentPosition/getCurrentPosition.types'; import * as GooglePlacesUtils from '@libs/GooglePlacesUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import CurrentLocationButton from './CurrentLocationButton'; import isCurrentTargetInsideContainer from './isCurrentTargetInsideContainer'; +import type {AddressSearchProps, RenamedInputKeysProps} from './types'; // The error that's being thrown below will be ignored until we fork the // react-native-google-places-autocomplete repo and replace the // VirtualizedList component with a VirtualizedList-backed instead LogBox.ignoreLogs(['VirtualizedLists should never be nested']); -const propTypes = { - /** The ID used to uniquely identify the input in a Form */ - inputID: PropTypes.string, - - /** Saves a draft of the input value when used in a form */ - shouldSaveDraft: PropTypes.bool, - - /** Callback that is called when the text input is blurred */ - onBlur: PropTypes.func, - - /** Error text to display */ - errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), - - /** Hint text to display */ - hint: PropTypes.string, - - /** The label to display for the field */ - label: PropTypes.string.isRequired, - - /** The value to set the field to initially */ - value: PropTypes.string, - - /** The value to set the field to initially */ - defaultValue: PropTypes.string, - - /** A callback function when the value of this field has changed */ - onInputChange: PropTypes.func.isRequired, - - /** A callback function when an address has been auto-selected */ - onPress: PropTypes.func, - - /** Customize the TextInput container */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), - - /** Should address search be limited to results in the USA */ - isLimitedToUSA: PropTypes.bool, - - /** Shows a current location button in suggestion list */ - canUseCurrentLocation: PropTypes.bool, - - /** A list of predefined places that can be shown when the user isn't searching for something */ - predefinedPlaces: PropTypes.arrayOf( - PropTypes.shape({ - /** A description of the location (usually the address) */ - description: PropTypes.string, - - /** The name of the location */ - name: PropTypes.string, - - /** Data required by the google auto complete plugin to know where to put the markers on the map */ - geometry: PropTypes.shape({ - /** Data about the location */ - location: PropTypes.shape({ - /** Lattitude of the location */ - lat: PropTypes.number, - - /** Longitude of the location */ - lng: PropTypes.number, - }), - }), - }), - ), - - /** A map of inputID key names */ - renamedInputKeys: PropTypes.shape({ - street: PropTypes.string, - street2: PropTypes.string, - city: PropTypes.string, - state: PropTypes.string, - lat: PropTypes.string, - lng: PropTypes.string, - zipCode: PropTypes.string, - }), - - /** Maximum number of characters allowed in search input */ - maxInputLength: PropTypes.number, - - /** The result types to return from the Google Places Autocomplete request */ - resultTypes: PropTypes.string, - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** Location bias for querying search results. */ - locationBias: PropTypes.string, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - inputID: undefined, - shouldSaveDraft: false, - onBlur: () => {}, - onPress: () => {}, - errorText: '', - hint: '', - value: undefined, - defaultValue: undefined, - containerStyles: [], - isLimitedToUSA: false, - canUseCurrentLocation: false, - renamedInputKeys: { - street: 'addressStreet', - street2: 'addressStreet2', - city: 'addressCity', - state: 'addressState', - zipCode: 'addressZipCode', - lat: 'addressLat', - lng: 'addressLng', - }, - maxInputLength: undefined, - predefinedPlaces: [], - resultTypes: 'address', - locationBias: undefined, -}; - -function AddressSearch({ - canUseCurrentLocation, - containerStyles, - defaultValue, - errorText, - hint, - innerRef, - inputID, - isLimitedToUSA, - label, - maxInputLength, - network, - onBlur, - onInputChange, - onPress, - predefinedPlaces, - preferredLocale, - renamedInputKeys, - resultTypes, - shouldSaveDraft, - translate, - value, - locationBias, -}) { +function AddressSearch( + { + canUseCurrentLocation = false, + containerStyles, + defaultValue, + errorText = '', + hint = '', + inputID, + isLimitedToUSA = false, + label, + maxInputLength, + onBlur, + onInputChange, + onPress, + predefinedPlaces = [], + preferredLocale, + renamedInputKeys = { + street: 'addressStreet', + street2: 'addressStreet2', + city: 'addressCity', + state: 'addressState', + zipCode: 'addressZipCode', + lat: 'addressLat', + lng: 'addressLng', + }, + resultTypes = 'address', + shouldSaveDraft = false, + value, + locationBias, + }: AddressSearchProps, + ref: ForwardedRef, +) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); const [displayListViewBorder, setDisplayListViewBorder] = useState(false); const [isTyping, setIsTyping] = useState(false); const [isFocused, setIsFocused] = useState(false); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [searchValue, setSearchValue] = useState(value || defaultValue || ''); - const [locationErrorCode, setLocationErrorCode] = useState(null); + const [locationErrorCode, setLocationErrorCode] = useState(null); const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false); const shouldTriggerGeolocationCallbacks = useRef(true); - const containerRef = useRef(); + const containerRef = useRef(null); const query = useMemo( () => ({ language: preferredLocale, @@ -190,18 +84,18 @@ function AddressSearch({ [preferredLocale, resultTypes, isLimitedToUSA, locationBias], ); const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; - const saveLocationDetails = (autocompleteData, details) => { - const addressComponents = details.address_components; + const saveLocationDetails = (autocompleteData: GooglePlaceData, details: GooglePlaceDetail | null) => { + const addressComponents = details?.address_components; if (!addressComponents) { // When there are details, but no address_components, this indicates that some predefined options have been passed // to this component which don't match the usual properties coming from auto-complete. In that case, only a limited // amount of data massaging needs to happen for what the parent expects to get from this function. - if (_.size(details)) { - onPress({ - address: autocompleteData.description || lodashGet(details, 'description', ''), - lat: lodashGet(details, 'geometry.location.lat', 0), - lng: lodashGet(details, 'geometry.location.lng', 0), - name: lodashGet(details, 'name'), + if (details) { + onPress?.({ + address: autocompleteData.description ?? '', + lat: details.geometry.location.lat ?? 0, + lng: details.geometry.location.lng ?? 0, + name: details.name, }); } return; @@ -220,14 +114,19 @@ function AddressSearch({ administrative_area_level_2: stateFallback, country: countryPrimary, } = GooglePlacesUtils.getAddressComponents(addressComponents, { + // eslint-disable-next-line @typescript-eslint/naming-convention street_number: 'long_name', route: 'long_name', subpremise: 'long_name', locality: 'long_name', sublocality: 'long_name', + // eslint-disable-next-line @typescript-eslint/naming-convention postal_town: 'long_name', + // eslint-disable-next-line @typescript-eslint/naming-convention postal_code: 'long_name', + // eslint-disable-next-line @typescript-eslint/naming-convention administrative_area_level_1: 'short_name', + // eslint-disable-next-line @typescript-eslint/naming-convention administrative_area_level_2: 'long_name', country: 'short_name', }); @@ -235,6 +134,7 @@ function AddressSearch({ // The state's iso code (short_name) is needed for the StatePicker component but we also // need the state's full name (long_name) when we render the state in a TextInput. const {administrative_area_level_1: longStateName} = GooglePlacesUtils.getAddressComponents(addressComponents, { + // eslint-disable-next-line @typescript-eslint/naming-convention administrative_area_level_1: 'long_name', }); @@ -244,15 +144,16 @@ function AddressSearch({ country: countryFallbackLongName = '', state: stateAutoCompleteFallback = '', city: cityAutocompleteFallback = '', - } = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData.terms); + } = GooglePlacesUtils.getPlaceAutocompleteTerms(autocompleteData?.terms ?? []); - const countryFallback = _.findKey(CONST.ALL_COUNTRIES, (country) => country === countryFallbackLongName); + const countryFallback = Object.keys(CONST.ALL_COUNTRIES).find((country) => country === countryFallbackLongName); - const country = countryPrimary || countryFallback; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const country = countryPrimary || countryFallback || ''; const values = { street: `${streetNumber} ${streetName}`.trim(), - name: lodashGet(details, 'name', ''), + name: details.name ?? '', // Autocomplete returns any additional valid address fragments (e.g. Apt #) as subpremise. street2: subpremise, // Make sure country is updated first, since city and state will be reset if the country changes @@ -265,9 +166,9 @@ function AddressSearch({ city: locality || postalTown || sublocality || cityAutocompleteFallback, zipCode, - lat: lodashGet(details, 'geometry.location.lat', 0), - lng: lodashGet(details, 'geometry.location.lng', 0), - address: autocompleteData.description || lodashGet(details, 'formatted_address', ''), + lat: details.geometry.location.lat ?? 0, + lng: details.geometry.location.lng ?? 0, + address: autocompleteData.description || details.formatted_address || '', }; // If the address is not in the US, use the full length state name since we're displaying the address's @@ -283,7 +184,7 @@ function AddressSearch({ } // Set the state to be the same as the city in case the state is empty. - if (_.isEmpty(values.state)) { + if (!values.state) { values.state = values.city; } @@ -291,8 +192,8 @@ function AddressSearch({ // We are setting up a fallback to ensure "values.street" is populated with a relevant value if (!values.street && details.adr_address) { const streetAddressRegex = /([^<]*)<\/span>/; - const adr_address = details.adr_address.match(streetAddressRegex); - const streetAddressFallback = lodashGet(adr_address, [1], null); + const adrAddress = details.adr_address.match(streetAddressRegex); + const streetAddressFallback = adrAddress ? adrAddress?.[1] : null; if (streetAddressFallback) { values.street = streetAddressFallback; } @@ -300,28 +201,28 @@ function AddressSearch({ // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 - if (subpremise && typeof renamedInputKeys.street2 === 'undefined') { + if (subpremise && typeof renamedInputKeys?.street2 === 'undefined') { values.street += `, ${subpremise}`; } - const isValidCountryCode = lodashGet(CONST.ALL_COUNTRIES, country); + const isValidCountryCode = !!Object.keys(CONST.ALL_COUNTRIES).find((foundCountry) => foundCountry === country); if (isValidCountryCode) { values.country = country; } if (inputID) { - _.each(values, (inputValue, key) => { - const inputKey = lodashGet(renamedInputKeys, key, key); + Object.entries(values).forEach(([key, inputValue]) => { + const inputKey = renamedInputKeys?.[key as keyof RenamedInputKeysProps] ?? key; if (!inputKey) { return; } - onInputChange(inputValue, inputKey); + onInputChange?.(inputValue, inputKey); }); } else { - onInputChange(values); + onInputChange?.(values); } - onPress(values); + onPress?.(values); }; /** Gets the user's current location and registers success/error callbacks */ @@ -352,7 +253,7 @@ function AddressSearch({ address: CONST.YOUR_LOCATION_TEXT, name: CONST.YOUR_LOCATION_TEXT, }; - onPress(location); + onPress?.(location); }, (errorData) => { if (!shouldTriggerGeolocationCallbacks.current) { @@ -369,19 +270,22 @@ function AddressSearch({ ); }; - const renderHeaderComponent = () => - predefinedPlaces.length > 0 && ( - <> - {/* This will show current location button in list if there are some recent destinations */} - {shouldShowCurrentLocationButton && ( - - )} - {!value && {translate('common.recentDestinations')}} - - ); + const renderHeaderComponent = () => ( + <> + {predefinedPlaces.length > 0 && ( + <> + {/* This will show current location button in list if there are some recent destinations */} + {shouldShowCurrentLocationButton && ( + + )} + {!value && {translate('common.recentDestinations')}} + + )} + + ); // eslint-disable-next-line arrow-body-style useEffect(() => { @@ -393,10 +297,8 @@ function AddressSearch({ const listEmptyComponent = useCallback( () => - network.isOffline || !isTyping ? null : ( - {translate('common.noResultsFound')} - ), - [network.isOffline, isTyping, styles, translate], + !!isOffline || !isTyping ? null : {translate('common.noResultsFound')}, + [isOffline, isTyping, styles, translate], ); const listLoader = useCallback( @@ -465,27 +367,15 @@ function AddressSearch({ query={query} requestUrl={{ useOnPlatform: 'all', - url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: isOffline ? '' : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, - ref: (node) => { - if (!innerRef) { - return; - } - - if (_.isFunction(innerRef)) { - innerRef(node); - return; - } - - // eslint-disable-next-line no-param-reassign - innerRef.current = node; - }, + ref, label, containerStyles, errorText, - hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, + hint: displayListViewBorder || (predefinedPlaces?.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, value, defaultValue, inputID, @@ -499,20 +389,19 @@ function AddressSearch({ setIsFocused(false); setIsTyping(false); } - onBlur(); + onBlur?.(); }, autoComplete: 'off', - onInputChange: (text) => { + onInputChange: (text: string) => { setSearchValue(text); setIsTyping(true); if (inputID) { - onInputChange(text); + onInputChange?.(text); } else { onInputChange({street: text}); } - // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) { + if (!text && !predefinedPlaces.length) { setDisplayListViewBorder(false); } }, @@ -531,22 +420,21 @@ function AddressSearch({ isRowScrollable={false} listHoverColor={theme.border} listUnderlayColor={theme.buttonPressedBG} - onLayout={(event) => { + onLayout={(event: LayoutChangeEvent) => { // We use the height of the element to determine if we should hide the border of the listView dropdown // to prevent a lingering border when there are no address suggestions. setDisplayListViewBorder(event.nativeEvent.layout.height > variables.googleEmptyListViewHeight); }} inbetweenCompo={ // We want to show the current location button even if there are no recent destinations - predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + predefinedPlaces?.length === 0 && + shouldShowCurrentLocationButton && ( - ) : ( - <> ) } placeholder="" @@ -562,18 +450,6 @@ function AddressSearch({ ); } -AddressSearch.propTypes = propTypes; -AddressSearch.defaultProps = defaultProps; -AddressSearch.displayName = 'AddressSearch'; - -const AddressSearchWithRef = React.forwardRef((props, ref) => ( - -)); - -AddressSearchWithRef.displayName = 'AddressSearchWithRef'; +AddressSearch.displayName = 'AddressSearchWithRef'; -export default compose(withNetwork(), withLocalize)(AddressSearchWithRef); +export default forwardRef(AddressSearch); diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.js deleted file mode 100644 index 18bfc10a8dcb..000000000000 --- a/src/components/AddressSearch/isCurrentTargetInsideContainer.js +++ /dev/null @@ -1,8 +0,0 @@ -function isCurrentTargetInsideContainer(event, containerRef) { - // The related target check is required here - // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false - // it will make the auto complete component re-render before onPress is called making selecting an option not working. - return containerRef.current && event.target && containerRef.current.contains(event.relatedTarget); -} - -export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js deleted file mode 100644 index dbf0004b08d9..000000000000 --- a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.js +++ /dev/null @@ -1,6 +0,0 @@ -function isCurrentTargetInsideContainer() { - // The related target check is not required here because in native there is no race condition rendering like on the web - return false; -} - -export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.native.ts b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.ts new file mode 100644 index 000000000000..b53b9e3ddec0 --- /dev/null +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.native.ts @@ -0,0 +1,6 @@ +import type {IsCurrentTargetInsideContainerType} from './types'; + +// The related target check is not required here because in native there is no race condition rendering like on the web +const isCurrentTargetInsideContainer: IsCurrentTargetInsideContainerType = () => false; + +export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/isCurrentTargetInsideContainer.ts b/src/components/AddressSearch/isCurrentTargetInsideContainer.ts new file mode 100644 index 000000000000..a50eb747b400 --- /dev/null +++ b/src/components/AddressSearch/isCurrentTargetInsideContainer.ts @@ -0,0 +1,14 @@ +import type {IsCurrentTargetInsideContainerType} from './types'; + +const isCurrentTargetInsideContainer: IsCurrentTargetInsideContainerType = (event, containerRef) => { + // The related target check is required here + // because without it when we select an option, the onBlur will still trigger setting displayListViewBorder to false + // it will make the auto complete component re-render before onPress is called making selecting an option not working. + if (!containerRef.current || !event.target || !('relatedTarget' in event) || !('contains' in containerRef.current)) { + return false; + } + + return !!containerRef.current.contains(event.relatedTarget as Node); +}; + +export default isCurrentTargetInsideContainer; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts new file mode 100644 index 000000000000..8016f1b2ea39 --- /dev/null +++ b/src/components/AddressSearch/types.ts @@ -0,0 +1,96 @@ +import type {RefObject} from 'react'; +import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, View, ViewStyle} from 'react-native'; +import type {Place} from 'react-native-google-places-autocomplete'; +import type Locale from '@src/types/onyx/Locale'; + +type CurrentLocationButtonProps = { + /** Callback that is called when the button is clicked */ + onPress?: () => void; + + /** Boolean to indicate if the button is clickable */ + isDisabled?: boolean; +}; + +type RenamedInputKeysProps = { + street: string; + street2: string; + city: string; + state: string; + lat: string; + lng: string; + zipCode: string; +}; + +type OnPressProps = { + address: string; + lat: number; + lng: number; + name: string; +}; + +type StreetValue = { + street: string; +}; + +type AddressSearchProps = { + /** The ID used to uniquely identify the input in a Form */ + inputID?: string; + + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft?: boolean; + + /** Callback that is called when the text input is blurred */ + onBlur?: () => void; + + /** Error text to display */ + errorText?: string; + + /** Hint text to display */ + hint?: string; + + /** The label to display for the field */ + label: string; + + /** The value to set the field to initially */ + value?: string; + + /** The value to set the field to initially */ + defaultValue?: string; + + /** A callback function when the value of this field has changed */ + onInputChange: (value: string | number | RenamedInputKeysProps | StreetValue, key?: string) => void; + + /** A callback function when an address has been auto-selected */ + onPress?: (props: OnPressProps) => void; + + /** Customize the TextInput container */ + containerStyles?: StyleProp; + + /** Should address search be limited to results in the USA */ + isLimitedToUSA?: boolean; + + /** Shows a current location button in suggestion list */ + canUseCurrentLocation?: boolean; + + /** A list of predefined places that can be shown when the user isn't searching for something */ + predefinedPlaces?: Place[]; + + /** A map of inputID key names */ + renamedInputKeys: RenamedInputKeysProps; + + /** Maximum number of characters allowed in search input */ + maxInputLength?: number; + + /** The result types to return from the Google Places Autocomplete request */ + resultTypes?: string; + + /** Location bias for querying search results. */ + locationBias?: string; + + /** The user's preferred locale e.g. 'en', 'es-ES' */ + preferredLocale?: Locale; +}; + +type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; + +export type {CurrentLocationButtonProps, AddressSearchProps, RenamedInputKeysProps, IsCurrentTargetInsideContainerType}; diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index a345ec72ad11..f25fc978e3ee 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -15,7 +15,7 @@ type ConfirmModalProps = { onConfirm: () => void; /** A callback to call when the form has been closed */ - onCancel?: (ref?: React.RefObject) => void; + onCancel?: () => void; /** Modal visibility */ isVisible: boolean; diff --git a/src/components/ContextMenuItem.tsx b/src/components/ContextMenuItem.tsx index 781d2f718bcf..13106dac1018 100644 --- a/src/components/ContextMenuItem.tsx +++ b/src/components/ContextMenuItem.tsx @@ -104,3 +104,4 @@ function ContextMenuItem( ContextMenuItem.displayName = 'ContextMenuItem'; export default forwardRef(ContextMenuItem); +export type {ContextMenuItemHandle}; diff --git a/src/components/LocationErrorMessage/types.ts b/src/components/LocationErrorMessage/types.ts index 41b71dbc3c69..27aa89d07ede 100644 --- a/src/components/LocationErrorMessage/types.ts +++ b/src/components/LocationErrorMessage/types.ts @@ -1,3 +1,5 @@ +import type {GeolocationErrorCodeType} from '@libs/getCurrentPosition/getCurrentPosition.types'; + type LocationErrorMessageProps = { /** A callback that runs when close icon is pressed */ onClose: () => void; @@ -9,7 +11,7 @@ type LocationErrorMessageProps = { * - code 2 = location is unavailable or there is some connection issue * - code 3 = location fetch timeout */ - locationErrorCode?: -1 | 1 | 2 | 3; + locationErrorCode?: GeolocationErrorCodeType | null; }; export default LocationErrorMessageProps; diff --git a/src/components/Modal/index.android.tsx b/src/components/Modal/index.android.tsx index 4d7ae128a114..86a1fd272185 100644 --- a/src/components/Modal/index.android.tsx +++ b/src/components/Modal/index.android.tsx @@ -1,6 +1,5 @@ import React from 'react'; import {AppState} from 'react-native'; -import withWindowDimensions from '@components/withWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; @@ -28,4 +27,4 @@ function Modal({useNativeDriver = true, ...rest}: BaseModalProps) { } Modal.displayName = 'Modal'; -export default withWindowDimensions(Modal); +export default Modal; diff --git a/src/components/Modal/index.ios.tsx b/src/components/Modal/index.ios.tsx index cbe58a071d7d..b26ba6cd0f89 100644 --- a/src/components/Modal/index.ios.tsx +++ b/src/components/Modal/index.ios.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import withWindowDimensions from '@components/withWindowDimensions'; import BaseModal from './BaseModal'; import type BaseModalProps from './types'; @@ -15,4 +14,4 @@ function Modal({children, ...rest}: BaseModalProps) { } Modal.displayName = 'Modal'; -export default withWindowDimensions(Modal); +export default Modal; diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx index 56f3c76a8879..71c0fe47ffca 100644 --- a/src/components/Modal/index.tsx +++ b/src/components/Modal/index.tsx @@ -1,5 +1,4 @@ import React, {useState} from 'react'; -import withWindowDimensions from '@components/withWindowDimensions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import StatusBar from '@libs/StatusBar'; @@ -55,4 +54,4 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = ( } Modal.displayName = 'Modal'; -export default withWindowDimensions(Modal); +export default Modal; diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts index d43f5ce67398..0773f0741233 100644 --- a/src/components/Modal/types.ts +++ b/src/components/Modal/types.ts @@ -1,7 +1,6 @@ -import type {View, ViewStyle} from 'react-native'; +import type {ViewStyle} from 'react-native'; import type {ModalProps} from 'react-native-modal'; import type {ValueOf} from 'type-fest'; -import type {WindowDimensionsProps} from '@components/withWindowDimensions/types'; import type CONST from '@src/CONST'; type PopoverAnchorPosition = { @@ -11,57 +10,56 @@ type PopoverAnchorPosition = { left?: number; }; -type BaseModalProps = WindowDimensionsProps & - Partial & { - /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ - fullscreen?: boolean; +type BaseModalProps = Partial & { + /** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */ + fullscreen?: boolean; - /** Should we close modal on outside click */ - shouldCloseOnOutsideClick?: boolean; + /** Should we close modal on outside click */ + shouldCloseOnOutsideClick?: boolean; - /** Should we announce the Modal visibility changes? */ - shouldSetModalVisibility?: boolean; + /** Should we announce the Modal visibility changes? */ + shouldSetModalVisibility?: boolean; - /** Callback method fired when the user requests to close the modal */ - onClose: (ref?: React.RefObject) => void; + /** Callback method fired when the user requests to close the modal */ + onClose: () => void; - /** State that determines whether to display the modal or not */ - isVisible: boolean; + /** State that determines whether to display the modal or not */ + isVisible: boolean; - /** Callback method fired when the user requests to submit the modal content. */ - onSubmit?: () => void; + /** Callback method fired when the user requests to submit the modal content. */ + onSubmit?: () => void; - /** Callback method fired when the modal is hidden */ - onModalHide?: () => void; + /** Callback method fired when the modal is hidden */ + onModalHide?: () => void; - /** Callback method fired when the modal is shown */ - onModalShow?: () => void; + /** Callback method fired when the modal is shown */ + onModalShow?: () => void; - /** Style of modal to display */ - type?: ValueOf; + /** Style of modal to display */ + type?: ValueOf; - /** The anchor position of a popover modal. Has no effect on other modal types. */ - popoverAnchorPosition?: PopoverAnchorPosition; + /** The anchor position of a popover modal. Has no effect on other modal types. */ + popoverAnchorPosition?: PopoverAnchorPosition; - outerStyle?: ViewStyle; + outerStyle?: ViewStyle; - /** Whether the modal should go under the system statusbar */ - statusBarTranslucent?: boolean; + /** Whether the modal should go under the system statusbar */ + statusBarTranslucent?: boolean; - /** Whether the modal should avoid the keyboard */ - avoidKeyboard?: boolean; + /** Whether the modal should avoid the keyboard */ + avoidKeyboard?: boolean; - /** Modal container styles */ - innerContainerStyle?: ViewStyle; + /** Modal container styles */ + innerContainerStyle?: ViewStyle; - /** - * Whether the modal should hide its content while animating. On iOS, set to true - * if `useNativeDriver` is also true, to avoid flashes in the UI. - * - * See: https://github.com/react-native-modal/react-native-modal/pull/116 - * */ - hideModalContentWhileAnimating?: boolean; - }; + /** + * Whether the modal should hide its content while animating. On iOS, set to true + * if `useNativeDriver` is also true, to avoid flashes in the UI. + * + * See: https://github.com/react-native-modal/react-native-modal/pull/116 + * */ + hideModalContentWhileAnimating?: boolean; +}; export default BaseModalProps; export type {PopoverAnchorPosition}; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index fe47a2e8cefe..58d022ef9d65 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -53,7 +53,7 @@ function PopoverWithoutOverlay( close: onClose, anchorRef, }); - removeOnClose = Modal.setCloseModal(() => onClose(anchorRef)); + removeOnClose = Modal.setCloseModal(onClose); } else { onModalHide(); close(anchorRef); diff --git a/src/components/QRShare/QRShareWithDownload/index.native.tsx b/src/components/QRShare/QRShareWithDownload/index.native.tsx index d1d9f13147f1..7d192c84c454 100644 --- a/src/components/QRShare/QRShareWithDownload/index.native.tsx +++ b/src/components/QRShare/QRShareWithDownload/index.native.tsx @@ -3,6 +3,7 @@ import React, {forwardRef, useImperativeHandle, useRef} from 'react'; import ViewShot from 'react-native-view-shot'; import getQrCodeFileName from '@components/QRShare/getQrCodeDownloadFileName'; import type {QRShareProps} from '@components/QRShare/types'; +import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import fileDownload from '@libs/fileDownload'; import QRShare from '..'; @@ -10,14 +11,16 @@ import type QRShareWithDownloadHandle from './types'; function QRShareWithDownload(props: QRShareProps, ref: ForwardedRef) { const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + const qrCodeScreenshotRef = useRef(null); useImperativeHandle( ref, () => ({ - download: () => qrCodeScreenshotRef.current?.capture?.().then((uri) => fileDownload(uri, getQrCodeFileName(props.title))), + download: () => qrCodeScreenshotRef.current?.capture?.().then((uri) => fileDownload(uri, getQrCodeFileName(props.title), translate('fileDownload.success.qrMessage'))), }), - [props.title], + [props.title, translate], ); return ( diff --git a/src/components/Reactions/MiniQuickEmojiReactions.tsx b/src/components/Reactions/MiniQuickEmojiReactions.tsx index 9f38da6bdb3d..1b489166e949 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.tsx +++ b/src/components/Reactions/MiniQuickEmojiReactions.tsx @@ -1,6 +1,5 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {Emoji} from '@assets/emojis/types'; import BaseMiniContextMenuItem from '@components/BaseMiniContextMenuItem'; @@ -16,16 +15,7 @@ import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportActionReactions} from '@src/types/onyx'; -import type {BaseQuickEmojiReactionsProps} from './QuickEmojiReactions/types'; - -type MiniQuickEmojiReactionsOnyxProps = { - /** All the emoji reactions for the report action. */ - emojiReactions: OnyxEntry; - - /** The user's preferred skin tone. */ - preferredSkinTone: OnyxEntry; -}; +import type {BaseQuickEmojiReactionsOnyxProps, BaseQuickEmojiReactionsProps} from './QuickEmojiReactions/types'; type MiniQuickEmojiReactionsProps = BaseQuickEmojiReactionsProps & { /** @@ -112,11 +102,14 @@ function MiniQuickEmojiReactions({ MiniQuickEmojiReactions.displayName = 'MiniQuickEmojiReactions'; -export default withOnyx({ +export default withOnyx({ preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, emojiReactions: { key: ({reportActionID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${reportActionID}`, }, + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, })(MiniQuickEmojiReactions); diff --git a/src/components/Reactions/QuickEmojiReactions/types.ts b/src/components/Reactions/QuickEmojiReactions/types.ts index d782d5ae35c7..9c17a87c56c0 100644 --- a/src/components/Reactions/QuickEmojiReactions/types.ts +++ b/src/components/Reactions/QuickEmojiReactions/types.ts @@ -11,18 +11,7 @@ type OpenPickerCallback = (element?: PickerRefElement, anchorOrigin?: AnchorOrig type CloseContextMenuCallback = () => void; -type BaseQuickEmojiReactionsOnyxProps = { - /** All the emoji reactions for the report action. */ - emojiReactions: OnyxEntry; - - /** The user's preferred locale. */ - preferredLocale: OnyxEntry; - - /** The user's preferred skin tone. */ - preferredSkinTone: OnyxEntry; -}; - -type BaseQuickEmojiReactionsProps = BaseQuickEmojiReactionsOnyxProps & { +type BaseReactionsProps = { /** Callback to fire when an emoji is selected. */ onEmojiSelected: (emoji: Emoji, emojiReactions: OnyxEntry) => void; @@ -45,7 +34,20 @@ type BaseQuickEmojiReactionsProps = BaseQuickEmojiReactionsOnyxProps & { reportActionID: string; }; -type QuickEmojiReactionsProps = BaseQuickEmojiReactionsProps & { +type BaseQuickEmojiReactionsOnyxProps = { + /** All the emoji reactions for the report action. */ + emojiReactions: OnyxEntry; + + /** The user's preferred locale. */ + preferredLocale: OnyxEntry; + + /** The user's preferred skin tone. */ + preferredSkinTone: OnyxEntry; +}; + +type BaseQuickEmojiReactionsProps = BaseReactionsProps & BaseQuickEmojiReactionsOnyxProps; + +type QuickEmojiReactionsProps = BaseReactionsProps & { /** * Function that can be called to close the context menu * in which this component is rendered. diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 96c9e1b364d6..9cb27e6fac4a 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -1,3 +1,4 @@ +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import {truncate} from 'lodash'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -12,6 +13,7 @@ import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithoutFeedback'; import refPropTypes from '@components/refPropTypes'; +import RenderHTML from '@components/RenderHTML'; import {showContextMenuForReport} from '@components/ShowContextMenuContext'; import Text from '@components/Text'; import transactionPropTypes from '@components/transactionPropTypes'; @@ -132,6 +134,7 @@ function MoneyRequestPreview(props) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const parser = new ExpensiMark(); if (_.isEmpty(props.iouReport) && !props.isBillSplit) { return null; @@ -328,7 +331,8 @@ function MoneyRequestPreview(props) { {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( {translate('iou.pendingConversionMessage')} )} - {(shouldShowDescription || shouldShowMerchant) && {merchantOrDescription}} + {shouldShowDescription && } + {shouldShowMerchant && {merchantOrDescription}} {props.isBillSplit && !_.isEmpty(participantAccountIDs) && requestAmount > 0 && ( diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx index a509d8d922e1..8ef837ed986d 100644 --- a/src/components/ReportActionItem/TaskPreview.tsx +++ b/src/components/ReportActionItem/TaskPreview.tsx @@ -1,5 +1,7 @@ import Str from 'expensify-common/lib/str'; import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {Text as RNText} from 'react-native'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -63,7 +65,7 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & chatReportID: string; /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: Element; + contextMenuAnchor: RNText | null; /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: () => void; @@ -112,7 +114,7 @@ function TaskPreview({ onPress={() => Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))} onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} onPressOut={() => ControlSelection.unblock()} - onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action ?? {}, checkIfContextMenuActive)} + onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)} style={[styles.flexRow, styles.justifyContentBetween]} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('task.task')} diff --git a/src/components/SelectionList/BaseListItem.js b/src/components/SelectionList/BaseListItem.js index cfd39ab0ebb8..6a067ea0fe3d 100644 --- a/src/components/SelectionList/BaseListItem.js +++ b/src/components/SelectionList/BaseListItem.js @@ -105,11 +105,11 @@ function BaseListItem({ textStyles={[ styles.optionDisplayName, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, - isUserItem || item.isSelected || item.alternateText ? styles.sidebarLinkTextBold : null, + styles.sidebarLinkTextBold, styles.pre, item.alternateText ? styles.mb1 : null, ]} - alternateTextStyles={[styles.optionAlternateText, styles.textLabelSupporting, isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText, styles.pre]} + alternateTextStyles={[styles.textLabelSupporting, styles.lh16, styles.pre]} isDisabled={isDisabled} onSelectRow={onSelectRow} showTooltip={showTooltip} diff --git a/src/components/ShowContextMenuContext.js b/src/components/ShowContextMenuContext.js deleted file mode 100644 index 04ccd5002b60..000000000000 --- a/src/components/ShowContextMenuContext.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; -import CONST from '@src/CONST'; - -const ShowContextMenuContext = React.createContext({ - anchor: null, - report: null, - action: undefined, - checkIfContextMenuActive: () => {}, -}); - -ShowContextMenuContext.displayName = 'ShowContextMenuContext'; - -/** - * Show the report action context menu. - * - * @param {Object} event - Press event object - * @param {Element} anchor - Context menu anchor - * @param {String} reportID - Active Report ID - * @param {Object} action - ReportAction for ContextMenu - * @param {Function} checkIfContextMenuActive Callback to update context menu active state - * @param {Boolean} [isArchivedRoom=false] - Is the report an archived room - */ -function showContextMenuForReport(event, anchor, reportID, action, checkIfContextMenuActive, isArchivedRoom = false) { - if (!DeviceCapabilities.canUseTouchScreen()) { - return; - } - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - '', - anchor, - reportID, - action.reportActionID, - ReportUtils.getOriginalReportID(reportID, action), - undefined, - checkIfContextMenuActive, - checkIfContextMenuActive, - isArchivedRoom, - ); -} - -export {ShowContextMenuContext, showContextMenuForReport}; diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts new file mode 100644 index 000000000000..17557051bef9 --- /dev/null +++ b/src/components/ShowContextMenuContext.ts @@ -0,0 +1,64 @@ +import {createContext} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Text as RNText} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; +import CONST from '@src/CONST'; +import type {Report, ReportAction} from '@src/types/onyx'; + +type ShowContextMenuContextProps = { + anchor: RNText | null; + report: OnyxEntry; + action: OnyxEntry; + checkIfContextMenuActive: () => void; +}; + +const ShowContextMenuContext = createContext({ + anchor: null, + report: null, + action: null, + checkIfContextMenuActive: () => {}, +}); + +ShowContextMenuContext.displayName = 'ShowContextMenuContext'; + +/** + * Show the report action context menu. + * + * @param event - Press event object + * @param anchor - Context menu anchor + * @param reportID - Active Report ID + * @param action - ReportAction for ContextMenu + * @param checkIfContextMenuActive Callback to update context menu active state + * @param isArchivedRoom - Is the report an archived room + */ +function showContextMenuForReport( + event: GestureResponderEvent | MouseEvent, + anchor: RNText | null, + reportID: string, + action: OnyxEntry, + checkIfContextMenuActive: () => void, + isArchivedRoom = false, +) { + if (!DeviceCapabilities.canUseTouchScreen()) { + return; + } + + ReportActionContextMenu.showContextMenu( + CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + '', + anchor, + reportID, + action?.reportActionID, + ReportUtils.getOriginalReportID(reportID, action), + undefined, + checkIfContextMenuActive, + checkIfContextMenuActive, + isArchivedRoom, + ); +} + +export {ShowContextMenuContext, showContextMenuForReport}; diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index d19d835d68bb..99b3e98588ac 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -263,7 +263,7 @@ function BaseTextInput( return ( <> diff --git a/src/languages/en.ts b/src/languages/en.ts index b6fa37560536..e9a698fd4d82 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1803,6 +1803,7 @@ export default { success: { title: 'Downloaded!', message: 'Attachment successfully downloaded!', + qrMessage: 'Check your photos or downloads folder for a copy of your QR code. Protip: Add it to a presentation for your audience to scan and connect with you directly.', }, generalError: { title: 'Attachment Error', @@ -2032,9 +2033,11 @@ export default { cardDamaged: 'My card was damaged', cardLostOrStolen: 'My card was lost or stolen', confirmAddressTitle: "Please confirm the address below is where you'd like us to send your new card.", - currentCardInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.', + cardDamagedInfo: 'Your new card will arrive in 2-3 business days, and your existing card will continue to work until you activate your new one.', + cardLostOrStolenInfo: 'Your current card will be permanently deactivated as soon as your order is placed. Most cards arrive in a few business days.', address: 'Address', deactivateCardButton: 'Deactivate card', + shipNewCardButton: 'Ship new card', addressError: 'Address is required', reasonError: 'Reason is required', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 271f0787bfde..97cc7077c4e2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1829,6 +1829,8 @@ export default { success: { title: '!Descargado!', message: 'Archivo descargado correctamente', + qrMessage: + 'Busca la copia de tu código QR en la carpeta de fotos o descargas. Consejo: Añádelo a una presentación para que el público pueda escanearlo y conectar contigo directamente.', }, generalError: { title: 'Error en la descarga', @@ -2519,9 +2521,11 @@ export default { cardDamaged: 'Mi tarjeta está dañada', cardLostOrStolen: 'He perdido o me han robado la tarjeta', confirmAddressTitle: 'Confirma que la dirección que aparece a continuación es a la que deseas que te enviemos tu nueva tarjeta.', - currentCardInfo: 'La tarjeta actual se desactivará permanentemente en cuanto se realice el pedido. La mayoría de las tarjetas llegan en unos pocos días laborables.', + cardDamagedInfo: 'La nueva tarjeta te llegará en 2-3 días laborables y la tarjeta actual seguirá funcionando hasta que actives la nueva.', + cardLostOrStolenInfo: 'La tarjeta actual se desactivará permanentemente en cuanto realices el pedido. La mayoría de las tarjetas llegan en pocos días laborables.', address: 'Dirección', deactivateCardButton: 'Desactivar tarjeta', + shipNewCardButton: 'Enviar tarjeta nueva', addressError: 'La dirección es obligatoria', reasonError: 'Se requiere justificación', }, diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 37b7a9424fee..9052819f731f 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -436,10 +436,9 @@ function getLastMessageTextForReport(report) { lastMessageTextFromReport = lodashGet(lastReportAction, 'message[0].text', ''); } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); - } else { - lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; } - return lastMessageTextFromReport; + + return lastMessageTextFromReport || lodashGet(report, 'lastMessageText', ''); } /** diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index ee4fa201ee2f..f967cb244268 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -6,9 +6,9 @@ import OnyxUtils from 'react-native-onyx/lib/utils'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ActionName, ChangeLog} from '@src/types/onyx/OriginalMessage'; +import type {ActionName, ChangeLog, OriginalMessageReimbursementDequeued} from '@src/types/onyx/OriginalMessage'; import type Report from '@src/types/onyx/Report'; -import type {Message, ReportActions} from '@src/types/onyx/ReportAction'; +import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -136,7 +136,7 @@ function isInviteMemberAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM; } -function isReimbursementDeQueuedAction(reportAction: OnyxEntry): boolean { +function isReimbursementDeQueuedAction(reportAction: OnyxEntry): reportAction is ReportActionBase & OriginalMessageReimbursementDequeued { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 76e4da2233c6..e4b82aa36c7a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -16,7 +16,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, PolicyReportField, Report, ReportAction, ReportMetadata, Session, Transaction} from '@src/types/onyx'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; -import type {IOUMessage, OriginalMessageActionName, OriginalMessageCreated, ReimbursementDeQueuedMessage} from '@src/types/onyx/OriginalMessage'; +import type {IOUMessage, OriginalMessageActionName, OriginalMessageCreated, OriginalMessageReimbursementDequeued, ReimbursementDeQueuedMessage} from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type {NotificationPreference} from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; @@ -1609,7 +1609,7 @@ function getReimbursementQueuedActionMessage(reportAction: OnyxEntry, report: OnyxEntry): string { +function getReimbursementDeQueuedActionMessage(reportAction: OnyxEntry, report: OnyxEntry | EmptyObject): string { const amount = CurrencyUtils.convertToDisplayString(Math.abs(report?.total ?? 0), report?.currency); const originalMessage = reportAction?.originalMessage as ReimbursementDeQueuedMessage | undefined; if (originalMessage?.cancellationReason === CONST.REPORT.CANCEL_PAYMENT_REASONS.ADMIN) { @@ -4404,9 +4404,9 @@ function getReportFieldTitle(report: OnyxEntry, reportField: PolicyRepor /** * Checks if thread replies should be displayed */ -function shouldDisplayThreadReplies(reportAction: ReportAction, reportID: string): boolean { - const hasReplies = (reportAction.childVisibleActionCount ?? 0) > 0; - return hasReplies && !!reportAction.childCommenterCount && !isThreadFirstChat(reportAction, reportID); +function shouldDisplayThreadReplies(reportAction: OnyxEntry, reportID: string): boolean { + const hasReplies = (reportAction?.childVisibleActionCount ?? 0) > 0; + return hasReplies && !!reportAction?.childCommenterCount && !isThreadFirstChat(reportAction, reportID); } /** @@ -4418,7 +4418,7 @@ function shouldDisplayThreadReplies(reportAction: ReportAction, reportID: string * - The action is a whisper action and it's neither a report preview nor IOU action * - The action is the thread's first chat */ -function shouldDisableThread(reportAction: ReportAction, reportID: string) { +function shouldDisableThread(reportAction: OnyxEntry, reportID: string) { const isSplitBillAction = ReportActionsUtils.isSplitBillAction(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); @@ -4426,9 +4426,9 @@ function shouldDisableThread(reportAction: ReportAction, reportID: string) { const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction); return ( - CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction.actionName) || + CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction?.actionName) || isSplitBillAction || - (isDeletedAction && !reportAction.childVisibleActionCount) || + (isDeletedAction && !reportAction?.childVisibleActionCount) || (isWhisperAction && !isReportPreviewAction && !isIOUAction) || isThreadFirstChat(reportAction, reportID) ); diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 172b0ac73ca6..aa892d3817aa 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -52,10 +52,10 @@ function reportVirtualExpensifyCardFraud(cardID: number) { /** * Call the API to deactivate the card and request a new one - * @param cardId - id of the card that is going to be replaced + * @param cardID - id of the card that is going to be replaced * @param reason - reason for replacement */ -function requestReplacementExpensifyCard(cardId: number, reason: ReplacementReason) { +function requestReplacementExpensifyCard(cardID: number, reason: ReplacementReason) { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -88,12 +88,12 @@ function requestReplacementExpensifyCard(cardId: number, reason: ReplacementReas ]; type RequestReplacementExpensifyCardParams = { - cardId: number; + cardID: number; reason: string; }; const parameters: RequestReplacementExpensifyCardParams = { - cardId, + cardID, reason, }; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 421e72d2392c..430d88b98569 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -2491,7 +2491,7 @@ function updateMoneyRequestAmountAndCurrency(transactionID, transactionThreadRep } /** - * @param {String} transactionID + * @param {String | undefined} transactionID * @param {Object} reportAction - the money request reportAction we are deleting * @param {Boolean} isSingleTransactionView */ @@ -3405,7 +3405,6 @@ function cancelPayment(expenseReport, chatReport) { key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, value: { ...chatReport, - hasOutstandingIOU: true, hasOutstandingChildRequest: true, iouReportID: expenseReport.reportID, }, @@ -3449,7 +3448,6 @@ function cancelPayment(expenseReport, chatReport) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, value: { - hasOutstandingIOU: false, hasOutstandingChildRequest: false, iouReportID: 0, }, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9067e5592937..6099b06c70eb 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2,7 +2,7 @@ import {format as timezoneFormat, utcToZonedTime} from 'date-fns-tz'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import isEmpty from 'lodash/isEmpty'; -import {DeviceEventEmitter, InteractionManager} from 'react-native'; +import {DeviceEventEmitter, InteractionManager, Linking} from 'react-native'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {NullishDeep} from 'react-native-onyx/lib/types'; @@ -127,6 +127,14 @@ const allReports: OnyxCollection = {}; let conciergeChatReportID: string | undefined; 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; +}); + /** Get the private pusher channel name for a Report. */ function getReportChannelName(reportID: string): string { return `${CONST.PUSHER.PRIVATE_REPORT_CHANNEL_PREFIX}${reportID}${CONFIG.PUSHER.SUFFIX}`; @@ -343,6 +351,7 @@ function addActions(reportID: string, text = '', file?: File) { timezone?: string; shouldAllowActionableMentionWhispers?: boolean; clientCreatedTime?: string; + isOldDotConciergeChat?: boolean; }; const parameters: AddCommentOrAttachementParameters = { @@ -355,6 +364,10 @@ function addActions(reportID: string, text = '', file?: File) { clientCreatedTime: file ? attachmentAction?.created : reportCommentAction?.created, }; + if (reportIDDeeplinkedFromOldDot === reportID && report?.participantAccountIDs?.length === 1 && Number(report.participantAccountIDs?.[0]) === CONST.ACCOUNT_ID.CONCIERGE) { + parameters.isOldDotConciergeChat = true; + } + const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1988,7 +2001,7 @@ function toggleEmojiReaction( reportID: string, reportAction: ReportAction, reactionObject: Emoji, - existingReactions: ReportActionReactions | undefined, + existingReactions: OnyxEntry, paramSkinTone: number = preferredSkinTone, ) { const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index 09cc1222310f..055abf140e64 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -7,11 +7,12 @@ import type {ReadFileAsync, SplitExtensionFromFileName} from './types'; /** * Show alert on successful attachment download + * @param successMessage */ -function showSuccessAlert() { +function showSuccessAlert(successMessage?: string) { Alert.alert( Localize.translateLocal('fileDownload.success.title'), - Localize.translateLocal('fileDownload.success.message'), + successMessage ?? Localize.translateLocal('fileDownload.success.message'), [ { text: Localize.translateLocal('common.ok'), diff --git a/src/libs/fileDownload/index.android.ts b/src/libs/fileDownload/index.android.ts index 577a42dd14a3..7c9d3caf6865 100644 --- a/src/libs/fileDownload/index.android.ts +++ b/src/libs/fileDownload/index.android.ts @@ -33,7 +33,7 @@ function hasAndroidPermission(): Promise { /** * Handling the download */ -function handleDownload(url: string, fileName: string): Promise { +function handleDownload(url: string, fileName: string, successMessage?: string): Promise { return new Promise((resolve) => { const dirs = RNFetchBlob.fs.dirs; @@ -84,7 +84,7 @@ function handleDownload(url: string, fileName: string): Promise { if (attachmentPath) { RNFetchBlob.fs.unlink(attachmentPath); } - FileUtils.showSuccessAlert(); + FileUtils.showSuccessAlert(successMessage); }) .catch(() => { FileUtils.showGeneralErrorAlert(); @@ -96,12 +96,12 @@ function handleDownload(url: string, fileName: string): Promise { /** * Checks permission and downloads the file for Android */ -const fileDownload: FileDownload = (url, fileName) => +const fileDownload: FileDownload = (url, fileName, successMessage) => new Promise((resolve) => { hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { - return handleDownload(url, fileName); + return handleDownload(url, fileName, successMessage); } FileUtils.showPermissionErrorAlert(); }) diff --git a/src/libs/fileDownload/index.ios.ts b/src/libs/fileDownload/index.ios.ts index 7672b4b14926..4990c389fd9f 100644 --- a/src/libs/fileDownload/index.ios.ts +++ b/src/libs/fileDownload/index.ios.ts @@ -69,7 +69,7 @@ function downloadVideo(fileUrl: string, fileName: string): Promise { /** * Download the file based on type(image, video, other file types)for iOS */ -const fileDownload: FileDownload = (fileUrl, fileName) => +const fileDownload: FileDownload = (fileUrl, fileName, successMessage) => new Promise((resolve) => { let fileDownloadPromise; const fileType = FileUtils.getFileType(fileUrl); @@ -93,7 +93,7 @@ const fileDownload: FileDownload = (fileUrl, fileName) => return; } - FileUtils.showSuccessAlert(); + FileUtils.showSuccessAlert(successMessage); }) .catch((err) => { // iOS shows permission popup only once. Subsequent request will only throw an error. diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts index bc8ba0807eb1..6d92bddd5816 100644 --- a/src/libs/fileDownload/types.ts +++ b/src/libs/fileDownload/types.ts @@ -1,6 +1,6 @@ import type {Asset} from 'react-native-image-picker'; -type FileDownload = (url: string, fileName: string) => Promise; +type FileDownload = (url: string, fileName: string, successMessage?: string) => Promise; type ImageResolution = {width: number; height: number}; type GetImageResolution = (url: File | Asset) => Promise; diff --git a/src/libs/getCurrentPosition/getCurrentPosition.types.ts b/src/libs/getCurrentPosition/getCurrentPosition.types.ts index 792f51ceba7d..67a510efa97f 100644 --- a/src/libs/getCurrentPosition/getCurrentPosition.types.ts +++ b/src/libs/getCurrentPosition/getCurrentPosition.types.ts @@ -1,3 +1,5 @@ +import type {ValueOf} from 'type-fest'; + type GeolocationSuccessCallback = (position: { coords: { latitude: number; @@ -11,8 +13,10 @@ type GeolocationSuccessCallback = (position: { timestamp: number; }) => void; +type GeolocationErrorCodeType = ValueOf | null; + type GeolocationErrorCallback = (error: { - code: (typeof GeolocationErrorCode)[keyof typeof GeolocationErrorCode]; + code: GeolocationErrorCodeType; message: string; PERMISSION_DENIED: typeof GeolocationErrorCode.PERMISSION_DENIED; POSITION_UNAVAILABLE: typeof GeolocationErrorCode.POSITION_UNAVAILABLE; @@ -51,4 +55,4 @@ type GetCurrentPosition = (success: GeolocationSuccessCallback, error: Geolocati export {GeolocationErrorCode}; -export type {GeolocationSuccessCallback, GeolocationErrorCallback, GeolocationOptions, GetCurrentPosition}; +export type {GeolocationSuccessCallback, GeolocationErrorCallback, GeolocationOptions, GetCurrentPosition, GeolocationErrorCodeType}; diff --git a/src/libs/localFileDownload/index.android.ts b/src/libs/localFileDownload/index.android.ts index b6d8ea13738f..dd266d3be405 100644 --- a/src/libs/localFileDownload/index.android.ts +++ b/src/libs/localFileDownload/index.android.ts @@ -7,7 +7,7 @@ import type LocalFileDownload from './types'; * and textContent, so we're able to copy it to the Android public download dir. * After the file is copied, it is removed from the internal dir. */ -const localFileDownload: LocalFileDownload = (fileName, textContent) => { +const localFileDownload: LocalFileDownload = (fileName, textContent, successMessage) => { const newFileName = FileUtils.appendTimeToFileName(fileName); const dir = RNFetchBlob.fs.dirs.DocumentDir; const path = `${dir}/${newFileName}.txt`; @@ -23,7 +23,7 @@ const localFileDownload: LocalFileDownload = (fileName, textContent) => { path, ) .then(() => { - FileUtils.showSuccessAlert(); + FileUtils.showSuccessAlert(successMessage); }) .catch(() => { FileUtils.showGeneralErrorAlert(); diff --git a/src/libs/localFileDownload/types.ts b/src/libs/localFileDownload/types.ts index 2086e2334d39..68e013e60bb3 100644 --- a/src/libs/localFileDownload/types.ts +++ b/src/libs/localFileDownload/types.ts @@ -1,3 +1,3 @@ -type LocalFileDownload = (fileName: string, textContent: string) => void; +type LocalFileDownload = (fileName: string, textContent: string, successMessage?: string) => void; export default LocalFileDownload; diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js deleted file mode 100755 index fc06176edd3b..000000000000 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js +++ /dev/null @@ -1,204 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {memo, useMemo, useRef, useState} from 'react'; -import {InteractionManager, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import ContextMenuItem from '@components/ContextMenuItem'; -import {withBetas} from '@components/OnyxProvider'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; -import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; -import useNetwork from '@hooks/useNetwork'; -import useStyleUtils from '@hooks/useStyleUtils'; -import compose from '@libs/compose'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ContextMenuActions from './ContextMenuActions'; -import {defaultProps as GenericReportActionContextMenuDefaultProps, propTypes as genericReportActionContextMenuPropTypes} from './genericReportActionContextMenuPropTypes'; -import {hideContextMenu} from './ReportActionContextMenu'; - -const propTypes = { - /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ - type: PropTypes.string, - - /** Target node which is the target of ContentMenu */ - anchor: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), - - /** Flag to check if the chat participant is Chronos */ - isChronosReport: PropTypes.bool, - - /** Whether the provided report is an archived room */ - isArchivedRoom: PropTypes.bool, - - contentRef: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.func]), - - ...genericReportActionContextMenuPropTypes, - ...withLocalizePropTypes, - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - type: CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - anchor: null, - contentRef: null, - isChronosReport: false, - isArchivedRoom: false, - ...GenericReportActionContextMenuDefaultProps, -}; -function BaseReportActionContextMenu(props) { - const StyleUtils = useStyleUtils(); - const menuItemRefs = useRef({}); - const [shouldKeepOpen, setShouldKeepOpen] = useState(false); - const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth); - const {isOffline} = useNetwork(); - - const reportAction = useMemo(() => { - if (_.isEmpty(props.reportActions) || props.reportActionID === '0') { - return {}; - } - return props.reportActions[props.reportActionID] || {}; - }, [props.reportActions, props.reportActionID]); - - const shouldShowFilter = (contextAction) => - contextAction.shouldShow( - props.type, - reportAction, - props.isArchivedRoom, - props.betas, - props.anchor, - props.isChronosReport, - props.reportID, - props.isPinnedChat, - props.isUnreadChat, - isOffline, - ); - - const shouldEnableArrowNavigation = !props.isMini && (props.isVisible || shouldKeepOpen); - const filteredContextMenuActions = _.filter(ContextMenuActions, shouldShowFilter); - - // Context menu actions that are not rendered as menu items are excluded from arrow navigation - const nonMenuItemActionIndexes = _.map(filteredContextMenuActions, (contextAction, index) => (_.isFunction(contextAction.renderContent) ? index : undefined)); - const disabledIndexes = _.filter(nonMenuItemActionIndexes, (index) => !_.isUndefined(index)); - - const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ - initialFocusedIndex: -1, - disabledIndexes, - maxIndex: filteredContextMenuActions.length - 1, - isActive: shouldEnableArrowNavigation, - }); - - /** - * Checks if user is anonymous. If true and the action doesn't accept for anonymous user, hides the context menu and - * shows the sign in modal. Else, executes the callback. - * - * @param {Function} callback - * @param {Boolean} isAnonymousAction - */ - const interceptAnonymousUser = (callback, isAnonymousAction = false) => { - if (Session.isAnonymousUser() && !isAnonymousAction) { - hideContextMenu(false); - - InteractionManager.runAfterInteractions(() => { - Session.signOutAndRedirectToSignIn(); - }); - } else { - callback(); - } - }; - - useKeyboardShortcut( - CONST.KEYBOARD_SHORTCUTS.ENTER, - (event) => { - if (!menuItemRefs.current[focusedIndex]) { - return; - } - - // Ensures the event does not cause side-effects beyond the context menu, e.g. when an outside element is focused - if (event) { - event.stopPropagation(); - } - - menuItemRefs.current[focusedIndex].triggerPressAndUpdateSuccess(); - setFocusedIndex(-1); - }, - {isActive: shouldEnableArrowNavigation}, - ); - - return ( - (props.isVisible || shouldKeepOpen) && ( - - {_.map(filteredContextMenuActions, (contextAction, index) => { - const closePopup = !props.isMini; - const payload = { - reportAction, - reportID: props.reportID, - draftMessage: props.draftMessage, - selection: props.selection, - close: () => setShouldKeepOpen(false), - openContextMenu: () => setShouldKeepOpen(true), - interceptAnonymousUser, - }; - - if (contextAction.renderContent) { - // make sure that renderContent isn't mixed with unsupported props - if (__DEV__ && (contextAction.text != null || contextAction.icon != null)) { - throw new Error('Dev error: renderContent() and text/icon cannot be used together.'); - } - - return contextAction.renderContent(closePopup, payload); - } - - return ( - { - menuItemRefs.current[index] = ref; - }} - icon={contextAction.icon} - text={props.translate(contextAction.textTranslateKey, {action: reportAction})} - successIcon={contextAction.successIcon} - successText={contextAction.successTextTranslateKey ? props.translate(contextAction.successTextTranslateKey) : undefined} - isMini={props.isMini} - key={contextAction.textTranslateKey} - onPress={() => interceptAnonymousUser(() => contextAction.onPress(closePopup, payload), contextAction.isAnonymousAction)} - description={contextAction.getDescription(props.selection, props.isSmallScreenWidth)} - isAnonymousAction={contextAction.isAnonymousAction} - isFocused={focusedIndex === index} - /> - ); - })} - - ) - ); -} - -BaseReportActionContextMenu.propTypes = propTypes; -BaseReportActionContextMenu.defaultProps = defaultProps; - -export default compose( - withLocalize, - withBetas(), - withWindowDimensions, - withOnyx({ - reportActions: { - key: ({originalReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, - canEvict: false, - }, - }), -)( - memo(BaseReportActionContextMenu, (prevProps, nextProps) => { - const prevReportAction = lodashGet(prevProps.reportActions, prevProps.reportActionID, ''); - const nextReportAction = lodashGet(nextProps.reportActions, nextProps.reportActionID, ''); - - // We only want to re-render when the report action that is attached to is changed - if (prevReportAction !== nextReportAction) { - return false; - } - return _.isEqual(_.omit(prevProps, 'reportActions'), _.omit(nextProps, 'reportActions')); - }), -); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx new file mode 100755 index 000000000000..a9fe2b482ae1 --- /dev/null +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -0,0 +1,245 @@ +import lodashIsEqual from 'lodash/isEqual'; +import type {MutableRefObject, RefObject} from 'react'; +import React, {memo, useMemo, useRef, useState} from 'react'; +import {InteractionManager, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import type {ContextMenuItemHandle} from '@components/ContextMenuItem'; +import ContextMenuItem from '@components/ContextMenuItem'; +import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; +import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Beta, ReportAction, ReportActions} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {ContextMenuActionPayload} from './ContextMenuActions'; +import ContextMenuActions from './ContextMenuActions'; +import type {ContextMenuType} from './ReportActionContextMenu'; +import {hideContextMenu} from './ReportActionContextMenu'; + +type BaseReportActionContextMenuOnyxProps = { + /** Beta features list */ + betas: OnyxEntry; + + /** All of the actions of the report */ + reportActions: OnyxEntry; +}; + +type BaseReportActionContextMenuProps = BaseReportActionContextMenuOnyxProps & { + /** The ID of the report this report action is attached to. */ + reportID: string; + + /** The ID of the report action this context menu is attached to. */ + reportActionID: string; + + /** The ID of the original report from which the given reportAction is first created. */ + // originalReportID is used in withOnyx to get the reportActions for the original report + // eslint-disable-next-line react/no-unused-prop-types + originalReportID: string; + + /** + * If true, this component will be a small, row-oriented menu that displays icons but not text. + * If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. + */ + isMini?: boolean; + + /** Controls the visibility of this component. */ + isVisible?: boolean; + + /** The copy selection. */ + selection?: string; + + /** Draft message - if this is set the comment is in 'edit' mode */ + draftMessage?: string; + + /** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */ + type?: ContextMenuType; + + /** Target node which is the target of ContentMenu */ + anchor?: MutableRefObject; + + /** Flag to check if the chat participant is Chronos */ + isChronosReport?: boolean; + + /** Whether the provided report is an archived room */ + isArchivedRoom?: boolean; + + /** Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action */ + isPinnedChat?: boolean; + + /** Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ + isUnreadChat?: boolean; + + /** Content Ref */ + contentRef?: RefObject; +}; + +type MenuItemRefs = Record; + +function BaseReportActionContextMenu({ + type = CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, + anchor, + contentRef, + isChronosReport = false, + isArchivedRoom = false, + isMini = false, + isVisible = false, + isPinnedChat = false, + isUnreadChat = false, + selection = '', + draftMessage = '', + reportActionID, + reportID, + betas, + reportActions, +}: BaseReportActionContextMenuProps) { + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + const menuItemRefs = useRef({}); + const [shouldKeepOpen, setShouldKeepOpen] = useState(false); + const wrapperStyle = StyleUtils.getReportActionContextMenuStyles(isMini, isSmallScreenWidth); + const {isOffline} = useNetwork(); + + const reportAction: OnyxEntry = useMemo(() => { + if (isEmptyObject(reportActions) || reportActionID === '0') { + return null; + } + return reportActions[reportActionID] ?? null; + }, [reportActions, reportActionID]); + + const shouldEnableArrowNavigation = !isMini && (isVisible || shouldKeepOpen); + const filteredContextMenuActions = ContextMenuActions.filter((contextAction) => + contextAction.shouldShow(type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, !!isOffline), + ); + + // Context menu actions that are not rendered as menu items are excluded from arrow navigation + const nonMenuItemActionIndexes = filteredContextMenuActions.map((contextAction, index) => + 'renderContent' in contextAction && typeof contextAction.renderContent === 'function' ? index : undefined, + ); + const disabledIndexes = nonMenuItemActionIndexes.filter((index): index is number => index !== undefined); + + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({ + initialFocusedIndex: -1, + disabledIndexes, + maxIndex: filteredContextMenuActions.length - 1, + isActive: shouldEnableArrowNavigation, + }); + + /** + * Checks if user is anonymous. If true and the action doesn't accept for anonymous user, hides the context menu and + * shows the sign in modal. Else, executes the callback. + */ + const interceptAnonymousUser = (callback: () => void, isAnonymousAction = false) => { + if (Session.isAnonymousUser() && !isAnonymousAction) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + callback(); + } + }; + + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ENTER, + (event) => { + if (!menuItemRefs.current[focusedIndex]) { + return; + } + + // Ensures the event does not cause side-effects beyond the context menu, e.g. when an outside element is focused + if (event) { + event.stopPropagation(); + } + + menuItemRefs.current[focusedIndex]?.triggerPressAndUpdateSuccess?.(); + setFocusedIndex(-1); + }, + {isActive: shouldEnableArrowNavigation}, + ); + + return ( + (isVisible || shouldKeepOpen) && ( + + {filteredContextMenuActions.map((contextAction, index) => { + const closePopup = !isMini; + const payload: ContextMenuActionPayload = { + reportAction: reportAction as ReportAction, + reportID, + draftMessage, + selection, + close: () => setShouldKeepOpen(false), + openContextMenu: () => setShouldKeepOpen(true), + interceptAnonymousUser, + }; + + if ('renderContent' in contextAction) { + return contextAction.renderContent(closePopup, payload); + } + + const {textTranslateKey} = contextAction; + const isKeyInActionUpdateKeys = + textTranslateKey === 'reportActionContextMenu.editAction' || + textTranslateKey === 'reportActionContextMenu.deleteAction' || + textTranslateKey === 'reportActionContextMenu.deleteConfirmation'; + const text = textTranslateKey && (isKeyInActionUpdateKeys ? translate(textTranslateKey, {action: reportAction}) : translate(textTranslateKey)); + + return ( + { + menuItemRefs.current[index] = ref; + }} + icon={contextAction.icon} + text={text ?? ''} + successIcon={contextAction.successIcon} + successText={contextAction.successTextTranslateKey ? translate(contextAction.successTextTranslateKey) : undefined} + isMini={isMini} + key={contextAction.textTranslateKey} + onPress={() => interceptAnonymousUser(() => contextAction.onPress?.(closePopup, payload), contextAction.isAnonymousAction)} + description={contextAction.getDescription?.(selection) ?? ''} + isAnonymousAction={contextAction.isAnonymousAction} + isFocused={focusedIndex === index} + /> + ); + })} + + ) + ); +} + +export default withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + reportActions: { + key: ({originalReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, + canEvict: false, + }, +})( + memo(BaseReportActionContextMenu, (prevProps, nextProps) => { + const {reportActions: prevReportActions, ...prevPropsWithoutReportActions} = prevProps; + const {reportActions: nextReportActions, ...nextPropsWithoutReportActions} = nextProps; + + const prevReportAction = prevReportActions?.[prevProps.reportActionID] ?? ''; + const nextReportAction = nextReportActions?.[nextProps.reportActionID] ?? ''; + + // We only want to re-render when the report action that is attached to is changed + if (prevReportAction !== nextReportAction) { + return false; + } + + return lodashIsEqual(prevPropsWithoutReportActions, nextPropsWithoutReportActions); + }), +); + +export type {BaseReportActionContextMenuProps}; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx similarity index 73% rename from src/pages/home/report/ContextMenu/ContextMenuActions.js rename to src/pages/home/report/ContextMenu/ContextMenuActions.tsx index aa815b0b32dc..a281065d9ce4 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -1,7 +1,8 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import lodashGet from 'lodash/get'; +import type {MutableRefObject} from 'react'; import React from 'react'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {Emoji} from '@assets/emojis/types'; import * as Expensicons from '@components/Icon/Expensicons'; import MiniQuickEmojiReactions from '@components/Reactions/MiniQuickEmojiReactions'; import QuickEmojiReactions from '@components/Reactions/QuickEmojiReactions'; @@ -22,24 +23,20 @@ import * as TaskUtils from '@libs/TaskUtils'; import * as Download from '@userActions/Download'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; +import type {Beta, ReportAction, ReportActionReactions} from '@src/types/onyx'; +import type IconAsset from '@src/types/utils/IconAsset'; import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; -/** - * Gets the HTML version of the message in an action. - * @param {Object} reportAction - * @return {String} - */ -function getActionText(reportAction) { - const message = _.last(lodashGet(reportAction, 'message', null)); - return lodashGet(message, 'html', ''); +/** Gets the HTML version of the message in an action */ +function getActionText(reportAction: OnyxEntry): string { + const message = reportAction?.message?.at(-1) ?? null; + return message?.html ?? ''; } -/** - * Sets the HTML string to Clipboard. - * @param {String} content - */ -function setClipboardMessage(content) { +/** Sets the HTML string to Clipboard */ +function setClipboardMessage(content: string) { const parser = new ExpensiMark(); if (!Clipboard.canSetHtml()) { Clipboard.setString(parser.htmlToMarkdown(content)); @@ -49,16 +46,63 @@ function setClipboardMessage(content) { } } +type ShouldShow = ( + type: string, + reportAction: OnyxEntry, + isArchivedRoom: boolean, + betas: OnyxEntry, + menuTarget: MutableRefObject | undefined, + isChronosReport: boolean, + reportID: string, + isPinnedChat: boolean, + isUnreadChat: boolean, + isOffline: boolean, +) => boolean; + +type ContextMenuActionPayload = { + reportAction: ReportAction; + reportID: string; + draftMessage: string; + selection: string; + close: () => void; + openContextMenu: () => void; + interceptAnonymousUser: (callback: () => void, isAnonymousAction?: boolean) => void; +}; + +type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; + +type RenderContent = (closePopover: boolean, payload: ContextMenuActionPayload) => React.ReactElement; + +type GetDescription = (selection?: string) => string | void; + +type ContextMenuActionWithContent = { + renderContent: RenderContent; +}; + +type ContextMenuActionWithIcon = { + textTranslateKey: TranslationPaths; + icon: IconAsset; + successTextTranslateKey?: TranslationPaths; + successIcon?: IconAsset; + onPress: OnPress; + getDescription: GetDescription; +}; + +type ContextMenuAction = (ContextMenuActionWithContent | ContextMenuActionWithIcon) & { + isAnonymousAction: boolean; + shouldShow: ShouldShow; +}; + // A list of all the context actions in this menu. -export default [ +const ContextMenuActions: ContextMenuAction[] = [ { isAnonymousAction: false, - shouldKeepOpen: true, - shouldShow: (type, reportAction) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && _.has(reportAction, 'message') && !ReportActionsUtils.isMessageDeleted(reportAction), + shouldShow: (type, reportAction): reportAction is ReportAction => + type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !!reportAction && 'message' in reportAction && !ReportActionsUtils.isMessageDeleted(reportAction), renderContent: (closePopover, {reportID, reportAction, close: closeManually, openContextMenu}) => { const isMini = !closePopover; - const closeContextMenu = (onHideCallback) => { + const closeContextMenu = (onHideCallback?: () => void) => { if (isMini) { closeManually(); if (onHideCallback) { @@ -69,7 +113,7 @@ export default [ } }; - const toggleEmojiAndCloseMenu = (emoji, existingReactions) => { + const toggleEmojiAndCloseMenu = (emoji: Emoji, existingReactions: OnyxEntry) => { Report.toggleEmojiReaction(reportID, reportAction, emoji, existingReactions); closeContextMenu(); }; @@ -81,7 +125,7 @@ export default [ onEmojiSelected={toggleEmojiAndCloseMenu} onPressOpenPicker={openContextMenu} onEmojiPickerClosed={closeContextMenu} - reportActionID={reportAction.reportActionID} + reportActionID={reportAction?.reportActionID} reportAction={reportAction} /> ); @@ -92,7 +136,7 @@ export default [ key="BaseQuickEmojiReactions" closeContextMenu={closeContextMenu} onEmojiSelected={toggleEmojiAndCloseMenu} - reportActionID={reportAction.reportActionID} + reportActionID={reportAction?.reportActionID} reportAction={reportAction} /> ); @@ -104,20 +148,20 @@ export default [ icon: Expensicons.Download, successTextTranslateKey: 'common.download', successIcon: Expensicons.Download, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline): reportAction is ReportAction => { const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); - const messageHtml = lodashGet(reportAction, ['message', 0, 'html']); - return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline; + const messageHtml = reportAction?.message?.at(0)?.html; + return ( + isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && !!reportAction?.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline + ); }, onPress: (closePopover, {reportAction}) => { - const message = _.last(lodashGet(reportAction, 'message', [{}])); - const html = lodashGet(message, 'html', ''); - const attachmentDetails = getAttachmentDetails(html); - const {originalFileName, sourceURL} = attachmentDetails; - const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL); - const sourceID = (sourceURL.match(CONST.REGEX.ATTACHMENT_ID) || [])[1]; + const html = getActionText(reportAction); + const {originalFileName, sourceURL} = getAttachmentDetails(html); + const sourceURLWithAuth = addEncryptedAuthTokenToURL(sourceURL ?? ''); + const sourceID = (sourceURL?.match(CONST.REGEX.ATTACHMENT_ID) ?? [])[1]; Download.setDownload(sourceID, true); - fileDownload(sourceURLWithAuth, originalFileName).then(() => Download.setDownload(sourceID, false)); + fileDownload(sourceURLWithAuth, originalFileName ?? '').then(() => Download.setDownload(sourceID, false)); if (closePopover) { hideContextMenu(true, ReportActionComposeFocusManager.focus); } @@ -128,9 +172,7 @@ export default [ isAnonymousAction: false, textTranslateKey: 'reportActionContextMenu.replyInThread', icon: Expensicons.ChatBubble, - successTextTranslateKey: '', - successIcon: null, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => { if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) { return false; } @@ -140,12 +182,12 @@ export default [ if (closePopover) { hideContextMenu(false, () => { ReportActionComposeFocusManager.focus(); - Report.navigateToAndOpenChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID); + Report.navigateToAndOpenChildReport(reportAction?.childReportID ?? '0', reportAction, reportID); }); return; } - Report.navigateToAndOpenChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID); + Report.navigateToAndOpenChildReport(reportAction?.childReportID ?? '0', reportAction, reportID); }, getDescription: () => {}, }, @@ -153,16 +195,15 @@ export default [ isAnonymousAction: false, textTranslateKey: 'reportActionContextMenu.subscribeToThread', icon: Expensicons.Bell, - successTextTranslateKey: '', - successIcon: null, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => { const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(reportAction, reportID); - const subscribed = childReportNotificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); - const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; - const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); + const subscribed = childReportNotificationPreference !== 'hidden'; + const isCommentAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); + const isReportPreviewAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; + const isIOUAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); + const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction); return !subscribed && !isWhisperAction && (isCommentAction || isReportPreviewAction || isIOUAction) && (!isDeletedAction || shouldDisplayThreadReplies); }, @@ -171,13 +212,13 @@ export default [ if (closePopover) { hideContextMenu(false, () => { ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }); return; } ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }, getDescription: () => {}, }, @@ -185,9 +226,7 @@ export default [ isAnonymousAction: false, textTranslateKey: 'reportActionContextMenu.unsubscribeFromThread', icon: Expensicons.BellSlash, - successTextTranslateKey: '', - successIcon: null, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID) => { + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => { const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction); const isDeletedAction = ReportActionsUtils.isDeletedAction(reportAction); const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(reportAction, reportID); @@ -195,9 +234,9 @@ export default [ if (type !== CONST.CONTEXT_MENU_TYPES.REPORT_ACTION) { return false; } - const isCommentAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); - const isReportPreviewAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; - const isIOUAction = reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); + const isCommentAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportUtils.isThreadFirstChat(reportAction, reportID); + const isReportPreviewAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; + const isIOUAction = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && !ReportActionsUtils.isSplitBillAction(reportAction); return subscribed && (isCommentAction || isReportPreviewAction || isIOUAction) && (!isDeletedAction || shouldDisplayThreadReplies); }, onPress: (closePopover, {reportAction, reportID}) => { @@ -205,13 +244,13 @@ export default [ if (closePopover) { hideContextMenu(false, () => { ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }); return; } ReportActionComposeFocusManager.focus(); - Report.toggleSubscribeToChildReport(lodashGet(reportAction, 'childReportID', '0'), reportAction, reportID, childReportNotificationPreference); + Report.toggleSubscribeToChildReport(reportAction?.childReportID ?? '0', reportAction, reportID, childReportNotificationPreference); }, getDescription: () => {}, }, @@ -239,7 +278,7 @@ export default [ Clipboard.setString(EmailUtils.trimMailTo(selection)); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, - getDescription: (selection) => EmailUtils.prefixMailSeparatorsWithBreakOpportunities(EmailUtils.trimMailTo(selection)), + getDescription: (selection) => EmailUtils.prefixMailSeparatorsWithBreakOpportunities(EmailUtils.trimMailTo(selection ?? '')), }, { isAnonymousAction: true, @@ -256,8 +295,7 @@ export default [ onPress: (closePopover, {reportAction, selection}) => { const isTaskAction = ReportActionsUtils.isTaskAction(reportAction); const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); - const message = _.last(lodashGet(reportAction, 'message', [{}])); - const messageHtml = isTaskAction ? TaskUtils.getTaskReportActionMessage(reportAction.actionName) : lodashGet(message, 'html', ''); + const messageHtml = isTaskAction ? TaskUtils.getTaskReportActionMessage(reportAction?.actionName) : getActionText(reportAction); const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); if (!isAttachment) { @@ -281,11 +319,11 @@ export default [ const taskPreviewMessage = TaskUtils.getTaskCreatedMessage(reportAction); Clipboard.setString(taskPreviewMessage); } else if (ReportActionsUtils.isMemberChangeAction(reportAction)) { - const logMessage = ReportActionsUtils.getMemberChangeMessageFragment(reportAction).html; + const logMessage = ReportActionsUtils.getMemberChangeMessageFragment(reportAction).html ?? ''; setClipboardMessage(logMessage); } else if (ReportActionsUtils.isSubmittedExpenseAction(reportAction)) { - const submittedMessage = _.reduce(reportAction.message, (acc, curr) => `${acc}${curr.text}`, ''); - Clipboard.setString(submittedMessage); + const submittedMessage = reportAction?.message?.reduce((acc, curr) => `${acc}${curr.text}`, ''); + Clipboard.setString(submittedMessage ?? ''); } else if (content) { setClipboardMessage(content); } @@ -308,12 +346,12 @@ export default [ const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction); // Only hide the copylink menu item when context menu is opened over img element. - const isAttachmentTarget = lodashGet(menuTarget, 'tagName') === 'IMG' && isAttachment; + const isAttachmentTarget = menuTarget?.current?.tagName === 'IMG' && isAttachment; return Permissions.canUseCommentLinking(betas) && type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !ReportActionsUtils.isMessageDeleted(reportAction); }, onPress: (closePopover, {reportAction, reportID}) => { Environment.getEnvironmentURL().then((environmentURL) => { - const reportActionID = lodashGet(reportAction, 'reportActionID'); + const reportActionID = reportAction?.reportActionID; Clipboard.setString(`${environmentURL}/r/${reportID}/${reportActionID}`); }); hideContextMenu(true, ReportActionComposeFocusManager.focus); @@ -326,10 +364,10 @@ export default [ textTranslateKey: 'reportActionContextMenu.markAsUnread', icon: Expensicons.Mail, successIcon: Expensicons.Checkmark, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat) => + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION || (type === CONST.CONTEXT_MENU_TYPES.REPORT && !isUnreadChat), onPress: (closePopover, {reportAction, reportID}) => { - Report.markCommentAsUnread(reportID, reportAction.created); + Report.markCommentAsUnread(reportID, reportAction?.created); if (closePopover) { hideContextMenu(true, ReportActionComposeFocusManager.focus); } @@ -342,7 +380,8 @@ export default [ textTranslateKey: 'reportActionContextMenu.markAsRead', icon: Expensicons.Mail, successIcon: Expensicons.Checkmark, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isUnreadChat, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat, isUnreadChat) => + type === CONST.CONTEXT_MENU_TYPES.REPORT && isUnreadChat, onPress: (closePopover, {reportID}) => { Report.readNewestAction(reportID); if (closePopover) { @@ -361,11 +400,11 @@ export default [ onPress: (closePopover, {reportID, reportAction, draftMessage}) => { if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { hideContextMenu(false); - const childReportID = lodashGet(reportAction, 'childReportID', 0); + const childReportID = reportAction?.childReportID ?? '0'; if (!childReportID) { const thread = ReportUtils.buildTransactionThread(reportAction, reportID); - const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs); - Report.openReport(thread.reportID, userLogins, thread, reportAction.reportActionID); + const userLogins = PersonalDetailsUtils.getLoginsByAccountIDs(thread.participantAccountIDs ?? []); + Report.openReport(thread.reportID, userLogins, thread, reportAction?.reportActionID); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(thread.reportID)); return; } @@ -374,7 +413,7 @@ export default [ return; } const editAction = () => { - if (_.isUndefined(draftMessage)) { + if (!draftMessage) { Report.saveReportActionDraft(reportID, reportAction, getActionText(reportAction)); } else { Report.deleteReportActionDraft(reportID, reportAction); @@ -419,7 +458,7 @@ export default [ isAnonymousAction: false, textTranslateKey: 'common.pin', icon: Expensicons.Pin, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isPinnedChat, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isPinnedChat, onPress: (closePopover, {reportID}) => { Report.togglePinnedState(reportID, false); if (closePopover) { @@ -432,7 +471,7 @@ export default [ isAnonymousAction: false, textTranslateKey: 'common.unPin', icon: Expensicons.Pin, - shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isPinnedChat, + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID, isPinnedChat) => type === CONST.CONTEXT_MENU_TYPES.REPORT && isPinnedChat, onPress: (closePopover, {reportID}) => { Report.togglePinnedState(reportID, true); if (closePopover) { @@ -450,16 +489,18 @@ export default [ ReportUtils.canFlagReportAction(reportAction, reportID) && !isArchivedRoom && !isChronosReport && - !ReportUtils.isConciergeChatReport(reportID) && - reportAction.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE, + reportAction?.actorAccountID !== CONST.ACCOUNT_ID.CONCIERGE, onPress: (closePopover, {reportID, reportAction}) => { if (closePopover) { - hideContextMenu(false, () => Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction.reportActionID))); + hideContextMenu(false, () => Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction?.reportActionID))); return; } - Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction.reportActionID)); + Navigation.navigate(ROUTES.FLAG_COMMENT.getRoute(reportID, reportAction?.reportActionID)); }, getDescription: () => {}, }, ]; + +export default ContextMenuActions; +export type {ContextMenuActionPayload}; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js deleted file mode 100644 index d858206cdfc3..000000000000 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import useStyleUtils from '@hooks/useStyleUtils'; -import BaseReportActionContextMenu from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; -import { - defaultProps as GenericReportActionContextMenuDefaultProps, - propTypes as genericReportActionContextMenuPropTypes, -} from '@pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes'; -import CONST from '@src/CONST'; - -const propTypes = { - ..._.omit(genericReportActionContextMenuPropTypes, ['isMini']), - - /** Should the reportAction this menu is attached to have the appearance of being - * grouped with the previous reportAction? */ - displayAsGroup: PropTypes.bool, -}; - -const defaultProps = { - ..._.omit(GenericReportActionContextMenuDefaultProps, ['isMini']), - displayAsGroup: false, -}; - -function MiniReportActionContextMenu(props) { - const StyleUtils = useStyleUtils(); - - return ( - - - - ); -} - -MiniReportActionContextMenu.propTypes = propTypes; -MiniReportActionContextMenu.defaultProps = defaultProps; -MiniReportActionContextMenu.displayName = 'MiniReportActionContextMenu'; - -export default MiniReportActionContextMenu; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js deleted file mode 100644 index 461f67a0a4bc..000000000000 --- a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export default () => null; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx new file mode 100644 index 000000000000..7be6a850d51b --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.native.tsx @@ -0,0 +1,4 @@ +import type MiniReportActionContextMenuProps from './types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default (props: MiniReportActionContextMenuProps) => null; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.tsx b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.tsx new file mode 100644 index 000000000000..df1226eed900 --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/index.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import {View} from 'react-native'; +import useStyleUtils from '@hooks/useStyleUtils'; +import BaseReportActionContextMenu from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; +import CONST from '@src/CONST'; +import type MiniReportActionContextMenuProps from './types'; + +function MiniReportActionContextMenu({displayAsGroup = false, ...rest}: MiniReportActionContextMenuProps) { + const StyleUtils = useStyleUtils(); + + return ( + + + + ); +} + +MiniReportActionContextMenu.displayName = 'MiniReportActionContextMenu'; + +export default MiniReportActionContextMenu; diff --git a/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts new file mode 100644 index 000000000000..98b38dcb6968 --- /dev/null +++ b/src/pages/home/report/ContextMenu/MiniReportActionContextMenu/types.ts @@ -0,0 +1,8 @@ +import type {BaseReportActionContextMenuProps} from '@pages/home/report/ContextMenu/BaseReportActionContextMenu'; + +type MiniReportActionContextMenuProps = Omit & { + /** Should the reportAction this menu is attached to have the appearance of being grouped with the previous reportAction? */ + displayAsGroup?: boolean; +}; + +export default MiniReportActionContextMenuProps; diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx similarity index 65% rename from src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js rename to src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 1c93c3bc90c7..46b783bca3f9 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -1,23 +1,45 @@ +import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import type {EmitterSubscription, GestureResponderEvent, NativeTouchEvent, View} from 'react-native'; import {Dimensions} from 'react-native'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent'; import useLocalize from '@hooks/useLocalize'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; +import type {ReportAction} from '@src/types/onyx'; import BaseReportActionContextMenu from './BaseReportActionContextMenu'; +import type {ContextMenuType, ReportActionContextMenu} from './ReportActionContextMenu'; -function PopoverReportActionContextMenu(_props, ref) { +type ContextMenuAnchorCallback = (x: number, y: number) => void; + +type ContextMenuAnchor = {measureInWindow: (callback: ContextMenuAnchorCallback) => void}; + +type Location = { + x: number; + y: number; +}; + +function extractPointerEvent(event: GestureResponderEvent | MouseEvent): MouseEvent | NativeTouchEvent { + if ('nativeEvent' in event) { + return event.nativeEvent; + } + return event; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef) { const {translate} = useLocalize(); const reportIDRef = useRef('0'); - const typeRef = useRef(undefined); - const reportActionRef = useRef({}); + const typeRef = useRef(); + const reportActionRef = useRef>(null); const reportActionIDRef = useRef('0'); const originalReportIDRef = useRef('0'); const selectionRef = useRef(''); - const reportActionDraftMessageRef = useRef(undefined); + const reportActionDraftMessageRef = useRef(); const cursorRelativePosition = useRef({ horizontal: 0, @@ -41,11 +63,11 @@ function PopoverReportActionContextMenu(_props, ref) { const [isChatPinned, setIsChatPinned] = useState(false); const [hasUnreadMessages, setHasUnreadMessages] = useState(false); - const contentRef = useRef(null); - const anchorRef = useRef(null); - const dimensionsEventListener = useRef(null); - const contextMenuAnchorRef = useRef(null); - const contextMenuTargetNode = useRef(null); + const contentRef = useRef(null); + const anchorRef = useRef(null); + const dimensionsEventListener = useRef(null); + const contextMenuAnchorRef = useRef(null); + const contextMenuTargetNode = useRef(null); const onPopoverShow = useRef(() => {}); const onPopoverHide = useRef(() => {}); @@ -55,16 +77,11 @@ function PopoverReportActionContextMenu(_props, ref) { const onPopoverHideActionCallback = useRef(() => {}); const callbackWhenDeleteModalHide = useRef(() => {}); - /** - * Get the Context menu anchor position - * We calculate the achor coordinates from measureInWindow async method - * - * @returns {Promise} - */ + /** Get the Context menu anchor position. We calculate the anchor coordinates from measureInWindow async method */ const getContextMenuMeasuredLocation = useCallback( () => - new Promise((resolve) => { - if (contextMenuAnchorRef.current && _.isFunction(contextMenuAnchorRef.current.measureInWindow)) { + new Promise((resolve) => { + if (contextMenuAnchorRef.current && typeof contextMenuAnchorRef.current.measureInWindow === 'function') { contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y})); } else { resolve({x: 0, y: 0}); @@ -73,9 +90,7 @@ function PopoverReportActionContextMenu(_props, ref) { [], ); - /** - * This gets called on Dimensions change to find the anchor coordinates for the action context menu. - */ + /** This gets called on Dimensions change to find the anchor coordinates for the action context menu. */ const measureContextMenuAnchorPosition = useCallback(() => { if (!isPopoverVisible) { return; @@ -87,8 +102,8 @@ function PopoverReportActionContextMenu(_props, ref) { } popoverAnchorPosition.current = { - horizontal: cursorRelativePosition.horizontal + x, - vertical: cursorRelativePosition.vertical + y, + horizontal: cursorRelativePosition.current.horizontal + x, + vertical: cursorRelativePosition.current.vertical + y, }; }); }, [isPopoverVisible, getContextMenuMeasuredLocation]); @@ -104,38 +119,34 @@ function PopoverReportActionContextMenu(_props, ref) { }; }, [measureContextMenuAnchorPosition]); - /** - * Whether Context Menu is active for the Report Action. - * - * @param {Number|String} actionID - * @return {Boolean} - */ - const isActiveReportAction = (actionID) => Boolean(actionID) && (reportActionIDRef.current === actionID || reportActionRef.current.reportActionID === actionID); + /** Whether Context Menu is active for the Report Action. */ + const isActiveReportAction: ReportActionContextMenu['isActiveReportAction'] = (actionID) => + !!actionID && (reportActionIDRef.current === actionID || reportActionRef.current?.reportActionID === actionID); const clearActiveReportAction = () => { reportActionIDRef.current = '0'; - reportActionRef.current = {}; + reportActionRef.current = null; }; /** * Show the ReportActionContextMenu modal popover. * - * @param {string} type - context menu type [EMAIL, LINK, REPORT_ACTION] - * @param {Object} [event] - A press event. - * @param {String} [selection] - Copied content. - * @param {Element} contextMenuAnchor - popoverAnchor - * @param {String} reportID - Active Report Id - * @param {Object} reportActionID - ReportAction for ContextMenu - * @param {String} originalReportID - The currrent Report Id of the reportAction - * @param {String} draftMessage - ReportAction Draftmessage - * @param {Function} [onShow] - Run a callback when Menu is shown - * @param {Function} [onHide] - Run a callback when Menu is hidden - * @param {Boolean} isArchivedRoom - Whether the provided report is an archived room - * @param {Boolean} isChronosReport - Flag to check if the chat participant is Chronos - * @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action - * @param {Boolean} isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action + * @param type - context menu type [EMAIL, LINK, REPORT_ACTION] + * @param [event] - A press event. + * @param [selection] - Copied content. + * @param contextMenuAnchor - popoverAnchor + * @param reportID - Active Report Id + * @param reportActionID - ReportAction for ContextMenu + * @param originalReportID - The currrent Report Id of the reportAction + * @param draftMessage - ReportAction Draftmessage + * @param [onShow] - Run a callback when Menu is shown + * @param [onHide] - Run a callback when Menu is hidden + * @param isArchivedRoom - Whether the provided report is an archived room + * @param isChronosReport - Flag to check if the chat participant is Chronos + * @param isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action + * @param isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action */ - const showContextMenu = ( + const showContextMenu: ReportActionContextMenu['showContextMenu'] = ( type, event, selection, @@ -151,9 +162,9 @@ function PopoverReportActionContextMenu(_props, ref) { isPinnedChat = false, isUnreadChat = false, ) => { - const nativeEvent = event.nativeEvent || {}; + const {pageX = 0, pageY = 0} = extractPointerEvent(event); contextMenuAnchorRef.current = contextMenuAnchor; - contextMenuTargetNode.current = nativeEvent.target; + contextMenuTargetNode.current = event.target as HTMLElement; setInstanceID(Math.random().toString(36).substr(2, 5)); @@ -162,18 +173,18 @@ function PopoverReportActionContextMenu(_props, ref) { getContextMenuMeasuredLocation().then(({x, y}) => { popoverAnchorPosition.current = { - horizontal: nativeEvent.pageX - x, - vertical: nativeEvent.pageY - y, + horizontal: pageX - x, + vertical: pageY - y, }; popoverAnchorPosition.current = { - horizontal: nativeEvent.pageX, - vertical: nativeEvent.pageY, + horizontal: pageX, + vertical: pageY, }; typeRef.current = type; - reportIDRef.current = reportID; - reportActionIDRef.current = reportActionID; - originalReportIDRef.current = originalReportID; + reportIDRef.current = reportID ?? '0'; + reportActionIDRef.current = reportActionID ?? '0'; + originalReportIDRef.current = originalReportID ?? '0'; selectionRef.current = selection; setIsPopoverVisible(true); reportActionDraftMessageRef.current = draftMessage; @@ -184,9 +195,7 @@ function PopoverReportActionContextMenu(_props, ref) { }); }; - /** - * After Popover shows, call the registered onPopoverShow callback and reset it - */ + /** After Popover shows, call the registered onPopoverShow callback and reset it */ const runAndResetOnPopoverShow = () => { onPopoverShow.current(); @@ -194,19 +203,13 @@ function PopoverReportActionContextMenu(_props, ref) { onPopoverShow.current = () => {}; }; - /** - * Run the callback and return a noop function to reset it - * @param {Function} callback - * @returns {Function} - */ - const runAndResetCallback = (callback) => { + /** Run the callback and return a noop function to reset it */ + const runAndResetCallback = (callback: () => void) => { callback(); return () => {}; }; - /** - * After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it - */ + /** After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it */ const runAndResetOnPopoverHide = () => { reportIDRef.current = '0'; reportActionIDRef.current = '0'; @@ -218,10 +221,10 @@ function PopoverReportActionContextMenu(_props, ref) { /** * Hide the ReportActionContextMenu modal popover. - * @param {Function} onHideActionCallback Callback to be called after popover is completely hidden + * @param onHideActionCallback Callback to be called after popover is completely hidden */ - const hideContextMenu = (onHideActionCallback) => { - if (_.isFunction(onHideActionCallback)) { + const hideContextMenu: ReportActionContextMenu['hideContextMenu'] = (onHideActionCallback) => { + if (typeof onHideActionCallback === 'function') { onPopoverHideActionCallback.current = onHideActionCallback; } @@ -232,10 +235,11 @@ function PopoverReportActionContextMenu(_props, ref) { const confirmDeleteAndHideModal = useCallback(() => { callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current)); - if (ReportActionsUtils.isMoneyRequestAction(reportActionRef.current)) { - IOU.deleteMoneyRequest(reportActionRef.current.originalMessage.IOUTransactionID, reportActionRef.current); - } else { - Report.deleteReportComment(reportIDRef.current, reportActionRef.current); + const reportAction = reportActionRef.current; + if (ReportActionsUtils.isMoneyRequestAction(reportAction) && reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { + IOU.deleteMoneyRequest(reportAction?.originalMessage?.IOUTransactionID, reportAction); + } else if (reportAction) { + Report.deleteReportComment(reportIDRef.current, reportAction); } setIsDeleteCommentConfirmModalVisible(false); }, []); @@ -250,15 +254,8 @@ function PopoverReportActionContextMenu(_props, ref) { setHasUnreadMessages(false); }; - /** - * Opens the Confirm delete action modal - * @param {String} reportID - * @param {Object} reportAction - * @param {Boolean} [shouldSetModalVisibility] - * @param {Function} [onConfirm] - * @param {Function} [onCancel] - */ - const showDeleteModal = (reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) => { + /** Opens the Confirm delete action modal */ + const showDeleteModal: ReportActionContextMenu['showDeleteModal'] = (reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) => { onCancelDeleteModal.current = onCancel; onComfirmDeleteModal.current = onConfirm; diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index 11166d3c6289..dc8729840af9 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -34,7 +34,7 @@ type ShowContextMenu = ( type ReportActionContextMenu = { showContextMenu: ShowContextMenu; - hideContextMenu: (callback: OnHideCallback) => void; + hideContextMenu: (callback?: OnHideCallback) => void; showDeleteModal: (reportID: string, reportAction: OnyxEntry, shouldSetModalVisibility?: boolean, onConfirm?: OnConfirm, onCancel?: OnCancel) => void; hideDeleteModal: () => void; isActiveReportAction: (accountID: string | number) => boolean; @@ -176,3 +176,4 @@ function clearActiveReportAction() { } export {contextMenuRef, showContextMenu, hideContextMenu, isActiveReportAction, clearActiveReportAction, showDeleteModal, hideDeleteModal}; +export type {ContextMenuType, ShowContextMenu, ReportActionContextMenu}; diff --git a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js b/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js deleted file mode 100644 index b9f892c1b9ff..000000000000 --- a/src/pages/home/report/ContextMenu/genericReportActionContextMenuPropTypes.js +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** The ID of the report this report action is attached to. */ - reportID: PropTypes.string.isRequired, - - /** The ID of the report action this context menu is attached to. */ - reportActionID: PropTypes.string.isRequired, - - /** The ID of the original report from which the given reportAction is first created. */ - originalReportID: PropTypes.string.isRequired, - - /** If true, this component will be a small, row-oriented menu that displays icons but not text. - If false, this component will be a larger, column-oriented menu that displays icons alongside text in each row. */ - isMini: PropTypes.bool, - - /** Controls the visibility of this component. */ - isVisible: PropTypes.bool, - - /** The copy selection. */ - selection: PropTypes.string, - - /** Draft message - if this is set the comment is in 'edit' mode */ - draftMessage: PropTypes.string, -}; - -const defaultProps = { - isMini: false, - isVisible: false, - selection: '', - draftMessage: undefined, -}; - -export {propTypes, defaultProps}; diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index da6fb1d74f49..0949081435c4 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -136,7 +136,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ indexOffset = formatResults.newIndexOffset; if (maxParticipantsReached) { - return newSections; + return [newSections, {}]; } newSections.push({ @@ -229,13 +229,13 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ const headerMessage = useMemo( () => OptionsListUtils.getHeaderMessage( - newChatOptions.personalDetails.length + newChatOptions.recentReports.length !== 0, + _.get(newChatOptions, 'personalDetails.length', 0) + _.get(newChatOptions, 'recentReports.length', 0) !== 0, Boolean(newChatOptions.userToInvite), searchTerm.trim(), maxParticipantsReached, _.some(participants, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())), ), - [maxParticipantsReached, newChatOptions.personalDetails.length, newChatOptions.recentReports.length, newChatOptions.userToInvite, participants, searchTerm], + [maxParticipantsReached, newChatOptions, participants, searchTerm], ); // When search term updates we will fetch any reports diff --git a/src/pages/settings/Wallet/ReportCardLostPage.js b/src/pages/settings/Wallet/ReportCardLostPage.js index b01dc99cb485..49b69188c377 100644 --- a/src/pages/settings/Wallet/ReportCardLostPage.js +++ b/src/pages/settings/Wallet/ReportCardLostPage.js @@ -23,14 +23,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import assignedCardPropTypes from './assignedCardPropTypes'; +const OPTIONS_KEYS = { + DAMAGED: 'damaged', + STOLEN: 'stolen', +}; + /** Options for reason selector */ const OPTIONS = [ { - key: 'damaged', + key: OPTIONS_KEYS.DAMAGED, label: 'reportCardLostOrDamaged.cardDamaged', }, { - key: 'stolen', + key: OPTIONS_KEYS.STOLEN, label: 'reportCardLostOrDamaged.cardLostOrStolen', }, ]; @@ -107,7 +112,7 @@ function ReportCardLostPage({ return; } - Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARDS.getRoute(domain)); + Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain)); }, [domain, formData.isLoading, prevIsLoading, physicalCard.errors]); useEffect(() => { @@ -156,6 +161,8 @@ function ReportCardLostPage({ Navigation.goBack(ROUTES.SETTINGS_WALLET); }; + const isDamaged = reason && reason.key === OPTIONS_KEYS.DAMAGED; + return ( Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} numberOfLinesTitle={2} /> - {translate('reportCardLostOrDamaged.currentCardInfo')} + {isDamaged ? ( + {translate('reportCardLostOrDamaged.cardDamagedInfo')} + ) : ( + {translate('reportCardLostOrDamaged.cardLostOrStolenInfo')} + )} ) : ( diff --git a/src/styles/index.ts b/src/styles/index.ts index 54326ec575df..966927c40651 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -677,8 +677,11 @@ const styles = (theme: ThemeColors) => }, loadingVBAAnimation: { - width: 140, - height: 140, + width: '100%', + }, + + loadingVBAAnimationWeb: { + width: '100%', }, pickerSmall: (backgroundColor = theme.highlightBG) => diff --git a/src/types/modules/react-native-google-places-autocomplete.d.ts b/src/types/modules/react-native-google-places-autocomplete.d.ts new file mode 100644 index 000000000000..442c941ed9cd --- /dev/null +++ b/src/types/modules/react-native-google-places-autocomplete.d.ts @@ -0,0 +1,15 @@ +import type {ViewProps} from 'react-native'; +import type {GooglePlacesAutocompleteProps as BaseGooglePlacesAutocompleteProps, Term} from 'react-native-google-places-autocomplete'; + +declare module 'react-native-google-places-autocomplete' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface GooglePlacesAutocompleteProps extends ViewProps, BaseGooglePlacesAutocompleteProps {} + + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface GooglePlaceData { + isPredefinedPlace: string; + name: string; + description: string; + terms?: Term[]; + } +} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index 09be2d9e04dd..3da158985f71 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -241,7 +241,9 @@ type OriginalMessageReimbursementQueued = { type OriginalMessageReimbursementDequeued = { actionName: typeof CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTDEQUEUED; - originalMessage: unknown; + originalMessage: { + expenseReportID: string; + }; }; type OriginalMessageMoved = { @@ -287,4 +289,5 @@ export type { OriginalMessageIOU, OriginalMessageCreated, OriginalMessageAddComment, + OriginalMessageReimbursementDequeued, }; diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js index 4a363d1de36b..ebffc71e4e0e 100644 --- a/tests/unit/MigrationTest.js +++ b/tests/unit/MigrationTest.js @@ -51,8 +51,8 @@ describe('Migrations', () => { it('Should remove any individual reportActions that have no data in Onyx', () => Onyx.multiSet({ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - 1: {}, - 2: {}, + 1: null, + 2: null, }, }) .then(PersonalDetailsByAccountID) diff --git a/tests/unit/ReportActionsUtilsTest.js b/tests/unit/ReportActionsUtilsTest.js index 19a89d1c892c..b8b6eb5e7673 100644 --- a/tests/unit/ReportActionsUtilsTest.js +++ b/tests/unit/ReportActionsUtilsTest.js @@ -368,7 +368,7 @@ describe('ReportActionsUtils', () => { callback: () => { Onyx.disconnect(connectionID); const res = ReportActionsUtils.getLastVisibleAction(report.reportID); - expect(res).toEqual(action2); + expect(res).toBe(action2); resolve(); }, });