diff --git a/src/components/SelectionList/InviteMemberListItem.tsx b/src/components/SelectionList/InviteMemberListItem.tsx index 5b3442d0e4ef..03a27c88fa68 100644 --- a/src/components/SelectionList/InviteMemberListItem.tsx +++ b/src/components/SelectionList/InviteMemberListItem.tsx @@ -13,9 +13,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; -import type {InviteMemberListItemProps} from './types'; +import type {InviteMemberListItemProps, ListItem} from './types'; -function InviteMemberListItem({ +function InviteMemberListItem({ item, isFocused, showTooltip, @@ -26,7 +26,7 @@ function InviteMemberListItem({ onDismissError, shouldPreventDefaultFocusOnSelectRow, rightHandSideComponent, -}: InviteMemberListItemProps) { +}: InviteMemberListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx index 808fa740bfb3..e26926e75e13 100644 --- a/src/components/SelectionList/RadioListItem.tsx +++ b/src/components/SelectionList/RadioListItem.tsx @@ -4,9 +4,9 @@ import TextWithTooltip from '@components/TextWithTooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; -import type {RadioListItemProps} from './types'; +import type {ListItem, RadioListItemProps} from './types'; -function RadioListItem({ +function RadioListItem({ item, isFocused, showTooltip, @@ -17,7 +17,7 @@ function RadioListItem({ rightHandSideComponent, isMultilineSupported = false, onFocus, -}: RadioListItemProps) { +}: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; const indentsLength = (item.text?.length ?? 0) - (fullTitle?.length ?? 0); diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index cc87d84baf03..c2680c92780a 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -10,9 +10,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; -import type {TableListItemProps} from './types'; +import type {ListItem, TableListItemProps} from './types'; -function TableListItem({ +function TableListItem({ item, isFocused, showTooltip, @@ -24,7 +24,7 @@ function TableListItem({ shouldPreventDefaultFocusOnSelectRow, rightHandSideComponent, onFocus, -}: TableListItemProps) { +}: TableListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 7ab777e7e0f1..940828ebcac3 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -14,9 +14,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; -import type {UserListItemProps} from './types'; +import type {ListItem, UserListItemProps} from './types'; -function UserListItem({ +function UserListItem({ item, isFocused, showTooltip, @@ -27,7 +27,7 @@ function UserListItem({ onDismissError, shouldPreventDefaultFocusOnSelectRow, rightHandSideComponent, -}: UserListItemProps) { +}: UserListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index e15dea542be6..d89f4d5b92f3 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -12,7 +12,7 @@ import type RadioListItem from './RadioListItem'; import type TableListItem from './TableListItem'; import type UserListItem from './UserListItem'; -type CommonListItemProps = { +type CommonListItemProps = { /** Whether this item is focused (for arrow key controls) */ isFocused?: boolean; @@ -122,9 +122,9 @@ type ListItem = { brickRoadIndicator?: BrickRoad | '' | null; }; -type ListItemProps = CommonListItemProps & { +type ListItemProps = CommonListItemProps & { /** The section list item */ - item: ListItem; + item: TItem; /** Additional styles to apply to text */ style?: StyleProp; @@ -146,10 +146,10 @@ type BaseListItemProps = CommonListItemProps & { errors?: Errors | ReceiptErrors | null; pendingAction?: PendingAction | null; FooterComponent?: ReactElement; - children?: ReactElement | ((hovered: boolean) => ReactElement); + children?: ReactElement> | ((hovered: boolean) => ReactElement>); }; -type UserListItemProps = ListItemProps & { +type UserListItemProps = ListItemProps & { /** Errors that this user may contain */ errors?: Errors | ReceiptErrors | null; @@ -160,11 +160,11 @@ type UserListItemProps = ListItemProps & { FooterComponent?: ReactElement; }; -type InviteMemberListItemProps = UserListItemProps; +type InviteMemberListItemProps = UserListItemProps; -type RadioListItemProps = ListItemProps; +type RadioListItemProps = ListItemProps; -type TableListItemProps = ListItemProps; +type TableListItemProps = ListItemProps; type ValidListItem = typeof RadioListItem | typeof UserListItem | typeof TableListItem | typeof InviteMemberListItem; @@ -294,7 +294,7 @@ type BaseSelectionListProps = Partial & { shouldDelayFocus?: boolean; /** Component to display on the right side of each child */ - rightHandSideComponent?: ((item: ListItem) => ReactElement | null) | ReactElement | null; + rightHandSideComponent?: ((item: TItem) => ReactElement | null) | ReactElement | null; /** Whether to show the loading indicator for new options */ isLoadingNewOptions?: boolean; diff --git a/src/hooks/useStyledSafeAreaInsets.ts b/src/hooks/useStyledSafeAreaInsets.ts new file mode 100644 index 000000000000..bfd9c32a46ae --- /dev/null +++ b/src/hooks/useStyledSafeAreaInsets.ts @@ -0,0 +1,37 @@ +// eslint-disable-next-line no-restricted-imports +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import useStyleUtils from './useStyleUtils'; + +/** + * Custom hook to get the styled safe area insets. + * This hook utilizes the `SafeAreaInsetsContext` to retrieve the current safe area insets + * and applies styling adjustments using the `useStyleUtils` hook. + * + * @returns An object containing the styled safe area insets and additional styles. + * @returns .paddingTop The top padding adjusted for safe area. + * @returns .paddingBottom The bottom padding adjusted for safe area. + * @returns .insets The safe area insets object or undefined if not available. + * @returns .safeAreaPaddingBottomStyle An object containing the bottom padding style adjusted for safe area. + * + * @example + * // How to use this hook in a component + * function MyComponent() { + * const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useStyledSafeAreaInsets(); + * + * // Use these values to style your component accordingly + * } + */ +function useStyledSafeAreaInsets() { + const StyleUtils = useStyleUtils(); + const insets = useSafeAreaInsets(); + + const {paddingTop, paddingBottom} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + return { + paddingTop, + paddingBottom, + insets: insets ?? undefined, + safeAreaPaddingBottomStyle: {paddingBottom}, + }; +} + +export default useStyledSafeAreaInsets; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index b2e243053bbc..aa16d7b2dc5a 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2220,6 +2220,18 @@ 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[0]; + + return firstNonEmptyDataObj.keyForList ? firstNonEmptyDataObj.keyForList : ''; +} /** * Filters options based on the search input value */ @@ -2341,6 +2353,7 @@ export { createOptionFromReport, getReportOption, getTaxRatesSection, + getFirstKeyForList, }; -export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption}; +export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption, Option}; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 7711501ccde3..8137bb0e8515 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -1,20 +1,27 @@ +import isEmpty from 'lodash/isEmpty'; +import reject from 'lodash/reject'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; import {useOptionsList} from '@components/OptionListContextProvider'; -import OptionsSelector from '@components/OptionsSelector'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import {PressableWithFeedback} from '@components/Pressable'; +import ReferralProgramCTA from '@components/ReferralProgramCTA'; +import SelectCircle from '@components/SelectCircle'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import doInteractionTask from '@libs/DoInteractionTask'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -32,268 +39,279 @@ type NewChatPageProps = { const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({isGroupChat}: NewChatPageProps) { - const [dismissedReferralBanners] = useOnyx(ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS); +function useOptions({isGroupChat}: NewChatPageProps) { + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState>([]); + const [betas] = useOnyx(ONYXKEYS.BETAS); const [newGroupDraft] = useOnyx(ONYXKEYS.NEW_GROUP_CHAT_DRAFT); + const personalData = useCurrentUserPersonalDetails(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [betas] = useOnyx(ONYXKEYS.BETAS); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const {options: listOptions, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); - const {translate} = useLocalize(); + const options = useMemo(() => { + const filteredOptions = OptionsListUtils.getFilteredOptions( + listOptions.reports ?? [], + listOptions.personalDetails ?? [], + betas ?? [], + debouncedSearchTerm, + selectedOptions, + isGroupChat ? excludedGroupEmails : [], + false, + true, + false, + {}, + [], + false, + {}, + [], + true, + ); + const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; + + const headerMessage = OptionsListUtils.getHeaderMessage( + filteredOptions.personalDetails.length + filteredOptions.recentReports.length !== 0, + Boolean(filteredOptions.userToInvite), + debouncedSearchTerm.trim(), + maxParticipantsReached, + selectedOptions.some((participant) => participant?.searchText?.toLowerCase?.().includes(debouncedSearchTerm.trim().toLowerCase())), + ); + return {...filteredOptions, headerMessage, maxParticipantsReached}; + }, [betas, debouncedSearchTerm, isGroupChat, listOptions.personalDetails, listOptions.reports, selectedOptions]); - const styles = useThemeStyles(); + useEffect(() => { + if (!debouncedSearchTerm.length || options.maxParticipantsReached) { + return; + } - const personalData = useCurrentUserPersonalDetails(); + Report.searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm, options.maxParticipantsReached]); - const getGroupParticipants = () => { + useEffect(() => { if (!newGroupDraft?.participants) { - return []; + return; } const selectedParticipants = newGroupDraft.participants.filter((participant) => participant.accountID !== personalData.accountID); const newSelectedOptions = selectedParticipants.map((participant): OptionData => { const baseOption = OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant.login, reportID: ''}, personalDetails); - return {...baseOption, reportID: baseOption.reportID ?? ''}; + return {...baseOption, reportID: baseOption.reportID ?? '', isSelected: true}; }); - return newSelectedOptions; - }; - - const [searchTerm, setSearchTerm] = useState(''); - const [filteredRecentReports, setFilteredRecentReports] = useState([]); - const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); - const [filteredUserToInvite, setFilteredUserToInvite] = useState(); - const [selectedOptions, setSelectedOptions] = useState(getGroupParticipants); + setSelectedOptions(newSelectedOptions); + }, [newGroupDraft, personalData, personalDetails]); + + return {...options, searchTerm, debouncedSearchTerm, setSearchTerm, areOptionsInitialized: areOptionsInitialized && didScreenTransitionEnd, selectedOptions, setSelectedOptions}; +} + +function NewChatPage({isGroupChat}: NewChatPageProps) { + const {translate} = useLocalize(); const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); - const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, - }); - - const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; - const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); + const styles = useThemeStyles(); + const personalData = useCurrentUserPersonalDetails(); + const {insets} = useStyledSafeAreaInsets(); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); - const headerMessage = OptionsListUtils.getHeaderMessage( - filteredPersonalDetails.length + filteredRecentReports.length !== 0, - Boolean(filteredUserToInvite), - searchTerm.trim(), + const { + headerMessage, maxParticipantsReached, - selectedOptions.some((participant) => participant?.searchText?.toLowerCase().includes(searchTerm.trim().toLowerCase())), - ); + searchTerm, + debouncedSearchTerm, + setSearchTerm, + selectedOptions, + setSelectedOptions, + recentReports, + personalDetails, + userToInvite, + areOptionsInitialized, + } = useOptions({ + isGroupChat, + }); - const sections = useMemo((): OptionsListUtils.CategorySection[] => { + const [sections, firstKeyForList] = useMemo(() => { const sectionsList: OptionsListUtils.CategorySection[] = []; + let firstKey = ''; - const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, maxParticipantsReached); + const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(debouncedSearchTerm, selectedOptions, recentReports, personalDetails, maxParticipantsReached); sectionsList.push(formatResults.section); + if (!firstKey) { + firstKey = OptionsListUtils.getFirstKeyForList(formatResults.section.data); + } + if (maxParticipantsReached) { - return sectionsList; + return [sectionsList, firstKey]; } sectionsList.push({ title: translate('common.recents'), - data: filteredRecentReports, - shouldShow: filteredRecentReports.length > 0, + data: recentReports, + shouldShow: !isEmpty(recentReports), }); + if (!firstKey) { + firstKey = OptionsListUtils.getFirstKeyForList(recentReports); + } sectionsList.push({ title: translate('common.contacts'), - data: filteredPersonalDetails, - shouldShow: filteredPersonalDetails.length > 0, + data: personalDetails, + shouldShow: !isEmpty(personalDetails), }); + if (!firstKey) { + firstKey = OptionsListUtils.getFirstKeyForList(personalDetails); + } - if (filteredUserToInvite) { + if (userToInvite) { sectionsList.push({ title: undefined, - data: [filteredUserToInvite], + data: [userToInvite], shouldShow: true, }); + if (!firstKey) { + firstKey = OptionsListUtils.getFirstKeyForList([userToInvite]); + } } - return sectionsList; - }, [translate, filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, maxParticipantsReached, selectedOptions, searchTerm]); - - /** - * Removes a selected option from list if already selected. If not already selected add this option to the list. - */ - const toggleOption = (option: OptionData) => { - const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); - - let newSelectedOptions; - - if (isOptionInList) { - newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); - } else { - newSelectedOptions = [...selectedOptions, option]; - } - - const { - recentReports, - personalDetails: newChatPersonalDetails, - userToInvite, - } = OptionsListUtils.getFilteredOptions( - options.reports ?? [], - options.personalDetails ?? [], - betas ?? [], - searchTerm, - newSelectedOptions, - isGroupChat ? excludedGroupEmails : [], - false, - true, - false, - {}, - [], - false, - {}, - [], - true, - ); - setSelectedOptions(newSelectedOptions); - setFilteredRecentReports(recentReports); - setFilteredPersonalDetails(newChatPersonalDetails); - setFilteredUserToInvite(userToInvite); - }; + return [sectionsList, firstKey]; + }, [debouncedSearchTerm, selectedOptions, recentReports, personalDetails, maxParticipantsReached, translate, userToInvite]); /** * 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 createChat = (option: OptionData) => { - let login = ''; + const createChat = useCallback( + (option?: OptionsListUtils.Option) => { + let login = ''; + + if (option?.login) { + login = option.login; + } else if (selectedOptions.length === 1) { + login = selectedOptions[0].login ?? ''; + } + if (!login) { + Log.warn('Tried to create chat with empty login'); + return; + } + Report.navigateToAndOpenReport([login]); + }, + [selectedOptions], + ); - if (option.login) { - login = option.login; - } else if (selectedOptions.length === 1) { - login = selectedOptions[0].login ?? ''; - } + const itemRightSideComponent = useCallback( + (item: ListItem & OptionsListUtils.Option) => { + /** + * Removes a selected option from list if already selected. If not already selected add this option to the list. + * @param option + */ + function toggleOption(option: ListItem & Partial) { + const isOptionInList = !!option.isSelected; - if (!login) { - Log.warn('Tried to create chat with empty login'); - return; - } + let newSelectedOptions; - Report.navigateToAndOpenReport([login]); - }; - /** - * Navigates to create group confirm page - */ - const navigateToConfirmPage = () => { - if (!personalData || !personalData.login || !personalData.accountID) { - return; - } - const selectedParticipants: SelectedParticipant[] = selectedOptions.map((option: OptionData) => ({login: option.login ?? '', accountID: option.accountID ?? -1})); - const logins = [...selectedParticipants, {login: personalData.login, accountID: personalData.accountID}]; - Report.setGroupDraft({participants: logins}); - Navigation.navigate(ROUTES.NEW_CHAT_CONFIRM); - }; - - const updateOptions = useCallback(() => { - const { - recentReports, - personalDetails: newChatPersonalDetails, - userToInvite, - } = OptionsListUtils.getFilteredOptions( - options.reports ?? [], - options.personalDetails ?? [], - betas ?? [], - searchTerm, - selectedOptions, - isGroupChat ? excludedGroupEmails : [], - false, - true, - false, - {}, - [], - false, - {}, - [], - true, - ); + if (isOptionInList) { + newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID ?? ''}]; + } - setFilteredRecentReports(recentReports); - setFilteredPersonalDetails(newChatPersonalDetails); - setFilteredUserToInvite(userToInvite); - // props.betas is not added as dependency since it doesn't change during the component lifecycle - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [options, searchTerm]); + setSelectedOptions(newSelectedOptions); + } - useEffect(() => { - const interactionTask = doInteractionTask(() => { - setDidScreenTransitionEnd(true); - }); + if (item.isSelected) { + return ( + toggleOption(item)} + disabled={item.isDisabled} + role={CONST.ACCESSIBILITY_ROLE.CHECKBOX} + accessibilityLabel={CONST.ACCESSIBILITY_ROLE.CHECKBOX} + style={[styles.flexRow, styles.alignItemsCenter, styles.ml3]} + > + + + ); + } + + return ( +