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}));
}
});