Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate NewChatPage to functional component #20170

Merged
merged 11 commits into from
Jul 3, 2023
267 changes: 114 additions & 153 deletions src/pages/NewChatPage.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import _ from 'underscore';
import React, {Component} from 'react';
import React, {useState, useEffect, useMemo} from 'react';
import {View} from 'react-native';
import PropTypes from 'prop-types';
import {withOnyx} from 'react-native-onyx';
Expand Down Expand Up @@ -44,226 +44,187 @@ const defaultProps = {
reports: {},
};

class NewChatPage extends Component {
constructor(props) {
super(props);

this.toggleOption = this.toggleOption.bind(this);
this.createChat = this.createChat.bind(this);
this.createGroup = this.createGroup.bind(this);
this.updateOptionsWithSearchTerm = this.updateOptionsWithSearchTerm.bind(this);
this.excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE);

const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
props.reports,
props.personalDetails,
props.betas,
'',
[],
this.props.isGroupChat ? this.excludedGroupEmails : [],
);
this.state = {
searchTerm: '',
recentReports,
personalDetails,
selectedOptions: [],
userToInvite,
};
}

componentDidUpdate(prevProps) {
if (_.isEqual(prevProps.reports, this.props.reports) && _.isEqual(prevProps.personalDetails, this.props.personalDetails)) {
return;
}
this.updateOptionsWithSearchTerm(this.state.searchTerm);
}

/**
* Returns the sections needed for the OptionsSelector
*
* @param {Boolean} maxParticipantsReached
* @returns {Array}
*/
getSections(maxParticipantsReached) {
const sections = [];
const NewChatPage = (props) => {
const [searchTerm, setSearchTerm] = useState('');
const [filteredRecentReports, setFilteredRecentReports] = useState([]);
const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]);
const [filteredUserToInvite, setFilteredUserToInvite] = useState();
const [selectedOptions, setSelectedOptions] = useState([]);

const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE);
const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
const headerMessage = OptionsListUtils.getHeaderMessage(
filteredPersonalDetails.length + filteredRecentReports.length !== 0,
Boolean(filteredUserToInvite),
searchTerm,
maxParticipantsReached,
);
const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(props.personalDetails);

