diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -3,7 +3,6 @@ import invariant from 'invariant'; import * as React from 'react'; import { View, Text, ActivityIndicator, Alert } from 'react-native'; -import { createSelector } from 'reselect'; import { changeThreadSettingsActionTypes, @@ -15,17 +14,10 @@ userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; -import SearchIndex from 'lib/shared/search-index'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadActualMembers } from 'lib/shared/thread-utils'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import { - type ThreadInfo, - type ChangeThreadSettingsPayload, - type UpdateThreadRequest, -} from 'lib/types/thread-types'; +import { type ThreadInfo } from 'lib/types/thread-types'; import { type AccountUserInfo } from 'lib/types/user-types'; -import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, @@ -33,7 +25,7 @@ import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; -import { createTagInput, BaseTagInput } from '../../components/tag-input.react'; +import { createTagInput } from '../../components/tag-input.react'; import UserList from '../../components/user-list.react'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; @@ -48,234 +40,220 @@ returnKeyType: 'go', }; +const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; + export type AddUsersModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo, }; -type BaseProps = { +type Props = { +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, }; -type Props = { - ...BaseProps, - // Redux state - +parentThreadInfo: ?ThreadInfo, - +communityThreadInfo: ?ThreadInfo, - +otherUserInfos: { [id: string]: AccountUserInfo }, - +userSearchIndex: SearchIndex, - +changeThreadSettingsLoadingStatus: LoadingStatus, - +styles: typeof unboundStyles, - // Redux dispatch functions - +dispatchActionPromise: DispatchActionPromise, - // async functions that hit server APIs - +changeThreadSettings: ( - request: UpdateThreadRequest, - ) => Promise, -}; -type State = { - +usernameInputText: string, - +userInfoInputArray: $ReadOnlyArray, -}; -type PropsAndState = { ...Props, ...State }; -class AddUsersModal extends React.PureComponent { - state: State = { - usernameInputText: '', - userInfoInputArray: [], - }; - tagInput: ?BaseTagInput = null; +function AddUsersModal(props: Props): React.Node { + const [usernameInputText, setUsernameInputText] = React.useState(''); + const [userInfoInputArray, setUserInfoInputArray] = React.useState< + $ReadOnlyArray, + >([]); - userSearchResultsSelector = createSelector( - (propsAndState: PropsAndState) => propsAndState.usernameInputText, - (propsAndState: PropsAndState) => propsAndState.otherUserInfos, - (propsAndState: PropsAndState) => propsAndState.userSearchIndex, - (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, - (propsAndState: PropsAndState) => propsAndState.route.params.threadInfo, - (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, - (propsAndState: PropsAndState) => propsAndState.communityThreadInfo, - ( - text: string, - userInfos: { [id: string]: AccountUserInfo }, - searchIndex: SearchIndex, - userInfoInputArray: $ReadOnlyArray, - threadInfo: ThreadInfo, - parentThreadInfo: ?ThreadInfo, - communityThreadInfo: ?ThreadInfo, - ) => { - const excludeUserIDs = userInfoInputArray - .map(userInfo => userInfo.id) - .concat(threadActualMembers(threadInfo.members)); + const tagInputRef = React.useRef(); + const onUnknownErrorAlertAcknowledged = React.useCallback(() => { + setUsernameInputText(''); + setUserInfoInputArray([]); + invariant(tagInputRef.current, 'tagInput should be set'); + tagInputRef.current.focus(); + }, []); - return getPotentialMemberItems( - text, - userInfos, - searchIndex, - excludeUserIDs, - parentThreadInfo, - communityThreadInfo, - threadInfo.type, - ); - }, - ); - - get userSearchResults() { - return this.userSearchResultsSelector({ ...this.props, ...this.state }); - } + const { navigation } = props; + const { goBackOnce } = navigation; + const close = React.useCallback(() => { + goBackOnce(); + }, [goBackOnce]); - render() { - let addButton = null; - const inputLength = this.state.userInfoInputArray.length; - if (inputLength > 0) { - let activityIndicator = null; - if (this.props.changeThreadSettingsLoadingStatus === 'loading') { - activityIndicator = ( - - - - ); - } - const addButtonText = `Add (${inputLength})`; - addButton = ( - + const callChangeThreadSettings = useServerCall(changeThreadSettings); + const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); + const { route } = props; + const { threadInfo } = route.params; + const threadID = threadInfo.id; + const addUsersToThread = React.useCallback(async () => { + try { + const result = await callChangeThreadSettings({ + threadID: threadID, + changes: { newMemberIDs: userInfoInputIDs }, + }); + close(); + return result; + } catch (e) { + Alert.alert( + 'Unknown error', + 'Uhh... try again?', + [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], + { cancelable: false }, ); + throw e; } + }, [ + callChangeThreadSettings, + threadID, + userInfoInputIDs, + close, + onUnknownErrorAlertAcknowledged, + ]); - let cancelButton; - if (this.props.changeThreadSettingsLoadingStatus !== 'loading') { - cancelButton = ( - - ); - } else { - cancelButton = ; + const inputLength = userInfoInputArray.length; + const dispatchActionPromise = useDispatchActionPromise(); + const userInfoInputArrayEmpty = inputLength === 0; + const onPressAdd = React.useCallback(() => { + if (userInfoInputArrayEmpty) { + return; } + dispatchActionPromise(changeThreadSettingsActionTypes, addUsersToThread()); + }, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]); - const inputProps = { - ...tagInputProps, - onSubmitEditing: this.onPressAdd, - }; - return ( - - - - - {cancelButton} - {addButton} + const changeThreadSettingsLoadingStatus = useSelector( + createLoadingStatusSelector(changeThreadSettingsActionTypes), + ); + const isLoading = changeThreadSettingsLoadingStatus === 'loading'; + + const styles = useStyles(unboundStyles); + + let addButton = null; + if (inputLength > 0) { + let activityIndicator = null; + if (isLoading) { + activityIndicator = ( + + - + ); + } + const addButtonText = `Add (${inputLength})`; + addButton = ( + ); } - close = () => { - this.props.navigation.goBackOnce(); - }; + let cancelButton; + if (!isLoading) { + cancelButton = ( + + ); + } else { + cancelButton = ; + } - tagInputRef = (tagInput: ?BaseTagInput) => { - this.tagInput = tagInput; - }; + const threadMemberIDs = React.useMemo( + () => threadActualMembers(threadInfo.members), + [threadInfo.members], + ); + const excludeUserIDs = React.useMemo( + () => userInfoInputIDs.concat(threadMemberIDs), + [userInfoInputIDs, threadMemberIDs], + ); - onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { - if (this.props.changeThreadSettingsLoadingStatus === 'loading') { - return; - } - this.setState({ userInfoInputArray }); - }; + const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); + const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); + const { parentThreadID, community } = props.route.params.threadInfo; + const parentThreadInfo = useSelector(state => + parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, + ); + const communityThreadInfo = useSelector(state => + community ? threadInfoSelector(state)[community] : null, + ); + const userSearchResults = React.useMemo( + () => + getPotentialMemberItems( + usernameInputText, + otherUserInfos, + userSearchIndex, + excludeUserIDs, + parentThreadInfo, + communityThreadInfo, + threadInfo.type, + ), + [ + usernameInputText, + otherUserInfos, + userSearchIndex, + excludeUserIDs, + parentThreadInfo, + communityThreadInfo, + threadInfo.type, + ], + ); - tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; + const onChangeTagInput = React.useCallback( + (newUserInfoInputArray: $ReadOnlyArray) => { + if (!isLoading) { + setUserInfoInputArray(newUserInfoInputArray); + } + }, + [isLoading], + ); - setUsernameInputText = (text: string) => { - if (this.props.changeThreadSettingsLoadingStatus === 'loading') { - return; - } - this.setState({ usernameInputText: text }); - }; + const onChangeTagInputText = React.useCallback( + (text: string) => { + if (!isLoading) { + setUsernameInputText(text); + } + }, + [isLoading], + ); - onUserSelect = (userID: string) => { - if (this.props.changeThreadSettingsLoadingStatus === 'loading') { - return; - } - for (const existingUserInfo of this.state.userInfoInputArray) { - if (userID === existingUserInfo.id) { + const onUserSelect = React.useCallback( + (userID: string) => { + if (isLoading) { return; } - } - const userInfoInputArray = [ - ...this.state.userInfoInputArray, - this.props.otherUserInfos[userID], - ]; - this.setState({ - userInfoInputArray, - usernameInputText: '', - }); - }; - - onPressAdd = () => { - if (this.state.userInfoInputArray.length === 0) { - return; - } - this.props.dispatchActionPromise( - changeThreadSettingsActionTypes, - this.addUsersToThread(), - ); - }; - - async addUsersToThread() { - try { - const newMemberIDs = this.state.userInfoInputArray.map( - userInfo => userInfo.id, - ); - const result = await this.props.changeThreadSettings({ - threadID: this.props.route.params.threadInfo.id, - changes: { newMemberIDs }, - }); - this.close(); - return result; - } catch (e) { - Alert.alert( - 'Unknown error', - 'Uhh... try again?', - [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], - { cancelable: false }, - ); - throw e; - } - } - - onErrorAcknowledged = () => { - invariant(this.tagInput, 'nameInput should be set'); - this.tagInput.focus(); - }; + if (userInfoInputIDs.some(existingUserID => userID === existingUserID)) { + return; + } + setUserInfoInputArray(oldUserInfoInputArray => [ + ...oldUserInfoInputArray, + otherUserInfos[userID], + ]); + setUsernameInputText(''); + }, + [isLoading, userInfoInputIDs, otherUserInfos], + ); - onUnknownErrorAlertAcknowledged = () => { - this.setState( - { - userInfoInputArray: [], - usernameInputText: '', - }, - this.onErrorAcknowledged, - ); - }; + const inputProps = React.useMemo( + () => ({ + ...tagInputProps, + onSubmitEditing: onPressAdd, + }), + [onPressAdd], + ); + return ( + + + + + {cancelButton} + {addButton} + + + ); } const unboundStyles = { @@ -310,38 +288,8 @@ }, }; -const ConnectedAddUsersModal: React.ComponentType = React.memo( - function ConnectedAddUsersModal(props: BaseProps) { - const { parentThreadID, community } = props.route.params.threadInfo; - - const parentThreadInfo = useSelector(state => - parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, - ); - const communityThreadInfo = useSelector(state => - community ? threadInfoSelector(state)[community] : null, - ); - const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); - const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); - const changeThreadSettingsLoadingStatus = useSelector( - createLoadingStatusSelector(changeThreadSettingsActionTypes), - ); - const styles = useStyles(unboundStyles); - const dispatchActionPromise = useDispatchActionPromise(); - const callChangeThreadSettings = useServerCall(changeThreadSettings); - return ( - - ); - }, +const MemoizedAddUsersModal: React.ComponentType = React.memo( + AddUsersModal, ); -export default ConnectedAddUsersModal; +export default MemoizedAddUsersModal;