From 7cb70d284e80e036a80116b08544ecea7ae041c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20M=C3=B3rawski?= Date: Fri, 5 Apr 2024 19:01:02 +0200 Subject: [PATCH 1/5] refactorings --- src/components/ReferralProgramCTA.tsx | 6 +- src/components/ScreenWrapper.tsx | 29 +- src/hooks/useScreenWrapperTransitionStatus.ts | 17 + src/hooks/useStyledSafeAreaInsets.ts | 37 ++ src/libs/OptionsListUtils.ts | 16 +- src/pages/NewChatPage.tsx | 459 +++++++++--------- src/pages/SearchPage/SearchPageFooter.tsx | 10 +- src/pages/SearchPage/index.tsx | 47 +- ...yForRefactorRequestParticipantsSelector.js | 37 +- .../MoneyRequestParticipantsSelector.js | 60 +-- 10 files changed, 384 insertions(+), 334 deletions(-) create mode 100644 src/hooks/useScreenWrapperTransitionStatus.ts create mode 100644 src/hooks/useStyledSafeAreaInsets.ts diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index c93b75bf11ad..ff5e8bc543e5 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -26,9 +27,10 @@ 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?: ViewStyle; }; -function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: ReferralProgramCTAProps) { +function ReferralProgramCTA({referralContentType, dismissedReferralBanners, style}: ReferralProgramCTAProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -46,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.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/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index b78e274371ca..9dbb1a3c60fe 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -1,7 +1,7 @@ import {useNavigation} from '@react-navigation/native'; import type {StackNavigationProp} from '@react-navigation/stack'; import type {ForwardedRef, ReactNode} from 'react'; -import React, {forwardRef, useEffect, useRef, useState} from 'react'; +import React, {createContext, forwardRef, useEffect, useMemo, useRef, useState} from 'react'; import type {DimensionValue, StyleProp, ViewStyle} from 'react-native'; import {Keyboard, PanResponder, View} from 'react-native'; import {PickerAvoidingView} from 'react-native-picker-select'; @@ -99,6 +99,8 @@ type ScreenWrapperProps = { shouldShowOfflineIndicatorInWideScreen?: boolean; }; +const ScreenWrapperStatusContext = createContext({didScreenTransitionEnd: false}); + function ScreenWrapper( { shouldEnableMaxHeight = false, @@ -201,6 +203,7 @@ function ScreenWrapper( }, []); const isAvoidingViewportScroll = useTackInputFocus(shouldEnableMaxHeight && shouldAvoidScrollOnVirtualViewport && Browser.isMobileSafari()); + const contextValue = useMemo(() => ({didScreenTransitionEnd}), [didScreenTransitionEnd]); return ( @@ -251,16 +254,18 @@ function ScreenWrapper( {isDevelopment && } - { - // If props.children is a function, call it to provide the insets to the children. - typeof children === 'function' - ? children({ - insets, - safeAreaPaddingBottomStyle, - didScreenTransitionEnd, - }) - : children - } + + { + // If props.children is a function, call it to provide the insets to the children. + typeof children === 'function' + ? children({ + insets, + safeAreaPaddingBottomStyle, + didScreenTransitionEnd, + }) + : children + } + {isSmallScreenWidth && shouldShowOfflineIndicator && } {!isSmallScreenWidth && shouldShowOfflineIndicatorInWideScreen && ( ; - /** All of the personal details for everyone */ - personalDetails: OnyxEntry; - - betas: OnyxEntry; - - /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: OnyxEntry; - /** Whether we are searching for reports in the server */ isSearchingForReports: OnyxEntry; }; @@ -48,282 +49,292 @@ type NewChatPageProps = NewChatPageWithOnyxProps & { isGroupChat?: boolean; }; +const EMPTY_ARRAY: Array>> = []; + const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, isSearchingForReports, dismissedReferralBanners, newGroupDraft}: NewChatPageProps) { - const {translate} = useLocalize(); +function useOptions({isGroupChat, newGroupDraft}: Omit) { + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState>([]); + const betas = useBetas(); + const personalData = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const {options: listOptions, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); + console.log('newchatpage', didScreenTransitionEnd, areOptionsInitialized); - const styles = useThemeStyles(); + 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 personalData = useCurrentUserPersonalDetails(); + useEffect(() => { + if (!debouncedSearchTerm.length || options.maxParticipantsReached) { + return; + } + + 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, isSearchingForReports, newGroupDraft}: 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 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, + newGroupDraft, + }); - const sections = useMemo((): OptionsListUtils.CategorySection[] => { + const [sections, firstKeyForList] = useMemo((): [OptionsListUtils.CategorySection[], string] => { 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: 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: OptionsListUtils.Option) { + 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(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 () => { - if (!interactionTask) { + return ( +