const sections = useMemo(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used useMemo here as it was also used here -

const sections = useMemo(() => {

But I propose to remove it following the guidelines in React docs -

Optimizing with useMemo is only valuable in a few cases:

  1. The calculation you’re putting in useMemo is noticeably slow, and its dependencies rarely change.
  2. You pass it as a prop to a component wrapped in memo. You want to skip re-rendering if the value hasn’t changed. Memoization lets your component re-render only when dependencies aren’t the same.
  3. The value you’re passing is later used as a dependency of some Hook. For example, maybe another useMemo calculation value depends on it. Or maybe you are depending on this value from useEffect.

2 and 3 are not applicable here. For the first point, the dependencies change often in our case which would anyway recalculate the value.

If you agree then we can remove it from TaskAssigneeSelectorModal too because another issue there is that it uses props as a dependency when it only uses props.translate during calculation -

}, [filteredCurrentUserOption, filteredPersonalDetails, filteredRecentReports, filteredUserToInvite, props]);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussion on useCallback, useMemo in slack

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aimane-chnaif I will remove useMemo here if you agree.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. I don't see much benefit of performance improvement using useMemo here.

const sectionsList = [];
let indexOffset = 0;

if (this.props.isGroupChat) {
sections.push({
if (props.isGroupChat) {
sectionsList.push({
title: undefined,
data: this.state.selectedOptions,
shouldShow: !_.isEmpty(this.state.selectedOptions),
data: selectedOptions,
shouldShow: !_.isEmpty(selectedOptions),
indexOffset,
});
indexOffset += this.state.selectedOptions.length;
indexOffset += selectedOptions.length;

if (maxParticipantsReached) {
return sections;
return sectionsList;
}
}

// Filtering out selected users from the search results
const filterText = _.reduce(this.state.selectedOptions, (str, {login}) => `${str} ${login}`, '');
const recentReportsWithoutSelected = _.filter(this.state.recentReports, ({login}) => !filterText.includes(login));
const personalDetailsWithoutSelected = _.filter(this.state.personalDetails, ({login}) => !filterText.includes(login));
const hasUnselectedUserToInvite = this.state.userToInvite && !filterText.includes(this.state.userToInvite.login);
const filterText = _.reduce(selectedOptions, (str, {login}) => `${str} ${login}`, '');
const recentReportsWithoutSelected = _.filter(filteredRecentReports, ({login}) => !filterText.includes(login));
const personalDetailsWithoutSelected = _.filter(filteredPersonalDetails, ({login}) => !filterText.includes(login));
const hasUnselectedUserToInvite = filteredUserToInvite && !filterText.includes(filteredUserToInvite.login);

sections.push({
title: this.props.translate('common.recents'),
sectionsList.push({
title: props.translate('common.recents'),
data: recentReportsWithoutSelected,
shouldShow: !_.isEmpty(recentReportsWithoutSelected),
indexOffset,
});
indexOffset += recentReportsWithoutSelected.length;

sections.push({
title: this.props.translate('common.contacts'),
sectionsList.push({
title: props.translate('common.contacts'),
data: personalDetailsWithoutSelected,
shouldShow: !_.isEmpty(personalDetailsWithoutSelected),
indexOffset,
});
indexOffset += personalDetailsWithoutSelected.length;

if (hasUnselectedUserToInvite) {
sections.push({
sectionsList.push({
title: undefined,
data: [this.state.userToInvite],
data: [filteredUserToInvite],
shouldShow: true,
indexOffset,
});
}

return sections;
}
return sectionsList;
// eslint-disable-next-line react-hooks/exhaustive-deps -- to avoid destructuring props and adding all 'props' as a dependency
}, [props.isGroupChat, props.translate, selectedOptions, filteredRecentReports, filteredPersonalDetails, filteredUserToInvite, maxParticipantsReached]);

updateOptionsWithSearchTerm(searchTerm = '') {
const updateOptionsWithSearchTerm = (newSearchTerm = '') => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't wrap it in useCallback based on the guidelines in React docs -

Caching a function with useCallback is only valuable in a few cases:

  1. You pass it as a prop to a component wrapped in memo. You want to skip re-rendering if the value hasn’t changed. Memoization lets your component re-render only if dependencies changed.
  2. The function you’re passing is later used as a dependency of some Hook. For example, another function wrapped in useCallback depends on it, or you depend on this function from useEffect.

The first point is NA as OptionsSelector is not wrapped in memo so it will change anyway. The second point is NA since it is not a dependency of any hook.

Another reason is if we wrap it in useCallback then excludedGroupEmails has to be added as a dependency and it is not a state variable so it will refer to a new object in memory upon each rerender which would need another hook for memoization thus I think it's best not to add.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aimane-chnaif if I use function, eslint throws error -
image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saw your comment on slack

I will update the PR and only use function keyword when the function is not a prop.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't use function here as it was passed down as prop

const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
this.props.reports,
this.props.personalDetails,
this.props.betas,
searchTerm,
props.reports,
props.personalDetails,
props.betas,
newSearchTerm,
[],
this.props.isGroupChat ? this.excludedGroupEmails : [],
props.isGroupChat ? excludedGroupEmails : [],
);
this.setState({
searchTerm,
userToInvite,
recentReports,
personalDetails,
});
}
setSearchTerm(newSearchTerm);
setFilteredRecentReports(recentReports);
setFilteredPersonalDetails(personalDetails);
setFilteredUserToInvite(userToInvite);
};

/**
* Removes a selected option from list if already selected. If not already selected add this option to the list.
* @param {Object} option
*/
toggleOption(option) {
this.setState((prevState) => {
const isOptionInList = _.some(prevState.selectedOptions, (selectedOption) => selectedOption.login === option.login);
const toggleOption = (option) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login);

let newSelectedOptions;
let newSelectedOptions;

if (isOptionInList) {
newSelectedOptions = _.reject(prevState.selectedOptions, (selectedOption) => selectedOption.login === option.login);
} else {
newSelectedOptions = [...prevState.selectedOptions, option];
}
if (isOptionInList) {
newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
} else {
newSelectedOptions = [...selectedOptions, option];
}

const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(
this.props.reports,
this.props.personalDetails,
this.props.betas,
prevState.searchTerm,
[],
this.excludedGroupEmails,
);
const {recentReports, personalDetails, userToInvite} = OptionsListUtils.getNewChatOptions(props.reports, props.personalDetails, props.betas, searchTerm, [], excludedGroupEmails);

return {
selectedOptions: newSelectedOptions,
recentReports,
personalDetails,
userToInvite,
searchTerm: prevState.searchTerm,
};
});
}
setSelectedOptions(newSelectedOptions);
setFilteredRecentReports(recentReports);
setFilteredPersonalDetails(personalDetails);
setFilteredUserToInvite(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.
*
* @param {Object} option
*/
createChat(option) {
const createChat = (option) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Report.navigateToAndOpenReport([option.login]);
}
};

/**
* 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.
*/
createGroup() {
if (!this.props.isGroupChat) {
const createGroup = () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

didn't use function here as it was passed down as prop

if (!props.isGroupChat) {
return;
}

const userLogins = _.pluck(this.state.selectedOptions, 'login');
const userLogins = _.pluck(selectedOptions, 'login');
if (userLogins.length < 1) {
return;
}
Report.navigateToAndOpenReport(userLogins);
}

render() {
const maxParticipantsReached = this.state.selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
const sections = this.getSections(maxParticipantsReached);
const headerMessage = OptionsListUtils.getHeaderMessage(
this.state.personalDetails.length + this.state.recentReports.length !== 0,
Boolean(this.state.userToInvite),
this.state.searchTerm,
maxParticipantsReached,
);
const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(this.props.personalDetails);

return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
>
{({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
<HeaderWithCloseButton
title={this.props.isGroupChat ? this.props.translate('sidebarScreen.newGroup') : this.props.translate('sidebarScreen.newChat')}
onCloseButtonPress={() => Navigation.dismissModal(true)}
};

useEffect(() => {
updateOptionsWithSearchTerm(searchTerm);
// all dependencies are not added below -
// 1. searchTerm - when searchTerm changes updateOptionsWithSearchTerm is called by OptionsSelector's onChangeText prop
// 2. updateOptionsWithSearchTerm - it will change its reference upon each rerender unnecessarily
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.reports, props.personalDetails]);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only added these two dependencies based on the code in componentDidUpdate.


return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
shouldEnableMaxHeight
>
{({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => (
<>
<HeaderWithCloseButton
title={props.isGroupChat ? props.translate('sidebarScreen.newGroup') : props.translate('sidebarScreen.newChat')}
onCloseButtonPress={() => Navigation.dismissModal(true)}
/>
<View style={[styles.flex1, styles.w100, styles.pRelative, selectedOptions.length > 0 ? safeAreaPaddingBottomStyle : {}]}>
<OptionsSelector
canSelectMultipleOptions={props.isGroupChat}
sections={sections}
selectedOptions={selectedOptions}
value={searchTerm}
onSelectRow={(option) => (props.isGroupChat ? toggleOption(option) : createChat(option))}
onChangeText={updateOptionsWithSearchTerm}
headerMessage={headerMessage}
boldStyle
shouldFocusOnSelectRow={props.isGroupChat}
shouldShowConfirmButton={props.isGroupChat}
shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady}
confirmButtonText={props.translate('newChatPage.createGroup')}
onConfirmSelection={createGroup}
textInputLabel={props.translate('optionsSelector.nameEmailOrPhoneNumber')}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
/>
<View style={[styles.flex1, styles.w100, styles.pRelative, this.state.selectedOptions.length > 0 ? safeAreaPaddingBottomStyle : {}]}>
<OptionsSelector
canSelectMultipleOptions={this.props.isGroupChat}
sections={sections}
selectedOptions={this.state.selectedOptions}
value={this.state.searchTerm}
onSelectRow={(option) => (this.props.isGroupChat ? this.toggleOption(option) : this.createChat(option))}
onChangeText={this.updateOptionsWithSearchTerm}
headerMessage={headerMessage}
boldStyle
shouldFocusOnSelectRow={this.props.isGroupChat}
shouldShowConfirmButton={this.props.isGroupChat}
shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady}
confirmButtonText={this.props.translate('newChatPage.createGroup')}
onConfirmSelection={this.createGroup}
textInputLabel={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')}
safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle}
/>
</View>
</>
)}
</ScreenWrapper>
);
}
}
</View>
</>
)}
</ScreenWrapper>
);
};

NewChatPage.propTypes = propTypes;
NewChatPage.defaultProps = defaultProps;
NewChatPage.displayName = 'NewChatPage';

export default compose(
withLocalize,
Expand Down