diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 40fb1115ac36..2694e2b83f7f 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -655,9 +655,10 @@ class BaseOptionsSelector extends Component { )} {this.props.shouldShowReferralCTA && ( - - - + )} {shouldShowFooter && ( diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 6db37ce1320a..c5a91b3fdceb 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -25,9 +26,11 @@ type ReferralProgramCTAProps = ReferralProgramCTAOnyxProps & { | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND; + + style?: StyleProp; }; -function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: ReferralProgramCTAProps) { +function ReferralProgramCTA({referralContentType, style, dismissedReferralBanners}: ReferralProgramCTAProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -45,7 +48,7 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref onPress={() => { Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(referralContentType, Navigation.getActiveRouteWithoutParams())); }} - style={[styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10, padding: 10}, styles.pl5]} + style={[styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10, padding: 10}, styles.pl5, style]} accessibilityLabel="referral" role={CONST.ACCESSIBILITY_ROLE.BUTTON} > 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/DoInteractionTask/index.ts b/src/libs/DoInteractionTask/index.ts index 0eadb8f7dbee..c53f08be88dc 100644 --- a/src/libs/DoInteractionTask/index.ts +++ b/src/libs/DoInteractionTask/index.ts @@ -1,7 +1,8 @@ +import DomUtils from '@libs/DomUtils'; import type DoInteractionTask from './types'; const doInteractionTask: DoInteractionTask = (callback) => { - callback(); + DomUtils.requestAnimationFrame(callback); return null; }; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 72393e89ae1a..c707f937eed5 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -1,19 +1,28 @@ +import {useFocusEffect} from '@react-navigation/native'; +import isEmpty from 'lodash/isEmpty'; +import reject from 'lodash/reject'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {View} from 'react-native'; +import type {SectionListData} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Button from '@components/Button'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import OfflineIndicator from '@components/OfflineIndicator'; -import OptionsSelector from '@components/OptionsSelector'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import {useBetas, usePersonalDetails} from '@components/OnyxProvider'; +import {PressableWithFeedback} from '@components/Pressable'; +import ReferralProgramCTA from '@components/ReferralProgramCTA'; +import SelectCircle from '@components/SelectCircle'; +import SelectionList from '@components/SelectionList'; +import type {ListItem, Section} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; +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 * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -22,20 +31,11 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import type {DismissedReferralBanners} from '@src/types/onyx/Account'; type NewChatPageWithOnyxProps = { /** All reports shared with the user */ reports: OnyxCollection; - /** All of the personal details for everyone */ - personalDetails: OnyxEntry; - - betas: OnyxEntry; - - /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: DismissedReferralBanners; - /** Whether we are searching for reports in the server */ isSearchingForReports: OnyxEntry; }; @@ -46,119 +46,126 @@ type NewChatPageProps = NewChatPageWithOnyxProps & { const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingForReports, dismissedReferralBanners}: NewChatPageProps) { +const EMPTY_ARRAY: Array>> = []; + +function useOptions({reports, isGroupChat}: Omit) { + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const personalDetails = usePersonalDetails(); + const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails); + const [selectedOptions, setSelectedOptions] = useState>([]); + const betas = useBetas(); + + const options = useMemo(() => { + const filteredOptions = OptionsListUtils.getFilteredOptions( + reports, + personalDetails, + betas, + debouncedSearchTerm, + selectedOptions, + isGroupChat ? excludedGroupEmails : [], + false, + true, + false, + {}, + [], + false, + {}, + [], + true, + false, + ); + 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, personalDetails, reports, selectedOptions]); + + useEffect(() => { + if (!debouncedSearchTerm.length || options.maxParticipantsReached) { + return; + } + + Report.searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm, options.maxParticipantsReached]); + + return {...options, searchTerm, debouncedSearchTerm, setSearchTerm, isOptionsDataReady, selectedOptions, setSelectedOptions}; +} + +function NewChatPage({isGroupChat, reports, isSearchingForReports}: NewChatPageProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [searchTerm, setSearchTerm] = useState(''); - const [filteredRecentReports, setFilteredRecentReports] = useState([]); - const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); - const [filteredUserToInvite, setFilteredUserToInvite] = useState(); - const [selectedOptions, setSelectedOptions] = useState([]); + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); - const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - - const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; - const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); - 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())), - ); - - const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails); - - const sections = useMemo((): OptionsListUtils.CategorySection[] => { - const sectionsList: OptionsListUtils.CategorySection[] = []; + searchTerm, + debouncedSearchTerm, + setSearchTerm, + selectedOptions, + setSelectedOptions, + recentReports, + personalDetails, + userToInvite, + isOptionsDataReady, + } = useOptions({ + reports, + isGroupChat, + }); + + const sections = useMemo(() => { + const sectionsList = []; let indexOffset = 0; - const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, maxParticipantsReached, indexOffset); + const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(debouncedSearchTerm, selectedOptions, recentReports, personalDetails, true, indexOffset); sectionsList.push(formatResults.section); - indexOffset = formatResults.newIndexOffset; if (maxParticipantsReached) { - return sectionsList; + return sectionsList as unknown as Array>>; } sectionsList.push({ title: translate('common.recents'), - data: filteredRecentReports, - shouldShow: filteredRecentReports.length > 0, + data: recentReports, + shouldShow: !isEmpty(recentReports), indexOffset, }); - indexOffset += filteredRecentReports.length; + indexOffset += recentReports.length; sectionsList.push({ title: translate('common.contacts'), - data: filteredPersonalDetails, - shouldShow: filteredPersonalDetails.length > 0, + data: personalDetails, + shouldShow: !isEmpty(personalDetails), indexOffset, }); - indexOffset += filteredPersonalDetails.length; + indexOffset += personalDetails.length; - if (filteredUserToInvite) { + if (userToInvite) { sectionsList.push({ title: undefined, - data: [filteredUserToInvite], + data: [userToInvite], shouldShow: true, indexOffset, }); } - 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( - reports, - personalDetails, - betas ?? [], - searchTerm, - newSelectedOptions, - isGroupChat ? excludedGroupEmails : [], - false, - true, - false, - {}, - [], - false, - {}, - [], - true, - ); - - setSelectedOptions(newSelectedOptions); - setFilteredRecentReports(recentReports); - setFilteredPersonalDetails(newChatPersonalDetails); - setFilteredUserToInvite(userToInvite); - }; + return sectionsList as unknown as Array>>; + }, [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) => { + const createChat = (option: ListItem) => { if (!option.login) { return; } @@ -166,139 +173,141 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF }; /** - * Creates a new group chat with all the selected options and the current user, - * or navigates to the existing chat if one with those participants already exists. + * This hook is used to set the state of didScreenTransitionEnd to true after the screen has transitioned. + * This is used to prevent the screen from rendering sections until transition has ended. */ - const createGroup = () => { - const logins = selectedOptions.map((option) => option.login).filter((login): login is string => typeof login === 'string'); + useFocusEffect( + React.useCallback(() => { + const task = InteractionManager.runAfterInteractions(() => { + setDidScreenTransitionEnd(true); + }); - if (logins.length < 1) { - return; - } + return () => task.cancel(); + }, []), + ); - Report.navigateToAndOpenReport(logins); - }; + const itemRightSideComponent = useCallback( + (listItem: ListItem) => { + const item = listItem as ListItem & OptionData; + /** + * 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 & OptionData) { + const isOptionInList = !!option.isSelected; + + let newSelectedOptions: Array; + + if (isOptionInList) { + newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true}]; + } + + setSelectedOptions(newSelectedOptions); + } - const updateOptions = useCallback(() => { - const { - recentReports, - personalDetails: newChatPersonalDetails, - userToInvite, - } = OptionsListUtils.getFilteredOptions( - reports, - personalDetails, - betas ?? [], - searchTerm, - selectedOptions, - isGroupChat ? excludedGroupEmails : [], - false, - true, - false, - {}, - [], - false, - {}, - [], - true, - ); - 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 - }, [reports, personalDetails, searchTerm]); + 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]} + > + + + ); + } - useEffect(() => { - const interactionTask = doInteractionTask(() => { - setDidScreenTransitionEnd(true); - }); + return ( +