From fb92697b2f7c64b971e5a8d8c8ac0ee0157b6fbf Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Sun, 10 May 2026 20:25:19 +0530 Subject: [PATCH] Migrate useSearchSelector.base.ts from useOptionsList to useFilteredOptions (part 1) --- config/eslint/eslint.seatbelt.tsv | 3 +- .../Search/FilterComponents/UserSelector.tsx | 117 ++----- src/hooks/useContactImport.ts | 13 +- src/hooks/useContactImportNew.ts | 59 ++++ .../usePersonalDetailSearchSelector/base.ts | 288 ++++++++++++++++++ .../index.native.ts | 52 ++++ .../usePersonalDetailSearchSelector/index.ts | 17 ++ .../base.ts} | 10 +- .../index.native.ts} | 6 +- .../index.ts} | 4 +- src/libs/ContactUtils.ts | 66 +++- src/libs/OptionsListUtils/index.ts | 104 ------- .../PersonalDetailOptionsListUtils/index.ts | 64 +++- .../PersonalDetailOptionsListUtils/types.ts | 15 +- src/pages/RoomInvitePage.tsx | 113 ++----- tests/unit/useSearchSelectorTest.tsx | 2 +- 16 files changed, 602 insertions(+), 331 deletions(-) create mode 100644 src/hooks/useContactImportNew.ts create mode 100644 src/hooks/usePersonalDetailSearchSelector/base.ts create mode 100644 src/hooks/usePersonalDetailSearchSelector/index.native.ts create mode 100644 src/hooks/usePersonalDetailSearchSelector/index.ts rename src/hooks/{useSearchSelector.base.ts => useSearchSelector/base.ts} (98%) rename src/hooks/{useSearchSelector.native.ts => useSearchSelector/index.native.ts} (93%) rename src/hooks/{useSearchSelector.ts => useSearchSelector/index.ts} (84%) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 5eed3f58d78d..7c5583989f6d 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -228,6 +228,7 @@ "../../src/hooks/useOutstandingBalanceGuard.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/hooks/usePaginatedReportActions.ts" "react-hooks/refs" 1 "../../src/hooks/usePaymentOptions.ts" "react-hooks/refs" 1 +"../../src/hooks/usePersonalDetailSearchSelector/index.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/usePrevious.ts" "react-hooks/refs" 1 "../../src/hooks/useProactiveAppReview.ts" "react-hooks/purity" 1 "../../src/hooks/useRestoreInputFocus/index.android.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 @@ -238,7 +239,7 @@ "../../src/hooks/useSearchBulkActions.ts" "react-hooks/refs" 1 "../../src/hooks/useSearchHighlightAndScroll.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/useSearchResults.ts" "react-hooks/set-state-in-effect" 1 -"../../src/hooks/useSearchSelector.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 +"../../src/hooks/useSearchSelector/index.native.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1 "../../src/hooks/useSelectionModeReportActions.ts" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 2 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/purity" 1 "../../src/hooks/useSidebarOrderedReports.tsx" "react-hooks/refs" 5 diff --git a/src/components/Search/FilterComponents/UserSelector.tsx b/src/components/Search/FilterComponents/UserSelector.tsx index f3f5d59b1a7c..c153f4428781 100644 --- a/src/components/Search/FilterComponents/UserSelector.tsx +++ b/src/components/Search/FilterComponents/UserSelector.tsx @@ -1,18 +1,14 @@ -import isEmpty from 'lodash/isEmpty'; -import React, {useRef, useState} from 'react'; +import React, {useRef} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SelectionList from '@components/SelectionList'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useSearchSelector from '@hooks/useSearchSelector'; +import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import {getParticipantsOption} from '@libs/OptionsListUtils'; -import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; -import type {OptionData} from '@libs/ReportUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ListFilterWrapper from './ListFilterViewWrapper'; @@ -27,102 +23,39 @@ function UserSelector({value = [], onChange}: UserSelectorProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const personalDetails = usePersonalDetails(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserAccountID = currentUserPersonalDetails.accountID; const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const initialSelectedOptions = value.reduce((options, id) => { + const initialSelectedAccountIDs = value.reduce>((acc, id) => { const participant = personalDetails?.[id]; if (!participant) { - return options; + return acc; } - const optionData = { - ...getParticipantsOption(participant, personalDetails), - isSelected: true, - }; + acc.add(id); + return acc; + }, new Set()); - if (optionData) { - options.push(optionData as OptionData); - } - - return options; - }, []); - - const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, toggleSelection, areOptionsInitialized, selectedOptionsForDisplay, onListEndReached} = useSearchSelector({ + const {searchTerm, setSearchTerm, availableOptions, totalOptionsCount, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, - initialSelected: initialSelectedOptions, + initialSelected: initialSelectedAccountIDs, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - includeUserToInvite: false, - includeCurrentUser: true, - onSelectionChange: (options) => onChange(options.flatMap((option) => (option.accountID ? [option.accountID.toString()] : []))), + includeCurrentUser: false, + includeRecentReports: false, + onSelectionChange: onChange, }); const listData = (() => { - const personalDetailsList = availableOptions.personalDetails.map((participant) => ({ - ...participant, - keyForList: String(participant.accountID), - })); - const recentReports = availableOptions.recentReports.map((report) => ({ - ...report, - keyForList: String(report.reportID), - })); - - const isCurrentUserSelected = selectedOptionsForDisplay.some((option) => option.accountID === currentUserAccountID); - - // Extract the current user from available options to guarantee they appear at the top. - // Falls back to creating from personal details to handle pagination edge cases. - let currentUserOption: OptionData | undefined; - if (!isCurrentUserSelected && currentUserAccountID) { - const currentUserPersonalDetail = personalDetailsList.find((p) => p.accountID === currentUserAccountID) ?? recentReports.find((r) => r.accountID === currentUserAccountID); - if (currentUserPersonalDetail) { - currentUserOption = currentUserPersonalDetail; - } else if (personalDetails?.[currentUserAccountID]) { - const candidateOption = getParticipantsOption(personalDetails[currentUserAccountID], personalDetails) as OptionData; - const trimmedSearchTerm = debouncedSearchTerm.trim().toLowerCase(); - if (!trimmedSearchTerm || doesPersonalDetailMatchSearchTerm(candidateOption, currentUserAccountID, trimmedSearchTerm)) { - currentUserOption = candidateOption; - } - } + if (!availableOptions.currentUserOption) { + return [...availableOptions.selectedOptions, ...availableOptions.personalDetails]; } - - // Filter current user from regular lists to avoid duplication - const filteredPersonalDetails = currentUserOption ? personalDetailsList.filter((p) => p.accountID !== currentUserAccountID) : personalDetailsList; - const filteredRecentReports = currentUserOption ? recentReports.filter((r) => r.accountID !== currentUserAccountID) : recentReports; - - // Place selected options first, then the current user, then the rest - const combinedOptions = [...selectedOptionsForDisplay, ...(currentUserOption ? [currentUserOption] : []), ...filteredPersonalDetails, ...filteredRecentReports]; - - // Sort so that selected items appear first; current user placement is handled explicitly above - combinedOptions.sort((a, b) => { - if (a.isSelected && !b.isSelected) { - return -1; - } - if (!a.isSelected && b.isSelected) { - return 1; - } - // Among selected items, prioritize the current user - if (a.isSelected && b.isSelected) { - if (a.accountID === currentUserAccountID) { - return -1; - } - if (b.accountID === currentUserAccountID) { - return 1; - } - } - return 0; - }); - - const combinedOptionsWithKeyForList = combinedOptions.map((option) => ({ - ...option, - keyForList: option.keyForList ?? option.login ?? '', - })); - return combinedOptionsWithKeyForList; + const isCurrentOptionSelected = availableOptions.currentUserOption.isSelected; + if (isCurrentOptionSelected) { + return [availableOptions.currentUserOption, ...availableOptions.selectedOptions, ...availableOptions.personalDetails]; + } + return [...availableOptions.selectedOptions, availableOptions.currentUserOption, ...availableOptions.personalDetails]; })(); - const headerMessage = isEmpty(listData) ? translate('common.noResultsFound') : undefined; + const headerMessage = listData.length === 0 ? translate('common.noResultsFound') : undefined; const selectUser = (option: OptionData) => { toggleSelection(option); @@ -130,13 +63,6 @@ function UserSelector({value = [], onChange}: UserSelectorProps) { }; const isLoadingNewOptions = !!isSearchingForReports; - const totalOptions = selectedOptionsForDisplay.length + availableOptions.personalDetails.length + availableOptions.recentReports.length; - const [totalOptionsCount, setTotalOptionsCount] = useState(totalOptions); - - if (totalOptions !== totalOptionsCount && !debouncedSearchTerm) { - setTotalOptionsCount(selectedOptionsForDisplay.length + availableOptions.personalDetails.length + availableOptions.recentReports.length); - } - const shouldShowSearchInput = totalOptionsCount >= CONST.STANDARD_LIST_ITEM_LIMIT; const textInputOptions = shouldShowSearchInput @@ -163,7 +89,6 @@ function UserSelector({value = [], onChange}: UserSelectorProps) { onSelectRow={selectUser} isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={!areOptionsInitialized} - onEndReached={onListEndReached} style={{contentContainerStyle: [styles.pb0]}} /> diff --git a/src/hooks/useContactImport.ts b/src/hooks/useContactImport.ts index e505a3bac2b8..0fc82abfc2a2 100644 --- a/src/hooks/useContactImport.ts +++ b/src/hooks/useContactImport.ts @@ -1,16 +1,14 @@ import {useCallback, useState} from 'react'; import {RESULTS} from 'react-native-permissions'; import type {PermissionStatus} from 'react-native-permissions'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import contactImport from '@libs/ContactImport'; import type {ContactImportResult} from '@libs/ContactImport/types'; import useContactPermissions from '@libs/ContactPermission/useContactPermissions'; -import getContacts from '@libs/ContactUtils'; +import {getContactsExtended} from '@libs/ContactUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails} from '@src/types/onyx'; -import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; @@ -32,20 +30,17 @@ type UseContactImportResult = { function useContactImport(): UseContactImportResult { const [contactPermissionState, setContactPermissionState] = useState(RESULTS.UNAVAILABLE); const [contacts, setContacts] = useState>>([]); - const {localeCompare} = useLocalize(); + const {localeCompare, formatPhoneNumber} = useLocalize(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); - const personalDetails = usePersonalDetails(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; const importAndSaveContacts = useCallback(() => { contactImport().then(({contactList, permissionStatus}: ContactImportResult) => { setContactPermissionState(permissionStatus); - const usersFromContact = getContacts(contactList, localeCompare, countryCode, loginList, currentUserEmail, personalDetails); + const usersFromContact = getContactsExtended(contactList, localeCompare, formatPhoneNumber, countryCode, loginList); setContacts(usersFromContact); }); - }, [localeCompare, countryCode, loginList, currentUserEmail, personalDetails]); + }, [localeCompare, formatPhoneNumber, countryCode, loginList]); useContactPermissions({ importAndSaveContacts, diff --git a/src/hooks/useContactImportNew.ts b/src/hooks/useContactImportNew.ts new file mode 100644 index 000000000000..5b38b1d34074 --- /dev/null +++ b/src/hooks/useContactImportNew.ts @@ -0,0 +1,59 @@ +import {useState} from 'react'; +import {RESULTS} from 'react-native-permissions'; +import type {PermissionStatus} from 'react-native-permissions'; +import contactImport from '@libs/ContactImport'; +import type {ContactImportResult} from '@libs/ContactImport/types'; +import useContactPermissions from '@libs/ContactPermission/useContactPermissions'; +import {getContacts} from '@libs/ContactUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useLocalize from './useLocalize'; +import useOnyx from './useOnyx'; + +/** + * Return type of the useContactImport hook. + */ +type UseContactImportResult = { + contacts: OptionData[]; + contactPermissionState: PermissionStatus; + importAndSaveContacts: () => void; + setContactPermissionState: React.Dispatch>; +}; + +/** + * Custom hook that handles importing device contacts, + * managing permissions, and transforming contact data + * into a format suitable for use in the app. + */ +function useContactImport(): UseContactImportResult { + const [contactPermissionState, setContactPermissionState] = useState(RESULTS.UNAVAILABLE); + const [contacts, setContacts] = useState([]); + const {localeCompare, formatPhoneNumber} = useLocalize(); + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + + const importAndSaveContacts = () => { + contactImport().then(({contactList, permissionStatus}: ContactImportResult) => { + setContactPermissionState(permissionStatus); + const usersFromContact = getContacts(contactList, localeCompare, formatPhoneNumber, countryCode, loginList); + setContacts(usersFromContact); + }); + }; + + useContactPermissions({ + importAndSaveContacts, + setContacts, + contactPermissionState, + setContactPermissionState, + }); + + return { + contacts, + contactPermissionState, + importAndSaveContacts, + setContactPermissionState, + }; +} + +export default useContactImport; diff --git a/src/hooks/usePersonalDetailSearchSelector/base.ts b/src/hooks/usePersonalDetailSearchSelector/base.ts new file mode 100644 index 000000000000..10b22dfe2fc9 --- /dev/null +++ b/src/hooks/usePersonalDetailSearchSelector/base.ts @@ -0,0 +1,288 @@ +import {useState} from 'react'; +import type {PermissionStatus} from 'react-native-permissions'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePersonalDetailOptions from '@hooks/usePersonalDetailOptions'; +import {filterOption, getValidOptions} from '@libs/PersonalDetailOptionsListUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; + +type UseSearchSelectorConfig = { + /** Selection mode - single or multiple selection */ + selectionMode: SearchSelectorSelectionMode; + + /** How many recent reports should be returned? The rest count from maxResultsPerPage will be with contacts. null value means CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW */ + maxRecentReportsToShow?: number; + + /** Max number of options to return in search results (including recent reports and personal details). null value means no limit */ + maxElements?: number; + + /** Whether to include user to invite option */ + includeUserToInvite?: boolean; + + /** Logins to exclude from results (hard exclusions - cannot be selected at all) */ + excludeLogins?: Record; + + /** Logins to exclude from suggestions only (soft exclusions - can still be manually entered) */ + excludeFromSuggestionsOnly?: Record; + + /** Whether to include recent reports (for getMemberInviteOptions) */ + includeRecentReports?: boolean; + + /** Whether to include current user */ + includeCurrentUser?: boolean; + + /** Whether to include domain emails */ + includeDomainEmail?: boolean; + + /** Enable phone contacts integration */ + enablePhoneContacts?: boolean; + + /** Callback when selection changes (multi-select mode) */ + onSelectionChange?: (selected: string[]) => void; + + /** Callback when single option is selected (single-select mode) */ + onSingleSelect?: (option: OptionData) => void; + + /** Initial selected options */ + initialSelected?: Set; + + /** Whether to initialize the hook */ + shouldInitialize?: boolean; + + /** Additional contact options to merge (used by platform-specific implementations) */ + contactOptions?: OptionData[]; + + /** Whether to filter with recent attendees */ + recentAttendees?: string[]; + + /** Whether to allow name-only options */ + shouldAllowNameOnlyOptions?: boolean; + + /** Whether to keep selected options in availableOptions instead of filtering them out */ + shouldKeepSelectedInAvailableOptions?: boolean; + + /** Initial Search Phrase */ + initialSearchPhrase?: string; +}; + +type ContactState = { + /** Current permission status */ + permissionStatus: PermissionStatus; + + /** Whether to show import UI */ + showImportUI: boolean; + + /** Function to trigger contact import */ + importContacts: () => void; + + /** Function to initiate contact import and set state */ + initiateContactImportAndSetState: () => void; + + /** Function to set permission state */ + setContactPermissionState: (status: PermissionStatus) => void; +}; + +type AvailableOptions = { + selectedOptions: OptionData[]; + recentOptions: OptionData[]; + personalDetails: OptionData[]; + userToInvite: OptionData | null; + extraOptions: OptionData[]; + currentUserOption: OptionData | null; +}; + +type UseSearchSelectorReturn = { + /** Current search term */ + searchTerm: string; + + /** Debounced search term */ + debouncedSearchTerm: string; + + /** Function to update search term */ + setSearchTerm: (value: string) => void; + + /** Currently selected options */ + selectedOptions: OptionData[]; + + /** Available (unselected) options */ + availableOptions: AvailableOptions; + + /* Total count of options (without filters) */ + totalOptionsCount: number; + + /** Function to toggle selection state of an option */ + toggleSelection: (option: OptionData) => void; + + /** Function to reset selection state of an option */ + resetSelection: () => void; + + /** Whether options are initialized */ + areOptionsInitialized: boolean; + + /** Contact-related state and functions (when enablePhoneContacts is true) */ + contactState?: ContactState; +}; + +const defaultListOptions = { + userToInvite: null, + recentOptions: [], + personalDetails: [], + selectedOptions: [], +}; + +/** + * Base hook that provides search functionality with selection logic for option lists. + * This contains the core logic without platform-specific dependencies. + */ +function usePersonalDetailSearchSelectorBase({ + selectionMode, + maxElements, + maxRecentReportsToShow, + includeUserToInvite = false, + includeDomainEmail = false, + excludeLogins = CONST.EMPTY_OBJECT, + excludeFromSuggestionsOnly = CONST.EMPTY_OBJECT, + includeRecentReports = true, + onSelectionChange, + onSingleSelect, + initialSelected = new Set(), + shouldInitialize = true, + contactOptions, + includeCurrentUser = false, + recentAttendees, + shouldAllowNameOnlyOptions = false, + shouldKeepSelectedInAvailableOptions = false, + initialSearchPhrase = '', +}: UseSearchSelectorConfig): UseSearchSelectorReturn { + const {translate, formatPhoneNumber} = useLocalize(); + const {options: defaultOptions, currentOption} = usePersonalDetailOptions({enabled: shouldInitialize}); + + const optionsWithContacts = (() => { + if (!contactOptions?.length || !shouldInitialize) { + return defaultOptions; + } + return (defaultOptions ?? []).concat(contactOptions); + })(); + const areOptionsInitialized = (optionsWithContacts?.length ?? 0) > 0; + const [selectedAccountIDs, setSelectedAccountIDs] = useState>(initialSelected); + const [extraOptions, setExtraOptions] = useState([]); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(initialSearchPhrase); + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserEmail = currentUserPersonalDetails.email ?? ''; + + const transformedOptions: OptionData[] = + optionsWithContacts?.map((option) => ({ + ...option, + isSelected: selectedAccountIDs.has(option.accountID.toString()), + })) ?? []; + + const selectedOptions = (() => { + const options: OptionData[] = []; + for (const option of transformedOptions) { + if (option.isSelected) { + options.push(option); + } + } + for (const option of extraOptions) { + if (option.isSelected) { + options.push(option); + } + } + return options; + })(); + + const optionsList = !areOptionsInitialized + ? defaultListOptions + : getValidOptions(transformedOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, { + excludeLogins, + excludeFromSuggestionsOnly, + includeSelectedOptions: shouldKeepSelectedInAvailableOptions, + includeRecentReports, + recentAttendees, + searchString: debouncedSearchTerm, + maxElements, + recentMaxElements: maxRecentReportsToShow, + includeUserToInvite, + includeCurrentUser, + includeDomainEmail, + extraOptions, + shouldAcceptName: shouldAllowNameOnlyOptions, + }); + + const currentUserSearchTerms = [translate('common.you'), translate('common.me')]; + const filteredCurrentUserOption = (() => { + const newOption = filterOption(currentOption, debouncedSearchTerm, currentUserSearchTerms); + if (newOption) { + return { + ...newOption, + isSelected: selectedAccountIDs.has(newOption.accountID.toString()), + }; + } + return newOption; + })(); + + const existingAccountIDs = new Set(optionsWithContacts?.map((option) => option.accountID.toString())); + + /** + * Toggle selection state of option based on selection mode + */ + const toggleSelection = (option: OptionData) => { + if (selectionMode === CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE) { + onSingleSelect?.(option); + return; + } + + const isSelected = selectedAccountIDs.has(option.accountID.toString()); + + if (isSelected) { + // If the option is selected, remove it from the selected logins + const isInExtraOption = extraOptions.some((extraOption) => extraOption.accountID === option.accountID); + if (isInExtraOption) { + setExtraOptions((prev) => prev.filter((extraOption) => extraOption.accountID !== option.accountID)); + } + const newSet = new Set([...selectedAccountIDs].filter((accountID) => accountID !== option.accountID.toString())); + setSelectedAccountIDs(newSet); + onSelectionChange?.(Array.from(newSet)); + } else { + const newSet = new Set(selectedAccountIDs).add(option.accountID.toString()); + setSelectedAccountIDs(newSet); + onSelectionChange?.(Array.from(newSet)); + if (!existingAccountIDs.has(option.accountID.toString())) { + setExtraOptions((prev) => [...prev, {...option, isSelected: true}]); + } + } + }; + + const resetSelection = () => { + setExtraOptions([]); + setSelectedAccountIDs(new Set()); + }; + + return { + searchTerm, + debouncedSearchTerm, + setSearchTerm, + selectedOptions, + availableOptions: { + ...optionsList, + currentUserOption: filteredCurrentUserOption, + extraOptions, + }, + totalOptionsCount: optionsWithContacts?.length ?? 0, + toggleSelection, + resetSelection, + areOptionsInitialized, + contactState: undefined, + }; +} + +export default usePersonalDetailSearchSelectorBase; +export type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn, SearchSelectorSelectionMode}; diff --git a/src/hooks/usePersonalDetailSearchSelector/index.native.ts b/src/hooks/usePersonalDetailSearchSelector/index.native.ts new file mode 100644 index 000000000000..9554fbf7a4e1 --- /dev/null +++ b/src/hooks/usePersonalDetailSearchSelector/index.native.ts @@ -0,0 +1,52 @@ +// eslint-disable-next-line no-restricted-imports +import {InteractionManager} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import useContactImport from '@hooks/useContactImportNew'; +import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './base'; +import usePersonalDetailSearchSelectorBase from './base'; + +/** + * Hook that combines search functionality with selection logic for option lists. + * Leverages heap optimization for performance with large datasets. + * Native version includes phone contacts integration. + * + * @param config - Configuration object for the hook + * @returns Object with search and selection utilities + */ +function usePersonalDetailSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorReturn { + const {enablePhoneContacts = false} = config; + + // Phone contacts logic + const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); + const showImportContacts = enablePhoneContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); + + const initiateContactImportAndSetState = () => { + setContactPermissionState(RESULTS.GRANTED); + InteractionManager.runAfterInteractions(importAndSaveContacts); + }; + + // Use base hook with contact options + const baseResult = usePersonalDetailSearchSelectorBase({ + ...config, + contactOptions: enablePhoneContacts ? contacts : undefined, + }); + + // Build contact state if enabled + const contactState: ContactState | undefined = enablePhoneContacts + ? { + permissionStatus: contactPermissionState, + showImportUI: showImportContacts, + importContacts: importAndSaveContacts, + initiateContactImportAndSetState, + setContactPermissionState, + } + : undefined; + + return { + ...baseResult, + contactState, + }; +} + +export default usePersonalDetailSearchSelector; +export type {ContactState}; diff --git a/src/hooks/usePersonalDetailSearchSelector/index.ts b/src/hooks/usePersonalDetailSearchSelector/index.ts new file mode 100644 index 000000000000..248ce60aaf36 --- /dev/null +++ b/src/hooks/usePersonalDetailSearchSelector/index.ts @@ -0,0 +1,17 @@ +import usePersonalDetailSearchSelectorBase from './base'; +import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './base'; + +/** + * Hook that combines search functionality with selection logic for option lists. + * Leverages heap optimization for performance with large datasets. + * Web version without phone contacts integration. + * + * @param config - Configuration object for the hook + * @returns Object with search and selection utilities + */ +function usePersonalDetailSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorReturn { + return usePersonalDetailSearchSelectorBase(config); +} + +export default usePersonalDetailSearchSelector; +export type {ContactState}; diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector/base.ts similarity index 98% rename from src/hooks/useSearchSelector.base.ts rename to src/hooks/useSearchSelector/base.ts index 3dce98a2ff75..ad5e5380aaed 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector/base.ts @@ -3,6 +3,11 @@ import {useState} from 'react'; import type {PermissionStatus} from 'react-native-permissions'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebounce from '@hooks/useDebounce'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useOnyx from '@hooks/useOnyx'; +import useSortedActions from '@hooks/useSortedActions'; import type {GetOptionsConfig, Option, Options, SearchOption} from '@libs/OptionsListUtils'; import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; import {getPersonalDetailSearchTerms} from '@libs/OptionsListUtils/searchMatchUtils'; @@ -10,11 +15,6 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useDebounce from './useDebounce'; -import useDebouncedState from './useDebouncedState'; -import useOnyx from './useOnyx'; -import useSortedActions from './useSortedActions'; type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick< typeof CONST.SEARCH_SELECTOR, diff --git a/src/hooks/useSearchSelector.native.ts b/src/hooks/useSearchSelector/index.native.ts similarity index 93% rename from src/hooks/useSearchSelector.native.ts rename to src/hooks/useSearchSelector/index.native.ts index 221f47595aff..1158051fdb9e 100644 --- a/src/hooks/useSearchSelector.native.ts +++ b/src/hooks/useSearchSelector/index.native.ts @@ -2,9 +2,9 @@ import {useCallback, useMemo} from 'react'; // eslint-disable-next-line no-restricted-imports import {InteractionManager} from 'react-native'; import {RESULTS} from 'react-native-permissions'; -import useContactImport from './useContactImport'; -import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; -import useSearchSelectorBase from './useSearchSelector.base'; +import useContactImport from '@hooks/useContactImport'; +import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './base'; +import useSearchSelectorBase from './base'; /** * Hook that combines search functionality with selection logic for option lists. diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector/index.ts similarity index 84% rename from src/hooks/useSearchSelector.ts rename to src/hooks/useSearchSelector/index.ts index e2db08890704..8e0cf0242810 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector/index.ts @@ -1,5 +1,5 @@ -import useSearchSelectorBase from './useSearchSelector.base'; -import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; +import useSearchSelectorBase from './base'; +import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './base'; /** * Hook that combines search functionality with selection logic for option lists. diff --git a/src/libs/ContactUtils.ts b/src/libs/ContactUtils.ts index 66c79de8232d..91d7f7336fa6 100644 --- a/src/libs/ContactUtils.ts +++ b/src/libs/ContactUtils.ts @@ -1,10 +1,11 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {Login, PersonalDetails, PersonalDetailsList} from '@src/types/onyx'; +import type {LoginList, PersonalDetails} from '@src/types/onyx'; import type {DeviceContact, StringHolder} from './ContactImport/types'; -import {getUserToInviteContactOption} from './OptionsListUtils'; import type {SearchOption} from './OptionsListUtils'; +import {getContactOption} from './PersonalDetailOptionsListUtils'; +import type {OptionData} from './PersonalDetailOptionsListUtils'; import RandomAvatarUtils from './RandomAvatarUtils'; function sortEmailObjects(emails: StringHolder[], localeCompare: LocaleContextProps['localeCompare']): string[] { @@ -29,14 +30,13 @@ function sortEmailObjects(emails: StringHolder[], localeCompare: LocaleContextPr }); } -const getContacts = ( +function getContacts( deviceContacts: DeviceContact[] | [], localeCompare: LocaleContextProps['localeCompare'], + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], countryCode: number, - loginList: OnyxEntry, - currentUserEmail: string, - personalDetails: OnyxEntry, -): Array> => { + loginList: OnyxEntry, +): OptionData[] { return deviceContacts .map((contact) => { const email = sortEmailObjects(contact?.emailAddresses ?? [], localeCompare)?.at(0) ?? ''; @@ -46,9 +46,7 @@ const getContacts = ( const firstName = contact?.firstName ?? ''; const lastName = contact?.lastName ?? ''; - return getUserToInviteContactOption({ - selectedOptions: [], - optionsToExclude: [], + return getContactOption({ searchValue: email || phoneNumber || firstName || '', firstName, lastName, @@ -56,13 +54,49 @@ const getContacts = ( phone: phoneNumber, avatar: avatarSource, countryCode, + formatPhoneNumber, loginList, - currentUserEmail, - personalDetails, }); }) - .filter((contact): contact is SearchOption => contact !== null); -}; + .filter((contact): contact is OptionData => contact !== null); +} + +function extendPersonalDetailOption(option: OptionData): SearchOption { + const userDetails = { + accountID: option.accountID, + avatar: option.icons?.[0].source, + displayName: option.text, + login: option.login, + pronouns: '', + phoneNumber: option.phoneNumber, + validated: true, + }; + + return { + ...option, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: option.reportID ?? '', + item: userDetails, + participantsList: [userDetails], + isDefaultRoom: false, + isPinned: false, + isChatRoom: false, + shouldShowSubscript: false, + isPolicyExpenseChat: false, + lastMessageText: '', + isBold: true, + }; +} + +function getContactsExtended( + deviceContacts: DeviceContact[] | [], + localeCompare: LocaleContextProps['localeCompare'], + formatPhoneNumber: LocaleContextProps['formatPhoneNumber'], + countryCode: number, + loginList: OnyxEntry, +): Array> { + const contactOptions = getContacts(deviceContacts, localeCompare, formatPhoneNumber, countryCode, loginList); + return contactOptions.map(extendPersonalDetailOption); +} -export default getContacts; -export {sortEmailObjects}; +export {getContacts, getContactsExtended, sortEmailObjects}; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index a8aea6849495..67435c61375e 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2006,109 +2006,6 @@ function getUserToInviteOption({ return userToInvite; } -function getUserToInviteContactOption({ - searchValue = '', - optionsToExclude = [], - selectedOptions = [], - firstName = '', - lastName = '', - email = '', - phone = '', - avatar = '', - countryCode = CONST.DEFAULT_COUNTRY_CODE, - loginList = {}, - currentUserEmail, -}: GetUserToInviteConfig): SearchOption | null { - // If email is provided, use it as the primary identifier - - const effectiveSearchValue = email || searchValue; - - // Handle phone number parsing for either provided phone or searchValue - - const phoneToCheck = phone || searchValue; - const normalizedPhoneNumber = appendCountryCode(Str.removeSMSDomain(phoneToCheck), countryCode); - const parsedPhoneNumber = parsePhoneNumber(normalizedPhoneNumber); - - // Validate email (either provided email or searchValue) - const isValidEmail = Str.isValidEmail(effectiveSearchValue) && !Str.isDomainEmail(effectiveSearchValue) && !Str.endsWith(effectiveSearchValue, CONST.SMS.DOMAIN); - - const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); - - const sanitizedPhoneLogin = isValidPhoneNumber ? addSMSDomainIfPhoneNumber(parsedPhoneNumber.number?.e164 ?? normalizedPhoneNumber) : ''; - const login = email ? effectiveSearchValue : (sanitizedPhoneLogin ?? searchValue); - const normalizedLoginToExclude = addSMSDomainIfPhoneNumber(login).toLowerCase(); - - const isCurrentUserLogin = isCurrentUser({login} as PersonalDetails, loginList, currentUserEmail); - const isInSelectedOption = selectedOptions.some((option) => 'login' in option && option.login === login); - - const isInOptionToExclude = optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === normalizedLoginToExclude) !== -1; - - if (!effectiveSearchValue || isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber) || isInOptionToExclude) { - return null; - } - - // Generates an optimistic account ID for new users not yet saved in Onyx - const optimisticAccountID = generateAccountID(login); - - // Construct display name if firstName/lastName are provided - - const displayName = firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName || effectiveSearchValue; - - // Create the base user details that will be used in both item and participantsList - const userDetails = { - accountID: optimisticAccountID, - - avatar: avatar || FallbackAvatar, - firstName: firstName ?? '', - lastName: lastName ?? '', - displayName, - login, - pronouns: '', - phoneNumber: phone ?? '', - validated: true, - }; - - const userToInvite = { - item: userDetails, - text: displayName, - displayName, - firstName, - lastName, - alternateText: displayName !== login ? login : undefined, - brickRoadIndicator: null, - icons: [ - { - source: userDetails.avatar, - type: CONST.ICON_TYPE_AVATAR, - name: login, - id: optimisticAccountID, - }, - ], - tooltipText: null, - participantsList: [userDetails], - accountID: optimisticAccountID, - login, - reportID: '', - phoneNumber: phone ?? '', - hasDraftComment: false, - keyForList: optimisticAccountID.toString(), - isDefaultRoom: false, - isPinned: false, - isWaitingOnBankAccount: false, - isIOUReportOwner: false, - iouReportAmount: 0, - isChatRoom: false, - shouldShowSubscript: false, - isPolicyExpenseChat: false, - isExpenseReport: false, - lastMessageText: '', - isBold: true, - isOptimisticAccount: true, - }; - - return userToInvite; -} - function isValidReport(option: SearchOption, policy: OnyxEntry, config: IsValidReportsConfig, draftComment: string | undefined, chatReport: OnyxEntry): boolean { const { betas = [], @@ -3422,7 +3319,6 @@ export { getReportOption, getSearchOptions, getSearchValueForPhoneOrEmail, - getUserToInviteContactOption, getUserToInviteOption, getValidOptions, hasEnabledOptions, diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index 32ea2620116f..5329ee10efeb 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -13,7 +13,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {LoginList, OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, Report, ReportAttributesDerivedValue} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {GetOptionsConfig, GetUserToInviteConfig, OptionData, Options, PreviewConfig, PrivateIsArchivedMap} from './types'; +import type {GetContactConfig, GetOptionsConfig, GetUserToInviteConfig, OptionData, Options, PreviewConfig, PrivateIsArchivedMap} from './types'; /** * Creates a personal details list option @@ -121,6 +121,52 @@ function getUserToInviteOption({searchValue, countryCode, formatPhoneNumber, log ); } +function getContactOption({searchValue, firstName, lastName, email, phone, avatar, countryCode, loginList, formatPhoneNumber}: GetContactConfig): OptionData | null { + // If email is provided, use it as the primary identifier + const effectiveSearchValue = email || searchValue; + + // Handle phone number parsing for either provided phone or searchValue + const phoneToCheck = phone || searchValue; + const normalizedPhoneNumber = appendCountryCode(Str.removeSMSDomain(phoneToCheck), countryCode); + const parsedPhoneNumber = parsePhoneNumber(normalizedPhoneNumber); + + // Validate email (either provided email or searchValue) + const isValidEmail = Str.isValidEmail(effectiveSearchValue) && !Str.isDomainEmail(effectiveSearchValue) && !Str.endsWith(effectiveSearchValue, CONST.SMS.DOMAIN); + + const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); + + const sanitizedPhoneLogin = isValidPhoneNumber ? addSMSDomainIfPhoneNumber(parsedPhoneNumber.number?.e164 ?? normalizedPhoneNumber) : ''; + const login = email ? effectiveSearchValue : (sanitizedPhoneLogin ?? searchValue); + + const isCurrentUserLogin = Object.keys(loginList ?? {}).some((loginToTest) => loginToTest.toLowerCase() === login); + + if (!effectiveSearchValue || isCurrentUserLogin || (!isValidEmail && !isValidPhoneNumber)) { + return null; + } + + // Generates an optimistic account ID for new users not yet saved in Onyx + const optimisticAccountID = generateAccountID(login); + + // Construct display name if firstName/lastName are provided + + const displayName = firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName || effectiveSearchValue; + + // Create the base user details that will be used in both item and participantsList + const userDetails = { + accountID: optimisticAccountID, + avatar: avatar || FallbackAvatar, + firstName: firstName ?? '', + lastName: lastName ?? '', + displayName, + login, + pronouns: '', + phoneNumber: phone ?? '', + validated: true, + }; + + return createOption(userDetails, undefined, formatPhoneNumber); +} + /** * Sort reports by archived status and last visible action */ @@ -211,7 +257,8 @@ function getValidOptions( countryCode: number, loginList?: OnyxEntry, { - excludeLogins = {}, + excludeLogins = CONST.EMPTY_OBJECT, + excludeFromSuggestionsOnly = CONST.EMPTY_OBJECT, includeSelectedOptions = false, includeRecentReports = true, recentAttendees, @@ -271,11 +318,16 @@ function getValidOptions( } } + const loginsToExcludeFromSuggestions: Record = { + ...loginsToExclude, + ...excludeFromSuggestionsOnly, + }; + // Get valid recent reports: let recentOptions: OptionData[] = []; if (recentAttendees && recentAttendees?.length > 0) { - const recentAttendeesSet = new Set(recentAttendees.filter((login) => !loginsToExclude[login])); + const recentAttendeesSet = new Set(recentAttendees.filter((login) => !loginsToExcludeFromSuggestions[login])); const potentialRecentOptions: Record = {}; for (const option of options) { if (!option.login) { @@ -310,7 +362,7 @@ function getValidOptions( return false; } - if (!!option.login && loginsToExclude[option.login]) { + if (!!option.login && loginsToExcludeFromSuggestions[option.login]) { return false; } @@ -338,7 +390,7 @@ function getValidOptions( ) { return false; } - if (loginsToExclude[personalDetail.login]) { + if (loginsToExcludeFromSuggestions[personalDetail.login]) { return false; } @@ -434,6 +486,6 @@ function getHeaderMessage(translate: LocaleContextProps['translate'], searchValu return translate('common.noResultsFound'); } -export {createOption, canCreateOptimisticPersonalDetailOption, filterOption, matchesSearchTerms, getValidOptions, createOptionList, getHeaderMessage}; +export {createOption, getContactOption, canCreateOptimisticPersonalDetailOption, filterOption, matchesSearchTerms, getValidOptions, createOptionList, getHeaderMessage}; export type {OptionData, Options}; diff --git a/src/libs/PersonalDetailOptionsListUtils/types.ts b/src/libs/PersonalDetailOptionsListUtils/types.ts index e5eb0db951bf..05959ca9ecf1 100644 --- a/src/libs/PersonalDetailOptionsListUtils/types.ts +++ b/src/libs/PersonalDetailOptionsListUtils/types.ts @@ -36,6 +36,7 @@ type PreviewConfig = { type GetOptionsConfig = { excludeLogins?: Record; + excludeFromSuggestionsOnly?: Record; includeCurrentUser?: boolean; includeRecentReports?: boolean; includeSelectedOptions?: boolean; @@ -59,6 +60,18 @@ type GetUserToInviteConfig = { canInviteUser?: boolean; }; +type GetContactConfig = { + searchValue: string; + countryCode: number; + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + loginList: OnyxEntry; + firstName: string; + lastName: string; + email: string; + phone: string; + avatar: Icon['source']; +}; + type Options = { selectedOptions: OptionData[]; recentOptions: OptionData[]; @@ -68,4 +81,4 @@ type Options = { type PrivateIsArchivedMap = Record; -export type {OptionData, GetOptionsConfig, GetUserToInviteConfig, Options, PreviewConfig, PrivateIsArchivedMap}; +export type {OptionData, GetOptionsConfig, GetUserToInviteConfig, GetContactConfig, Options, PreviewConfig, PrivateIsArchivedMap}; diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 3633e9242555..6cf47722969a 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -1,5 +1,5 @@ import {pendingChatMembersSelector} from '@selectors/ReportMetaData'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import type {SectionListData} from 'react-native'; import {View} from 'react-native'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -13,11 +13,10 @@ import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDebouncedState from '@hooks/useDebouncedState'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePersonalDetailOptions from '@hooks/usePersonalDetailOptions'; +import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -27,12 +26,11 @@ import {READ_COMMANDS} from '@libs/API/types'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; -import memoize from '@libs/memoize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {RoomMembersNavigatorParamList} from '@libs/Navigation/types'; import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; -import {getHeaderMessage, getValidOptions} from '@libs/PersonalDetailOptionsListUtils'; +import {getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import type {MemberEmailsToAccountIDs} from '@libs/PolicyUtils'; @@ -48,15 +46,6 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithReportOrNotFoundProps} from './inbox/report/withReportOrNotFound'; import withReportOrNotFound from './inbox/report/withReportOrNotFound'; -const defaultListOptions = { - userToInvite: null, - recentOptions: [], - personalDetails: [], - selectedOptions: [], -}; - -const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'RoomInvitePage.getValidOptions'}); - type RoomInvitePageProps = WithReportOrNotFoundProps & WithNavigationTransitionEndProps & PlatformStackScreenProps; type MembersSection = SectionListData>; @@ -71,44 +60,14 @@ function RoomInvitePage({ const styles = useThemeStyles(); const reportAttributes = useReportAttributes(); const {translate, formatPhoneNumber} = useLocalize(); - const {options} = usePersonalDetailOptions({enabled: didScreenTransitionEnd}); - const areOptionsInitialized = (options?.length ?? 0) > 0; const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {selector: pendingChatMembersSelector}); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const delegateAccountID = useDelegateAccountID(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(userSearchPhrase ?? ''); - const [selectedLogins, setSelectedLogins] = useState>(new Set()); - const [extraOptions, setExtraOptions] = useState([]); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const isReportArchived = useReportIsArchived(report.reportID); - const loginToAccountIDMap = (() => { - const map: Record = {}; - for (const option of extraOptions) { - const login = option.login; - if (login) { - map[login] = option.accountID; - } - } - for (const option of options ?? []) { - const login = option.login; - if (login) { - map[login] = option.accountID; - } - } - return map; - })(); - - const transformedOptions = - options?.map((option) => ({ - ...option, - isSelected: selectedLogins.has(option.login ?? ''), - })) ?? []; - // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers: Record = { ...CONST.EXPENSIFY_EMAILS_OBJECT, @@ -120,63 +79,42 @@ function RoomInvitePage({ excludedUsers[smsDomain] = true; } - const optionsList = !areOptionsInitialized - ? defaultListOptions - : memoizedGetValidOptions(transformedOptions, currentUserEmail, formatPhoneNumber, countryCode, loginList, { - excludeLogins: excludedUsers, - extraOptions, - includeRecentReports: false, - searchString: debouncedSearchTerm, - includeCurrentUser: false, - includeUserToInvite: true, - }); + const {searchTerm, debouncedSearchTerm, setSearchTerm, selectedOptions, availableOptions, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ + shouldInitialize: didScreenTransitionEnd, + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + excludeLogins: excludedUsers, + includeCurrentUser: false, + includeRecentReports: false, + includeUserToInvite: true, + initialSearchPhrase: userSearchPhrase, + }); const sections: MembersSection[] = []; if (areOptionsInitialized) { - if (optionsList.userToInvite) { + if (availableOptions.userToInvite) { sections.push({ title: undefined, - data: [optionsList.userToInvite], + data: [availableOptions.userToInvite], sectionIndex: 0, }); } else { - if (optionsList.selectedOptions.length > 0) { + if (availableOptions.selectedOptions.length > 0) { sections.push({ title: undefined, - data: optionsList.selectedOptions, + data: availableOptions.selectedOptions, sectionIndex: 0, }); } - if (optionsList.personalDetails.length > 0) { + if (availableOptions.personalDetails.length > 0) { sections.push({ title: translate('common.contacts'), - data: optionsList.personalDetails, - sectionIndex: optionsList.selectedOptions.length > 0 ? 1 : 0, + data: availableOptions.personalDetails, + sectionIndex: availableOptions.selectedOptions.length > 0 ? 1 : 0, }); } } } - const existingLogins = new Set(options?.map((option) => option.login ?? '')); - - const toggleOption = (option: OptionData) => { - const isSelected = selectedLogins.has(option.login ?? ''); - - if (isSelected) { - // If the option is selected, remove it from the selected logins - const isInExtraOption = extraOptions.some((extraOption) => extraOption.login === option.login); - if (isInExtraOption) { - setExtraOptions((prev) => prev.filter((extraOption) => extraOption.login !== option.login)); - } - setSelectedLogins((prev) => new Set([...prev].filter((login) => login !== option.login))); - } else { - setSelectedLogins((prev) => new Set([...prev, option.login ?? ''])); - if (!existingLogins.has(option.login ?? '')) { - setExtraOptions((prev) => [...prev, {...option, isSelected: true}]); - } - } - }; - // Non policy members should not be able to view the participants of a room const reportID = report?.reportID; const isPolicyEmployee = isPolicyEmployeeUtil(report?.policyID, policy); @@ -186,17 +124,18 @@ function RoomInvitePage({ const ancestors = useAncestors(report); - const validSelectedLogins = Array.from(selectedLogins).filter((login) => !excludedUsers[login]); + const validSelectedOptions = selectedOptions.filter((option) => !excludedUsers[option.login ?? '']); const inviteUsers = () => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_USERS); - if (validSelectedLogins.length === 0) { + if (validSelectedOptions.length === 0) { return; } const invitedEmailsToAccountIDs: MemberEmailsToAccountIDs = {}; - for (const login of validSelectedLogins) { - const accountID = loginToAccountIDMap[login] ?? CONST.DEFAULT_NUMBER_ID; + for (const option of validSelectedOptions) { + const login = option.login ?? ''; + const accountID = option.accountID; invitedEmailsToAccountIDs[login] = accountID; } if (report?.reportID) { @@ -272,7 +211,7 @@ function RoomInvitePage({ sections={sections} ListItem={InviteMemberListItem} textInputOptions={textInputOptions} - onSelectRow={toggleOption} + onSelectRow={toggleSelection} confirmButtonOptions={{ onConfirm: inviteUsers, }} @@ -284,7 +223,7 @@ function RoomInvitePage({ />