diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 403622bbe..60f42ef89 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,527 +1,493 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; import _flow from 'lodash/fp/flow'; import _sortBy from 'lodash/fp/sortBy'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, Alert } from 'react-native'; import { createSelector } from 'reselect'; import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getPotentialMemberItems } from 'lib/shared/search-utils'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; -import { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, - threadInfoPropType, type ThreadType, threadTypes, - threadTypePropType, type NewThreadRequest, type NewThreadResult, } from 'lib/types/thread-types'; -import { - type AccountUserInfo, - accountUserInfoPropType, -} from 'lib/types/user-types'; +import { type AccountUserInfo } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; import { SingleLine } from '../components/single-line.react'; -import TagInput from '../components/tag-input.react'; +import { createTagInput, BaseTagInput } from '../components/tag-input.react'; import ThreadList from '../components/thread-list.react'; import ThreadVisibility from '../components/thread-visibility.react'; import UserList from '../components/user-list.react'; import type { NavigationRoute } from '../navigation/route-names'; import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; -import { - type Colors, - colorsPropType, - useColors, - useStyles, -} from '../themes/colors'; +import { type Colors, useColors, useStyles } from '../themes/colors'; import type { ChatNavigationProp } from './chat.react'; +const TagInput = createTagInput(); + const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| - threadType?: ThreadType, - parentThreadInfo?: ThreadInfo, + +threadType?: ThreadType, + +parentThreadInfo?: ThreadInfo, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ComposeThread'>, +route: NavigationRoute<'ComposeThread'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +threadInfos: { [id: string]: ThreadInfo }, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +newThread: (request: NewThreadRequest) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class ComposeThread extends React.PureComponent { - static propTypes = { - navigation: PropTypes.shape({ - setParams: PropTypes.func.isRequired, - setOptions: PropTypes.func.isRequired, - navigate: PropTypes.func.isRequired, - pushNewThread: PropTypes.func.isRequired, - }).isRequired, - route: PropTypes.shape({ - key: PropTypes.string.isRequired, - params: PropTypes.shape({ - threadType: threadTypePropType, - parentThreadInfo: threadInfoPropType, - }).isRequired, - }).isRequired, - parentThreadInfo: threadInfoPropType, - loadingStatus: loadingStatusPropType.isRequired, - otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, - userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, - threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired, - colors: colorsPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - newThread: PropTypes.func.isRequired, - }; state: State = { usernameInputText: '', userInfoInputArray: [], }; - tagInput: ?TagInput; + tagInput: ?BaseTagInput; createThreadPressed = false; waitingOnThreadID: ?string; componentDidMount() { this.setLinkButton(true); } setLinkButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } componentDidUpdate(prevProps: Props) { const oldReduxParentThreadInfo = prevProps.parentThreadInfo; const newReduxParentThreadInfo = this.props.parentThreadInfo; if ( newReduxParentThreadInfo && newReduxParentThreadInfo !== oldReduxParentThreadInfo ) { this.props.navigation.setParams({ parentThreadInfo: newReduxParentThreadInfo, }); } if ( this.waitingOnThreadID && this.props.threadInfos[this.waitingOnThreadID] && !prevProps.threadInfos[this.waitingOnThreadID] ) { const threadInfo = this.props.threadInfos[this.waitingOnThreadID]; this.props.navigation.pushNewThread(threadInfo); } } static getParentThreadInfo(props: { route: NavigationRoute<'ComposeThread'>, }): ?ThreadInfo { return props.route.params.parentThreadInfo; } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.route.params.threadType, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, threadType: ?ThreadType, ) => getPotentialMemberItems( text, userInfos, searchIndex, userInfoInputArray.map((userInfo) => userInfo.id), parentThreadInfo, threadType, ), ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } existingThreadsSelector = createSelector( (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( parentThreadInfo: ?ThreadInfo, threadInfos: { [id: string]: ThreadInfo }, userInfoInputArray: $ReadOnlyArray, ) => { const userIDs = userInfoInputArray.map((userInfo) => userInfo.id); if (userIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && (!parentThreadInfo || threadInfo.parentThreadID === parentThreadInfo.id) && userIDs.every((userID) => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, ); get existingThreads() { return this.existingThreadsSelector({ ...this.props, ...this.state }); } render() { let existingThreadsSection = null; const { existingThreads, userSearchResults } = this; if (existingThreads.length > 0) { existingThreadsSection = ( Existing threads ); } let parentThreadRow = null; const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); if (parentThreadInfo) { const threadType = this.props.route.params.threadType; invariant( threadType !== undefined && threadType !== null, `no threadType provided for ${parentThreadInfo.id}`, ); const threadVisibilityColor = this.props.colors.modalForegroundLabel; parentThreadRow = ( within {parentThreadInfo.uiName} ); } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressCreateThread, }; return ( {parentThreadRow} To: {existingThreadsSection} ); } - tagInputRef = (tagInput: ?TagInput) => { + tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressCreateThread = () => { if (this.createThreadPressed) { return; } if (this.state.userInfoInputArray.length === 0) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a thread containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: this.dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { this.dispatchNewChatThreadAction(); } }; dispatchNewChatThreadAction = async () => { this.createThreadPressed = true; this.props.dispatchActionPromise( newThreadActionTypes, this.newChatThreadAction(), ); }; async newChatThreadAction() { this.setLinkButton(false); try { const threadTypeParam = this.props.route.params.threadType; const threadType = threadTypeParam ?? threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); invariant( threadType !== 5, 'Creating sidebars from thread composer is not yet supported', ); const result = await this.props.newThread({ type: threadType, parentThreadID: parentThreadInfo ? parentThreadInfo.id : null, initialMemberIDs, color: parentThreadInfo ? parentThreadInfo.color : null, }); this.waitingOnThreadID = result.newThreadID; return result; } catch (e) { this.createThreadPressed = false; this.setLinkButton(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState({ usernameInputText: '' }, this.onErrorAcknowledged); }; onSelectExistingThread = (threadID: string) => { const threadInfo = this.props.threadInfos[threadID]; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, parentThreadLabel: { color: 'modalSubtextLabel', fontSize: 16, paddingLeft: 6, }, parentThreadName: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 6, }, parentThreadRow: { alignItems: 'center', backgroundColor: 'modalSubtext', flexDirection: 'row', paddingLeft: 12, paddingVertical: 6, }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; export default React.memo(function ConnectedComposeThread( props: BaseProps, ) { const parentThreadInfoID = props.route.params.parentThreadInfo?.id; const reduxParentThreadInfo = useSelector((state) => parentThreadInfoID ? threadInfoSelector(state)[parentThreadInfoID] : null, ); const loadingStatus = useSelector( createLoadingStatusSelector(newThreadActionTypes), ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const threadInfos = useSelector(threadInfoSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return ( ); }); diff --git a/native/chat/message-list-thread-search.react.js b/native/chat/message-list-thread-search.react.js index 0d9167e32..68ed2b402 100644 --- a/native/chat/message-list-thread-search.react.js +++ b/native/chat/message-list-thread-search.react.js @@ -1,133 +1,135 @@ // @flow import * as React from 'react'; import { Text, View } from 'react-native'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types'; -import TagInput from '../components/tag-input.react'; +import { createTagInput } from '../components/tag-input.react'; import UserList from '../components/user-list.react'; import { useStyles } from '../themes/colors'; +const TagInput = createTagInput(); + type Props = {| +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchResults: $ReadOnlyArray, |}; const inputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export default React.memo(function MessageListThreadSearch({ usernameInputText, updateUsernameInput, userInfoInputArray, updateTagInput, otherUserInfos, userSearchResults, }) { const styles = useStyles(unboundStyles); const onUserSelect = React.useCallback( (userID: string) => { for (const existingUserInfo of userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const newUserInfoInputArray = [ ...userInfoInputArray, otherUserInfos[userID], ]; updateUsernameInput(''); updateTagInput(newUserInfoInputArray); }, [otherUserInfos, updateTagInput, updateUsernameInput, userInfoInputArray], ); const tagDataLabelExtractor = React.useCallback( (userInfo: AccountUserInfo) => userInfo.username, [], ); const isSearchResultVisible = (userInfoInputArray.length === 0 || usernameInputText.length > 0) && userSearchResults.length > 0; let separator = null; let userList = null; let userSelectionAdditionalStyles = styles.userSelectionLimitedHeight; if (isSearchResultVisible) { userList = ( ); separator = ; userSelectionAdditionalStyles = null; } return ( <> To: {userList} {separator} ); }); const unboundStyles = { userSelection: { backgroundColor: 'panelBackground', flex: 1, }, userSelectionLimitedHeight: { flex: 0, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, tagInput: { flex: 1, }, userList: { backgroundColor: 'modalBackground', paddingLeft: 35, paddingRight: 12, flex: 1, }, separator: { height: 1, backgroundColor: 'modalForegroundBorder', }, }; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 03d9edfd7..2c20475fe 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,358 +1,337 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, ActivityIndicator, Alert } from 'react-native'; import { createSelector } from 'reselect'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { 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 { loadingStatusPropType } from 'lib/types/loading-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, - threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; -import { - type AccountUserInfo, - accountUserInfoPropType, -} from 'lib/types/user-types'; +import { type AccountUserInfo } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; -import TagInput from '../../components/tag-input.react'; +import { createTagInput, BaseTagInput } 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'; import { useSelector } from '../../redux/redux-utils'; import { useStyles } from '../../themes/colors'; +const TagInput = createTagInput(); + const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| - presentedFrom: string, - threadInfo: ThreadInfo, + +presentedFrom: string, + +threadInfo: ThreadInfo, |}; type BaseProps = {| +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?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 { - static propTypes = { - navigation: PropTypes.shape({ - goBackOnce: PropTypes.func.isRequired, - }).isRequired, - route: PropTypes.shape({ - params: PropTypes.shape({ - threadInfo: threadInfoPropType.isRequired, - }).isRequired, - }).isRequired, - parentThreadInfo: threadInfoPropType, - otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, - userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, - changeThreadSettingsLoadingStatus: loadingStatusPropType.isRequired, - styles: PropTypes.objectOf(PropTypes.object).isRequired, - dispatchActionPromise: PropTypes.func.isRequired, - changeThreadSettings: PropTypes.func.isRequired, - }; state: State = { usernameInputText: '', userInfoInputArray: [], }; - tagInput: ?TagInput = null; + tagInput: ?BaseTagInput = null; 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, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { const excludeUserIDs = userInfoInputArray .map((userInfo) => userInfo.id) .concat(threadActualMembers(threadInfo.members)); return getPotentialMemberItems( text, userInfos, searchIndex, excludeUserIDs, parentThreadInfo, threadInfo.type, ); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } 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 = ( ); } let cancelButton; if (this.props.changeThreadSettingsLoadingStatus !== 'loading') { cancelButton = ( ); } else { cancelButton = ; } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( {cancelButton} {addButton} ); } close = () => { this.props.navigation.goBackOnce(); }; - tagInputRef = (tagInput: ?TagInput) => { + tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { 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(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { userInfoInputArray: [], usernameInputText: '', }, this.onErrorAcknowledged, ); }; } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'greenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; export default React.memo(function ConnectedAddUsersModal( props: BaseProps, ) { const { parentThreadID } = props.route.params.threadInfo; const parentThreadInfo = useSelector((state) => parentThreadID ? threadInfoSelector(state)[parentThreadID] : 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 ( ); }); diff --git a/native/components/tag-input.react.js b/native/components/tag-input.react.js index 4e1d80b8c..5d6445479 100644 --- a/native/components/tag-input.react.js +++ b/native/components/tag-input.react.js @@ -1,492 +1,471 @@ // @flow import invariant from 'invariant'; -import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, TextInput, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, ScrollView, - ViewPropTypes, Platform, } from 'react-native'; -import { connect } from 'lib/utils/redux-utils'; - -import type { AppState } from '../redux/redux-setup'; -import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; +import { useSelector } from '../redux/redux-utils'; +import { useColors, type Colors } from '../themes/colors'; import type { LayoutEvent } from '../types/react-native'; import type { ViewStyle, TextStyle } from '../types/styles'; -type Props = {| +type TagInputProps = {| /** * An array of tags, which can be any type, as long as labelExtractor below * can extract a string from it. */ - value: $ReadOnlyArray, + +value: $ReadOnlyArray, /** * A handler to be called when array of tags change. */ - onChange: (items: $ReadOnlyArray) => void, + +onChange: (items: $ReadOnlyArray) => void, /** * Function to extract string value for label from item */ - labelExtractor: (tagData: T) => string, + +labelExtractor: (tagData: T) => string, /** * The text currently being displayed in the TextInput following the list of * tags. */ - text: string, + +text: string, /** * This callback gets called when the user in the TextInput. The caller should * update the text prop when this is called if they want to access input. */ - onChangeText: (text: string) => void, + +onChangeText: (text: string) => mixed, /** * If `true`, text and tags are not editable. The default value is `false`. */ - disabled?: boolean, + +disabled?: boolean, /** * Background color of tags */ - tagColor?: string, + +tagColor?: string, /** * Text color of tags */ - tagTextColor?: string, + +tagTextColor?: string, /** * Styling override for container surrounding tag text */ - tagContainerStyle?: ViewStyle, + +tagContainerStyle?: ViewStyle, /** * Styling override for tag's text component */ - tagTextStyle?: TextStyle, + +tagTextStyle?: TextStyle, /** * Color of text input */ - inputColor?: string, + +inputColor?: string, /** * Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.) */ - inputProps?: React.ElementConfig, + +inputProps?: React.ElementConfig, /** * Min height of the tag input on screen */ - minHeight: number, + +minHeight: number, /** * Max height of the tag input on screen (will scroll if max height reached) */ - maxHeight: number, + +maxHeight: number, /** * Callback that gets passed the new component height when it changes */ - onHeightChange?: (height: number) => void, + +onHeightChange?: (height: number) => void, /** * inputWidth if text === "". we want this number explicitly because if we're * forced to measure the component, there can be a short jump between the old * value and the new value, which looks sketchy. */ - defaultInputWidth: number, - innerRef?: (tagInput: ?TagInput) => void, - // Redux state - windowWidth: number, - colors: Colors, + +defaultInputWidth: number, +|}; +type BaseTagInputProps = {| + ...TagInputProps, + +windowWidth: number, + +colors: Colors, |}; type State = {| - wrapperHeight: number, - contentHeight: number, - wrapperWidth: number, - spaceLeft: number, + +wrapperHeight: number, + +contentHeight: number, + +wrapperWidth: number, + +spaceLeft: number, |}; -class TagInput extends React.PureComponent, State> { - static propTypes = { - value: PropTypes.array.isRequired, - onChange: PropTypes.func.isRequired, - labelExtractor: PropTypes.func.isRequired, - text: PropTypes.string.isRequired, - onChangeText: PropTypes.func.isRequired, - tagColor: PropTypes.string, - tagTextColor: PropTypes.string, - tagContainerStyle: ViewPropTypes.style, - tagTextStyle: Text.propTypes.style, - inputColor: PropTypes.string, - inputProps: PropTypes.shape(TextInput.propTypes), - minHeight: PropTypes.number, - maxHeight: PropTypes.number, - onHeightChange: PropTypes.func, - defaultInputWidth: PropTypes.number, - innerRef: PropTypes.func, - windowWidth: PropTypes.number.isRequired, - colors: colorsPropType.isRequired, - }; +class BaseTagInput extends React.PureComponent, State> { // scroll to bottom scrollViewHeight = 0; scrollToBottomAfterNextScrollViewLayout = false; // refs tagInput: ?React.ElementRef = null; scrollView: ?React.ElementRef = null; lastChange: ?{| time: number, prevText: string |}; static defaultProps = { minHeight: 30, maxHeight: 75, defaultInputWidth: 90, }; - constructor(props: Props) { + constructor(props: BaseTagInputProps) { super(props); this.state = { wrapperHeight: 30, // was wrapperHeight: 36, contentHeight: 0, wrapperWidth: props.windowWidth, spaceLeft: 0, }; } - componentDidMount() { - if (this.props.innerRef) { - this.props.innerRef(this); - } - } - - componentWillUnmount() { - if (this.props.innerRef) { - this.props.innerRef(null); - } - } - - static getDerivedStateFromProps(props: Props, state: State) { + static getDerivedStateFromProps(props: BaseTagInputProps, state: State) { const wrapperHeight = Math.max( Math.min(props.maxHeight, state.contentHeight), props.minHeight, ); return { wrapperHeight }; } - componentDidUpdate(prevProps: Props, prevState: State) { + componentDidUpdate(prevProps: BaseTagInputProps, prevState: State) { if ( this.props.onHeightChange && this.state.wrapperHeight !== prevState.wrapperHeight ) { this.props.onHeightChange(this.state.wrapperHeight); } } measureWrapper = (event: LayoutEvent) => { const wrapperWidth = event.nativeEvent.layout.width; if (wrapperWidth !== this.state.wrapperWidth) { this.setState({ wrapperWidth }); } }; onChangeText = (text: string) => { this.lastChange = { time: Date.now(), prevText: this.props.text }; this.props.onChangeText(text); }; onBlur = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ target: number }> }>, ) => { invariant(Platform.OS === 'ios', 'only iOS gets text on TextInput.onBlur'); const nativeEvent: $ReadOnly<{ target: number, text: string, }> = (event.nativeEvent: any); this.onChangeText(nativeEvent.text); }; onKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { lastChange } = this; let { text } = this.props; if ( Platform.OS === 'android' && lastChange !== null && lastChange !== undefined && Date.now() - lastChange.time < 150 ) { text = lastChange.prevText; } if (text !== '' || event.nativeEvent.key !== 'Backspace') { return; } const tags = [...this.props.value]; tags.pop(); this.props.onChange(tags); this.focus(); }; focus = () => { invariant(this.tagInput, 'should be set'); this.tagInput.focus(); }; removeIndex = (index: number) => { const tags = [...this.props.value]; tags.splice(index, 1); this.props.onChange(tags); }; scrollToBottom = () => { const scrollView = this.scrollView; invariant( scrollView, 'this.scrollView ref should exist before scrollToBottom called', ); scrollView.scrollToEnd(); }; render() { const tagColor = this.props.tagColor || this.props.colors.modalSubtext; const tagTextColor = this.props.tagTextColor || this.props.colors.modalForegroundLabel; const inputColor = this.props.inputColor || this.props.colors.modalForegroundLabel; const placeholderColor = this.props.colors.modalForegroundTertiaryLabel; const tags = this.props.value.map((tag, index) => ( )); let inputWidth; if (this.props.text === '') { inputWidth = this.props.defaultInputWidth; } else if (this.state.spaceLeft >= 100) { inputWidth = this.state.spaceLeft - 10; } else { inputWidth = this.state.wrapperWidth; } const defaultTextInputProps: React.ElementConfig = { blurOnSubmit: false, style: [ styles.textInput, { width: inputWidth, color: inputColor, }, ], autoCapitalize: 'none', autoCorrect: false, placeholder: 'Start typing', placeholderTextColor: placeholderColor, returnKeyType: 'done', keyboardType: 'default', }; const textInputProps: React.ElementConfig = { ...defaultTextInputProps, ...this.props.inputProps, // should not be overridden onKeyPress: this.onKeyPress, value: this.props.text, onBlur: Platform.OS === 'ios' ? this.onBlur : undefined, onChangeText: this.onChangeText, editable: !this.props.disabled, }; return ( {tags} ); } tagInputRef = (tagInput: ?React.ElementRef) => { this.tagInput = tagInput; }; scrollViewRef = (scrollView: ?React.ElementRef) => { this.scrollView = scrollView; }; onScrollViewContentSizeChange = (w: number, h: number) => { const oldContentHeight = this.state.contentHeight; if (h === oldContentHeight) { return; } let callback; if (h > oldContentHeight) { callback = () => { if (this.scrollViewHeight === this.props.maxHeight) { this.scrollToBottom(); } else { this.scrollToBottomAfterNextScrollViewLayout = true; } }; } this.setState({ contentHeight: h }, callback); }; onScrollViewLayout = (event: LayoutEvent) => { this.scrollViewHeight = event.nativeEvent.layout.height; if (this.scrollToBottomAfterNextScrollViewLayout) { this.scrollToBottom(); this.scrollToBottomAfterNextScrollViewLayout = false; } }; onLayoutLastTag = (endPosOfTag: number) => { const margin = 3; const spaceLeft = this.state.wrapperWidth - endPosOfTag - margin - 10; if (spaceLeft !== this.state.spaceLeft) { this.setState({ spaceLeft }); } }; } type TagProps = {| - index: number, - label: string, - isLastTag: boolean, - onLayoutLastTag: (endPosOfTag: number) => void, - removeIndex: (index: number) => void, - tagColor: string, - tagTextColor: string, - tagContainerStyle?: ViewStyle, - tagTextStyle?: TextStyle, - disabled?: boolean, + +index: number, + +label: string, + +isLastTag: boolean, + +onLayoutLastTag: (endPosOfTag: number) => void, + +removeIndex: (index: number) => void, + +tagColor: string, + +tagTextColor: string, + +tagContainerStyle?: ViewStyle, + +tagTextStyle?: TextStyle, + +disabled?: boolean, |}; class Tag extends React.PureComponent { - static propTypes = { - index: PropTypes.number.isRequired, - label: PropTypes.string.isRequired, - isLastTag: PropTypes.bool.isRequired, - onLayoutLastTag: PropTypes.func.isRequired, - removeIndex: PropTypes.func.isRequired, - tagColor: PropTypes.string.isRequired, - tagTextColor: PropTypes.string.isRequired, - tagContainerStyle: ViewPropTypes.style, - tagTextStyle: Text.propTypes.style, - }; curPos: ?number = null; componentDidUpdate(prevProps: TagProps) { if ( !prevProps.isLastTag && this.props.isLastTag && this.curPos !== null && this.curPos !== undefined ) { this.props.onLayoutLastTag(this.curPos); } } render() { return ( {this.props.label}  × ); } onPress = () => { this.props.removeIndex(this.props.index); }; onLayoutLastTag = (event: LayoutEvent) => { const layout = event.nativeEvent.layout; this.curPos = layout.width + layout.x; if (this.props.isLastTag) { this.props.onLayoutLastTag(this.curPos); } }; } const styles = StyleSheet.create({ tag: { borderRadius: 2, justifyContent: 'center', marginBottom: 3, marginRight: 3, paddingHorizontal: 6, paddingVertical: 2, }, tagInputContainer: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', }, tagInputContainerScroll: { flex: 1, }, tagText: { fontSize: 16, margin: 0, padding: 0, }, textInput: { borderBottomColor: 'transparent', flex: 0.6, fontSize: 16, height: 24, marginBottom: 3, marginHorizontal: 0, marginTop: 3, padding: 0, }, textInputContainer: {}, wrapper: {}, }); -export default connect((state: AppState) => ({ - windowWidth: state.dimensions.width, - colors: colorsSelector(state), -}))(TagInput); +type BaseConfig = React.Config< + TagInputProps, + typeof BaseTagInput.defaultProps, +>; + +function createTagInput(): React.AbstractComponent< + BaseConfig, + BaseTagInput, +> { + return React.forwardRef, BaseTagInput>( + function ForwardedTagInput( + props: BaseConfig, + ref: React.Ref, + ) { + const windowWidth = useSelector((state) => state.dimensions.width); + const colors = useColors(); + return ( + + ); + }, + ); +} + +export { createTagInput, BaseTagInput }; diff --git a/native/more/relationship-list.react.js b/native/more/relationship-list.react.js index 8a0e77830..ed3d01e18 100644 --- a/native/more/relationship-list.react.js +++ b/native/more/relationship-list.react.js @@ -1,567 +1,569 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, FlatList, Alert, Platform } from 'react-native'; import { createSelector } from 'reselect'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { type UserRelationships, type RelationshipRequest, userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types'; import type { UserSearchResult } from 'lib/types/search-types'; import type { UserInfos, GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; -import TagInput from '../components/tag-input.react'; +import { createTagInput, BaseTagInput } from '../components/tag-input.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { FriendListRouteName, BlockListRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../themes/colors'; import type { VerticalBounds } from '../types/layout-types'; import type { MoreNavigationProp } from './more.react'; import RelationshipListItem from './relationship-list-item.react'; +const TagInput = createTagInput(); + export type RelationshipListNavigate = $PropertyType< MoreNavigationProp<'FriendList' | 'BlockList'>, 'navigate', >; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; type ListItem = | {| +type: 'empty', +because: 'no-relationships' | 'no-results' |} | {| +type: 'header' |} | {| +type: 'footer' |} | {| +type: 'user', +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, |}; type BaseProps = {| +navigation: MoreNavigationProp<>, +route: NavigationRoute<'FriendList' | 'BlockList'>, |}; type Props = {| ...BaseProps, // Redux state +relationships: UserRelationships, +userInfos: UserInfos, +viewerID: ?string, +userStoreSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +searchUsers: (usernamePrefix: string) => Promise, +updateRelationships: (request: RelationshipRequest) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +verticalBounds: ?VerticalBounds, +searchInputText: string, +serverSearchResults: $ReadOnlyArray, +currentTags: $ReadOnlyArray, +userStoreSearchResults: Set, |}; type PropsAndState = {| ...Props, ...State |}; class RelationshipList extends React.PureComponent { flatListContainerRef = React.createRef(); - tagInput: ?TagInput = null; + tagInput: ?BaseTagInput = null; state: State = { verticalBounds: null, searchInputText: '', serverSearchResults: [], userStoreSearchResults: new Set(), currentTags: [], }; componentDidMount() { this.setSaveButton(false); } componentDidUpdate(prevProps: Props, prevState: State) { const prevTags = prevState.currentTags.length; const currentTags = this.state.currentTags.length; if (prevTags !== 0 && currentTags === 0) { this.setSaveButton(false); } else if (prevTags === 0 && currentTags !== 0) { this.setSaveButton(true); } } setSaveButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } static keyExtractor(item: ListItem) { if (item.userInfo) { return item.userInfo.id; } else if (item.type === 'empty') { return 'empty'; } else if (item.type === 'header') { return 'header'; } else if (item.type === 'footer') { return 'footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); } get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'RelationshipList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = RelationshipList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } render() { const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( Search: ); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.relationships, (propsAndState: PropsAndState) => propsAndState.route.name, (propsAndState: PropsAndState) => propsAndState.verticalBounds, (propsAndState: PropsAndState) => propsAndState.searchInputText, (propsAndState: PropsAndState) => propsAndState.serverSearchResults, (propsAndState: PropsAndState) => propsAndState.userStoreSearchResults, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.currentTags, ( relationships: UserRelationships, routeName: 'FriendList' | 'BlockList', verticalBounds: ?VerticalBounds, searchInputText: string, serverSearchResults: $ReadOnlyArray, userStoreSearchResults: Set, userInfos: UserInfos, viewerID: ?string, currentTags: $ReadOnlyArray, ) => { const defaultUsers = { [FriendListRouteName]: relationships.friends, [BlockListRouteName]: relationships.blocked, }[routeName]; const excludeUserIDsArray = currentTags .map((userInfo) => userInfo.id) .concat(viewerID || []); const excludeUserIDs = new Set(excludeUserIDsArray); let displayUsers = defaultUsers; if (searchInputText !== '') { const mergedUserInfos: { [id: string]: AccountUserInfo } = {}; for (const userInfo of serverSearchResults) { mergedUserInfos[userInfo.id] = userInfo; } for (const id of userStoreSearchResults) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedUserInfos[id] = { id, username, relationshipStatus }; } } const sortToEnd = []; const userSearchResults = []; const sortRelationshipTypesToEnd = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], }[routeName]; for (const userID in mergedUserInfos) { if (excludeUserIDs.has(userID)) { continue; } const userInfo = mergedUserInfos[userID]; if ( sortRelationshipTypesToEnd.includes(userInfo.relationshipStatus) ) { sortToEnd.push(userInfo); } else { userSearchResults.push(userInfo); } } displayUsers = userSearchResults.concat(sortToEnd); } let emptyItem; if (displayUsers.length === 0 && searchInputText === '') { emptyItem = { type: 'empty', because: 'no-relationships' }; } else if (displayUsers.length === 0) { emptyItem = { type: 'empty', because: 'no-results' }; } const mappedUsers = displayUsers.map((userInfo, index) => ({ type: 'user', userInfo, lastListItem: displayUsers.length - 1 === index, verticalBounds, })); return [] .concat(emptyItem ? emptyItem : []) .concat(emptyItem ? [] : { type: 'header' }) .concat(mappedUsers) .concat(emptyItem ? [] : { type: 'footer' }); }, ); - tagInputRef = (tagInput: ?TagInput) => { + tagInputRef = (tagInput: ?BaseTagInput) => { this.tagInput = tagInput; }; tagDataLabelExtractor = (userInfo: GlobalAccountUserInfo) => userInfo.username; onChangeTagInput = (currentTags: $ReadOnlyArray) => { this.setState({ currentTags }); }; onChangeSearchText = async (searchText: string) => { const excludeStatuses = { [FriendListRouteName]: [ userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], [BlockListRouteName]: [], }[this.props.route.name]; const results = this.props.userStoreSearchIndex .getSearchResults(searchText) .filter((userID) => { const relationship = this.props.userInfos[userID].relationshipStatus; return !excludeStatuses.includes(relationship); }); this.setState({ searchInputText: searchText, userStoreSearchResults: new Set(results), }); const serverSearchResults = await this.searchUsers(searchText); const filteredServerSearchResults = serverSearchResults.filter( (searchUserInfo) => { const userInfo = this.props.userInfos[searchUserInfo.id]; return ( !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) ); }, ); this.setState({ serverSearchResults: filteredServerSearchResults }); }; async searchUsers(usernamePrefix: string) { if (usernamePrefix.length === 0) { return []; } const { userInfos } = await this.props.searchUsers(usernamePrefix); return userInfos; } onFlatListContainerLayout = () => { const { flatListContainerRef } = this; if (!flatListContainerRef.current) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }, ); }; onSelect = (selectedUser: GlobalAccountUserInfo) => { this.setState((state) => { if (state.currentTags.find((o) => o.id === selectedUser.id)) { return null; } return { searchInputText: '', currentTags: state.currentTags.concat(selectedUser), }; }); }; onPressAdd = () => { if (this.state.currentTags.length === 0) { return; } this.props.dispatchActionPromise( updateRelationshipsActionTypes, this.updateRelationships(), ); }; async updateRelationships() { const routeName = this.props.route.name; const action = { [FriendListRouteName]: relationshipActions.FRIEND, [BlockListRouteName]: relationshipActions.BLOCK, }[routeName]; const userIDs = this.state.currentTags.map((userInfo) => userInfo.id); try { const result = await this.props.updateRelationships({ action, userIDs, }); this.setState({ currentTags: [], searchInputText: '', }); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: true, onDismiss: this.onUnknownErrorAlertAcknowledged }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentTags: [], searchInputText: '', }, this.onErrorAcknowledged, ); }; renderItem = ({ item }: { item: ListItem }) => { if (item.type === 'empty') { const action = { [FriendListRouteName]: 'added', [BlockListRouteName]: 'blocked', }[this.props.route.name]; const emptyMessage = item.because === 'no-relationships' ? `You haven't ${action} any users yet` : 'No results'; return {emptyMessage}; } else if (item.type === 'header' || item.type === 'footer') { return ; } else if (item.type === 'user') { return ( ); } else { invariant(false, `unexpected RelationshipList item type ${item.type}`); } }; } const unboundStyles = { container: { flex: 1, backgroundColor: 'panelBackground', }, contentContainer: { paddingTop: 12, paddingBottom: 24, }, separator: { backgroundColor: 'panelForegroundBorder', height: Platform.OS === 'android' ? 1.5 : 1, }, emptyText: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 12, }, tagInput: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; registerFetchKey(searchUsersActionTypes); registerFetchKey(updateRelationshipsActionTypes); export default React.memo(function ConnectedRelationshipList( props: BaseProps, ) { const relationships = useSelector(userRelationshipsSelector); const userInfos = useSelector((state) => state.userStore.userInfos); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); const dispatchActionPromise = useDispatchActionPromise(); const callSearchUsers = useServerCall(searchUsers); const callUpdateRelationships = useServerCall(updateRelationships); return ( ); });