diff --git a/.prettierignore b/.prettierignore index a9f7e1464529..98d06e8c5f71 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,6 +15,7 @@ package-lock.json *.css *.scss *.md +*.markdown # We need to modify the import here specifically, hence we disable prettier to get rid of the sorted imports src/libs/E2E/reactNativeLaunchingTest.ts # Temporary while we keep react-compiler in our repo diff --git a/android/app/build.gradle b/android/app/build.gradle index 2491cc21a400..ef488b979daf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009004102 - versionName "9.0.41-2" + versionCode 1009004104 + versionName "9.0.41-4" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/assets/images/table.svg b/assets/images/table.svg index 36d4ced774f1..dea1e990b97d 100644 --- a/assets/images/table.svg +++ b/assets/images/table.svg @@ -1,3 +1,3 @@ - - + + diff --git a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md index 03dd3d722d82..14b5225801d0 100644 --- a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md +++ b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md @@ -36,10 +36,6 @@ Once the member verifies their email address, all Domain Admins will be notified 3. Click the **Domain Members** tab on the left. 4. Under the Domain Members section, enter the first part of the member’s email address and click **Invite**. -{% include info.html %} -This can be any email address—it does not have to be an email address under the domain. If someone who is not a Domain Admin invites a new member to a workspace, that member must validate their account via email before they will have access to it. -{% include end-info.html %} - # Add Domain Admin 1. Hover over Settings, then click **Domains**. diff --git a/help/index.markdown b/help/index.md similarity index 100% rename from help/index.markdown rename to help/index.md diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2de5297dd7fb..6fe2eb969a89 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.41.2 + 9.0.41.4 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 31fc4454214c..b9f4f9bdfc73 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 9.0.41.2 + 9.0.41.4 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 0abd6fae99d5..11c01f0a0476 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.0.41 CFBundleVersion - 9.0.41.2 + 9.0.41.4 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 0ef9b9b19012..c5454d8545c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.41-2", + "version": "9.0.41-4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.41-2", + "version": "9.0.41-4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -50,7 +50,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.88", + "expensify-common": "2.0.94", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -112,7 +112,7 @@ "react-native-svg": "15.6.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "4.0.0-alpha.3", + "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", @@ -24037,9 +24037,9 @@ } }, "node_modules/expensify-common": { - "version": "2.0.88", - "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.88.tgz", - "integrity": "sha512-4k6X6BekydYSRWkWRMB/Ts0W5Zx3BskEpLQEuxpq+cW9QIvTyFliho/dMLaXYOqS6nMQuzkjJYqfGPx9agVnOg==", + "version": "2.0.94", + "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.94.tgz", + "integrity": "sha512-Cco5X6u4IL5aQlFqa2IgGgR+vAffYLxpPN2d7bzfptW/pRLY2L2JRJohgvXEswlCcTKFVt4nIJ4bx9YIOvzxBA==", "dependencies": { "awesome-phonenumber": "^5.4.0", "classnames": "2.5.0", @@ -35667,9 +35667,8 @@ } }, "node_modules/react-native-view-shot": { - "version": "4.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.0-alpha.3.tgz", - "integrity": "sha512-o0KVgC6XZqWmLUKVc4q6Ev1QW1kA4g/TF45wj8CgYS13wJuWYJ+nPGCHT9C2jvX/L65mtTollKXp2L8hbDnelg==", + "version": "3.8.0", + "license": "MIT", "dependencies": { "html2canvas": "^1.4.1" }, diff --git a/package.json b/package.json index aed1cf9a2c3f..2849074a1656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.41-2", + "version": "9.0.41-4", "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.", @@ -107,7 +107,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "2.0.88", + "expensify-common": "2.0.94", "expo": "51.0.31", "expo-av": "14.0.7", "expo-image": "1.12.15", @@ -169,7 +169,7 @@ "react-native-svg": "15.6.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "4.0.0-alpha.3", + "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", diff --git a/patches/react-native-draggable-flatlist+4.0.1.patch b/patches/react-native-draggable-flatlist+4.0.1.patch new file mode 100644 index 000000000000..348f1aa5de8a --- /dev/null +++ b/patches/react-native-draggable-flatlist+4.0.1.patch @@ -0,0 +1,94 @@ +diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx +index d7d98c2..2f59c7a 100644 +--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx +@@ -295,7 +295,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) { + const springTo = placeholderOffset.value - activeCellOffset.value; + touchTranslate.value = withSpring( + springTo, +- animationConfigRef.current, ++ animationConfigRef.value, + () => { + runOnJS(onDragEnd)({ + from: activeIndexAnim.value, +diff --git a/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx b/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx +index ea21575..66c5eed 100644 +--- a/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/context/refContext.tsx +@@ -1,14 +1,14 @@ + import React, { useContext } from "react"; + import { useMemo, useRef } from "react"; + import { FlatList } from "react-native-gesture-handler"; +-import Animated, { WithSpringConfig } from "react-native-reanimated"; ++import Animated, { type SharedValue, useSharedValue, WithSpringConfig } from "react-native-reanimated"; + import { DEFAULT_PROPS } from "../constants"; + import { useProps } from "./propsContext"; + import { CellData, DraggableFlatListProps } from "../types"; + + type RefContextValue = { + propsRef: React.MutableRefObject>; +- animationConfigRef: React.MutableRefObject; ++ animationConfigRef: SharedValue; + cellDataRef: React.MutableRefObject>; + keyToIndexRef: React.MutableRefObject>; + containerRef: React.RefObject; +@@ -54,8 +54,8 @@ function useSetupRefs({ + ...DEFAULT_PROPS.animationConfig, + ...animationConfig, + } as WithSpringConfig; +- const animationConfigRef = useRef(animConfig); +- animationConfigRef.current = animConfig; ++ const animationConfigRef = useSharedValue(animConfig); ++ animationConfigRef.value = animConfig; + + const cellDataRef = useRef(new Map()); + const keyToIndexRef = useRef(new Map()); +diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx b/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx +index ce4ab68..efea240 100644 +--- a/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx ++++ b/node_modules/react-native-draggable-flatlist/src/hooks/useCellTranslate.tsx +@@ -101,7 +101,7 @@ export function useCellTranslate({ cellIndex, cellSize, cellOffset }: Params) { + ? activeCellSize.value * (isAfterActive ? -1 : 1) + : 0; + +- return withSpring(translationAmt, animationConfigRef.current); ++ return withSpring(translationAmt, animationConfigRef.value); + }, [activeKey, cellIndex]); + + return translate; +diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts b/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts +index 7c20587..857c7d0 100644 +--- a/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts ++++ b/node_modules/react-native-draggable-flatlist/src/hooks/useOnCellActiveAnimation.ts +@@ -1,8 +1,9 @@ +-import { useRef } from "react"; +-import Animated, { ++ ++import { + useDerivedValue, + withSpring, + WithSpringConfig, ++ useSharedValue, + } from "react-native-reanimated"; + import { DEFAULT_ANIMATION_CONFIG } from "../constants"; + import { useAnimatedValues } from "../context/animatedValueContext"; +@@ -15,8 +16,8 @@ type Params = { + export function useOnCellActiveAnimation( + { animationConfig }: Params = { animationConfig: {} } + ) { +- const animationConfigRef = useRef(animationConfig); +- animationConfigRef.current = animationConfig; ++ const animationConfigRef = useSharedValue(animationConfig); ++ animationConfigRef.value = animationConfig; + + const isActive = useIsActive(); + +@@ -26,7 +27,7 @@ export function useOnCellActiveAnimation( + const toVal = isActive && isTouchActiveNative.value ? 1 : 0; + return withSpring(toVal, { + ...DEFAULT_ANIMATION_CONFIG, +- ...animationConfigRef.current, ++ ...animationConfigRef.value, + }); + }, [isActive]); + diff --git a/src/App.tsx b/src/App.tsx index 35254fa29b2a..177cc00c7dee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,9 +50,6 @@ LogBox.ignoreLogs([ // the timer is lost. Currently Expensify is using a 30 minutes interval to refresh personal details. // More details here: https://git.io/JJYeb 'Setting a timer for a long period of time', - // We silence this warning for now and will address all the places where it happens separately. - // Then we can remove this line so the problem does not occur in the future. - '[Reanimated] Tried to modify key `current`', ]); const fill = {flex: 1}; diff --git a/src/CONST.ts b/src/CONST.ts index 4ca9b45f13df..16757d5328dc 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4550,12 +4550,12 @@ const CONST = { 'Here’s how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to Workspaces.\n' + + '2. Go to *Workspaces*.\n' + '3. Select your workspace.\n' + '4. Click *Categories*.\n' + - '5. Enable and disable default categories.\n' + - '6. Click *Add categories* to make your own.\n' + - '7. For more controls like requiring a category for every expense, click *Settings*.\n' + + '5. Add or import your own categories.\n' + + "6. Disable any default categories you don't need.\n" + + '7. Require a category for every expense in *Settings*.\n' + '\n' + `[Take me to workspace category settings](${workspaceCategoriesLink}).`, }, diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 3408ffbc4803..50b84ae68469 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -1,21 +1,39 @@ import React, {useCallback, useContext, useMemo, useState} from 'react'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import * as SearchUtils from '@libs/SearchUtils'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {SearchContext, SelectedTransactions} from './types'; const defaultSearchContext = { currentSearchHash: -1, selectedTransactions: {}, + selectedReports: [], setCurrentSearchHash: () => {}, setSelectedTransactions: () => {}, clearSelectedTransactions: () => {}, + shouldShowStatusBarLoading: false, + setShouldShowStatusBarLoading: () => {}, }; const Context = React.createContext(defaultSearchContext); +function getReportsFromSelectedTransactions(data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[], selectedTransactions: SelectedTransactions) { + return (data ?? []) + .filter( + (item) => + !SearchUtils.isTransactionListItemType(item) && + !SearchUtils.isReportActionListItemType(item) && + item.reportID && + item?.transactions?.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), + ) + .map((item) => item.reportID); +} + function SearchContextProvider({children}: ChildrenProps) { - const [searchContextData, setSearchContextData] = useState>({ + const [searchContextData, setSearchContextData] = useState>({ currentSearchHash: defaultSearchContext.currentSearchHash, selectedTransactions: defaultSearchContext.selectedTransactions, + selectedReports: defaultSearchContext.selectedReports, }); const setCurrentSearchHash = useCallback((searchHash: number) => { @@ -25,10 +43,14 @@ function SearchContextProvider({children}: ChildrenProps) { })); }, []); - const setSelectedTransactions = useCallback((selectedTransactions: SelectedTransactions) => { + const setSelectedTransactions = useCallback((selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]) => { + // When selecting transactions, we also need to manage the reports to which these transactions belong. This is done to ensure proper exporting to CSV. + const selectedReports = getReportsFromSelectedTransactions(data, selectedTransactions); + setSearchContextData((prevState) => ({ ...prevState, selectedTransactions, + selectedReports, })); }, []); @@ -40,19 +62,24 @@ function SearchContextProvider({children}: ChildrenProps) { setSearchContextData((prevState) => ({ ...prevState, selectedTransactions: {}, + selectedReports: [], })); }, [searchContextData.currentSearchHash], ); + const [shouldShowStatusBarLoading, setShouldShowStatusBarLoading] = useState(false); + const searchContext = useMemo( () => ({ ...searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions, + shouldShowStatusBarLoading, + setShouldShowStatusBarLoading, }), - [searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions], + [searchContextData, setCurrentSearchHash, setSelectedTransactions, clearSelectedTransactions, shouldShowStatusBarLoading], ); return {children}; diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index 689f917fdccf..36b56867b99f 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -56,7 +56,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const selectedOptions = useMemo(() => { return selectedReportIDs.map((id) => { const report = getSelectedOptionData(OptionsListUtils.createOptionFromReport({...reports?.[`${ONYXKEYS.COLLECTION.REPORT}${id}`], reportID: id}, personalDetails)); - const alternateText = OptionsListUtils.getAlternateText(report, {showChatPreviewLine: true}); + const alternateText = OptionsListUtils.getAlternateText(report, {}); return {...report, alternateText}; }); }, [personalDetails, reports, selectedReportIDs]); @@ -65,7 +65,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen if (!areOptionsInitialized || !isScreenTransitionEnd) { return defaultListOptions; } - return OptionsListUtils.getSearchOptions(options); + return OptionsListUtils.getSearchOptions(options, '', undefined, false); }, [areOptionsInitialized, isScreenTransitionEnd, options]); const chatOptions = useMemo(() => { diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 2580298ac3ac..ecca1f00e8ce 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,17 +1,18 @@ -import React, {useMemo} from 'react'; +import React, {useMemo, useState} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; +import DecisionModal from '@components/DecisionModal'; import Header from '@components/Header'; import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/types'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import {usePersonalDetails} from '@components/OnyxProvider'; -import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; @@ -29,7 +30,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type IconAsset from '@src/types/utils/IconAsset'; import {useSearchContext} from './SearchContext'; @@ -94,10 +95,6 @@ function HeaderWrapper({icon, title, subtitle, children, subtitleStyles = {}}: H type SearchPageHeaderProps = { queryJSON: SearchQueryJSON; hash: number; - onSelectDeleteOption?: (itemsToDelete: string[]) => void; - setOfflineModalOpen?: () => void; - setDownloadErrorModalOpen?: () => void; - data?: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]; }; type SearchHeaderOptionValue = DeepValueOf | undefined; @@ -121,37 +118,26 @@ function getHeaderContent(type: SearchDataTypes): HeaderContent { } } -function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModalOpen, setDownloadErrorModalOpen, data}: SearchPageHeaderProps) { +function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {activeWorkspaceID} = useActiveWorkspace(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {selectedTransactions} = useSearchContext(); + const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); + const {selectedTransactions, clearSelectedTransactions, selectedReports} = useSearchContext(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); + const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); + const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const selectedReports: Array = useMemo( - () => - (data ?? []) - .filter( - (item) => - !SearchUtils.isTransactionListItemType(item) && - !SearchUtils.isReportActionListItemType(item) && - item.reportID && - item.transactions.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), - ) - .map((item) => item.reportID), - [data, selectedTransactions], - ); const {status, type} = queryJSON; - const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON); const headerSubtitle = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates); @@ -159,6 +145,15 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa const headerIcon = isCannedQuery ? getHeaderContent(type).icon : Illustrations.Filters; const subtitleStyles = isCannedQuery ? styles.textHeadlineH2 : {}; + const handleDeleteExpenses = () => { + if (selectedTransactionsKeys.length === 0) { + return; + } + + clearSelectedTransactions(); + setIsDeleteExpensesConfirmModalVisible(false); + SearchActions.deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); + }; const headerButtonsOptions = useMemo(() => { if (selectedTransactionsKeys.length === 0) { @@ -174,7 +169,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } @@ -182,7 +177,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa SearchActions.exportSearchItemsToCSV( {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, () => { - setDownloadErrorModalOpen?.(); + setIsDownloadErrorModalVisible(true); }, ); }, @@ -198,7 +193,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } @@ -217,7 +212,7 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } @@ -236,11 +231,10 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa shouldCloseModalOnSelect: true, onSelected: () => { if (isOffline) { - setOfflineModalOpen?.(); + setIsOfflineModalVisible(true); return; } - - onSelectDeleteOption?.(selectedTransactionsKeys); + setIsDeleteExpensesConfirmModalVisible(true); }, }); } @@ -270,14 +264,11 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa selectedTransactionsKeys, selectedTransactions, translate, - onSelectDeleteOption, hash, theme.icon, styles.colorMuted, styles.fontWeightNormal, isOffline, - setOfflineModalOpen, - setDownloadErrorModalOpen, activeWorkspaceID, selectedReports, styles.textWrap, @@ -286,10 +277,42 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa if (shouldUseNarrowLayout) { if (selectionMode?.isEnabled) { return ( - + + + { + setIsDeleteExpensesConfirmModalVisible(false); + }} + title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} + prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + setIsOfflineModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isOfflineModalVisible} + onClose={() => setIsOfflineModalVisible(false)} + /> + setIsDownloadErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isDownloadErrorModalVisible} + onClose={() => setIsDownloadErrorModalVisible(false)} + /> + ); } return null; @@ -304,34 +327,66 @@ function SearchPageHeader({queryJSON, hash, onSelectDeleteOption, setOfflineModa const displaySearchRouter = SearchUtils.isCannedSearchQuery(queryJSON); return ( - - <> - {headerButtonsOptions.length > 0 ? ( - null} - shouldAlwaysShowDropdownMenu - pressOnEnter - buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} - customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} - options={headerButtonsOptions} - isSplitButton={false} - shouldUseStyleUtilityForAnchorPosition - /> - ) : ( - - )} - {displaySearchRouter && } - - + <> + + <> + {headerButtonsOptions.length > 0 ? ( + null} + shouldAlwaysShowDropdownMenu + pressOnEnter + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} + customText={translate('workspace.common.selected', {count: selectedTransactionsKeys.length})} + options={headerButtonsOptions} + isSplitButton={false} + shouldUseStyleUtilityForAnchorPosition + /> + ) : ( + + )} + {displaySearchRouter && } + + + { + setIsDeleteExpensesConfirmModalVisible(false); + }} + title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} + prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + setIsOfflineModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isOfflineModalVisible} + onClose={() => setIsOfflineModalVisible(false)} + /> + setIsDownloadErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isDownloadErrorModalVisible} + onClose={() => setIsDownloadErrorModalVisible(false)} + /> + ); } diff --git a/src/components/Search/SearchStatusBar.tsx b/src/components/Search/SearchStatusBar.tsx index ea19ef5f4e99..ba531382b5f0 100644 --- a/src/components/Search/SearchStatusBar.tsx +++ b/src/components/Search/SearchStatusBar.tsx @@ -4,6 +4,7 @@ import type {ScrollView as RNScrollView} from 'react-native'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; import ScrollView from '@components/ScrollView'; +import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -15,12 +16,13 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; +import {useSearchContext} from './SearchContext'; import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryString, SearchStatus, TripSearchStatus} from './types'; type SearchStatusBarProps = { type: SearchDataTypes; status: SearchStatus; - resetOffset: () => void; + onStatusChange?: () => void; }; const expenseOptions: Array<{key: ExpenseSearchStatus; icon: IconAsset; text: TranslationPaths; query: SearchQueryString}> = [ @@ -151,7 +153,7 @@ function getOptions(type: SearchDataTypes) { } } -function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { +function SearchStatusBar({type, status, onStatusChange}: SearchStatusBarProps) { const {singleExecution} = useSingleExecution(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -160,17 +162,22 @@ function SearchStatusBar({type, status, resetOffset}: SearchStatusBarProps) { const options = getOptions(type); const scrollRef = useRef(null); const isScrolledRef = useRef(false); + const {shouldShowStatusBarLoading} = useSearchContext(); + + if (shouldShowStatusBarLoading) { + return ; + } return ( {options.map((item, index) => { const onPress = singleExecution(() => { - resetOffset(); + onStatusChange?.(); Navigation.setParams({q: item.query}); }); const isActive = status === item.key; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b415d91b7ab4..18a14c0ec719 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1,17 +1,15 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import ConfirmModal from '@components/ConfirmModal'; -import DecisionModal from '@components/DecisionModal'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; -import SearchStatusSkeleton from '@components/Skeletons/SearchStatusSkeleton'; -import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; @@ -33,12 +31,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SearchResults from '@src/types/onyx/SearchResults'; import {useSearchContext} from './SearchContext'; -import SearchPageHeader from './SearchPageHeader'; -import SearchStatusBar from './SearchStatusBar'; import type {SearchColumnType, SearchQueryJSON, SearchStatus, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; type SearchProps = { queryJSON: SearchQueryJSON; + onSearchListScroll?: (event: NativeSyntheticEvent) => void; + contentContainerStyle?: StyleProp; }; const transactionItemMobileHeight = 100; @@ -78,21 +76,17 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact return {...selectedTransactions, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, canHold: item.canHold, canUnhold: item.canUnhold, action: item.action}}; } -function Search({queryJSON}: SearchProps) { +function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchProps) { const {isOffline} = useNetwork(); - const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const styles = useThemeStyles(); const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout(); const navigation = useNavigation>(); const lastSearchResultsRef = useRef>(); - const {setCurrentSearchHash, setSelectedTransactions, selectedTransactions, clearSelectedTransactions} = useSearchContext(); + const {setCurrentSearchHash, setSelectedTransactions, selectedTransactions, clearSelectedTransactions, setShouldShowStatusBarLoading} = useSearchContext(); const {selectionMode} = useMobileSelectionMode(); const [offset, setOffset] = useState(0); - const [offlineModalVisible, setOfflineModalVisible] = useState(false); - const [selectedTransactionsToDelete, setSelectedTransactionsToDelete] = useState([]); - const [deleteExpensesConfirmModalVisible, setDeleteExpensesConfirmModalVisible] = useState(false); - const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false); const {type, status, sortBy, sortOrder, hash} = queryJSON; const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); @@ -126,25 +120,6 @@ function Search({queryJSON}: SearchProps) { // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [isOffline, offset, queryJSON]); - const handleOnCancelConfirmModal = () => { - setDeleteExpensesConfirmModalVisible(false); - }; - - const handleDeleteExpenses = () => { - if (selectedTransactionsToDelete.length === 0) { - return; - } - - clearSelectedTransactions(); - setDeleteExpensesConfirmModalVisible(false); - SearchActions.deleteMoneyRequestOnSearch(hash, selectedTransactionsToDelete); - }; - - const handleOnSelectDeleteOption = (itemsToDelete: string[]) => { - setSelectedTransactionsToDelete(itemsToDelete); - setDeleteExpensesConfirmModalVisible(true); - }; - const getItemHeight = useCallback( (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { if (SearchUtils.isTransactionListItemType(item) || SearchUtils.isReportActionListItemType(item)) { @@ -165,8 +140,6 @@ function Search({queryJSON}: SearchProps) { [isLargeScreenWidth], ); - const resetOffset = () => setOffset(0); - const getItemHeightMemoized = memoize(getItemHeight, { transformKey: ([item]) => { // List items are displayed differently on "L"arge and "N"arrow screens so the height will differ @@ -191,6 +164,11 @@ function Search({queryJSON}: SearchProps) { const isSearchResultsEmpty = !searchResults?.data || SearchUtils.isSearchResultsEmpty(searchResults); const prevIsSearchResultEmpty = usePrevious(isSearchResultsEmpty); + useEffect(() => { + /** We only want to display the skeleton for the status filters the first time we load them for a specific data type */ + setShouldShowStatusBarLoading(shouldShowLoadingState && searchResults?.search?.type !== type); + }, [searchResults?.search?.type, setShouldShowStatusBarLoading, shouldShowLoadingState, type]); + useEffect(() => { if (!isSearchResultsEmpty || prevIsSearchResultEmpty) { return; @@ -200,24 +178,10 @@ function Search({queryJSON}: SearchProps) { if (shouldShowLoadingState) { return ( - <> - - - {/* We only want to display the skeleton for the status filters the first time we load them for a specific data type */} - {searchResults?.search?.type === type ? ( - - ) : ( - - )} - - + ); } @@ -235,18 +199,9 @@ function Search({queryJSON}: SearchProps) { if (shouldShowEmptyState) { return ( - <> - - + - + ); } @@ -259,7 +214,7 @@ function Search({queryJSON}: SearchProps) { return; } - setSelectedTransactions(prepareTransactionsList(item, selectedTransactions)); + setSelectedTransactions(prepareTransactionsList(item, selectedTransactions), data); return; } @@ -270,14 +225,17 @@ function Search({queryJSON}: SearchProps) { delete reducedSelectedTransactions[transaction.keyForList]; }); - setSelectedTransactions(reducedSelectedTransactions); + setSelectedTransactions(reducedSelectedTransactions, data); return; } - setSelectedTransactions({ - ...selectedTransactions, - ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), - }); + setSelectedTransactions( + { + ...selectedTransactions, + ...Object.fromEntries(item.transactions.map(mapTransactionItemToSelectedEntry)), + }, + data, + ); }; const openReport = (item: TransactionListItemType | ReportListItemType | ReportActionListItemType) => { @@ -323,12 +281,12 @@ function Search({queryJSON}: SearchProps) { } if (areItemsOfReportType) { - setSelectedTransactions(Object.fromEntries((data as ReportListItemType[]).flatMap((item) => item.transactions.map(mapTransactionItemToSelectedEntry)))); + setSelectedTransactions(Object.fromEntries((data as ReportListItemType[]).flatMap((item) => item.transactions.map(mapTransactionItemToSelectedEntry))), data); return; } - setSelectedTransactions(Object.fromEntries((data as TransactionListItemType[]).map(mapTransactionItemToSelectedEntry))); + setSelectedTransactions(Object.fromEntries((data as TransactionListItemType[]).map(mapTransactionItemToSelectedEntry)), data); }; const onSortPress = (column: SearchColumnType, order: SortOrder) => { @@ -340,102 +298,61 @@ function Search({queryJSON}: SearchProps) { const shouldShowSorting = sortableSearchStatuses.includes(status); return ( - <> - setOfflineModalVisible(true)} - setDownloadErrorModalOpen={() => setDownloadErrorModalVisible(true)} - /> - - - sections={[{data: sortedSelectedData, isDisabled: false}]} - turnOnSelectionModeOnLongPress={type !== CONST.SEARCH.DATA_TYPES.CHAT} - onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} - onCheckboxPress={toggleTransaction} - onSelectAll={toggleAllTransactions} - customListHeader={ - !isLargeScreenWidth ? null : ( - - ) - } - canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple} - customListHeaderHeight={searchHeaderHeight} - // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, - // we have configured a larger windowSize and a longer delay between batch renders. - // The windowSize determines the number of items rendered before and after the currently visible items. - // A larger windowSize helps pre-render more items, reducing the likelihood of blank spaces appearing. - // The updateCellsBatchingPeriod sets the delay (in milliseconds) between rendering batches of cells. - // A longer delay allows the UI to handle rendering in smaller increments, which can improve performance and smoothness. - // For more information, refer to the React Native documentation: - // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#windowsize - // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#updatecellsbatchingperiod - windowSize={111} - updateCellsBatchingPeriod={200} - ListItem={ListItem} - onSelectRow={openReport} - getItemHeight={getItemHeightMemoized} - shouldSingleExecuteRowSelect - shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - shouldPreventDefault={false} - listHeaderWrapperStyle={[styles.ph8, styles.pv3, styles.pb5]} - containerStyle={[styles.pv0]} - showScrollIndicator={false} - onEndReachedThreshold={0.75} - onEndReached={fetchMoreResults} - listFooterContent={ - shouldShowLoadingMoreItems ? ( - - ) : undefined - } - /> - setSelectedTransactionsToDelete([])} - title={translate('iou.deleteExpense', {count: selectedTransactionsToDelete.length})} - prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsToDelete.length})} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - setOfflineModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={offlineModalVisible} - onClose={() => setOfflineModalVisible(false)} - /> - setDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={downloadErrorModalVisible} - onClose={() => setDownloadErrorModalVisible(false)} - /> - + + sections={[{data: sortedSelectedData, isDisabled: false}]} + turnOnSelectionModeOnLongPress={type !== CONST.SEARCH.DATA_TYPES.CHAT} + onTurnOnSelectionMode={(item) => item && toggleTransaction(item)} + onCheckboxPress={toggleTransaction} + onSelectAll={toggleAllTransactions} + customListHeader={ + !isLargeScreenWidth ? null : ( + + ) + } + onScroll={onSearchListScroll} + canSelectMultiple={type !== CONST.SEARCH.DATA_TYPES.CHAT && canSelectMultiple} + customListHeaderHeight={searchHeaderHeight} + // To enhance the smoothness of scrolling and minimize the risk of encountering blank spaces during scrolling, + // we have configured a larger windowSize and a longer delay between batch renders. + // The windowSize determines the number of items rendered before and after the currently visible items. + // A larger windowSize helps pre-render more items, reducing the likelihood of blank spaces appearing. + // The updateCellsBatchingPeriod sets the delay (in milliseconds) between rendering batches of cells. + // A longer delay allows the UI to handle rendering in smaller increments, which can improve performance and smoothness. + // For more information, refer to the React Native documentation: + // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#windowsize + // https://reactnative.dev/docs/0.73/optimizing-flatlist-configuration#updatecellsbatchingperiod + windowSize={111} + updateCellsBatchingPeriod={200} + ListItem={ListItem} + onSelectRow={openReport} + getItemHeight={getItemHeightMemoized} + shouldSingleExecuteRowSelect + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + shouldPreventDefault={false} + listHeaderWrapperStyle={[styles.ph8, styles.pt3]} + containerStyle={[styles.pv0, type === CONST.SEARCH.DATA_TYPES.CHAT && !isSmallScreenWidth && styles.pt3]} + showScrollIndicator={false} + onEndReachedThreshold={0.75} + onEndReached={fetchMoreResults} + listFooterContent={ + shouldShowLoadingMoreItems ? ( + + ) : undefined + } + contentContainerStyle={contentContainerStyle} + scrollEventThrottle={1} + /> ); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 195073f8b89f..3d35190bf1a4 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,6 +1,7 @@ import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; +import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; /** Model of the selected transaction */ type SelectedTransactionInfo = { @@ -34,9 +35,12 @@ type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus type SearchContext = { currentSearchHash: number; selectedTransactions: SelectedTransactions; + selectedReports: Array; setCurrentSearchHash: (hash: number) => void; - setSelectedTransactions: (selectedTransactions: SelectedTransactions) => void; + setSelectedTransactions: (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]) => void; clearSelectedTransactions: (hash?: number) => void; + shouldShowStatusBarLoading: boolean; + setShouldShowStatusBarLoading: (shouldShow: boolean) => void; }; type ASTNode = { diff --git a/src/components/SectionList/AnimatedSectionList.tsx b/src/components/SectionList/AnimatedSectionList.tsx new file mode 100644 index 000000000000..57e532dc80c8 --- /dev/null +++ b/src/components/SectionList/AnimatedSectionList.tsx @@ -0,0 +1,8 @@ +import {SectionList as RNSectionList} from 'react-native'; +import Animated from 'react-native-reanimated'; +import type {SectionListProps} from './types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const AnimatedSectionList = Animated.createAnimatedComponent>(RNSectionList); + +export default AnimatedSectionList; diff --git a/src/components/SectionList/index.android.tsx b/src/components/SectionList/index.android.tsx index 60c769945b8e..157e546b3ea9 100644 --- a/src/components/SectionList/index.android.tsx +++ b/src/components/SectionList/index.android.tsx @@ -1,10 +1,10 @@ import React, {forwardRef} from 'react'; -import {SectionList as RNSectionList} from 'react-native'; +import AnimatedSectionList from './AnimatedSectionList'; import type {SectionListProps, SectionListRef} from './types'; function SectionListWithRef(props: SectionListProps, ref: SectionListRef) { return ( - (props: SectionListProps, ref: SectionListRef) { return ( - ( onLongPressRow, shouldShowTextInput = !!textInputLabel || !!textInputIconLeft, shouldShowListEmptyContent = true, + scrollEventThrottle, + contentContainerStyle, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -736,6 +738,8 @@ function BaseSelectionList( ListFooterComponent={listFooterContent ?? ShowMoreButtonInstance} onEndReached={onEndReached} onEndReachedThreshold={onEndReachedThreshold} + scrollEventThrottle={scrollEventThrottle} + contentContainerStyle={contentContainerStyle} /> {children} diff --git a/src/components/SelectionList/index.tsx b/src/components/SelectionList/index.tsx index a6fd636cc215..b719737a963b 100644 --- a/src/components/SelectionList/index.tsx +++ b/src/components/SelectionList/index.tsx @@ -5,7 +5,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import BaseSelectionList from './BaseSelectionList'; import type {BaseSelectionListProps, ListItem, SelectionListHandle} from './types'; -function SelectionList(props: BaseSelectionListProps, ref: ForwardedRef) { +function SelectionList({onScroll, ...props}: BaseSelectionListProps, ref: ForwardedRef) { const [isScreenTouched, setIsScreenTouched] = useState(false); const touchStart = () => setIsScreenTouched(true); @@ -27,18 +27,21 @@ function SelectionList(props: BaseSelectionListProps { + // Only dismiss the keyboard whenever the user scrolls the screen + if (!isScreenTouched) { + return; + } + Keyboard.dismiss(); + }; + return ( { - // Only dismiss the keyboard whenever the user scrolls the screen - if (!isScreenTouched) { - return; - } - Keyboard.dismiss(); - }} + onScroll={onScroll ?? defaultOnScroll} /> ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index b0d657b202c6..1beee46f82f0 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,5 +1,16 @@ import type {MutableRefObject, ReactElement, ReactNode} from 'react'; -import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextInput, TextStyle, ViewStyle} from 'react-native'; +import type { + GestureResponderEvent, + InputModeOptions, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + SectionListData, + StyleProp, + TextInput, + TextStyle, + ViewStyle, +} from 'react-native'; import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; // eslint-disable-next-line no-restricted-imports import type CursorStyles from '@styles/utils/cursor/types'; @@ -408,7 +419,7 @@ type BaseSelectionListProps = Partial & { initiallyFocusedOptionKey?: string | null; /** Callback to fire when the list is scrolled */ - onScroll?: () => void; + onScroll?: (event: NativeSyntheticEvent) => void; /** Callback to fire when the list is scrolled and the user begins dragging */ onScrollBeginDrag?: () => void; @@ -543,6 +554,12 @@ type BaseSelectionListProps = Partial & { /** Whether to show the empty list content */ shouldShowListEmptyContent?: boolean; + + /** Scroll event throttle for preventing onScroll callbacks to be fired too often */ + scrollEventThrottle?: number; + + /** Additional styles to apply to scrollable content */ + contentContainerStyle?: StyleProp; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/Skeletons/SearchRowSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx index 55103cad4c6c..1169ffb9df94 100644 --- a/src/components/Skeletons/SearchRowSkeleton.tsx +++ b/src/components/Skeletons/SearchRowSkeleton.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import {View} from 'react-native'; +import type {StyleProp, ViewStyle} from 'react-native'; import {Circle, Rect} from 'react-native-svg'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -11,6 +13,7 @@ type SearchRowSkeletonProps = { shouldAnimate?: boolean; fixedNumItems?: number; gradientOpacityEnabled?: boolean; + containerStyle?: StyleProp; }; const barHeight = 8; @@ -29,158 +32,162 @@ const centralPanePadding = 40; // 80 is the width of the button on the right side const rightButtonWidth = 80; -function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: SearchRowSkeletonProps) { +function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false, containerStyle}: SearchRowSkeletonProps) { const styles = useThemeStyles(); const {windowWidth} = useWindowDimensions(); const {shouldUseNarrowLayout, isLargeScreenWidth} = useResponsiveLayout(); if (shouldUseNarrowLayout) { return ( + + ( + <> + + + + + + + + + + + + + + + )} + /> + + ); + } + + return ( + ( <> - - - - - - - - + {isLargeScreenWidth && ( + <> + + + + + )} + + )} /> - ); - } - - return ( - ( - <> - - - - {isLargeScreenWidth && ( - <> - - - - - )} - - - - - - )} - /> + ); } diff --git a/src/hooks/usePaymentMethodState/index.ts b/src/hooks/usePaymentMethodState/index.ts new file mode 100644 index 000000000000..75ae3ea48377 --- /dev/null +++ b/src/hooks/usePaymentMethodState/index.ts @@ -0,0 +1,28 @@ +import {useCallback, useState} from 'react'; +import type {PaymentMethodState} from './types'; + +const initialState: PaymentMethodState = { + isSelectedPaymentMethodDefault: false, + selectedPaymentMethod: {}, + formattedSelectedPaymentMethod: { + title: '', + }, + methodID: '', + selectedPaymentMethodType: '', +}; + +function usePaymentMethodState() { + const [paymentMethod, setPaymentMethod] = useState(initialState); + + const resetSelectedPaymentMethodData = useCallback(() => { + setPaymentMethod(initialState); + }, [setPaymentMethod]); + + return { + paymentMethod, + setPaymentMethod, + resetSelectedPaymentMethodData, + }; +} + +export default usePaymentMethodState; diff --git a/src/hooks/usePaymentMethodState/types.ts b/src/hooks/usePaymentMethodState/types.ts new file mode 100644 index 000000000000..260a9aec27cf --- /dev/null +++ b/src/hooks/usePaymentMethodState/types.ts @@ -0,0 +1,28 @@ +import type {ViewStyle} from 'react-native'; +import type {AccountData} from '@src/types/onyx'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type FormattedSelectedPaymentMethodIcon = { + icon: IconAsset; + iconHeight?: number; + iconWidth?: number; + iconStyles?: ViewStyle[]; + iconSize?: number; +}; + +type FormattedSelectedPaymentMethod = { + title: string; + icon?: FormattedSelectedPaymentMethodIcon; + description?: string; + type?: string; +}; + +type PaymentMethodState = { + isSelectedPaymentMethodDefault: boolean; + selectedPaymentMethod: AccountData; + formattedSelectedPaymentMethod: FormattedSelectedPaymentMethod; + methodID: string | number; + selectedPaymentMethodType: string; +}; + +export type {FormattedSelectedPaymentMethodIcon, FormattedSelectedPaymentMethod, PaymentMethodState}; diff --git a/src/languages/en.ts b/src/languages/en.ts index 6d579a2af2df..cb9fde8c053d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -451,6 +451,7 @@ const translations = { filterLogs: 'Filter Logs', network: 'Network', reportID: 'Report ID', + bankAccounts: 'Bank accounts', chooseFile: 'Choose file', dropTitle: 'Let it go', dropMessage: 'Drop your file here', @@ -1373,7 +1374,6 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Enable your wallet to send and receive money with friends.', walletEnabledToSendAndReceiveMoney: 'Your wallet has been enabled to send and receive money with friends.', enableWallet: 'Enable wallet', - bankAccounts: 'Bank accounts', addBankAccountToSendAndReceive: 'Adding a bank account allows you to get paid back for expenses you submit to a workspace.', addBankAccount: 'Add bank account', assignedCards: 'Assigned cards', @@ -3649,6 +3649,7 @@ const translations = { payingAsIndividual: 'Paying as an individual', payingAsBusiness: 'Paying as a business', }, + bankAccountsSubtitle: 'Add a bank account to receive invoice payments.', }, invite: { member: 'Invite member', diff --git a/src/languages/es.ts b/src/languages/es.ts index cb19b091b058..c4ef82c6bcc2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -442,6 +442,7 @@ const translations = { filterLogs: 'Registros de filtrado', network: 'La red', reportID: 'ID del informe', + bankAccounts: 'Cuentas bancarias', chooseFile: 'Elegir archivo', dropTitle: 'Suéltalo', dropMessage: 'Suelta tu archivo aquí', @@ -1370,7 +1371,6 @@ const translations = { enableWalletToSendAndReceiveMoney: 'Habilita tu Billetera Expensify para comenzar a enviar y recibir dinero con amigos.', walletEnabledToSendAndReceiveMoney: 'Tu billetera ha sido habilitada para enviar y recibir dinero con amigos.', enableWallet: 'Habilitar billetera', - bankAccounts: 'Cuentas bancarias', addBankAccountToSendAndReceive: 'Agregar una cuenta bancaria te permite recibir reembolsos por los gastos que envíes a un espacio de trabajo.', addBankAccount: 'Añadir cuenta bancaria', assignedCards: 'Tarjetas asignadas', @@ -3690,6 +3690,7 @@ const translations = { payingAsIndividual: 'Pago individual', payingAsBusiness: 'Pagar como una empresa', }, + bankAccountsSubtitle: 'Agrega una cuenta bancaria para recibir pagos de facturas.', }, invite: { member: 'Invitar miembros', diff --git a/src/libs/API/parameters/SetInvoicingTransferBankAccountParams.ts b/src/libs/API/parameters/SetInvoicingTransferBankAccountParams.ts new file mode 100644 index 000000000000..198a7b6ccf9a --- /dev/null +++ b/src/libs/API/parameters/SetInvoicingTransferBankAccountParams.ts @@ -0,0 +1,6 @@ +type SetInvoicingTransferBankAccountParams = { + bankAccountID: number; + policyID: string; +}; + +export default SetInvoicingTransferBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 9f51cab3f360..28a0e622689b 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -327,3 +327,4 @@ export type {default as UpdateCompanyCard} from './UpdateCompanyCard'; export type {default as UpdateCompanyCardNameParams} from './UpdateCompanyCardNameParams'; export type {default as SetCompanyCardExportAccountParams} from './SetCompanyCardExportAccountParams'; export type {default as SetMissingPersonalDetailsAndShipExpensifyCardParams} from './SetMissingPersonalDetailsAndShipExpensifyCardParams'; +export type {default as SetInvoicingTransferBankAccountParams} from './SetInvoicingTransferBankAccountParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b72b77ae4739..50a7c8f58286 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -408,6 +408,7 @@ const WRITE_COMMANDS = { UPDATE_COMPANY_CARD_NAME: 'SetCardName', SET_CARD_EXPORT_ACCOUNT: 'SetCardExportAccount', SET_MISSING_PERSONAL_DETAILS_AND_SHIP_EXPENSIFY_CARD: 'SetMissingPersonalDetailsAndShipExpensifyCard', + SET_INVOICING_TRANSFER_BANK_ACCOUNT: 'SetInvoicingTransferBankAccount', } as const; type WriteCommand = ValueOf; @@ -825,6 +826,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_XERO_SYNC_INVOICE_COLLECTIONS_ACCOUNT_ID]: Parameters.UpdateXeroGenericTypeParams; [WRITE_COMMANDS.UPDATE_XERO_SYNC_SYNC_REIMBURSED_REPORTS]: Parameters.UpdateXeroGenericTypeParams; [WRITE_COMMANDS.UPDATE_XERO_SYNC_REIMBURSEMENT_ACCOUNT_ID]: Parameters.UpdateXeroGenericTypeParams; + + [WRITE_COMMANDS.SET_INVOICING_TRANSFER_BANK_ACCOUNT]: Parameters.SetInvoicingTransferBankAccountParams; }; const READ_COMMANDS = { diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 98e490524bef..dee84a4f201f 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -1,7 +1,7 @@ import React, {memo, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import Onyx, {useOnyx} from 'react-native-onyx'; +import Onyx, {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import ActiveGuidesEventListener from '@components/ActiveGuidesEventListener'; import ComposeProviders from '@components/ComposeProviders'; @@ -50,7 +50,6 @@ import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type ReactComponentModule from '@src/types/utils/ReactComponentModule'; import beforeRemoveReportOpenedFromSearchRHP from './beforeRemoveReportOpenedFromSearchRHP'; import CENTRAL_PANE_SCREENS from './CENTRAL_PANE_SCREENS'; @@ -66,6 +65,17 @@ import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator'; import RightModalNavigator from './Navigators/RightModalNavigator'; import WelcomeVideoModalNavigator from './Navigators/WelcomeVideoModalNavigator'; +type AuthScreensProps = { + /** Session of currently logged in user */ + session: OnyxEntry; + + /** The report ID of the last opened public room as anonymous user */ + lastOpenedPublicRoomID: OnyxEntry; + + /** The last Onyx update ID was applied to the client */ + initialLastUpdateIDAppliedToClient: OnyxEntry; +}; + const loadReportAttachments = () => require('../../../pages/home/report/ReportAttachments').default; const loadValidateLoginPage = () => require('../../../pages/ValidateLoginPage').default; const loadLogOutPreviousUserPage = () => require('../../../pages/LogOutPreviousUserPage').default; @@ -213,10 +223,7 @@ const modalScreenListenersWithCancelSearch = { }, }; -function AuthScreens() { - const [session, sessionStatus] = useOnyx(ONYXKEYS.SESSION); - const [lastOpenedPublicRoomID, lastOpenedPublicRoomIDStatus] = useOnyx(ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID); - const [initialLastUpdateIDAppliedToClient, initialLastUpdateIDAppliedToClientStatus] = useOnyx(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT); +function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDAppliedToClient}: AuthScreensProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth, isSmallScreenWidth} = useResponsiveLayout(); @@ -398,9 +405,6 @@ function AuthScreens() { // Prevent unnecessary scrolling cardStyle: styles.cardStyleNavigator, }; - if (isLoadingOnyxValue(sessionStatus, lastOpenedPublicRoomIDStatus, initialLastUpdateIDAppliedToClientStatus)) { - return; - } return ( @@ -579,4 +583,16 @@ function AuthScreens() { AuthScreens.displayName = 'AuthScreens'; -export default memo(AuthScreens, () => true); +const AuthScreensMemoized = memo(AuthScreens, () => true); + +export default withOnyx({ + session: { + key: ONYXKEYS.SESSION, + }, + lastOpenedPublicRoomID: { + key: ONYXKEYS.LAST_OPENED_PUBLIC_ROOM_ID, + }, + initialLastUpdateIDAppliedToClient: { + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + }, +})(AuthScreensMemoized); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 51db5a693f91..b0c48eb37eb7 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -532,9 +532,15 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = const report = ReportUtils.getReportOrDraftReport(option.reportID); const isAdminRoom = ReportUtils.isAdminRoom(report); const isAnnounceRoom = ReportUtils.isAnnounceRoom(report); + const isGroupChat = ReportUtils.isGroupChat(report); + const isExpenseThread = ReportUtils.isMoneyRequest(report); - if (!!option.isThread || !!option.isMoneyRequestReport) { - return option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + if (isExpenseThread || option.isMoneyRequestReport) { + return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'iou.expense'); + } + + if (option.isThread) { + return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'threads.thread'); } if (option.isChatRoom && !isAdminRoom && !isAnnounceRoom) { @@ -546,7 +552,11 @@ function getAlternateText(option: ReportUtils.OptionData, {showChatPreviewLine = } if (option.isTaskReport) { - return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); + return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'task.task'); + } + + if (isGroupChat) { + return showChatPreviewLine && option.lastMessageText ? option.lastMessageText : Localize.translate(preferredLocale, 'common.group'); } return showChatPreviewLine && option.lastMessageText @@ -2084,7 +2094,7 @@ function getOptions( /** * Build the options for the Search view */ -function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = []): Options { +function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = [], isUsedInChatFinder = true): Options { Timing.start(CONST.TIMING.LOAD_SEARCH_OPTIONS); Performance.markStart(CONST.TIMING.LOAD_SEARCH_OPTIONS); const optionList = getOptions(options, { @@ -2094,7 +2104,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = includeMultipleParticipantReports: true, maxRecentReportsToShow: 0, // Unlimited sortByReportTypeInSearch: true, - showChatPreviewLine: true, + showChatPreviewLine: isUsedInChatFinder, includeP2P: true, forcePolicyNamePreview: true, includeOwnedWorkspaceChats: true, @@ -2102,7 +2112,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = includeMoneyRequests: true, includeTasks: true, includeSelfDM: true, - shouldBoldTitleByDefault: false, + shouldBoldTitleByDefault: !isUsedInChatFinder, }); Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); Performance.markEnd(CONST.TIMING.LOAD_SEARCH_OPTIONS); diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index bac3739af810..59f1a6b06aed 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -10,6 +10,7 @@ import type { DeletePaymentCardParams, MakeDefaultPaymentMethodParams, PaymentCardParams, + SetInvoicingTransferBankAccountParams, TransferWalletBalanceParams, UpdateBillingCurrencyParams, } from '@libs/API/parameters'; @@ -531,6 +532,50 @@ function setPaymentCardForm(values: AccountData) { }); } +/** + * Sets the default bank account to use for receiving payouts from + * + */ +function setInvoicingTransferBankAccount(bankAccountID: number, policyID: string, previousBankAccountID: number) { + const parameters: SetInvoicingTransferBankAccountParams = { + bankAccountID, + policyID, + }; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + bankAccount: { + transferBankAccountID: bankAccountID, + }, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + invoice: { + bankAccount: { + transferBankAccountID: previousBankAccountID, + }, + }, + }, + }, + ]; + + API.write(WRITE_COMMANDS.SET_INVOICING_TRANSFER_BANK_ACCOUNT, parameters, { + optimisticData, + failureData, + }); +} + export { deletePaymentCard, addPaymentCard, @@ -555,4 +600,5 @@ export { clearWalletTermsError, setPaymentCardForm, verifySetupIntent, + setInvoicingTransferBankAccount, }; diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 28bcb4e0df8b..430bbb0cfeae 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -158,6 +158,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) { multiline={numberOfLines > 1} defaultValue={value} disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)} + shouldInterceptSwipe /> ); })} @@ -175,6 +176,7 @@ function DebugDetails({data, onSave, onDelete, validate}: DebugDetailsProps) { label={key} defaultValue={String(value)} disabled={DETAILS_DISABLED_KEYS.includes(key as DetailsDisabledKeys)} + shouldInterceptSwipe /> ))} {numberFields.length === 0 && None} diff --git a/src/pages/Debug/DebugJSON.tsx b/src/pages/Debug/DebugJSON.tsx index 14e1da9e029c..6da1296464eb 100644 --- a/src/pages/Debug/DebugJSON.tsx +++ b/src/pages/Debug/DebugJSON.tsx @@ -1,7 +1,9 @@ import React, {useMemo} from 'react'; +import {View} from 'react-native'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; import ScrollView from '@components/ScrollView'; +import SwipeInterceptPanResponder from '@components/SwipeInterceptPanResponder'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -35,7 +37,12 @@ function DebugJSON({data}: DebugJSONProps) { }} icon={Expensicons.Copy} /> - {json} + + {json} + ); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index c3607d4a5947..47c29b99b02f 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -3,6 +3,8 @@ import React, {useMemo} from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; +import SearchPageHeader from '@components/Search/SearchPageHeader'; +import SearchStatusBar from '@components/Search/SearchStatusBar'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -39,7 +41,19 @@ function SearchPage({route}: SearchPageProps) { onBackButtonPress={handleOnBackButtonPress} shouldShowLink={false} > - {queryJSON && } + {queryJSON && ( + <> + + + + + )} ); diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx index 684d45df5a34..cbff3bc33f94 100644 --- a/src/pages/Search/SearchPageBottomTab.tsx +++ b/src/pages/Search/SearchPageBottomTab.tsx @@ -1,31 +1,64 @@ import React from 'react'; +import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Animated, {clamp, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; -import {useSearchContext} from '@components/Search/SearchContext'; +import SearchStatusBar from '@components/Search/SearchStatusBar'; import useActiveCentralPaneRoute from '@hooks/useActiveCentralPaneRoute'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; import * as SearchUtils from '@libs/SearchUtils'; import TopBar from '@navigation/AppNavigator/createCustomBottomTabNavigator/TopBar'; +import variables from '@styles/variables'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import SearchSelectionModeHeader from './SearchSelectionModeHeader'; import SearchTypeMenu from './SearchTypeMenu'; +const TOO_CLOSE_TO_TOP_DISTANCE = 10; +const TOO_CLOSE_TO_BOTTOM_DISTANCE = 10; +const ANIMATION_DURATION_IN_MS = 300; + function SearchPageBottomTab() { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {windowHeight} = useWindowDimensions(); const activeCentralPaneRoute = useActiveCentralPaneRoute(); const styles = useThemeStyles(); - const {clearSelectedTransactions} = useSearchContext(); const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + + const scrollOffset = useSharedValue(0); + const topBarOffset = useSharedValue(variables.searchHeaderHeight); + const topBarAnimatedStyle = useAnimatedStyle(() => ({ + top: topBarOffset.value, + })); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + const {contentOffset, layoutMeasurement, contentSize} = event; + if (windowHeight > contentSize.height) { + return; + } + const currentOffset = contentOffset.y; + const isScrollingDown = currentOffset > scrollOffset.value; + const distanceScrolled = currentOffset - scrollOffset.value; + if (isScrollingDown && contentOffset.y > TOO_CLOSE_TO_TOP_DISTANCE) { + // eslint-disable-next-line react-compiler/react-compiler + topBarOffset.value = clamp(topBarOffset.value - distanceScrolled, variables.minimalTopBarOffset, variables.searchHeaderHeight); + } else if (!isScrollingDown && distanceScrolled < 0 && contentOffset.y + layoutMeasurement.height < contentSize.height - TOO_CLOSE_TO_BOTTOM_DISTANCE) { + topBarOffset.value = withTiming(variables.searchHeaderHeight, {duration: ANIMATION_DURATION_IN_MS}); + } + scrollOffset.value = currentOffset; + }, + }); + const searchParams = activeCentralPaneRoute?.params as AuthScreensParamList[typeof SCREENS.SEARCH.CENTRAL_PANE]; const parsedQuery = SearchUtils.buildSearchQueryJSON(searchParams?.q); const searchName = searchParams?.name; @@ -36,19 +69,31 @@ function SearchPageBottomTab() { const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: SearchUtils.buildCannedSearchQuery()})); + if (!queryJSON) { + return ( + + + + ); + } + return ( - - {!selectionMode?.isEnabled && queryJSON ? ( - <> + {!selectionMode?.isEnabled ? ( + <> + + + - - ) : ( - { - clearSelectedTransactions(); - turnOffMobileSelectionMode(); - }} - /> - )} - {shouldUseNarrowLayout && queryJSON && } - + {shouldUseNarrowLayout && ( + { + topBarOffset.value = withTiming(variables.searchHeaderHeight, {duration: ANIMATION_DURATION_IN_MS}); + }} + /> + )} + + + ) : ( + + )} + {shouldUseNarrowLayout && ( + + )} ); } diff --git a/src/pages/Search/SearchSelectedNarrow.tsx b/src/pages/Search/SearchSelectedNarrow.tsx index 005a28fff755..536d96c23ed0 100644 --- a/src/pages/Search/SearchSelectedNarrow.tsx +++ b/src/pages/Search/SearchSelectedNarrow.tsx @@ -45,7 +45,7 @@ function SearchSelectedNarrow({options, itemsLength}: SearchSelectedNarrowProps) }; return ( - +