diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index 385543ccc..12955a305 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,94 +1,108 @@ // @flow import { userRelationshipStatus } from '../types/relationship-types'; -import type { ThreadInfo } from '../types/thread-types'; +import { + type ThreadInfo, + type ThreadType, + threadTypes, +} from '../types/thread-types'; import type { AccountUserInfo, UserListItem } from '../types/user-types'; import SearchIndex from './search-index'; import { userIsMember } from './thread-utils'; function getPotentialMemberItems( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, excludeUserIDs: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, + threadType: ?ThreadType, ): UserListItem[] { let results = []; const appendUserInfo = (userInfo: AccountUserInfo) => { if (!excludeUserIDs.includes(userInfo.id)) { results.push({ ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, userInfo.id), }); } }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (text === '') { results = results.filter((userInfo) => parentThreadInfo ? userInfo.isMemberOfParentThread : userInfo.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friendsAndParentMembers = []; for (const userResult of results) { const relationshipStatus = userResult.relationshipStatus; if (userResult.isMemberOfParentThread) { friendsAndParentMembers.unshift(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friendsAndParentMembers.push(userResult); } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { blockedUsers.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = friendsAndParentMembers .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfParentThread, relationshipStatus, ...result }) => { if (isMemberOfParentThread) { return { ...result }; } - let notice, alertText; + let notice, alertText, alertTitle; const userText = result.username; - if (relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER) { + if (!isMemberOfParentThread && threadType === threadTypes.SIDEBAR) { + notice = 'not in parent thread'; + alertTitle = 'Not in parent thread'; + alertText = + 'You can only add members of the parent thread to a sidebar'; + } else if ( + relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER + ) { notice = "you've blocked this user"; + alertTitle = 'Not a friend'; alertText = `Before you add ${userText} to this thread, ` + - `you'll need to unblock them and send a friend request. ` + - `You can do this from the Block List and Friend List in the More tab.`; + "you'll need to unblock them and send a friend request. " + + 'You can do this from the Block List and Friend List in the More tab.'; } else if (relationshipStatus !== userRelationshipStatus.FRIEND) { notice = 'not friend'; + alertTitle = 'Not a friend'; alertText = `Before you add ${userText} to this thread, ` + - `you'll need to send them a friend request. ` + - `You can do this from the Friend List in the More tab.`; + "you'll need to send them a friend request. " + + 'You can do this from the Friend List in the More tab.'; } else if (parentThreadInfo) { notice = 'not in parent thread'; } - return { ...result, notice, alertText }; + return { ...result, notice, alertText, alertTitle }; }, ); } export { getPotentialMemberItems }; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index 5063ac515..395244a4b 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,109 +1,111 @@ // @flow import PropTypes from 'prop-types'; import type { UserRelationshipStatus } from './relationship-types'; import type { UserInconsistencyReportCreationRequest } from './report-types'; export type GlobalUserInfo = {| +id: string, +username: ?string, |}; export type GlobalAccountUserInfo = {| +id: string, +username: string, |}; export type UserInfo = {| +id: string, +username: ?string, +relationshipStatus?: UserRelationshipStatus, |}; export type UserInfos = { +[id: string]: UserInfo }; export const userInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, relationshipStatus: PropTypes.number, }); export type AccountUserInfo = {| +id: string, +username: string, +relationshipStatus?: UserRelationshipStatus, |}; export const accountUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, relationshipStatus: PropTypes.number, }); export type UserStore = {| +userInfos: UserInfos, +inconsistencyReports: $ReadOnlyArray, |}; export type RelativeUserInfo = {| +id: string, +username: ?string, +isViewer: boolean, |}; export const relativeUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type LoggedInUserInfo = {| +id: string, +username: string, +email: string, +emailVerified: boolean, |}; export type LoggedOutUserInfo = {| +id: string, +anonymous: true, |}; export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; export const currentUserPropType = PropTypes.oneOfType([ PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, email: PropTypes.string.isRequired, emailVerified: PropTypes.bool.isRequired, }), PropTypes.shape({ id: PropTypes.string.isRequired, anonymous: PropTypes.oneOf([true]).isRequired, }), ]); export type AccountUpdate = {| +updatedFields: {| +email?: ?string, +password?: ?string, |}, +currentPassword: string, |}; export type UserListItem = {| +id: string, +username: string, +disabled?: boolean, +notice?: string, +alertText?: string, + +alertTitle?: string, |}; export const userListItemPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, disabled: PropTypes.bool, notice: PropTypes.string, alertText: PropTypes.string, + alertTitle: PropTypes.string, }); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 56a473f55..11dac4c51 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,521 +1,524 @@ // @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 { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import LinkButton from '../components/link-button.react'; import TagInput 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 { ChatNavigationProp } from './chat.react'; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| 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; 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) => { 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 ? threadTypeParam : threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); 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/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index f183e80cd..83639c7cc 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,357 +1,358 @@ // @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 { 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 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 tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| 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; 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) => { 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/user-list-user.react.js b/native/components/user-list-user.react.js index 68f148bcc..2ba172d50 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,94 +1,94 @@ // @flow import * as React from 'react'; import { Text, Platform, Alert } from 'react-native'; import type { UserListItem } from 'lib/types/user-types'; import { type Colors, useColors, useStyles } from '../themes/colors'; import type { TextStyle } from '../types/styles'; import Button from './button.react'; import { SingleLine } from './single-line.react'; // eslint-disable-next-line no-unused-vars const getUserListItemHeight = (item: UserListItem) => { // TODO consider parent thread notice return Platform.OS === 'ios' ? 31.5 : 33.5; }; type BaseProps = {| +userInfo: UserListItem, +onSelect: (userID: string) => void, +textStyle?: TextStyle, |}; type Props = {| ...BaseProps, // Redux state +colors: Colors, +styles: typeof unboundStyles, |}; class UserListUser extends React.PureComponent { render() { const { userInfo } = this.props; let notice = null; if (userInfo.notice) { notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { const { userInfo } = this.props; if (!userInfo.alertText) { this.props.onSelect(userInfo.id); return; } - Alert.alert('Not a friend', userInfo.alertText, [{ text: 'OK' }], { + Alert.alert(userInfo.alertTitle, userInfo.alertText, [{ text: 'OK' }], { cancelable: true, }); }; } const unboundStyles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; const ConnectedUserListUser = React.memo( function ConnectedUserListUser(props: BaseProps) { const colors = useColors(); const styles = useStyles(unboundStyles); return ; }, ); export { ConnectedUserListUser as UserListUser, getUserListItemHeight };