diff --git a/Mobile-Expensify b/Mobile-Expensify index bc251917ab4dc..5f9ae33e219da 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit bc251917ab4dcb31d32f73e2ed9a39c62905cb9a +Subproject commit 5f9ae33e219da7cea38e808d679f3222a0f64076 diff --git a/android/app/build.gradle b/android/app/build.gradle index 239272dc948bc..e7767bb1a185a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,8 +111,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009032807 - versionName "9.3.28-7" + versionCode 1009032808 + versionName "9.3.28-8" // 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/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index eddf2e70040d2..2458d6dd3d3da 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -44,7 +44,7 @@ CFBundleVersion - 9.3.28.7 + 9.3.28.8 FullStory OrgId diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 29e0bf0982125..969611965ff97 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.3.28 CFBundleVersion - 9.3.28.7 + 9.3.28.8 NSExtension NSExtensionPointIdentifier diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist index 489818613b060..780faeaad7364 100644 --- a/ios/ShareViewController/Info.plist +++ b/ios/ShareViewController/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 9.3.28 CFBundleVersion - 9.3.28.7 + 9.3.28.8 NSExtension NSExtensionAttributes diff --git a/package-lock.json b/package-lock.json index 4a79e3f6239c8..288852df51637 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.3.28-7", + "version": "9.3.28-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.3.28-7", + "version": "9.3.28-8", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index fd947f8e200af..5da9b5cf5a5af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.3.28-7", + "version": "9.3.28-8", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 593a179396ba6..3ffee4d911892 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import {FlashList} from '@shopify/flash-list'; import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list'; -import React, {useCallback, useImperativeHandle, useRef} from 'react'; +import React, {useImperativeHandle, useRef} from 'react'; import type {TextInputKeyPressEvent} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -97,26 +97,23 @@ function BaseSelectionListWithSections({ hasKeyBeenPressed.current = true; }; - const scrollToIndex = useCallback( - (index: number) => { - if (index < 0 || index >= flattenedData.length || !listRef.current) { - return; - } - const item = flattenedData.at(index); - if (!item) { - return; - } - try { - listRef.current.scrollToIndex({index}); - } catch (error) { - // FlashList may throw if layout for this index doesn't exist yet - // This can happen when data changes rapidly (e.g., during search filtering) - // The layout will be computed on next render, so we can safely ignore this - Log.warn('SelectionListWithSections: error scrolling to index', {error}); - } - }, - [flattenedData], - ); + const scrollToIndex = (index: number) => { + if (index < 0 || index >= flattenedData.length || !listRef.current) { + return; + } + const item = flattenedData.at(index); + if (!item) { + return; + } + try { + listRef.current.scrollToIndex({index}); + } catch (error) { + // FlashList may throw if layout for this index doesn't exist yet + // This can happen when data changes rapidly (e.g., during search filtering) + // The layout will be computed on next render, so we can safely ignore this + Log.warn('SelectionListWithSections: error scrolling to index', {error}); + } + }; const debouncedScrollToIndex = useDebounce(scrollToIndex, CONST.TIMING.LIST_SCROLLING_DEBOUNCE_TIME, {leading: true, trailing: true}); @@ -182,10 +179,6 @@ function BaseSelectionListWithSections({ innerTextInputRef.current?.focus(); }; - const clearInputAfterSelect = () => { - textInputOptions?.onChangeText?.(''); - }; - const updateAndScrollToFocusedIndex = (index: number, shouldScroll = true) => { setFocusedIndex(index); if (shouldScroll) { @@ -202,8 +195,6 @@ function BaseSelectionListWithSections({ useImperativeHandle(ref, () => ({ focusTextInput, - scrollToIndex, - clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedOption: getFocusedItem, diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 1a94e6212bebe..62272718b9fbd 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -53,8 +53,6 @@ type SelectionListWithSectionsProps = BaseSelectionListP type SelectionListWithSectionsHandle = { focusTextInput: () => void; - scrollToIndex: (index: number) => void; - clearInputAfterSelect: () => void; updateAndScrollToFocusedIndex: (index: number, shouldScroll?: boolean) => void; updateExternalTextInputFocus: (isTextInputFocused: boolean) => void; getFocusedOption: () => TItem | undefined; diff --git a/src/components/SelectionList/components/TextInput.tsx b/src/components/SelectionList/components/TextInput.tsx index 011bdd9b6e1ab..42e39a5514eeb 100644 --- a/src/components/SelectionList/components/TextInput.tsx +++ b/src/components/SelectionList/components/TextInput.tsx @@ -65,22 +65,7 @@ function TextInput({ }: TextInputProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const { - label, - value, - onChangeText, - errorText, - headerMessage, - hint, - disableAutoFocus, - placeholder, - maxLength, - inputMode, - ref: optionsRef, - style, - disableAutoCorrect, - shouldInterceptSwipe, - } = options ?? {}; + const {label, value, onChangeText, errorText, headerMessage, hint, disableAutoFocus, placeholder, maxLength, inputMode, ref: optionsRef, style, disableAutoCorrect} = options ?? {}; const resultsFound = headerMessage !== translate('common.noResultsFound'); const noData = dataLength === 0 && !showLoadingPlaceholder; const shouldShowHeaderMessage = !!headerMessage && (!isLoadingNewOptions || resultsFound || noData); @@ -149,8 +134,8 @@ function TextInput({ isLoading={isLoading} testID="selection-list-text-input" errorText={errorText} + shouldInterceptSwipe={false} autoCorrect={!disableAutoCorrect} - shouldInterceptSwipe={shouldInterceptSwipe ?? false} /> {shouldShowHeaderMessage && ( diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 4eb07895c4569..c3013b2632c65 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -250,9 +250,6 @@ type TextInputOptions = { /** Whether the text input auto correct should be disabled */ disableAutoCorrect?: boolean; - /** Whether the text input should intercept swipes */ - shouldInterceptSwipe?: boolean; - /** Styles for the text input */ style?: { /** Styles for the text input container */ diff --git a/src/hooks/useFilteredOptions.ts b/src/hooks/useFilteredOptions.ts index 27ed36bafae6d..f871769958a07 100644 --- a/src/hooks/useFilteredOptions.ts +++ b/src/hooks/useFilteredOptions.ts @@ -103,11 +103,15 @@ function useFilteredOptions(config: UseFilteredOptionsConfig = {}): UseFilteredO if (!options || isLoadingMore) { return; } - setIsLoadingMore(true); - setReportsLimit((prev) => prev + batchSize); + + const hasMoreToLoad = options.reports.length < totalReports; + if (hasMoreToLoad) { + setIsLoadingMore(true); + setReportsLimit((prev) => prev + batchSize); + } }; - const hasMore = options ? reportsLimit < totalReports : false; + const hasMore = options ? options.reports.length < totalReports : false; return { options, diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 91831ee3e5574..d21afff5e9e39 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2969,6 +2969,19 @@ function formatSectionsFromSearchTerm( }; } +/** + * Helper method to get the `keyForList` for the first option in the OptionsList + */ +function getFirstKeyForList(data?: Option[] | null) { + if (!data?.length) { + return ''; + } + + const firstNonEmptyDataObj = data.at(0); + + return firstNonEmptyDataObj?.keyForList ? firstNonEmptyDataObj?.keyForList : ''; +} + function getPersonalDetailSearchTerms(item: Partial, currentUserAccountID: number) { if (item.accountID === currentUserAccountID) { return getCurrentUserSearchTerms(item); @@ -3366,6 +3379,7 @@ export { getFilteredRecentAttendees, getCurrentUserSearchTerms, getEmptyOptions, + getFirstKeyForList, getHeaderMessage, getHeaderMessageForNonUserList, getIOUConfirmationOptionsFromPayeePersonalDetail, diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index b73bb75151957..e7e8dff67f037 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -1,19 +1,19 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; -import reportsSelector from '@selectors/Attributes'; +import {useFocusEffect} from '@react-navigation/native'; +import isEmpty from 'lodash/isEmpty'; import reject from 'lodash/reject'; import type {Ref} from 'react'; -import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; -import {Keyboard, Platform} from 'react-native'; +import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {Keyboard} from 'react-native'; import Button from '@components/Button'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectCircle from '@components/SelectCircle'; -import UserListItem from '@components/SelectionList/ListItem/UserListItem'; -import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; -import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; -import type {ListItem, SelectionListWithSectionsHandle} from '@components/SelectionList/types'; +// eslint-disable-next-line no-restricted-imports +import SelectionList from '@components/SelectionListWithSections'; +import type {ListItem, SelectionListHandle} from '@components/SelectionListWithSections/types'; +import UserListItem from '@components/SelectionListWithSections/UserListItem'; import useContactImport from '@hooks/useContactImport'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -29,16 +29,17 @@ import {navigateToAndOpenReport, searchInServer, setGroupDraft} from '@libs/acti import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; +import type {Option, Section} from '@libs/OptionsListUtils'; import { filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, + getFirstKeyForList, getHeaderMessage, getPersonalDetailSearchTerms, getUserToInviteOption, getValidOptions, } from '@libs/OptionsListUtils'; -import type {OptionWithKey} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -46,7 +47,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; -import getEmptyArray from '@src/types/utils/getEmptyArray'; import KeyboardUtils from '@src/utils/keyboard'; const excludedGroupEmails = new Set(CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE)); @@ -71,7 +71,6 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const {contacts} = useContactImport(); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const allPersonalDetails = usePersonalDetails(); - const isScreenFocused = useIsFocused(); const { options: listOptions, @@ -203,7 +202,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, [draftSelectedOptions, setSelectedOptions]); const handleEndReached = () => { - if (!hasMore || isLoadingMore || !areOptionsInitialized || !isScreenFocused) { + if (!hasMore || isLoadingMore || !areOptionsInitialized) { return; } loadMore(); @@ -241,10 +240,10 @@ function NewChatPage({ref}: NewChatPageProps) { const currentUserAccountID = personalData.accountID; const {top} = useSafeAreaInsets(); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); - const selectionListRef = useRef(null); - const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { - selector: reportsSelector, - }); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const [reportAttributesDerivedFull] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); + const reportAttributesDerived = reportAttributesDerivedFull?.reports; + const selectionListRef = useRef(null); const allPersonalDetails = usePersonalDetails(); const {singleExecution} = useSingleExecution(); @@ -253,25 +252,6 @@ function NewChatPage({ref}: NewChatPageProps) { focus: selectionListRef.current?.focusTextInput, })); - // Opacity toggle to fix FlashList layout issue when navigating back. - // FlashList compacts its layout when the screen is hidden but still mounted. - // Briefly setting opacity to 0 when the screen regains focus triggers a re-render - // that forces FlashList to recalculate its layout without a full remount. - const [listOpacity, setListOpacity] = useState(1); - const isFirstRender = useRef(true); - useFocusEffect( - useCallback(() => { - if (isFirstRender.current || Platform.OS !== 'web') { - isFirstRender.current = false; - return; - } - setListOpacity(0); - requestAnimationFrame(() => { - setListOpacity(1); - }); - }, []), - ); - const { headerMessage, searchTerm, @@ -287,7 +267,8 @@ function NewChatPage({ref}: NewChatPageProps) { areOptionsInitialized, } = useOptions(reportAttributesDerived); - const sections: Array> = []; + const sections: Section[] = []; + let firstKeyForList = ''; const formatResults = formatSectionsFromSearchTerm( debouncedSearchTerm, @@ -300,26 +281,41 @@ function NewChatPage({ref}: NewChatPageProps) { undefined, reportAttributesDerived, ); - sections.push({...formatResults.section, title: undefined, sectionIndex: 0}); + // Just a temporary fix to satisfy the type checker + // Will be fixed when migrating to use new SelectionListWithSections + sections.push({...formatResults.section, title: undefined, shouldShow: true}); + + if (!firstKeyForList) { + firstKeyForList = getFirstKeyForList(formatResults.section.data); + } sections.push({ title: translate('common.recents'), data: selectedOptions.length ? recentReports.filter((option) => !option.isSelfDM) : recentReports, - sectionIndex: 1, + shouldShow: !isEmpty(recentReports), }); + if (!firstKeyForList) { + firstKeyForList = getFirstKeyForList(recentReports); + } sections.push({ title: translate('common.contacts'), data: personalDetails, - sectionIndex: 2, + shouldShow: !isEmpty(personalDetails), }); + if (!firstKeyForList) { + firstKeyForList = getFirstKeyForList(personalDetails); + } if (userToInvite) { sections.push({ title: undefined, data: [userToInvite], - sectionIndex: 3, + shouldShow: true, }); + if (!firstKeyForList) { + firstKeyForList = getFirstKeyForList([userToInvite]); + } } /** @@ -334,10 +330,10 @@ function NewChatPage({ref}: NewChatPageProps) { newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); } else { newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID, keyForList: `${option.keyForList ?? option.reportID}`}]; - selectionListRef?.current?.scrollToIndex(0); + selectionListRef?.current?.scrollToIndex(0, true); } - selectionListRef.current?.clearInputAfterSelect(); + selectionListRef?.current?.clearInputAfterSelect?.(); if (!canUseTouchScreen()) { selectionListRef.current?.focusTextInput(); } @@ -363,7 +359,7 @@ function NewChatPage({ref}: NewChatPageProps) { * creates a new 1:1 chat with the option and the current user, * or navigates to the existing chat if one with those participants already exists. */ - const selectOption = (option?: OptionWithKey) => { + const selectOption = (option?: Option) => { if (option?.isSelfDM) { if (!option.reportID) { Navigation.dismissModal(); @@ -407,7 +403,7 @@ function NewChatPage({ref}: NewChatPageProps) { }); }; - const itemRightSideComponent = (item: OptionWithKey, isFocused?: boolean) => { + const itemRightSideComponent = (item: ListItem & Option, isFocused?: boolean) => { if (!!item.isSelfDM || (item.login && excludedGroupEmails.has(item.login)) || !item.login) { return null; } @@ -476,16 +472,6 @@ function NewChatPage({ref}: NewChatPageProps) { ); - const textInputOptions = { - label: translate('selectionList.nameEmailOrPhoneNumber'), - hint: isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '', - value: searchTerm, - onChangeText: setSearchTerm, - headerMessage, - disableAutoFocus: true, - shouldInterceptSwipe: true, - }; - return ( - + ref={selectionListRef} ListItem={UserListItem} - sections={areOptionsInitialized ? sections : getEmptyArray>()} + sections={areOptionsInitialized ? sections : CONST.EMPTY_ARRAY} + textInputValue={searchTerm} + textInputHint={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} + onChangeText={setSearchTerm} + textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} + headerMessage={headerMessage} onSelectRow={selectOption} - shouldShowTextInput - textInputOptions={textInputOptions} shouldSingleExecuteRowSelect - confirmButtonOptions={{ - onConfirm: (e, option) => (selectedOptions.length > 0 ? createGroup() : selectOption(option)), - }} + onConfirm={(e, option) => (selectedOptions.length > 0 ? createGroup() : selectOption(option))} rightHandSideComponent={itemRightSideComponent} footerContent={footerContent} showLoadingPlaceholder={!areOptionsInitialized} @@ -516,9 +503,10 @@ function NewChatPage({ref}: NewChatPageProps) { isLoadingNewOptions={!!isSearchingForReports || isLoadingMore} onEndReached={handleEndReached} onEndReachedThreshold={0.75} - disableMaintainingScrollPosition + initiallyFocusedOptionKey={firstKeyForList} + shouldTextInputInterceptSwipe addBottomSafeAreaPadding - style={{listStyle: {opacity: listOpacity}}} + textInputAutoFocus={false} /> ); diff --git a/tests/ui/NewChatPageTest.tsx b/tests/ui/NewChatPageTest.tsx index 5f7686834b4ce..d1a55f210e9a7 100644 --- a/tests/ui/NewChatPageTest.tsx +++ b/tests/ui/NewChatPageTest.tsx @@ -1,8 +1,7 @@ import * as NativeNavigation from '@react-navigation/native'; import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; import React from 'react'; -// eslint-disable-next-line no-restricted-imports -import {ScrollView} from 'react-native'; +import {SectionList} from 'react-native'; import Onyx from 'react-native-onyx'; import HTMLEngineProvider from '@components/HTMLEngineProvider'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; @@ -78,11 +77,11 @@ describe('NewChatPage', () => { act(() => { (NativeNavigation as NativeNavigationMock).triggerTransitionEnd(); }); - const scrollToSpy = jest.spyOn(ScrollView.prototype, 'scrollTo'); + const spy = jest.spyOn(SectionList.prototype, 'scrollToLocation'); const addButton = await waitFor(() => screen.getAllByText(translateLocal('newChatPage.addToGroup')).at(0)); if (addButton) { fireEvent.press(addButton); - expect(scrollToSpy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({itemIndex: 0})); } });