diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index a7360264e..b3f409c45 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,347 +1,343 @@ // @flow import _memoize from 'lodash/memoize.js'; import { createSelector } from 'reselect'; import bots from '../facts/bots.js'; import { getAvatarForUser, getRandomDefaultEmojiAvatar, } from '../shared/avatar-utils.js'; import { getSingleOtherUser } from '../shared/thread-utils.js'; import { type P2PMessageRecipient } from '../tunnelbroker/peer-to-peer-context.js'; import { type AuxUserInfos, type AuxUserInfo, } from '../types/aux-user-types.js'; import type { ClientEmojiAvatar } from '../types/avatar-types'; import { type IdentityPlatformDetails, identityDeviceTypes, } from '../types/identity-service-types.js'; import type { RelativeMemberInfo, RawThreadInfo, } from '../types/minimally-encoded-thread-permissions-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadTypeIsPersonal } from '../types/thread-types-enum.js'; import type { RawThreadInfos } from '../types/thread-types.js'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, CurrentUserInfo, } from '../types/user-types.js'; import { entries, values } from '../utils/objects.js'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: $ReadOnlyArray, viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos: RelativeUserInfo[] = []; for (const userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; const relativeUserInfo = { id: userID, username, isViewer: userID === viewerID, }; if (userID === viewerID) { relativeUserInfos.unshift(relativeUserInfo); } else { relativeUserInfos.push(relativeUserInfo); } } return relativeUserInfos; } function getRelativeMemberInfos( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray { const relativeMemberInfos: RelativeMemberInfo[] = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { if (!memberInfo.role) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; const { id, role, isSender, minimallyEncoded } = memberInfo; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ id, role, isSender, minimallyEncoded, username, isViewer: true, }); } else { relativeMemberInfos.push({ id, role, isSender, minimallyEncoded, username, isViewer: false, }); } } return relativeMemberInfos; } const emptyArray: $ReadOnlyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.userStore.userInfos, getRelativeMemberInfos, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: (state: BaseAppState<>) => { [id: string]: AccountUserInfo, } = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, - (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, - ( - userInfos: UserInfos, - currentUserID: ?string, - ): { [id: string]: AccountUserInfo } => { + (userInfos: UserInfos): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; - if (id === currentUserID || !username) { + if (!username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); const isLoggedIn = (state: BaseAppState<>): boolean => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const isLoggedInToKeyserver: ( keyserverID: ?string, ) => (state: BaseAppState<>) => boolean = _memoize( (keyserverID: ?string) => (state: BaseAppState<>) => { if (!keyserverID) { return false; } const cookie = state.keyserverStore.keyserverInfos[keyserverID]?.cookie; return !!cookie && cookie.startsWith('user='); }, ); const usersWithPersonalThreadSelector: ( state: BaseAppState<>, ) => $ReadOnlySet = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<>) => state.threadStore.threadInfos, (viewerID: ?string, threadInfos: RawThreadInfos) => { const personalThreadMembers = new Set(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; if ( !threadTypeIsPersonal(thread.type) || !thread.members.find(member => member.id === viewerID) ) { continue; } const otherMemberID = getSingleOtherUser(thread, viewerID); if (otherMemberID) { personalThreadMembers.add(otherMemberID); } } return personalThreadMembers; }, ); const savedEmojiAvatarSelectorForCurrentUser: ( state: BaseAppState<>, ) => () => ClientEmojiAvatar = createSelector( (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo, (currentUser: ?CurrentUserInfo) => { return () => { let userAvatar = getAvatarForUser(currentUser); if (userAvatar.type !== 'emoji') { userAvatar = getRandomDefaultEmojiAvatar(); } return userAvatar; }; }, ); const getRelativeUserIDs: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.userStore.userInfos, (userInfos: UserInfos): $ReadOnlyArray => Object.keys(userInfos), ); const usersWithMissingDeviceListSelector: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( getRelativeUserIDs, (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, ( userIDs: $ReadOnlyArray, auxUserInfos: AuxUserInfos, ): $ReadOnlyArray => userIDs.filter( userID => (!auxUserInfos[userID] || !auxUserInfos[userID].deviceList) && userID !== bots.commbot.userID, ), ); // Foreign Peer Devices are all devices of users we are aware of, // but not our own devices. const getForeignPeerDeviceIDs: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( auxUserInfos: AuxUserInfos, currentUserID: ?string, ): $ReadOnlyArray => entries(auxUserInfos) .map(([userID, auxUserInfo]: [string, AuxUserInfo]) => userID !== currentUserID && auxUserInfo.deviceList?.devices ? auxUserInfo.deviceList.devices : [], ) .flat(), ); export type DeviceIDAndPlatformDetails = { +deviceID: string, +platformDetails: ?IdentityPlatformDetails, }; const getOwnPeerDevices: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, ( auxUserInfos: AuxUserInfos, currentUserID: ?string, ): $ReadOnlyArray => { if (!currentUserID) { return []; } const devices = auxUserInfos[currentUserID]?.deviceList?.devices; if (!devices) { return []; } return devices.map(deviceID => ({ deviceID, platformDetails: auxUserInfos[currentUserID].devicesPlatformDetails?.[deviceID], })); }, ); function getKeyserverDeviceID( devices: $ReadOnlyArray, ): ?string { const keyserverDevice = devices.find( device => device.platformDetails?.deviceType === identityDeviceTypes.KEYSERVER, ); return keyserverDevice ? keyserverDevice.deviceID : null; } const getAllPeerDevices: (state: BaseAppState<>) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (auxUserInfos: AuxUserInfos): $ReadOnlyArray => values(auxUserInfos) .map( (auxUserInfo: AuxUserInfo) => auxUserInfo.deviceList?.devices ?? [], ) .flat(), ); const getAllPeerUserIDAndDeviceIDs: ( state: BaseAppState<>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (auxUserInfos: AuxUserInfos): $ReadOnlyArray => entries(auxUserInfos).flatMap( ([userID, { deviceList }]: [string, AuxUserInfo]) => deviceList?.devices.map(deviceID => ({ userID, deviceID, })) ?? [], ), ); const getOwnPrimaryDeviceID: (state: BaseAppState<>) => ?string = createSelector( (state: BaseAppState<>) => state.auxUserStore.auxUserInfos, (state: BaseAppState<>) => state.currentUserInfo && state.currentUserInfo.id, (auxUserInfos: AuxUserInfos, currentUserID: ?string): ?string => currentUserID && auxUserInfos[currentUserID]?.deviceList?.devices[0], ); export { userIDsToRelativeUserInfos, getRelativeMemberInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, isLoggedIn, isLoggedInToKeyserver, usersWithPersonalThreadSelector, savedEmojiAvatarSelectorForCurrentUser, getRelativeUserIDs, usersWithMissingDeviceListSelector, getForeignPeerDeviceIDs, getOwnPeerDevices, getKeyserverDeviceID, getAllPeerDevices, getAllPeerUserIDAndDeviceIDs, getOwnPrimaryDeviceID, }; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index 38f430a29..ede0924b6 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,371 +1,376 @@ // @flow import invariant from 'invariant'; import _filter from 'lodash/fp/filter.js'; import _flow from 'lodash/fp/flow.js'; import _sortBy from 'lodash/fp/sortBy.js'; import * as React from 'react'; import { Text, View } from 'react-native'; import { newThreadActionTypes, useNewThinThread, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { type ThreadType, threadTypes } from 'lib/types/thread-types-enum.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import type { ChatNavigationProp } from './chat.react.js'; import { useNavigateToThread } from './message-list-types.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import LinkButton from '../components/link-button.react.js'; import { type BaseTagInput, createTagInput, } from '../components/tag-input.react.js'; import ThreadList from '../components/thread-list.react.js'; import UserList from '../components/user-list.react.js'; import { useCalendarQuery } from '../navigation/nav-selectors.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles } from '../themes/colors.js'; import { unknownErrorAlertDetails } from '../utils/alert-messages.js'; import Alert from '../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type ComposeSubchannelParams = { +threadType: ThreadType, +parentThreadInfo: ThreadInfo, }; type Props = { +navigation: ChatNavigationProp<'ComposeSubchannel'>, +route: NavigationRoute<'ComposeSubchannel'>, }; function ComposeSubchannel(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const [createButtonEnabled, setCreateButtonEnabled] = React.useState(true); const tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); tagInputRef.current?.focus(); }, []); const waitingOnThreadIDRef = React.useRef(); const { threadType, parentThreadInfo } = props.route.params; const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const callNewThinThread = useNewThinThread(); const calendarQuery = useCalendarQuery(); const newChatThreadAction = React.useCallback(async () => { try { const assumedThreadType = threadType ?? threadTypes.COMMUNITY_SECRET_SUBTHREAD; const query = calendarQuery(); invariant( assumedThreadType === 3 || assumedThreadType === 4 || assumedThreadType === 6 || assumedThreadType === 7, "Sidebars and communities can't be created from the thread composer", ); const result = await callNewThinThread({ type: assumedThreadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: userInfoInputIDs, color: parentThreadInfo.color, calendarQuery: query, }); waitingOnThreadIDRef.current = result.newThreadID; return result; } catch (e) { setCreateButtonEnabled(true); Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ threadType, userInfoInputIDs, calendarQuery, parentThreadInfo, callNewThinThread, onUnknownErrorAlertAcknowledged, ]); const dispatchActionPromise = useDispatchActionPromise(); const dispatchNewChatThreadAction = React.useCallback(() => { setCreateButtonEnabled(false); void dispatchActionPromise(newThreadActionTypes, newChatThreadAction()); }, [dispatchActionPromise, newChatThreadAction]); const userInfoInputArrayEmpty = userInfoInputArray.length === 0; const onPressCreateThread = React.useCallback(() => { if (!createButtonEnabled) { return; } if (userInfoInputArrayEmpty) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a channel containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { dispatchNewChatThreadAction(); } }, [ createButtonEnabled, userInfoInputArrayEmpty, dispatchNewChatThreadAction, ]); const { navigation } = props; const { setOptions } = navigation; React.useEffect(() => { setOptions({ headerRight: () => ( ), }); }, [setOptions, onPressCreateThread, createButtonEnabled]); const { setParams } = navigation; const parentThreadInfoID = parentThreadInfo.id; const reduxParentThreadInfo = useSelector( state => threadInfoSelector(state)[parentThreadInfoID], ); React.useEffect(() => { if (reduxParentThreadInfo) { setParams({ parentThreadInfo: reduxParentThreadInfo }); } }, [reduxParentThreadInfo, setParams]); const threadInfos = useSelector(threadInfoSelector); const newlyCreatedThreadInfo = waitingOnThreadIDRef.current ? threadInfos[waitingOnThreadIDRef.current] : null; const { pushNewThread } = navigation; React.useEffect(() => { if (!newlyCreatedThreadInfo) { return; } const waitingOnThreadID = waitingOnThreadIDRef.current; if (waitingOnThreadID === null || waitingOnThreadID === undefined) { return; } waitingOnThreadIDRef.current = undefined; pushNewThread(newlyCreatedThreadInfo); }, [newlyCreatedThreadInfo, pushNewThread]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); + const viewerID = useSelector(state => state.currentUserInfo?.id); + const excludeUserIDs = React.useMemo( + () => [...userInfoInputIDs, ...(viewerID ? [viewerID] : [])], + [userInfoInputIDs, viewerID], + ); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, auxUserInfos, - excludeUserIDs: userInfoInputIDs, + excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType, }); const existingThreads: $ReadOnlyArray = React.useMemo(() => { if (userInfoInputIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && threadInfo.parentThreadID === parentThreadInfo.id && userInfoInputIDs.every(userID => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, [userInfoInputIDs, threadInfos, parentThreadInfo]); const navigateToThread = useNavigateToThread(); const onSelectExistingThread = React.useCallback( (threadID: string) => { const threadInfo = threadInfos[threadID]; navigateToThread({ threadInfo }); }, [threadInfos, navigateToThread], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [userInfoInputIDs, otherUserInfos], ); const styles = useStyles(unboundStyles); let existingThreadsSection = null; if (existingThreads.length > 0) { existingThreadsSection = ( Existing channels ); } const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressCreateThread, }), [onPressCreateThread], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( To: {existingThreadsSection} ); } 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', }, 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 MemoizedComposeSubchannel: React.ComponentType = React.memo(ComposeSubchannel); export default MemoizedComposeSubchannel; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 36fc72f90..147d0ab19 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,305 +1,310 @@ // @flow import * as React from 'react'; import { ActivityIndicator, Text, View } from 'react-native'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { useAddDMThreadMembers } from 'lib/shared/dm-ops/dm-op-utils.js'; import { usePotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypeIsThick } from 'lib/types/thread-types-enum.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import { type BaseTagInput, createTagInput, } from '../../components/tag-input.react.js'; import UserList from '../../components/user-list.react.js'; import type { RootNavigationProp } from '../../navigation/root-navigator.react.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { useStyles } from '../../themes/colors.js'; import { unknownErrorAlertDetails } from '../../utils/alert-messages.js'; import Alert from '../../utils/alert.js'; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; export type AddUsersModalParams = { +presentedFrom: string, +threadInfo: ThreadInfo, }; type Props = { +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, }; function AddUsersModal(props: Props): React.Node { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const tagInputRef = React.useRef>(); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setUsernameInputText(''); setUserInfoInputArray([]); tagInputRef.current?.focus(); }, []); const { navigation } = props; const { goBackOnce } = navigation; const close = React.useCallback(() => { goBackOnce(); }, [goBackOnce]); const callChangeThreadSettings = useChangeThreadSettings(); const userInfoInputIDs = userInfoInputArray.map(userInfo => userInfo.id); const { route } = props; const { threadInfo } = route.params; const threadID = threadInfo.id; const addUsersToThread = React.useCallback(async () => { try { const result = await callChangeThreadSettings({ thick: false, threadID: threadID, changes: { newMemberIDs: userInfoInputIDs }, }); close(); return result; } catch (e) { Alert.alert( unknownErrorAlertDetails.title, unknownErrorAlertDetails.message, [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ callChangeThreadSettings, threadID, userInfoInputIDs, close, onUnknownErrorAlertAcknowledged, ]); const inputLength = userInfoInputArray.length; const dispatchActionPromise = useDispatchActionPromise(); const userInfoInputArrayEmpty = inputLength === 0; const addDMThreadMembers = useAddDMThreadMembers(); const onPressAdd = React.useCallback(() => { if (userInfoInputArrayEmpty) { return; } if (threadTypeIsThick(threadInfo.type)) { void addDMThreadMembers(userInfoInputIDs, threadInfo); } else { void dispatchActionPromise( changeThreadSettingsActionTypes, addUsersToThread(), ); } }, [ userInfoInputArrayEmpty, threadInfo, dispatchActionPromise, addUsersToThread, addDMThreadMembers, userInfoInputIDs, ]); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const isLoading = changeThreadSettingsLoadingStatus === 'loading'; const styles = useStyles(unboundStyles); let addButton = null; if (inputLength > 0) { let activityIndicator = null; if (isLoading) { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (!isLoading) { cancelButton = ( ); } else { cancelButton = ; } const threadMemberIDs = React.useMemo( () => threadActualMembers(threadInfo.members), [threadInfo.members], ); + const viewerID = useSelector(state => state.currentUserInfo?.id); const excludeUserIDs = React.useMemo( - () => userInfoInputIDs.concat(threadMemberIDs), - [userInfoInputIDs, threadMemberIDs], + () => [ + ...userInfoInputIDs, + ...threadMemberIDs, + ...(viewerID ? [viewerID] : []), + ], + [userInfoInputIDs, threadMemberIDs, viewerID], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const { parentThreadID, community } = props.route.params.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, auxUserInfos, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }); const onChangeTagInput = React.useCallback( (newUserInfoInputArray: $ReadOnlyArray) => { if (!isLoading) { setUserInfoInputArray(newUserInfoInputArray); } }, [isLoading], ); const onChangeTagInputText = React.useCallback( (text: string) => { if (!isLoading) { setUsernameInputText(text); } }, [isLoading], ); const onUserSelect = React.useCallback( ({ id }: AccountUserInfo) => { if (isLoading) { return; } if (userInfoInputIDs.some(existingUserID => id === existingUserID)) { return; } setUserInfoInputArray(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [isLoading, userInfoInputIDs, otherUserInfos], ); const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressAdd, }), [onPressAdd], ); const userSearchResultWithENSNames = useENSNames(userSearchResults); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); return ( {cancelButton} {addButton} ); } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'vibrantGreenButton', 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 MemoizedAddUsersModal: React.ComponentType = React.memo(AddUsersModal); export default MemoizedAddUsersModal; diff --git a/web/settings/relationship/add-users-utils.js b/web/settings/relationship/add-users-utils.js index c3c80b5f6..edc2648d5 100644 --- a/web/settings/relationship/add-users-utils.js +++ b/web/settings/relationship/add-users-utils.js @@ -1,252 +1,260 @@ // @flow import _keyBy from 'lodash/fp/keyBy.js'; import * as React from 'react'; import { useSortedENSResolvedUsers } from 'lib/hooks/ens-cache.js'; import { useUserSearchIndex } from 'lib/selectors/nav-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { useSearchUsers, usePotentialMemberItems, } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import type { UserRelationshipStatus } from 'lib/types/relationship-types.js'; import type { ThreadType } from 'lib/types/thread-types-enum.js'; import type { GlobalAccountUserInfo, AccountUserInfo, UserListItem, } from 'lib/types/user-types.js'; import { values } from 'lib/utils/objects.js'; import { useAddUsersListContext } from './add-users-list-provider.react.js'; import { useSelector } from '../../redux/redux-utils.js'; type UseUserRelationshipUserInfosParams = { +searchText: string, +excludedStatuses: $ReadOnlySet, }; function useUserRelationshipUserInfos( params: UseUserRelationshipUserInfosParams, ): { +mergedUserInfos: { [string]: GlobalAccountUserInfo | AccountUserInfo, }, +sortedUsersWithENSNames: $ReadOnlyArray< GlobalAccountUserInfo | AccountUserInfo, >, } { const { searchText, excludedStatuses } = params; const { previouslySelectedUsers } = useAddUsersListContext(); const viewerID = useSelector(state => state.currentUserInfo?.id); const userInfos = useSelector(state => state.userStore.userInfos); const userInfosArray = React.useMemo(() => values(userInfos), [userInfos]); const userStoreSearchIndex = useUserSearchIndex(userInfosArray); const [userStoreSearchResults, setUserStoreSearchResults] = React.useState< $ReadOnlySet, >(new Set(userStoreSearchIndex.getSearchResults(searchText))); React.useEffect(() => { setUserStoreSearchResults( new Set(userStoreSearchIndex.getSearchResults(searchText)), ); }, [searchText, userStoreSearchIndex]); const serverSearchResults = useSearchUsers(searchText); const searchModeActive = searchText.length > 0; const mergedUserInfos = React.useMemo(() => { const mergedInfos: { [string]: GlobalAccountUserInfo | AccountUserInfo } = {}; for (const userInfo of serverSearchResults) { mergedInfos[userInfo.id] = userInfo; } const userStoreUserIDs = searchModeActive ? userStoreSearchResults : Object.keys(userInfos); for (const id of userStoreUserIDs) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedInfos[id] = { id, username, relationshipStatus }; } } return mergedInfos; }, [ searchModeActive, serverSearchResults, userInfos, userStoreSearchResults, ]); const filteredUsers = React.useMemo( () => Object.keys(mergedUserInfos) .map(userID => mergedUserInfos[userID]) .filter( user => user.id !== viewerID && (!user.relationshipStatus || !excludedStatuses.has(user.relationshipStatus)) && !previouslySelectedUsers.has(user.id), ), [excludedStatuses, mergedUserInfos, viewerID, previouslySelectedUsers], ); const sortedUsersWithENSNames = useSortedENSResolvedUsers(filteredUsers); const result = React.useMemo( () => ({ mergedUserInfos, sortedUsersWithENSNames, }), [mergedUserInfos, sortedUsersWithENSNames], ); return result; } type UseAddMembersListUserInfosParams = { +threadID: string, +searchText: string, }; function useAddMembersListUserInfos(params: UseAddMembersListUserInfosParams): { +userInfos: { [string]: UserListItem, }, +sortedUsersWithENSNames: $ReadOnlyArray, } { const { threadID, searchText } = params; const { previouslySelectedUsers } = useAddUsersListContext(); const threadInfo = useSelector(state => threadInfoSelector(state)[threadID]); const { parentThreadID, community } = threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); + const viewerID = useSelector(state => state.currentUserInfo?.id); const excludeUserIDs = React.useMemo( - () => - threadActualMembers(threadInfo.members).concat( - Array.from(previouslySelectedUsers.keys()), - ), - [previouslySelectedUsers, threadInfo.members], + () => [ + ...threadActualMembers(threadInfo.members), + ...previouslySelectedUsers.keys(), + ...(viewerID ? [viewerID] : []), + ], + [previouslySelectedUsers, threadInfo.members, viewerID], ); const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const userSearchResults = usePotentialMemberItems({ text: searchText, userInfos: otherUserInfos, auxUserInfos, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }); const userInfos = React.useMemo(() => { const mergedInfos: { [string]: UserListItem } = {}; for (const userInfo of userSearchResults) { mergedInfos[userInfo.id] = userInfo; } return mergedInfos; }, [userSearchResults]); const usersAvailableToAdd = React.useMemo( () => userSearchResults.filter(user => !user.alert), [userSearchResults], ); const sortedUsersWithENSNames = useSortedENSResolvedUsers(usersAvailableToAdd); const result = React.useMemo( () => ({ userInfos, sortedUsersWithENSNames, }), [userInfos, sortedUsersWithENSNames], ); return result; } type UseSubchannelAddMembersListUserInfosParams = { +parentThreadInfo: ThreadInfo, +searchText: string, +threadType: ThreadType, }; function useSubchannelAddMembersListUserInfos( params: UseSubchannelAddMembersListUserInfosParams, ): { +userInfos: { +[string]: UserListItem, }, +sortedUsersWithENSNames: $ReadOnlyArray, } { const { searchText, parentThreadInfo, threadType } = params; const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const { previouslySelectedUsers } = useAddUsersListContext(); const previouslySelectedUserIDs = React.useMemo( () => [...previouslySelectedUsers].map(([key]) => key), [previouslySelectedUsers], ); + const viewerID = useSelector(state => state.currentUserInfo?.id); + const excludeUserIDs = React.useMemo( + () => [...previouslySelectedUserIDs, ...(viewerID ? [viewerID] : [])], + [previouslySelectedUserIDs, viewerID], + ); + const auxUserInfos = useSelector(state => state.auxUserStore.auxUserInfos); const userSearchResults = usePotentialMemberItems({ text: searchText, userInfos: otherUserInfos, auxUserInfos, - excludeUserIDs: previouslySelectedUserIDs, + excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType, }); const filteredUserResults = React.useMemo( () => userSearchResults.filter(item => !item.alert), [userSearchResults], ); const userSearchResultWithENSNames = useSortedENSResolvedUsers(filteredUserResults); const userResults: { [id: string]: UserListItem } = React.useMemo( () => _keyBy('id')(filteredUserResults), [filteredUserResults], ); return { userInfos: userResults, sortedUsersWithENSNames: userSearchResultWithENSNames, }; } export { useUserRelationshipUserInfos, useAddMembersListUserInfos, useSubchannelAddMembersListUserInfos, };