diff --git a/lib/selectors/relationship-selectors.js b/lib/selectors/relationship-selectors.js new file mode 100644 index 000000000..64652d1c4 --- /dev/null +++ b/lib/selectors/relationship-selectors.js @@ -0,0 +1,41 @@ +// @flow + +import type { BaseAppState } from '../types/redux-types'; +import type { UserInfo } from '../types/user-types'; +import { + userRelationshipStatus, + type UserRelationships, +} from '../types/relationship-types'; + +import { createSelector } from 'reselect'; + +const userRelationshipsSelector: ( + state: BaseAppState<*>, +) => UserRelationships = createSelector( + (state: BaseAppState<*>) => state.userStore.userInfos, + (userInfos: { [id: string]: UserInfo }) => { + const friends = []; + const blocked = []; + for (const userID in userInfos) { + const userInfo = userInfos[userID]; + const { relationshipStatus } = userInfo; + if ( + relationshipStatus === userRelationshipStatus.FRIEND || + relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED || + relationshipStatus === userRelationshipStatus.REQUEST_SENT + ) { + friends.push(userInfo); + } + if ( + relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || + relationshipStatus === userRelationshipStatus.BOTH_BLOCKED + ) { + blocked.push(userInfo); + } + } + + return { friends, blocked }; + }, +); + +export { userRelationshipsSelector }; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index a356a503f..c495de834 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,60 +1,67 @@ // @flow +import type { UserInfo } from './user-types'; + import { values } from '../utils/objects'; export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; export const relationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); -type RelationshipAction = $Values; +export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values( relationshipActions, ); export type RelationshipRequest = {| action: RelationshipAction, userIDs: $ReadOnlyArray, |}; type SharedRelationshipRow = {| user1: string, user2: string, |}; export type DirectedRelationshipRow = {| ...SharedRelationshipRow, status: DirectedStatus, |}; export type UndirectedRelationshipRow = {| ...SharedRelationshipRow, status: UndirectedStatus, |}; export type RelationshipErrors = $Shape<{| invalid_user: string[], already_friends: string[], user_blocked: string[], |}>; + +export type UserRelationships = {| + +friends: $ReadOnlyArray, + +blocked: $ReadOnlyArray, +|}; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index d90677734..53e150d94 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,95 +1,97 @@ // @flow import type { UserInconsistencyReportCreationRequest } from './report-types'; import type { UserRelationshipStatus } from './relationship-types'; import PropTypes from 'prop-types'; 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, |} & UserInfo; export const accountUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, relationshipStatus: PropTypes.number, }); export type UserStore = {| userInfos: { [id: string]: UserInfo }, 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, - memberOfParentThread: boolean, + disabled?: boolean, + notice?: string, |}; export const userListItemPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, - memberOfParentThread: PropTypes.bool.isRequired, + disabled: PropTypes.bool, + notice: PropTypes.string, }); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 431f6e43a..dae0ad425 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,513 +1,518 @@ // @flow import type { AppState } from '../redux/redux-setup'; import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } 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 type { ChatNavigationProp } from './chat.react'; import type { NavigationRoute } from '../navigation/route-names'; import * as React from 'react'; import PropTypes from 'prop-types'; import { View, Text, Alert } from 'react-native'; import invariant from 'invariant'; import _flow from 'lodash/fp/flow'; import _filter from 'lodash/fp/filter'; import _sortBy from 'lodash/fp/sortBy'; import { createSelector } from 'reselect'; import { connect } from 'lib/utils/redux-utils'; import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { userInfoSelectorForOtherMembersOfThread, userSearchIndexForOtherMembersOfThread, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; import { getUserSearchResults } from 'lib/shared/search-utils'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import TagInput from '../components/tag-input.react'; import UserList from '../components/user-list.react'; import ThreadList from '../components/thread-list.react'; import LinkButton from '../components/link-button.react'; import { MessageListRouteName } from '../navigation/route-names'; import ThreadVisibility from '../components/thread-visibility.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| threadType?: ThreadType, parentThreadInfo?: ThreadInfo, |}; type Props = {| navigation: ChatNavigationProp<'ComposeThread'>, route: NavigationRoute<'ComposeThread'>, // Redux state parentThreadInfo: ?ThreadInfo, loadingStatus: LoadingStatus, otherUserInfos: { [id: string]: AccountUserInfo }, userSearchIndex: SearchIndex, threadInfos: { [id: string]: ThreadInfo }, colors: Colors, styles: typeof styles, // 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 = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput; createThreadPressed = false; waitingOnThreadID: ?string; constructor(props: Props) { super(props); 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), ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, - ) => - getUserSearchResults( + ) => { + const results = getUserSearchResults( text, userInfos, searchIndex, userInfoInputArray.map(userInfo => userInfo.id), parentThreadInfo, - ), + ); + return results.map(({ memberOfParentThread, ...result }) => ({ + ...result, + notice: !memberOfParentThread ? 'not in parent thread' : undefined, + })); + }, ); 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 }, ], ); } 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 styles = { 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, }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); export default connect( ( state: AppState, ownProps: { route: NavigationRoute<'ComposeThread'>, }, ) => { let reduxParentThreadInfo = null; const parentThreadInfo = ownProps.route.params.parentThreadInfo; if (parentThreadInfo) { reduxParentThreadInfo = threadInfoSelector(state)[parentThreadInfo.id]; } return { parentThreadInfo: reduxParentThreadInfo, loadingStatus: loadingStatusSelector(state), otherUserInfos: userInfoSelectorForOtherMembersOfThread((null: ?string))( state, ), userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state), threadInfos: threadInfoSelector(state), colors: colorsSelector(state), styles: stylesSelector(state), viewerID: state.currentUserInfo && state.currentUserInfo.id, }; }, { newThread }, )(ComposeThread); diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 1f911c699..169343ef3 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,353 +1,357 @@ // @flow import type { AppState } from '../../redux/redux-setup'; 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 type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; import type { RootNavigationProp } from '../../navigation/root-navigator.react'; import type { NavigationRoute } from '../../navigation/route-names'; import * as React from 'react'; import { View, Text, ActivityIndicator, Alert } from 'react-native'; import PropTypes from 'prop-types'; import invariant from 'invariant'; import { createSelector } from 'reselect'; import { userInfoSelectorForOtherMembersOfThread, userSearchIndexForOtherMembersOfThread, } from 'lib/selectors/user-selectors'; import SearchIndex from 'lib/shared/search-index'; import { getUserSearchResults } from 'lib/shared/search-utils'; import { connect } from 'lib/utils/redux-utils'; import { changeThreadSettingsActionTypes, changeThreadSettings, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadActualMembers } from 'lib/shared/thread-utils'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import UserList from '../../components/user-list.react'; import TagInput from '../../components/tag-input.react'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; import { styleSelector } from '../../themes/colors'; const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| presentedFrom: string, threadInfo: ThreadInfo, |}; type Props = {| navigation: RootNavigationProp<'AddUsersModal'>, route: NavigationRoute<'AddUsersModal'>, // Redux state parentThreadInfo: ?ThreadInfo, otherUserInfos: { [id: string]: AccountUserInfo }, userSearchIndex: SearchIndex, changeThreadSettingsLoadingStatus: LoadingStatus, styles: typeof styles, // 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 = { 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 getUserSearchResults( + const results = getUserSearchResults( text, userInfos, searchIndex, excludeUserIDs, parentThreadInfo, ); + return results.map(({ memberOfParentThread, ...result }) => ({ + ...result, + notice: !memberOfParentThread ? 'not in parent thread' : undefined, + })); }, ); 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 styles = { 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, }, }; const stylesSelector = styleSelector(styles); const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); export default connect( ( state: AppState, ownProps: { route: NavigationRoute<'AddUsersModal'>, }, ) => { let parentThreadInfo = null; const { parentThreadID } = ownProps.route.params.threadInfo; if (parentThreadID) { parentThreadInfo = threadInfoSelector(state)[parentThreadID]; } return { parentThreadInfo, otherUserInfos: userInfoSelectorForOtherMembersOfThread((null: ?string))( state, ), userSearchIndex: userSearchIndexForOtherMembersOfThread(null)(state), changeThreadSettingsLoadingStatus: changeThreadSettingsLoadingStatusSelector( state, ), styles: stylesSelector(state), }; }, { changeThreadSettings }, )(AddUsersModal); diff --git a/native/components/user-list-user.react.js b/native/components/user-list-user.react.js index 0b9dcdcfe..050a05cdd 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,101 +1,99 @@ // @flow import type { TextStyle } from '../types/styles'; import { type UserListItem, userListItemPropType } from 'lib/types/user-types'; import type { AppState } from '../redux/redux-setup'; import * as React from 'react'; import PropTypes from 'prop-types'; import { Text, Platform } from 'react-native'; import { connect } from 'lib/utils/redux-utils'; import Button from './button.react'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; 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 Props = {| userInfo: UserListItem, onSelect: (userID: string) => void, textStyle?: TextStyle, // Redux state colors: Colors, styles: typeof styles, |}; class UserListUser extends React.PureComponent { static propTypes = { userInfo: userListItemPropType.isRequired, onSelect: PropTypes.func.isRequired, textStyle: Text.propTypes.style, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { - let parentThreadNotice = null; - if (!this.props.userInfo.memberOfParentThread) { - parentThreadNotice = ( - - not in parent thread - - ); + const { userInfo } = this.props; + let notice = null; + if (userInfo.notice) { + notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { this.props.onSelect(this.props.userInfo.id); }; } const styles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, - parentThreadNotice: { + notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; const stylesSelector = styleSelector(styles); const WrappedUserListUser = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(UserListUser); export { WrappedUserListUser as UserListUser, getUserListItemHeight }; diff --git a/native/more/more.react.js b/native/more/more.react.js index 08282fcd1..5d563b9c9 100644 --- a/native/more/more.react.js +++ b/native/more/more.react.js @@ -1,124 +1,140 @@ // @flow import type { StackNavigationProp } from '@react-navigation/stack'; import * as React from 'react'; import { createStackNavigator } from '@react-navigation/stack'; import MoreScreen from './more-screen.react'; import EditEmail from './edit-email.react'; import EditPassword from './edit-password.react'; import DeleteAccount from './delete-account.react'; import BuildInfo from './build-info.react'; import DevTools from './dev-tools.react'; import AppearancePreferences from './appearance-preferences.react'; import RelationshipList from './relationship-list.react'; import RelationshipListAddButton from './relationship-list-add-button.react'; import { MoreScreenRouteName, EditEmailRouteName, EditPasswordRouteName, DeleteAccountRouteName, BuildInfoRouteName, DevToolsRouteName, AppearancePreferencesRouteName, FriendListRouteName, - AddFriendsModalRouteName, + RelationshipUpdateModalRouteName, BlockListRouteName, type ScreenParamList, type MoreParamList, } from '../navigation/route-names'; import MoreHeader from './more-header.react'; import HeaderBackButton from '../navigation/header-back-button.react'; const header = props => ; const headerBackButton = props => ; const screenOptions = { header, headerLeft: headerBackButton, }; const moreScreenOptions = { headerTitle: 'More' }; const editEmailOptions = { headerTitle: 'Change email' }; const editPasswordOptions = { headerTitle: 'Change password' }; const deleteAccountOptions = { headerTitle: 'Delete account' }; const buildInfoOptions = { headerTitle: 'Build info' }; const devToolsOptions = { headerTitle: 'Developer tools' }; const appearanceOptions = { headerTitle: 'Appearance' }; const friendListOptions = ({ navigation }) => ({ headerTitle: 'Friend list', // eslint-disable-next-line react/display-name headerRight: () => ( { - navigation.navigate({ name: AddFriendsModalRouteName }); + navigation.navigate({ + name: RelationshipUpdateModalRouteName, + params: { + target: 'friends', + }, + }); }} /> ), headerBackTitle: 'Back', }); -const blockListOptions = { +const blockListOptions = ({ navigation }) => ({ headerTitle: 'Block list', // eslint-disable-next-line react/display-name - headerRight: () => {}} />, + headerRight: () => ( + { + navigation.navigate({ + name: RelationshipUpdateModalRouteName, + params: { + target: 'blocked', + }, + }); + }} + /> + ), headerBackTitle: 'Back', -}; +}); export type MoreNavigationProp< RouteName: $Keys = $Keys, > = StackNavigationProp; const More = createStackNavigator< ScreenParamList, MoreParamList, MoreNavigationProp<>, >(); const MoreComponent = () => ( ); export default MoreComponent; diff --git a/native/more/relationship-list-item-tooltip-modal.react.js b/native/more/relationship-list-item-tooltip-modal.react.js new file mode 100644 index 000000000..a252e9591 --- /dev/null +++ b/native/more/relationship-list-item-tooltip-modal.react.js @@ -0,0 +1,106 @@ +// @flow + +import type { + DispatchFunctions, + ActionFunc, + BoundServerCall, +} from 'lib/utils/action-utils'; +import type { RelativeUserInfo } from 'lib/types/user-types'; +import type { AppNavigationProp } from '../navigation/app-navigator.react'; + +import * as React from 'react'; +import { Alert, TouchableOpacity } from 'react-native'; + +import { stringForUser } from 'lib/shared/user-utils'; +import { + updateRelationshipsActionTypes, + updateRelationships, +} from 'lib/actions/relationship-actions'; + +import { createTooltip, type TooltipParams } from '../navigation/tooltip.react'; +import PencilIcon from '../components/pencil-icon.react'; + +type Action = 'unfriend' | 'unblock'; + +type CustomProps = { + relativeUserInfo: RelativeUserInfo, + action: Action, +}; + +export type RelationshipListItemTooltipModalParams = TooltipParams< + $Diff, +>; + +function onRemoveUser( + props: CustomProps, + dispatchFunctions: DispatchFunctions, + bindServerCall: (serverCall: ActionFunc) => BoundServerCall, +) { + const boundRemoveRelationships = bindServerCall(updateRelationships); + const onConfirmRemoveUser = () => { + const customKeyName = `${updateRelationshipsActionTypes.started}:${props.relativeUserInfo.id}`; + dispatchFunctions.dispatchActionPromise( + updateRelationshipsActionTypes, + boundRemoveRelationships({ + action: props.action, + userIDs: [props.relativeUserInfo.id], + }), + { customKeyName }, + ); + }; + + const userText = stringForUser(props.relativeUserInfo); + const action = { + unfriend: 'removal', + unblock: 'unblock', + }[props.action]; + const message = { + unfriend: `remove ${userText} from friends?`, + unblock: `unblock ${userText}?`, + }[props.action]; + Alert.alert(`Confirm ${action}`, `Are you sure you want to ${message}`, [ + { text: 'Cancel', style: 'cancel' }, + { text: 'OK', onPress: onConfirmRemoveUser }, + ]); +} + +const spec = { + entries: [ + { + id: 'unfriend', + text: 'Unfriend', + onPress: (props, ...rest) => + onRemoveUser({ ...props, action: 'unfriend' }, ...rest), + }, + { + id: 'unblock', + text: 'Unblock', + onPress: (props, ...rest) => + onRemoveUser({ ...props, action: 'unblock' }, ...rest), + }, + ], +}; + +type Props = { + navigation: AppNavigationProp<'RelationshipListItemTooltipModal'>, +}; +class RelationshipListItemTooltipButton extends React.PureComponent { + render() { + return ( + + + + ); + } + + onPress = () => { + this.props.navigation.goBackOnce(); + }; +} + +const RelationshipListItemTooltipModal = createTooltip( + RelationshipListItemTooltipButton, + spec, +); + +export default RelationshipListItemTooltipModal; diff --git a/native/more/relationship-list-item.react.js b/native/more/relationship-list-item.react.js index c063a603e..6988992db 100644 --- a/native/more/relationship-list-item.react.js +++ b/native/more/relationship-list-item.react.js @@ -1,81 +1,261 @@ // @flow +import type { LoadingStatus } from 'lib/types/loading-types'; +import type { DispatchActionPromise } from 'lib/utils/action-utils'; +import { + type RelationshipRequest, + userRelationshipStatus, + relationshipActions, +} from 'lib/types/relationship-types'; +import type { NavigationRoute } from '../navigation/route-names'; +import type { VerticalBounds } from '../types/layout-types'; import type { AppState } from '../redux/redux-setup'; +import type { RelationshipListNavigate } from './relationship-list.react'; import * as React from 'react'; -import { View, TouchableOpacity } from 'react-native'; +import { + Alert, + View, + Text, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import invariant from 'invariant'; import { connect } from 'lib/utils/redux-utils'; import { type UserInfo } from 'lib/types/user-types'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { + updateRelationshipsActionTypes, + updateRelationships, +} from 'lib/actions/relationship-actions'; import PencilIcon from '../components/pencil-icon.react'; -import { type Colors, colorsSelector, styleSelector } from '../themes/colors'; import { SingleLine } from '../components/single-line.react'; +import { + withOverlayContext, + type OverlayContextType, +} from '../navigation/overlay-context'; +import { RelationshipListItemTooltipModalRouteName } from '../navigation/route-names'; +import { type Colors, colorsSelector, styleSelector } from '../themes/colors'; type Props = {| userInfo: UserInfo, lastListItem: boolean, + verticalBounds: ?VerticalBounds, + relationshipListRouteKey: string, + relationshipListRouteName: $PropertyType< + NavigationRoute<'FriendList' | 'BlockList'>, + 'name', + >, + navigate: RelationshipListNavigate, // Redux state + removeUserLoadingStatus: LoadingStatus, colors: Colors, styles: typeof styles, + // Redux dispatch functions + dispatchActionPromise: DispatchActionPromise, + // withOverlayContext + overlayContext: ?OverlayContextType, + // async functions that hit server APIs + updateRelationships: (request: RelationshipRequest) => Promise, |}; class RelationshipListItem extends React.PureComponent { editButton = React.createRef>(); render() { - const borderBottom = this.props.lastListItem ? null : styles.borderBottom; + const { lastListItem, removeUserLoadingStatus, userInfo } = this.props; + const borderBottom = lastListItem ? null : styles.borderBottom; + + let editButton = null; + if (removeUserLoadingStatus === 'loading') { + editButton = ( + + ); + } else if ( + userInfo.relationshipStatus === userRelationshipStatus.FRIEND || + userInfo.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER + ) { + editButton = ( + + + + + + ); + } else if ( + userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED + ) { + editButton = ( + + Accept + + ); + } else if ( + userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT + ) { + editButton = ( + + Cancel request + + ); + } return ( {this.props.userInfo.username} - - - - - + {editButton} ); } - onPressEdit = () => {}; + visibleEntryIDs() { + const { relationshipListRouteName } = this.props; + const id = { + FriendList: 'unfriend', + BlockList: 'unblock', + }[relationshipListRouteName]; + return [id]; + } + + onPressEdit = () => { + const { + editButton, + props: { verticalBounds }, + } = this; + const { overlayContext, userInfo } = this.props; + invariant( + overlayContext, + 'RelationshipListItem should have OverlayContext', + ); + overlayContext.setScrollBlockingModalStatus('open'); + + if (!editButton.current || !verticalBounds) { + return; + } + const { relationshipStatus, ...restUserInfo } = userInfo; + const relativeUserInfo = { + ...restUserInfo, + isViewer: false, + }; + editButton.current.measure((x, y, width, height, pageX, pageY) => { + const coordinates = { x: pageX, y: pageY, width, height }; + this.props.navigate({ + name: RelationshipListItemTooltipModalRouteName, + params: { + presentedFrom: this.props.relationshipListRouteKey, + initialCoordinates: coordinates, + verticalBounds, + visibleEntryIDs: this.visibleEntryIDs(), + relativeUserInfo, + }, + }); + }); + }; + + // We need to set onLayout in order to allow .measure() to be on the ref + onLayout = () => {}; + + onPressUpdateFriendship = () => { + const { id } = this.props.userInfo; + const customKeyName = `${updateRelationshipsActionTypes.started}:${id}`; + this.props.dispatchActionPromise( + updateRelationshipsActionTypes, + this.updateFriendship(), + { customKeyName }, + ); + }; + + get updateFriendshipAction() { + const { userInfo } = this.props; + if ( + userInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED + ) { + return relationshipActions.FRIEND; + } else if ( + userInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT + ) { + return relationshipActions.UNFRIEND; + } else { + return undefined; + } + } + + async updateFriendship() { + try { + const action = this.updateFriendshipAction; + invariant(action, 'invalid relationshipAction'); + const result = await this.props.updateRelationships({ + action, + userIDs: [this.props.userInfo.id], + }); + return result; + } catch (e) { + Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); + throw e; + } + } } const styles = { editButton: { paddingLeft: 10, }, container: { flex: 1, paddingHorizontal: 12, backgroundColor: 'panelForeground', }, innerContainer: { paddingVertical: 10, paddingHorizontal: 12, borderColor: 'panelForegroundBorder', flexDirection: 'row', }, borderBottom: { borderBottomWidth: 1, }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, }, + accept: { + color: 'link', + fontSize: 16, + paddingLeft: 6, + }, + cancel: { + color: 'redText', + fontSize: 16, + paddingLeft: 6, + }, }; const stylesSelector = styleSelector(styles); -export default connect((state: AppState) => ({ - colors: colorsSelector(state), - styles: stylesSelector(state), -}))(RelationshipListItem); +export default connect( + (state: AppState, ownProps: { userInfo: UserInfo }) => ({ + removeUserLoadingStatus: createLoadingStatusSelector( + updateRelationshipsActionTypes, + `${updateRelationshipsActionTypes.started}:${ownProps.userInfo.id}`, + )(state), + colors: colorsSelector(state), + styles: stylesSelector(state), + }), + { updateRelationships }, +)(withOverlayContext(RelationshipListItem)); diff --git a/native/more/relationship-list.react.js b/native/more/relationship-list.react.js index f69d2829a..565fa3e5e 100644 --- a/native/more/relationship-list.react.js +++ b/native/more/relationship-list.react.js @@ -1,130 +1,228 @@ // @flow +import type { UserInfo } from 'lib/types/user-types'; +import type { UserRelationships } from 'lib/types/relationship-types'; +import type { VerticalBounds } from '../types/layout-types'; import type { NavigationRoute } from '../navigation/route-names'; import type { AppState } from '../redux/redux-setup'; import type { MoreNavigationProp } from './more.react'; import * as React from 'react'; import { View, Text, FlatList } from 'react-native'; import invariant from 'invariant'; +import { createSelector } from 'reselect'; import { connect } from 'lib/utils/redux-utils'; -import { type UserInfo } from 'lib/types/user-types'; +import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors'; -import { AddFriendsModalRouteName } from '../navigation/route-names'; +import { + withOverlayContext, + type OverlayContextType, +} from '../navigation/overlay-context'; import { styleSelector, type IndicatorStyle, indicatorStyleSelector, } from '../themes/colors'; import RelationshipListItem from './relationship-list-item.react'; -const DATA: UserInfo[] = [ - { id: '1', username: 'John' }, - { id: '2', username: 'Emma' }, -]; - -const mapListData = (users: UserInfo[]): ListItem[] => { - const isEmpty = !users.length; - - const mapUser = (userInfo, index) => ({ - type: 'user', - userInfo, - lastListItem: users.length - 1 === index, - }); - - return [] - .concat(isEmpty ? { type: 'empty' } : []) - .concat(isEmpty ? [] : { type: 'header' }) - .concat(users.map(mapUser)) - .concat(isEmpty ? [] : { type: 'header' }); -}; - -const LIST_DATA = mapListData(DATA); +export type RelationshipListNavigate = $PropertyType< + MoreNavigationProp<'FriendList' | 'BlockList'>, + 'navigate', +>; type ListItem = | {| type: 'empty' |} | {| type: 'header' |} | {| type: 'footer' |} - | {| type: 'user', userInfo: UserInfo, lastListItem: boolean |}; + | {| + type: 'user', + userInfo: UserInfo, + lastListItem: boolean, + verticalBounds: ?VerticalBounds, + |}; type Props = {| navigation: MoreNavigationProp<>, route: NavigationRoute<'FriendList' | 'BlockList'>, + verticalBounds: ?VerticalBounds, // Redux state + relationships: UserRelationships, styles: typeof styles, indicatorStyle: IndicatorStyle, + // withOverlayContext + overlayContext: ?OverlayContextType, |}; -class RelationshipList extends React.PureComponent { +type State = {| + verticalBounds: ?VerticalBounds, +|}; +type PropsAndState = {| ...Props, ...State |}; +class RelationshipList extends React.PureComponent { + flatListContainerRef = React.createRef(); + + state = { + verticalBounds: null, + }; + + listDataSelector = createSelector( + (propsAndState: PropsAndState) => propsAndState.relationships, + (propsAndState: PropsAndState) => propsAndState.route.name, + (propsAndState: PropsAndState) => propsAndState.verticalBounds, + ( + relationships: UserRelationships, + routeName: $ElementType< + NavigationRoute<'FriendList' | 'BlockList'>, + 'name', + >, + verticalBounds: ?VerticalBounds, + ) => { + const users = { + FriendList: relationships.friends, + BlockList: relationships.blocked, + }[routeName]; + const isEmpty = !users.length; + + const mapUser = (userInfo, index) => ({ + type: 'user', + userInfo, + lastListItem: users.length - 1 === index, + verticalBounds, + }); + + return [] + .concat(isEmpty ? { type: 'empty' } : []) + .concat(isEmpty ? [] : { type: 'header' }) + .concat(users.map(mapUser)) + .concat(isEmpty ? [] : { type: 'footer' }); + }, + ); + + 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 { + return 'search'; + } + } + + 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() { return ( - + ); } + onFlatListContainerLayout = () => { + const { flatListContainerRef } = this; + if (!flatListContainerRef.current) { + 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 } }); + }, + ); + }; + renderItem = ({ item }: { item: ListItem }) => { if (item.type === 'empty') { + const action = { + FriendList: 'added', + BlockList: 'blocked', + }[this.props.route.name]; + return ( {`You haven't added any users yet`} + >{`You haven't ${action} any users yet`} ); } else if (item.type === 'header' || item.type === 'footer') { return ; } else if (item.type === 'user') { return ( ); } else { invariant(false, `unexpected RelationshipList item type ${item.type}`); } }; - - onPressAddFriends = () => { - this.props.navigation.navigate({ - name: AddFriendsModalRouteName, - }); - }; } const styles = { container: { flex: 1, backgroundColor: 'panelBackground', }, contentContainer: { marginTop: 24, }, separator: { backgroundColor: 'panelForegroundBorder', height: 1, }, emptyText: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 12, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ + relationships: userRelationshipsSelector(state), styles: stylesSelector(state), indicatorStyle: indicatorStyleSelector(state), -}))(RelationshipList); +}))(withOverlayContext(RelationshipList)); diff --git a/native/more/add-friends-modal.react.js b/native/more/relationship-update-modal.react.js similarity index 58% rename from native/more/add-friends-modal.react.js rename to native/more/relationship-update-modal.react.js index 74865c0b1..cf8a75e47 100644 --- a/native/more/add-friends-modal.react.js +++ b/native/more/relationship-update-modal.react.js @@ -1,230 +1,336 @@ // @flow import type { AccountUserInfo } from 'lib/types/user-types'; import type { UserSearchResult } from 'lib/types/search-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; +import { + type RelationshipRequest, + userRelationshipStatus, + relationshipActions, +} from 'lib/types/relationship-types'; import type { RootNavigationProp } from '../navigation/root-navigator.react'; import type { NavigationRoute } from '../navigation/route-names'; import type { AppState } from '../redux/redux-setup'; import * as React from 'react'; -import { Text, View } from 'react-native'; +import { Text, View, Alert } from 'react-native'; import { CommonActions } from '@react-navigation/native'; import { createSelector } from 'reselect'; import _keyBy from 'lodash/fp/keyBy'; +import invariant from 'invariant'; import { searchIndexFromUserInfos } from 'lib/selectors/user-selectors'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { getUserSearchResults } from 'lib/shared/search-utils'; import { connect } from 'lib/utils/redux-utils'; +import { values } from 'lib/utils/objects'; import { searchUsersActionTypes, searchUsers } from 'lib/actions/user-actions'; +import { + updateRelationshipsActionTypes, + updateRelationships, +} from 'lib/actions/relationship-actions'; import UserList from '../components/user-list.react'; import Modal from '../components/modal.react'; import Button from '../components/button.react'; import TagInput from '../components/tag-input.react'; import { styleSelector } from '../themes/colors'; const tagInputProps = { - placeholder: 'Select users to invite', autoFocus: true, returnKeyType: 'go', }; +export type RelationshipUpdateModalParams = {| + +target: 'friends' | 'blocked', +|}; + type Props = {| - navigation: RootNavigationProp<'AddFriendsModal'>, - route: NavigationRoute<'AddFriendsModal'>, + navigation: RootNavigationProp<'RelationshipUpdateModal'>, + route: NavigationRoute<'RelationshipUpdateModal'>, // Redux state + userInfos: { [id: string]: AccountUserInfo }, viewerID: ?string, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs searchUsers: (usernamePrefix: string) => Promise, - sendFriendRequest: (userIDs: string[]) => Promise, + updateRelationships: (request: RelationshipRequest) => Promise, |}; type State = {| usernameInputText: string, userInfoInputArray: $ReadOnlyArray, - userInfos: { [id: string]: AccountUserInfo }, + searchUserInfos: { [id: string]: AccountUserInfo }, |}; type PropsAndState = {| ...Props, ...State |}; -class AddFriendsModal extends React.PureComponent { +class RelationshipUpdateModal extends React.PureComponent { state = { usernameInputText: '', userInfoInputArray: [], - userInfos: {}, + searchUserInfos: {}, }; tagInput: ?TagInput = null; componentDidMount() { this.searchUsers(''); } async searchUsers(usernamePrefix: string) { const { userInfos } = await this.props.searchUsers(usernamePrefix); - this.setState({ userInfos: _keyBy(userInfo => userInfo.id)(userInfos) }); + this.setState({ + searchUserInfos: _keyBy(userInfo => userInfo.id)(userInfos), + }); } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, + (propsAndState: PropsAndState) => propsAndState.searchUserInfos, (propsAndState: PropsAndState) => propsAndState.userInfos, (propsAndState: PropsAndState) => propsAndState.viewerID, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( text: string, + searchUserInfos: { [id: string]: AccountUserInfo }, userInfos: { [id: string]: AccountUserInfo }, viewerID: ?string, userInfoInputArray: $ReadOnlyArray, ) => { + const { target } = this.props.route.params; + const excludeStatuses = []; + if (target === 'friends') { + excludeStatuses.push(userRelationshipStatus.BLOCKED_VIEWER); + excludeStatuses.push(userRelationshipStatus.BOTH_BLOCKED); + } + const excludeBlockedAndFriendIDs = values(searchUserInfos) + .filter(searchUserInfo => { + const userInfo = userInfos[searchUserInfo.id]; + return ( + userInfo && excludeStatuses.includes(userInfo.relationshipStatus) + ); + }) + .map(userInfo => userInfo.id); const excludeUserIDs = userInfoInputArray .map(userInfo => userInfo.id) - .concat(viewerID || []); - const searchIndex = searchIndexFromUserInfos(userInfos); - return getUserSearchResults(text, userInfos, searchIndex, excludeUserIDs); + .concat(viewerID || []) + .concat(excludeBlockedAndFriendIDs); + const searchIndex = searchIndexFromUserInfos(searchUserInfos); + const results = getUserSearchResults( + text, + searchUserInfos, + searchIndex, + excludeUserIDs, + ); + return results.map(result => { + const userInfo = userInfos[result.id]; + const disabledFriends = + userInfo && + userInfo.relationshipStatus === userRelationshipStatus.FRIEND; + if (target === 'friends' && disabledFriends) { + return { ...result, disabled: true, notice: 'Already friends' }; + } + const disabledBlocked = + userInfo && + (userInfo.relationshipStatus === + userRelationshipStatus.BLOCKED_BY_VIEWER || + userInfo.relationshipStatus === + userRelationshipStatus.BOTH_BLOCKED); + if (target === 'blocked' && disabledBlocked) { + return { ...result, disabled: true, notice: 'Already blocked' }; + } + return result; + }); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } render() { let addButton = null; const inputLength = this.state.userInfoInputArray.length; if (inputLength > 0) { const addButtonText = `Add (${inputLength})`; addButton = ( ); } + const { target } = this.props.route.params; + const action = { friends: 'invite', blocked: 'block' }[target]; const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, + placeholder: `Select users to ${action}`, }; return ( {addButton} ); } tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.searchUsers(text); this.setState({ usernameInputText: text }); }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; onUserSelect = (userID: string) => { if (this.state.userInfoInputArray.find(o => o.id === userID)) { return; } - const selectedUserInfo = this.state.userInfos[userID]; + const selectedUserInfo = this.state.searchUserInfos[userID]; this.setState(state => ({ userInfoInputArray: state.userInfoInputArray.concat(selectedUserInfo), usernameInputText: '', })); }; onPressAdd = () => { if (this.state.userInfoInputArray.length === 0) { return; } + this.props.dispatchActionPromise( + updateRelationshipsActionTypes, + this.updateRelationships(), + ); + }; - this.props.navigation.goBack(); + async updateRelationships() { + try { + const { target } = this.props.route.params; + const action = { + friends: relationshipActions.FRIEND, + blocked: relationshipActions.BLOCK, + }[target]; + const userIDs = this.state.userInfoInputArray.map( + userInfo => userInfo.id, + ); + const result = await this.props.updateRelationships({ + action, + userIDs, + }); + 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, 'tagInput should be set'); + this.tagInput.focus(); + }; + + onUnknownErrorAlertAcknowledged = () => { + this.setState( + { + userInfoInputArray: [], + usernameInputText: '', + }, + this.onErrorAcknowledged, + ); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } close = () => { this.goBackOnce(); }; } const styles = { 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, }, }; const stylesSelector = styleSelector(styles); registerFetchKey(searchUsersActionTypes); export default connect( (state: AppState) => { return { viewerID: state.currentUserInfo && state.currentUserInfo.id, + userInfos: state.userStore.userInfos, styles: stylesSelector(state), }; }, - { searchUsers }, -)(AddFriendsModal); + { searchUsers, updateRelationships }, +)(RelationshipUpdateModal); diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js index c01912f6a..3c41b4eab 100644 --- a/native/navigation/app-navigator.react.js +++ b/native/navigation/app-navigator.react.js @@ -1,193 +1,199 @@ // @flow import type { OverlayRouterNavigationProp } from './overlay-router'; import type { RootNavigationProp } from './root-navigator.react'; import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; import * as React from 'react'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { PersistGate } from 'redux-persist/integration/react'; import * as SplashScreen from 'expo-splash-screen'; import Icon from 'react-native-vector-icons/FontAwesome'; import { CalendarRouteName, ChatRouteName, MoreRouteName, TabNavigatorRouteName, MultimediaModalRouteName, MultimediaTooltipModalRouteName, ActionResultModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, + RelationshipListItemTooltipModalRouteName, CameraModalRouteName, type ScreenParamList, type TabParamList, type OverlayParamList, } from './route-names'; import Calendar from '../calendar/calendar.react'; import Chat from '../chat/chat.react'; import More from '../more/more.react'; import { tabBar } from './tab-bar.react'; import { createOverlayNavigator } from './overlay-navigator.react'; import MultimediaModal from '../media/multimedia-modal.react'; import { MultimediaTooltipModal } from '../chat/multimedia-tooltip-modal.react'; import ActionResultModal from './action-result-modal.react'; import { TextMessageTooltipModal } from '../chat/text-message-tooltip-modal.react'; import ThreadSettingsMemberTooltipModal from '../chat/settings/thread-settings-member-tooltip-modal.react'; +import RelationshipListItemTooltipModal from '../more/relationship-list-item-tooltip-modal.react'; import CameraModal from '../media/camera-modal.react'; import KeyboardStateContainer from '../keyboard/keyboard-state-container.react'; import PushHandler from '../push/push-handler.react'; import { getPersistor } from '../redux/persist'; import { RootContext } from '../root-context'; import { waitForInteractions } from '../utils/interactions'; import ChatIcon from '../chat/chat-icon.react'; let splashScreenHasHidden = false; const calendarTabOptions = { tabBarLabel: 'Calendar', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), }; const chatTabOptions = { tabBarLabel: 'Chat', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => , }; const moreTabOptions = { tabBarLabel: 'More', // eslint-disable-next-line react/display-name tabBarIcon: ({ color }) => ( ), }; export type TabNavigationProp< RouteName: $Keys = $Keys, > = BottomTabNavigationProp; const Tab = createBottomTabNavigator< ScreenParamList, TabParamList, TabNavigationProp<>, >(); const tabBarOptions = { keyboardHidesTabBar: false }; function TabNavigator() { return ( ); } export type AppNavigationProp< RouteName: $Keys = $Keys, > = OverlayRouterNavigationProp; const App = createOverlayNavigator< ScreenParamList, OverlayParamList, AppNavigationProp<>, >(); type AppNavigatorProps = { navigation: RootNavigationProp<'App'>, }; function AppNavigator(props: AppNavigatorProps) { const { navigation } = props; const rootContext = React.useContext(RootContext); const setNavStateInitialized = rootContext && rootContext.setNavStateInitialized; React.useEffect(() => { setNavStateInitialized && setNavStateInitialized(); }, [setNavStateInitialized]); const [ localSplashScreenHasHidden, setLocalSplashScreenHasHidden, ] = React.useState(splashScreenHasHidden); React.useEffect(() => { if (localSplashScreenHasHidden) { return; } splashScreenHasHidden = true; (async () => { await waitForInteractions(); try { await SplashScreen.hideAsync(); setLocalSplashScreenHasHidden(true); } catch {} })(); }, [localSplashScreenHasHidden]); let pushHandler; if (localSplashScreenHasHidden) { pushHandler = ( ); } return ( + {pushHandler} ); } const styles = { icon: { fontSize: 28, }, }; export default AppNavigator; diff --git a/native/navigation/root-navigator.react.js b/native/navigation/root-navigator.react.js index 0b24b8f52..53c0b2251 100644 --- a/native/navigation/root-navigator.react.js +++ b/native/navigation/root-navigator.react.js @@ -1,190 +1,190 @@ // @flow import * as React from 'react'; import { Platform } from 'react-native'; import { enableScreens } from 'react-native-screens'; import { createNavigatorFactory, useNavigationBuilder, type StackNavigationState, type StackOptions, type StackNavigationEventMap, type StackNavigatorProps, type ExtraStackNavigatorProps, } from '@react-navigation/native'; import { StackView, TransitionPresets } from '@react-navigation/stack'; import { LoggedOutModalRouteName, VerificationModalRouteName, AppRouteName, ThreadPickerModalRouteName, AddUsersModalRouteName, CustomServerModalRouteName, ColorPickerModalRouteName, ComposeSubthreadModalRouteName, - AddFriendsModalRouteName, + RelationshipUpdateModalRouteName, type ScreenParamList, type RootParamList, } from './route-names'; import LoggedOutModal from '../account/logged-out-modal.react'; import VerificationModal from '../account/verification-modal.react'; import AppNavigator from './app-navigator.react'; import ThreadPickerModal from '../calendar/thread-picker-modal.react'; import AddUsersModal from '../chat/settings/add-users-modal.react'; import CustomServerModal from '../more/custom-server-modal.react'; import ColorPickerModal from '../chat/settings/color-picker-modal.react'; -import AddFriendsModal from '../more/add-friends-modal.react'; +import RelationshipUpdateModal from '../more/relationship-update-modal.react'; import ComposeSubthreadModal from '../chat/settings/compose-subthread-modal.react'; import RootRouter, { type RootRouterNavigationProp } from './root-router'; import { RootNavigatorContext } from './root-navigator-context'; if (Platform.OS !== 'android' || Platform.Version >= 21) { // Older Android devices get stack overflows when trying to draw deeply nested // view structures. We've tried to get our draw depth down as much as possible // without going into React Navigation internals or creating a separate render // path for these old Android devices. Because react-native-screens increases // the draw depth enough to cause crashes in some scenarios, we disable it // here for those devices enableScreens(); } type RootNavigatorProps = StackNavigatorProps>; function RootNavigator({ initialRouteName, children, screenOptions, ...rest }: RootNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder(RootRouter, { initialRouteName, children, screenOptions, }); const [keyboardHandlingEnabled, setKeyboardHandlingEnabled] = React.useState( true, ); const rootNavigationContext = React.useMemo( () => ({ setKeyboardHandlingEnabled }), [setKeyboardHandlingEnabled], ); return ( ); } const createRootNavigator = createNavigatorFactory< StackNavigationState, StackOptions, StackNavigationEventMap, RootRouterNavigationProp<>, ExtraStackNavigatorProps, >(RootNavigator); const baseTransitionPreset = Platform.select({ ios: TransitionPresets.ModalSlideFromBottomIOS, default: TransitionPresets.FadeFromBottomAndroid, }); const transitionPreset = { ...baseTransitionPreset, cardStyleInterpolator: interpolatorProps => { const baseCardStyleInterpolator = baseTransitionPreset.cardStyleInterpolator( interpolatorProps, ); const overlayOpacity = interpolatorProps.current.progress.interpolate({ inputRange: [0, 1], outputRange: ([0, 0.7]: number[]), // Flow... extrapolate: 'clamp', }); return { ...baseCardStyleInterpolator, overlayStyle: [ baseCardStyleInterpolator.overlayStyle, { opacity: overlayOpacity }, ], }; }, }; const defaultScreenOptions = { gestureEnabled: Platform.OS === 'ios', animationEnabled: Platform.OS !== 'web', cardStyle: { backgroundColor: 'transparent' }, ...transitionPreset, }; const disableGesturesScreenOptions = { gestureEnabled: false, }; const modalOverlayScreenOptions = { cardOverlayEnabled: true, }; export type RootNavigationProp< RouteName: $Keys = $Keys, > = RootRouterNavigationProp; const Root = createRootNavigator< ScreenParamList, RootParamList, RootNavigationProp<>, >(); const RootComponent = () => { return ( ); }; export default RootComponent; diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js index 4e4c75cfa..6b65ec735 100644 --- a/native/navigation/route-names.js +++ b/native/navigation/route-names.js @@ -1,152 +1,158 @@ // @flow import type { LeafRoute } from '@react-navigation/native'; import type { VerificationModalParams } from '../account/verification-modal.react'; import type { ThreadPickerModalParams } from '../calendar/thread-picker-modal.react'; import type { AddUsersModalParams } from '../chat/settings/add-users-modal.react'; +import type { RelationshipUpdateModalParams } from '../more/relationship-update-modal.react'; +import type { RelationshipListItemTooltipModalParams } from '../more/relationship-list-item-tooltip-modal.react'; import type { CustomServerModalParams } from '../more/custom-server-modal.react'; import type { ColorPickerModalParams } from '../chat/settings/color-picker-modal.react'; import type { ComposeSubthreadModalParams } from '../chat/settings/compose-subthread-modal.react'; import type { MultimediaModalParams } from '../media/multimedia-modal.react'; import type { MultimediaTooltipModalParams } from '../chat/multimedia-tooltip-modal.react'; import type { ActionResultModalParams } from './action-result-modal.react'; import type { TextMessageTooltipModalParams } from '../chat/text-message-tooltip-modal.react'; import type { ThreadSettingsMemberTooltipModalParams } from '../chat/settings/thread-settings-member-tooltip-modal.react'; import type { CameraModalParams } from '../media/camera-modal.react'; import type { ComposeThreadParams } from '../chat/compose-thread.react'; import type { ThreadSettingsParams } from '../chat/settings/thread-settings.react'; import type { DeleteThreadParams } from '../chat/settings/delete-thread.react'; import type { MessageListParams } from '../chat/message-list-types'; export const AppRouteName = 'App'; export const TabNavigatorRouteName = 'TabNavigator'; export const ComposeThreadRouteName = 'ComposeThread'; export const DeleteThreadRouteName = 'DeleteThread'; export const ThreadSettingsRouteName = 'ThreadSettings'; export const MessageListRouteName = 'MessageList'; export const VerificationModalRouteName = 'VerificationModal'; export const LoggedOutModalRouteName = 'LoggedOutModal'; export const MoreRouteName = 'More'; export const MoreScreenRouteName = 'MoreScreen'; +export const RelationshipListItemTooltipModalRouteName = + 'RelationshipListItemTooltipModal'; export const ChatRouteName = 'Chat'; export const ChatThreadListRouteName = 'ChatThreadList'; export const HomeChatThreadListRouteName = 'HomeChatThreadList'; export const BackgroundChatThreadListRouteName = 'BackgroundChatThreadList'; export const CalendarRouteName = 'Calendar'; export const BuildInfoRouteName = 'BuildInfo'; export const DeleteAccountRouteName = 'DeleteAccount'; export const DevToolsRouteName = 'DevTools'; export const EditEmailRouteName = 'EditEmail'; export const EditPasswordRouteName = 'EditPassword'; export const AppearancePreferencesRouteName = 'AppearancePreferences'; export const ThreadPickerModalRouteName = 'ThreadPickerModal'; export const AddUsersModalRouteName = 'AddUsersModal'; export const CustomServerModalRouteName = 'CustomServerModal'; export const ColorPickerModalRouteName = 'ColorPickerModal'; export const ComposeSubthreadModalRouteName = 'ComposeSubthreadModal'; export const MultimediaModalRouteName = 'MultimediaModal'; export const MultimediaTooltipModalRouteName = 'MultimediaTooltipModal'; export const ActionResultModalRouteName = 'ActionResultModal'; export const TextMessageTooltipModalRouteName = 'TextMessageTooltipModal'; export const ThreadSettingsMemberTooltipModalRouteName = 'ThreadSettingsMemberTooltipModal'; export const CameraModalRouteName = 'CameraModal'; export const FriendListRouteName = 'FriendList'; export const BlockListRouteName = 'BlockList'; -export const AddFriendsModalRouteName = 'AddFriendsModal'; +export const RelationshipUpdateModalRouteName = 'RelationshipUpdateModal'; export type RootParamList = {| LoggedOutModal: void, VerificationModal: VerificationModalParams, App: void, ThreadPickerModal: ThreadPickerModalParams, AddUsersModal: AddUsersModalParams, CustomServerModal: CustomServerModalParams, ColorPickerModal: ColorPickerModalParams, ComposeSubthreadModal: ComposeSubthreadModalParams, - AddFriendsModal: void, + RelationshipUpdateModal: RelationshipUpdateModalParams, |}; export type TooltipModalParamList = {| MultimediaTooltipModal: MultimediaTooltipModalParams, TextMessageTooltipModal: TextMessageTooltipModalParams, ThreadSettingsMemberTooltipModal: ThreadSettingsMemberTooltipModalParams, + RelationshipListItemTooltipModal: RelationshipListItemTooltipModalParams, |}; export type OverlayParamList = {| TabNavigator: void, MultimediaModal: MultimediaModalParams, ActionResultModal: ActionResultModalParams, CameraModal: CameraModalParams, ...TooltipModalParamList, |}; export type TabParamList = {| Calendar: void, Chat: void, More: void, |}; export type ChatParamList = {| ChatThreadList: void, MessageList: MessageListParams, ComposeThread: ComposeThreadParams, ThreadSettings: ThreadSettingsParams, DeleteThread: DeleteThreadParams, |}; export type ChatTopTabsParamList = {| HomeChatThreadList: void, BackgroundChatThreadList: void, |}; export type MoreParamList = {| MoreScreen: void, EditEmail: void, EditPassword: void, DeleteAccount: void, BuildInfo: void, DevTools: void, AppearancePreferences: void, FriendList: void, BlockList: void, |}; export type ScreenParamList = {| ...RootParamList, ...OverlayParamList, ...TabParamList, ...ChatParamList, ...ChatTopTabsParamList, ...MoreParamList, |}; export type NavigationRoute> = {| ...LeafRoute, +params: $ElementType, |}; export const accountModals = [ LoggedOutModalRouteName, VerificationModalRouteName, ]; export const scrollBlockingChatModals = [ MultimediaModalRouteName, MultimediaTooltipModalRouteName, TextMessageTooltipModalRouteName, ThreadSettingsMemberTooltipModalRouteName, + RelationshipListItemTooltipModalRouteName, ]; export const chatRootModals = [ AddUsersModalRouteName, ColorPickerModalRouteName, ComposeSubthreadModalRouteName, ]; export const threadRoutes = [ MessageListRouteName, ThreadSettingsRouteName, DeleteThreadRouteName, ComposeThreadRouteName, ];