diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index fe639e8bf..796818a3c 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,172 +1,214 @@ // @flow import * as React from 'react'; import { createSelector } from 'reselect'; import { useENSNames } from '../hooks/ens-cache.js'; import SearchIndex from '../shared/search-index.js'; import { memberHasAdminPowers } from '../shared/thread-utils.js'; import type { Platform } from '../types/device-types.js'; import { type CalendarQuery, defaultCalendarQuery, } from '../types/entry-types.js'; import type { CalendarFilter } from '../types/filter-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; -import type { RawThreadInfo, ThreadInfo } from '../types/thread-types.js'; +import type { + RelativeMemberInfo, + RawThreadInfo, + ThreadInfo, +} from '../types/thread-types'; import type { UserInfo } from '../types/user-types.js'; import { getConfig } from '../utils/config.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; function timeUntilCalendarRangeExpiration( lastUserInteractionCalendar: number, ): ?number { const inactivityLimit = getConfig().calendarRangeInactivityLimit; if (inactivityLimit === null || inactivityLimit === undefined) { return null; } return lastUserInteractionCalendar + inactivityLimit - Date.now(); } function calendarRangeExpired(lastUserInteractionCalendar: number): boolean { const timeUntil = timeUntilCalendarRangeExpiration( lastUserInteractionCalendar, ); if (timeUntil === null || timeUntil === undefined) { return false; } return timeUntil <= 0; } const currentCalendarQuery: ( state: BaseAppState<>, ) => (calendarActive: boolean) => CalendarQuery = createSelector( (state: BaseAppState<>) => state.entryStore.lastUserInteractionCalendar, (state: BaseAppState<>) => state.navInfo, (state: BaseAppState<>) => state.calendarFilters, ( lastUserInteractionCalendar: number, navInfo: BaseNavInfo, calendarFilters: $ReadOnlyArray, ) => { // Return a function since we depend on the time of evaluation return (calendarActive: boolean, platform: ?Platform): CalendarQuery => { if (calendarActive) { return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; } if (calendarRangeExpired(lastUserInteractionCalendar)) { return defaultCalendarQuery(platform); } return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; }; }, ); -// Without allAtOnce, useThreadSearchIndex is very expensive. useENSNames would -// trigger its recalculation for each ENS name as it streams in, but we would -// prefer to trigger its recaculation just once for every update of the -// underlying Redux data. +// Without allAtOnce, useThreadSearchIndex and useUserSearchIndex are very +// expensive. useENSNames would trigger their recalculation for each ENS name +// as it streams in, but we would prefer to trigger their recaculation just +// once for every update of the underlying Redux data. const useENSNamesOptions = { allAtOnce: true }; +function useUserSearchIndex( + userInfos: $ReadOnlyArray, +): SearchIndex { + const membersWithENSNames = useENSNames(userInfos, useENSNamesOptions); + + const memberMap = React.useMemo(() => { + const result = new Map(); + for (const userInfo of membersWithENSNames) { + result.set(userInfo.id, userInfo); + } + return result; + }, [membersWithENSNames]); + + return React.useMemo(() => { + const searchIndex = new SearchIndex(); + + for (const userInfo of userInfos) { + const searchTextArray = []; + + const rawUsername = userInfo.username; + if (rawUsername) { + searchTextArray.push(rawUsername); + } + + const resolvedUserInfo = memberMap.get(userInfo.id); + const resolvedUsername = resolvedUserInfo?.username; + if (resolvedUsername && resolvedUsername !== rawUsername) { + searchTextArray.push(resolvedUsername); + } + + searchIndex.addEntry(userInfo.id, searchTextArray.join(' ')); + } + + return searchIndex; + }, [userInfos, memberMap]); +} + function useThreadSearchIndex( threadInfos: $ReadOnlyArray, ): SearchIndex { const userInfos = useSelector(state => state.userStore.userInfos); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nonViewerMembers = React.useMemo(() => { const allMembersOfAllThreads = new Map(); for (const threadInfo of threadInfos) { for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } if (!allMembersOfAllThreads.has(member.id)) { const userInfo = userInfos[member.id]; if (userInfo?.username) { allMembersOfAllThreads.set(member.id, userInfo); } } } } return [...allMembersOfAllThreads.values()]; }, [threadInfos, userInfos, viewerID]); const nonViewerMembersWithENSNames = useENSNames( nonViewerMembers, useENSNamesOptions, ); const memberMap = React.useMemo(() => { const result = new Map(); for (const userInfo of nonViewerMembersWithENSNames) { result.set(userInfo.id, userInfo); } return result; }, [nonViewerMembersWithENSNames]); return React.useMemo(() => { const searchIndex = new SearchIndex(); for (const threadInfo of threadInfos) { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (const member of threadInfo.members) { const isParentAdmin = memberHasAdminPowers(member); if (!member.role && !isParentAdmin) { continue; } if (member.id === viewerID) { continue; } const userInfo = userInfos[member.id]; const rawUsername = userInfo?.username; if (rawUsername) { searchTextArray.push(rawUsername); } const resolvedUserInfo = memberMap.get(member.id); const username = resolvedUserInfo?.username; if (username && username !== rawUsername) { searchTextArray.push(username); } } searchIndex.addEntry(threadInfo.id, searchTextArray.join(' ')); } return searchIndex; }, [threadInfos, viewerID, userInfos, memberMap]); } function useGlobalThreadSearchIndex(): SearchIndex { const threadInfos = useSelector(state => state.threadStore.threadInfos); const threadInfosArray = React.useMemo( () => values(threadInfos), [threadInfos], ); return useThreadSearchIndex(threadInfosArray); } export { timeUntilCalendarRangeExpiration, currentCalendarQuery, + useUserSearchIndex, useThreadSearchIndex, useGlobalThreadSearchIndex, }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index d5e447097..3b2838739 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,451 +1,452 @@ // @flow import * as React from 'react'; import { messageID } from './message-utils.js'; import SearchIndex from './search-index.js'; import { userIsMember, threadMemberHasPermission, getContainingThreadID, } from './thread-utils.js'; import { useSearchMessages as useSearchMessagesAction, searchMessagesActionTypes, } from '../actions/message-actions.js'; import { searchUsers, searchUsersActionTypes, } from '../actions/user-actions.js'; import { ENSCacheContext } from '../components/ens-cache-provider.react.js'; import genesis from '../facts/genesis.js'; import type { ChatMessageInfoItem, MessageListData, } from '../selectors/chat-selectors.js'; +import { useUserSearchIndex } from '../selectors/nav-selectors.js'; import { relationshipBlockedInEitherDirection } from '../shared/relationship-utils.js'; import type { MessageInfo, RawMessageInfo } from '../types/message-types.js'; import { userRelationshipStatus } from '../types/relationship-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; import { type ThreadInfo } from '../types/thread-types.js'; import type { AccountUserInfo, UserListItem, GlobalAccountUserInfo, } from '../types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from '../utils/action-utils.js'; import { isValidENSName } from '../utils/ens-helpers.js'; import { values } from '../utils/objects.js'; import { useSelector } from '../utils/redux-utils.js'; const notFriendNotice = 'not friend'; function appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }: { +results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, }, +excludeUserIDs: $ReadOnlyArray, +userInfo: AccountUserInfo | GlobalAccountUserInfo, +parentThreadInfo: ?ThreadInfo, +communityThreadInfo: ?ThreadInfo, +containingThreadInfo: ?ThreadInfo, }) { const { id } = userInfo; if (excludeUserIDs.includes(id) || id in results) { return; } if ( communityThreadInfo && !threadMemberHasPermission( communityThreadInfo, id, threadPermissions.KNOW_OF, ) ) { return; } results[id] = { ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, id), isMemberOfContainingThread: userIsMember(containingThreadInfo, id), }; } function usePotentialMemberItems({ text, userInfos, - searchIndex, excludeUserIDs, includeServerSearchUsers, inputParentThreadInfo, inputCommunityThreadInfo, threadType, }: { +text: string, +userInfos: { +[id: string]: AccountUserInfo }, - +searchIndex: SearchIndex, +excludeUserIDs: $ReadOnlyArray, +includeServerSearchUsers?: $ReadOnlyArray, +inputParentThreadInfo?: ?ThreadInfo, +inputCommunityThreadInfo?: ?ThreadInfo, +threadType?: ?ThreadType, }): UserListItem[] { + const searchIndex: SearchIndex = useUserSearchIndex(values(userInfos)); + const communityThreadInfo = React.useMemo( () => inputCommunityThreadInfo && inputCommunityThreadInfo.id !== genesis.id ? inputCommunityThreadInfo : null, [inputCommunityThreadInfo], ); const parentThreadInfo = React.useMemo( () => inputParentThreadInfo && inputParentThreadInfo.id !== genesis.id ? inputParentThreadInfo : null, [inputParentThreadInfo], ); const containgThreadID = threadType ? getContainingThreadID(parentThreadInfo, threadType) : null; const containingThreadInfo = React.useMemo(() => { if (containgThreadID === parentThreadInfo?.id) { return parentThreadInfo; } else if (containgThreadID === communityThreadInfo?.id) { return communityThreadInfo; } return null; }, [containgThreadID, communityThreadInfo, parentThreadInfo]); const filteredUserResults = React.useMemo(() => { const results: { [id: string]: { ...AccountUserInfo | GlobalAccountUserInfo, isMemberOfParentThread: boolean, isMemberOfContainingThread: boolean, }, } = {}; if (text === '') { for (const id in userInfos) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo({ results, excludeUserIDs, userInfo: userInfos[id], parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } if (includeServerSearchUsers) { for (const userInfo of includeServerSearchUsers) { appendUserInfo({ results, excludeUserIDs, userInfo, parentThreadInfo, communityThreadInfo, containingThreadInfo, }); } } let userResults = values(results); if (text === '') { userResults = userResults.filter(userInfo => { if (!containingThreadInfo) { return userInfo.relationshipStatus === userRelationshipStatus.FRIEND; } if (!userInfo.isMemberOfContainingThread) { return false; } const { relationshipStatus } = userInfo; if (!relationshipStatus) { return true; } return !relationshipBlockedInEitherDirection(relationshipStatus); }); } return userResults; }, [ text, userInfos, searchIndex, excludeUserIDs, includeServerSearchUsers, parentThreadInfo, containingThreadInfo, communityThreadInfo, ]); const sortedMembers = React.useMemo(() => { const nonFriends = []; const blockedUsers = []; const friends = []; const containingThreadMembers = []; const parentThreadMembers = []; for (const userResult of filteredUserResults) { const { relationshipStatus } = userResult; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { blockedUsers.push(userResult); } else if (userResult.isMemberOfParentThread) { parentThreadMembers.push(userResult); } else if (userResult.isMemberOfContainingThread) { containingThreadMembers.push(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friends.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = parentThreadMembers .concat(containingThreadMembers) .concat(friends) .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfContainingThread, isMemberOfParentThread, relationshipStatus, ...result }) => { let notice, alert; const username = result.username; if ( relationshipStatus && relationshipBlockedInEitherDirection(relationshipStatus) ) { notice = 'user is blocked'; alert = { title: 'User is blocked', text: `Before you add ${username} to this chat, ` + 'you’ll need to unblock them. You can do this from the Block List ' + 'in the Profile tab.', }; } else if (!isMemberOfContainingThread && containingThreadInfo) { if (threadType !== threadTypes.SIDEBAR) { notice = 'not in community'; alert = { title: 'Not in community', text: 'You can only add members of the community to this chat', }; } else { notice = 'not in parent chat'; alert = { title: 'Not in parent chat', text: 'You can only add members of the parent chat to a thread', }; } } else if ( !containingThreadInfo && relationshipStatus !== userRelationshipStatus.FRIEND ) { notice = notFriendNotice; alert = { title: 'Not a friend', text: `Before you add ${username} to this chat, ` + 'you’ll need to send them a friend request. ' + 'You can do this from the Friend List in the Profile tab.', }; } else if (parentThreadInfo && !isMemberOfParentThread) { notice = 'not in parent chat'; } if (notice) { result = { ...result, notice }; } if (alert) { result = { ...result, alert }; } return result; }, ); }, [containingThreadInfo, filteredUserResults, parentThreadInfo, threadType]); return sortedMembers; } function useSearchMessages(): ( query: string, threadID: string, onResultsReceived: ( messages: $ReadOnlyArray, endReached: boolean, queryID: number, threadID: string, ) => mixed, queryID: number, cursor?: ?string, ) => void { const callSearchMessages = useSearchMessagesAction(); const dispatchActionPromise = useDispatchActionPromise(); return React.useCallback( (query, threadID, onResultsReceived, queryID, cursor) => { const searchMessagesPromise = (async () => { if (query === '') { onResultsReceived([], true, queryID, threadID); return; } const { messages, endReached } = await callSearchMessages({ query, threadID, cursor, }); onResultsReceived(messages, endReached, queryID, threadID); })(); void dispatchActionPromise( searchMessagesActionTypes, searchMessagesPromise, ); }, [callSearchMessages, dispatchActionPromise], ); } function useForwardLookupSearchText(originalText: string): string { const cacheContext = React.useContext(ENSCacheContext); const { ensCache } = cacheContext; const lowercaseText = originalText.toLowerCase(); const [usernameToSearch, setUsernameToSearch] = React.useState(lowercaseText); React.useEffect(() => { void (async () => { if (!ensCache || !isValidENSName(lowercaseText)) { setUsernameToSearch(lowercaseText); return; } const address = await ensCache.getAddressForName(lowercaseText); if (address) { setUsernameToSearch(address); } else { setUsernameToSearch(lowercaseText); } })(); }, [ensCache, lowercaseText]); return usernameToSearch; } function useSearchUsers( usernameInputText: string, ): $ReadOnlyArray { const currentUserID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const forwardLookupSearchText = useForwardLookupSearchText(usernameInputText); const [serverSearchResults, setServerSearchResults] = React.useState< $ReadOnlyArray, >([]); const callSearchUsers = useServerCall(searchUsers); const dispatchActionPromise = useDispatchActionPromise(); React.useEffect(() => { if (forwardLookupSearchText.length === 0) { setServerSearchResults([]); return; } const searchUsersPromise = (async () => { try { const { userInfos } = await callSearchUsers(forwardLookupSearchText); setServerSearchResults( userInfos.filter(({ id }) => id !== currentUserID), ); } catch (err) { setServerSearchResults([]); } })(); void dispatchActionPromise(searchUsersActionTypes, searchUsersPromise); }, [ callSearchUsers, currentUserID, dispatchActionPromise, forwardLookupSearchText, ]); return serverSearchResults; } function filterChatMessageInfosForSearch( chatMessageInfos: MessageListData, translatedSearchResults: $ReadOnlyArray, ): ?(ChatMessageInfoItem[]) { if (!chatMessageInfos) { return null; } const idSet = new Set(translatedSearchResults.map(messageID)); const uniqueChatMessageInfoItemsMap = new Map(); for (const item of chatMessageInfos) { if (item.itemType !== 'message' || item.messageInfoType !== 'composable') { continue; } const id = messageID(item.messageInfo); if (idSet.has(id)) { uniqueChatMessageInfoItemsMap.set(id, item); } } const sortedChatMessageInfoItems: ChatMessageInfoItem[] = []; for (let i = 0; i < translatedSearchResults.length; i++) { const id = messageID(translatedSearchResults[i]); const match = uniqueChatMessageInfoItemsMap.get(id); if (match) { sortedChatMessageInfoItems.push(match); } } return sortedChatMessageInfoItems; } export { usePotentialMemberItems, notFriendNotice, useSearchMessages, useSearchUsers, filterChatMessageInfosForSearch, useForwardLookupSearchText, }; diff --git a/native/chat/compose-subchannel.react.js b/native/chat/compose-subchannel.react.js index f9e51bc78..cb844244f 100644 --- a/native/chat/compose-subchannel.react.js +++ b/native/chat/compose-subchannel.react.js @@ -1,374 +1,369 @@ // @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 { View, Text } from 'react-native'; import { newThreadActionTypes, useNewThread, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; -import { - userInfoSelectorForPotentialMembers, - userSearchIndexForPotentialMembers, -} from 'lib/selectors/user-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 ThreadType, threadTypes } from 'lib/types/thread-types-enum.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-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 { createTagInput, type BaseTagInput, } 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 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 callNewThread = useNewThread(); 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 callNewThread({ type: assumedThreadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: userInfoInputIDs, color: parentThreadInfo.color, calendarQuery: query, }); waitingOnThreadIDRef.current = result.newThreadID; return result; } catch (e) { setCreateButtonEnabled(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ threadType, userInfoInputIDs, calendarQuery, parentThreadInfo, callNewThread, 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({ // eslint-disable-next-line react/display-name 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 userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { community } = parentThreadInfo; const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, - searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, 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/message-list-container.react.js b/native/chat/message-list-container.react.js index aaa4670f6..e3773a9b4 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,438 +1,433 @@ // @flow import Icon from '@expo/vector-icons/FontAwesome5.js'; import { useNavigationState } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { View, Text } from 'react-native'; import genesis from 'lib/facts/genesis.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; -import { - userInfoSelectorForPotentialMembers, - userSearchIndexForPotentialMembers, -} from 'lib/selectors/user-selectors.js'; +import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems, useSearchUsers, } from 'lib/shared/search-utils.js'; import { useExistingThreadInfoFinder, pendingThreadType, } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { type MessagesMeasurer, useHeightMeasurer } from './chat-context.js'; import { ChatInputBar } from './chat-input-bar.react.js'; import type { ChatNavigationProp } from './chat.react.js'; import { type NativeChatMessageItem, useNativeMessageListData, } from './message-data.react.js'; import MessageListThreadSearch from './message-list-thread-search.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import MessageList from './message-list.react.js'; import ParentThreadHeader from './parent-thread-header.react.js'; import ContentLoading from '../components/content-loading.react.js'; import { InputStateContext } from '../input/input-state.js'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { ThreadSettingsRouteName, MessageResultsScreenRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; import type { ChatMessageItemWithHeight } from '../types/chat-types.js'; const unboundStyles = { pinnedCountBanner: { backgroundColor: 'panelForeground', height: 30, flexDirection: 'row', textAlign: 'center', justifyContent: 'center', alignItems: 'center', }, pinnedCountText: { color: 'panelBackgroundLabel', marginRight: 5, }, container: { backgroundColor: 'listBackground', flex: 1, }, threadContent: { flex: 1, }, hiddenThreadContent: { height: 0, opacity: 0, }, }; type BaseProps = { +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; type Props = { ...BaseProps, // Redux state +usernameInputText: string, +updateUsernameInput: (text: string) => void, +userInfoInputArray: $ReadOnlyArray, +updateTagInput: (items: $ReadOnlyArray) => void, +resolveToUser: (user: AccountUserInfo) => void, +userSearchResults: $ReadOnlyArray, +threadInfo: ThreadInfo, +genesisThreadInfo: ?ThreadInfo, +messageListData: ?$ReadOnlyArray, +colors: Colors, +styles: $ReadOnly, // withOverlayContext +overlayContext: ?OverlayContextType, +measureMessages: MessagesMeasurer, }; type State = { +listDataWithHeights: ?$ReadOnlyArray, }; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; get frozen(): boolean { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } setListData = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; componentDidMount() { this.props.measureMessages( this.props.messageListData, this.props.threadInfo, this.setListData, ); } componentDidUpdate(prevProps: Props) { const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if ( oldListData !== newListData || prevProps.threadInfo !== this.props.threadInfo || prevProps.measureMessages !== this.props.measureMessages ) { this.props.measureMessages( newListData, this.props.threadInfo, this.allHeightsMeasured, ); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render(): React.Node { const { threadInfo, styles } = this.props; const { listDataWithHeights } = this.state; const { searching } = this.props.route.params; let searchComponent = null; if (searching) { const { userInfoInputArray, genesisThreadInfo } = this.props; // It's technically possible for the client to be missing the Genesis // ThreadInfo when it first opens up (before the server delivers it) let parentThreadHeader; if (genesisThreadInfo) { parentThreadHeader = ( ); } searchComponent = ( <> {parentThreadHeader} ); } const showMessageList = !searching || this.props.userInfoInputArray.length > 0; let messageList; if (showMessageList && listDataWithHeights) { messageList = ( ); } else if (showMessageList) { messageList = ( ); } const threadContentStyles = showMessageList ? [styles.threadContent] : [styles.hiddenThreadContent]; const pointerEvents = showMessageList ? 'auto' : 'none'; const threadContent = ( {messageList} ); return ( {searchComponent} {threadContent} ); } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const ConnectedMessageListContainer: React.ComponentType = React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const [usernameInputText, setUsernameInputText] = React.useState(''); const [userInfoInputArray, setUserInfoInputArray] = React.useState< $ReadOnlyArray, >([]); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); - const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const serverSearchResults = useSearchUsers(usernameInputText); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, - searchIndex: userSearchIndex, excludeUserIDs: userInfoInputArray.map(userInfo => userInfo.id), includeServerSearchUsers: serverSearchResults, }); const [baseThreadInfo, setBaseThreadInfo] = React.useState( props.route.params.threadInfo, ); const existingThreadInfoFinder = useExistingThreadInfoFinder(baseThreadInfo); const isSearching = !!props.route.params.searching; const threadInfo = React.useMemo( () => existingThreadInfoFinder({ searching: isSearching, userInfoInputArray, }), [existingThreadInfoFinder, isSearching, userInfoInputArray], ); invariant( threadInfo, 'threadInfo must be specified in messageListContainer', ); const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState should be set in MessageListContainer'); const isFocused = props.navigation.isFocused(); const { setPendingThreadUpdateHandler } = inputState; React.useEffect(() => { if (!isFocused) { return undefined; } setPendingThreadUpdateHandler(threadInfo.id, setBaseThreadInfo); return () => { setPendingThreadUpdateHandler(threadInfo.id, undefined); }; }, [setPendingThreadUpdateHandler, isFocused, threadInfo.id]); const { setParams } = props.navigation; const navigationStack = useNavigationState(state => state.routes); React.useEffect(() => { const topRoute = navigationStack[navigationStack.length - 1]; if (topRoute?.name !== ThreadSettingsRouteName) { return; } setBaseThreadInfo(threadInfo); if (isSearching) { setParams({ searching: false }); } }, [isSearching, navigationStack, setParams, threadInfo]); const hideSearch = React.useCallback(() => { setBaseThreadInfo(threadInfo); setParams({ searching: false }); }, [setParams, threadInfo]); React.useEffect(() => { if (!isSearching) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState, isSearching]); React.useEffect(() => { setParams({ threadInfo }); }, [setParams, threadInfo]); const updateTagInput = React.useCallback( (input: $ReadOnlyArray) => setUserInfoInputArray(input), [], ); const updateUsernameInput = React.useCallback( (text: string) => setUsernameInputText(text), [], ); const { editInputMessage } = inputState; const resolveToUser = React.useCallback( (user: AccountUserInfo) => { const resolvedThreadInfo = existingThreadInfoFinder({ searching: true, userInfoInputArray: [user], }); invariant( resolvedThreadInfo, 'resolvedThreadInfo must be specified in messageListContainer', ); editInputMessage({ message: '', mode: 'prepend' }); setBaseThreadInfo(resolvedThreadInfo); setParams({ searching: false, threadInfo: resolvedThreadInfo }); }, [existingThreadInfoFinder, editInputMessage, setParams], ); const messageListData = useNativeMessageListData({ searching: isSearching, userInfoInputArray, threadInfo, }); const colors = useColors(); const styles = useStyles(unboundStyles); const overlayContext = React.useContext(OverlayContext); const measureMessages = useHeightMeasurer(); const genesisThreadInfo = useSelector( state => threadInfoSelector(state)[genesis.id], ); let bannerText; if (!threadInfo.pinnedCount || threadInfo.pinnedCount === 0) { bannerText = ''; } else { const messageNoun = threadInfo.pinnedCount === 1 ? 'message' : 'messages'; bannerText = `${threadInfo.pinnedCount} pinned ${messageNoun}`; } const navigateToMessageResults = React.useCallback(() => { props.navigation.navigate<'MessageResultsScreen'>({ name: MessageResultsScreenRouteName, params: { threadInfo, }, key: `MessageResultsScreen${threadInfo.id}`, }); }, [props.navigation, threadInfo]); const pinnedCountBanner = React.useMemo(() => { if (!bannerText) { return null; } return ( {bannerText} ); }, [ navigateToMessageResults, bannerText, styles.pinnedCountBanner, styles.pinnedCountText, colors.panelBackgroundLabel, ]); return ( {pinnedCountBanner} ); }); export default ConnectedMessageListContainer; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index 2f84f4fc1..a27a17b44 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,290 +1,285 @@ // @flow import * as React from 'react'; import { View, Text, ActivityIndicator } 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, - userSearchIndexForPotentialMembers, -} from 'lib/selectors/user-selectors.js'; +import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { type AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import Button from '../../components/button.react.js'; import Modal from '../../components/modal.react.js'; import { createTagInput, type BaseTagInput, } 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 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({ threadID: threadID, changes: { newMemberIDs: userInfoInputIDs }, }); close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } }, [ callChangeThreadSettings, threadID, userInfoInputIDs, close, onUnknownErrorAlertAcknowledged, ]); const inputLength = userInfoInputArray.length; const dispatchActionPromise = useDispatchActionPromise(); const userInfoInputArrayEmpty = inputLength === 0; const onPressAdd = React.useCallback(() => { if (userInfoInputArrayEmpty) { return; } void dispatchActionPromise( changeThreadSettingsActionTypes, addUsersToThread(), ); }, [userInfoInputArrayEmpty, dispatchActionPromise, addUsersToThread]); 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 excludeUserIDs = React.useMemo( () => userInfoInputIDs.concat(threadMemberIDs), [userInfoInputIDs, threadMemberIDs], ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); - const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const { parentThreadID, community } = props.route.params.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const communityThreadInfo = useSelector(state => community ? threadInfoSelector(state)[community] : null, ); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, - searchIndex: userSearchIndex, 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/native/community-creation/community-creation-members.react.js b/native/community-creation/community-creation-members.react.js index 4cf399ba5..4a773fc6b 100644 --- a/native/community-creation/community-creation-members.react.js +++ b/native/community-creation/community-creation-members.react.js @@ -1,192 +1,187 @@ // @flow import * as React from 'react'; import { ActivityIndicator } from 'react-native'; import { useChangeThreadSettings, changeThreadSettingsActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; -import { - userInfoSelectorForPotentialMembers, - userSearchIndexForPotentialMembers, -} from 'lib/selectors/user-selectors.js'; +import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems } from 'lib/shared/search-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo } from 'lib/types/user-types.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import CommunityCreationContentContainer from './community-creation-content-container.react.js'; import CommunityCreationKeyserverLabel from './community-creation-keyserver-label.react.js'; import type { CommunityCreationNavigationProp } from './community-creation-navigator.react.js'; import RegistrationContainer from '../account/registration/registration-container.react.js'; import { useNavigateToThread } from '../chat/message-list-types.js'; import LinkButton from '../components/link-button.react.js'; import { createTagInput, type BaseTagInput, } from '../components/tag-input.react.js'; import UserList from '../components/user-list.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; export type CommunityCreationMembersScreenParams = { +announcement: boolean, +threadID: string, }; const TagInput = createTagInput(); const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; const tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; type Props = { +navigation: CommunityCreationNavigationProp<'CommunityCreationMembers'>, +route: NavigationRoute<'CommunityCreationMembers'>, }; const changeThreadSettingsLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); function CommunityCreationMembers(props: Props): React.Node { const { announcement, threadID } = props.route.params; const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const changeThreadSettingsLoadingStatus: LoadingStatus = useSelector( changeThreadSettingsLoadingStatusSelector, ); const { navigation } = props; const { setOptions } = navigation; const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); - const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const [usernameInputText, setUsernameInputText] = React.useState(''); const [selectedUsers, setSelectedUsers] = React.useState< $ReadOnlyArray, >([]); const selectedUserIDs = React.useMemo( () => selectedUsers.map(userInfo => userInfo.id), [selectedUsers], ); const navigateToThread = useNavigateToThread(); const threadInfos = useSelector(threadInfoSelector); const communityThreadInfo = threadInfos[threadID]; const addSelectedUsersToCommunity = React.useCallback(() => { void dispatchActionPromise( changeThreadSettingsActionTypes, (async () => { const result = await callChangeThreadSettings({ threadID, changes: { newMemberIDs: selectedUserIDs }, }); navigateToThread({ threadInfo: communityThreadInfo }); return result; })(), ); }, [ callChangeThreadSettings, communityThreadInfo, dispatchActionPromise, navigateToThread, selectedUserIDs, threadID, ]); const exitCommunityCreationFlow = React.useCallback(() => { navigateToThread({ threadInfo: communityThreadInfo }); }, [communityThreadInfo, navigateToThread]); const activityIndicatorStyle = React.useMemo( () => ({ paddingRight: 20 }), [], ); React.useEffect(() => { setOptions({ // eslint-disable-next-line react/display-name headerRight: () => { if (changeThreadSettingsLoadingStatus === 'loading') { return ( ); } return ( ); }, }); }, [ activityIndicatorStyle, addSelectedUsersToCommunity, changeThreadSettingsLoadingStatus, exitCommunityCreationFlow, selectedUserIDs.length, setOptions, ]); const userSearchResults = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, - searchIndex: userSearchIndex, excludeUserIDs: selectedUserIDs, threadType: announcement ? threadTypes.COMMUNITY_ANNOUNCEMENT_ROOT : threadTypes.COMMUNITY_ROOT, }); const onSelectUser = React.useCallback( ({ id }: AccountUserInfo) => { if (selectedUserIDs.some(existingUserID => id === existingUserID)) { return; } setSelectedUsers(oldUserInfoInputArray => [ ...oldUserInfoInputArray, otherUserInfos[id], ]); setUsernameInputText(''); }, [otherUserInfos, selectedUserIDs], ); const tagInputRef = React.useRef>(); return ( ); } export default CommunityCreationMembers; diff --git a/web/chat/chat-thread-composer.react.js b/web/chat/chat-thread-composer.react.js index 6b9679935..95b766189 100644 --- a/web/chat/chat-thread-composer.react.js +++ b/web/chat/chat-thread-composer.react.js @@ -1,253 +1,249 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { useLoggedInUserInfo } from 'lib/hooks/account-hooks.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; -import { userSearchIndexForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems, useSearchUsers, notFriendNotice, } from 'lib/shared/search-utils.js'; import { createPendingThread, threadIsPending, useExistingThreadInfoFinder, } from 'lib/shared/thread-utils.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import type { AccountUserInfo, UserListItem } from 'lib/types/user-types.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import css from './chat-thread-composer.css'; import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; import Label from '../components/label.react.js'; import Search from '../components/search.react.js'; import type { InputState } from '../input/input-state.js'; import Alert from '../modals/alert.react.js'; import { updateNavInfoActionType } from '../redux/action-types.js'; -import { useSelector } from '../redux/redux-utils.js'; type Props = { +userInfoInputArray: $ReadOnlyArray, +otherUserInfos: { [id: string]: AccountUserInfo }, +threadID: string, +inputState: InputState, }; type ActiveThreadBehavior = | 'reset-active-thread-if-pending' | 'keep-active-thread'; function ChatThreadComposer(props: Props): React.Node { const { userInfoInputArray, otherUserInfos, threadID, inputState } = props; const [usernameInputText, setUsernameInputText] = React.useState(''); const dispatch = useDispatch(); - const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const userInfoInputIDs = React.useMemo( () => userInfoInputArray.map(userInfo => userInfo.id), [userInfoInputArray], ); const serverSearchResults = useSearchUsers(usernameInputText); const userListItems = usePotentialMemberItems({ text: usernameInputText, userInfos: otherUserInfos, - searchIndex: userSearchIndex, excludeUserIDs: userInfoInputIDs, includeServerSearchUsers: serverSearchResults, }); const userListItemsWithENSNames = useENSNames(userListItems); const { pushModal } = useModalContext(); const loggedInUserInfo = useLoggedInUserInfo(); invariant(loggedInUserInfo, 'loggedInUserInfo should be set'); const pendingPrivateThread = React.useRef( createPendingThread({ viewerID: loggedInUserInfo.id, threadType: threadTypes.PRIVATE, members: [loggedInUserInfo], }), ); const existingThreadInfoFinderForCreatingThread = useExistingThreadInfoFinder( pendingPrivateThread.current, ); const onSelectUserFromSearch = React.useCallback( (userListItem: UserListItem) => { const { alert, notice, disabled, ...user } = userListItem; setUsernameInputText(''); if (!alert) { dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: [...userInfoInputArray, user], }, }); } else if ( notice === notFriendNotice && userInfoInputArray.length === 0 ) { const newUserInfoInputArray = [ { id: userListItem.id, username: userListItem.username }, ]; const threadInfo = existingThreadInfoFinderForCreatingThread({ searching: true, userInfoInputArray: newUserInfoInputArray, }); dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadInfo?.id, pendingThread: threadInfo, }, }); } else { pushModal({alert.text}); } }, [ dispatch, existingThreadInfoFinderForCreatingThread, pushModal, userInfoInputArray, ], ); const onRemoveUserFromSelected = React.useCallback( (userID: string) => { const newSelectedUserList = userInfoInputArray.filter( ({ id }) => userID !== id, ); if (_isEqual(userInfoInputArray)(newSelectedUserList)) { return; } dispatch({ type: updateNavInfoActionType, payload: { selectedUserList: newSelectedUserList, }, }); }, [dispatch, userInfoInputArray], ); const userSearchResultList = React.useMemo(() => { if ( !userListItemsWithENSNames.length || (!usernameInputText && userInfoInputArray.length) ) { return null; } const userItems = userListItemsWithENSNames.map( (userSearchResult: UserListItem) => { return (
  • ); }, ); return
      {userItems}
    ; }, [ onSelectUserFromSearch, userInfoInputArray.length, userListItemsWithENSNames, usernameInputText, ]); const hideSearch = React.useCallback( (threadBehavior: ActiveThreadBehavior = 'keep-active-thread') => { dispatch({ type: updateNavInfoActionType, payload: { chatMode: 'view', activeChatThreadID: threadBehavior === 'keep-active-thread' || !threadIsPending(threadID) ? threadID : null, }, }); }, [dispatch, threadID], ); const onCloseSearch = React.useCallback(() => { hideSearch('reset-active-thread-if-pending'); }, [hideSearch]); const userInfoInputArrayWithENSNames = useENSNames(userInfoInputArray); const tagsList = React.useMemo(() => { if (!userInfoInputArrayWithENSNames?.length) { return null; } const labels = userInfoInputArrayWithENSNames.map(user => { return ( ); }); return
    {labels}
    ; }, [userInfoInputArrayWithENSNames, onRemoveUserFromSelected]); React.useEffect(() => { if (!inputState) { return undefined; } inputState.registerSendCallback(hideSearch); return () => inputState.unregisterSendCallback(hideSearch); }, [hideSearch, inputState]); const threadSearchContainerStyles = classNames(css.threadSearchContainer, { [css.fullHeight]: !userInfoInputArray.length, }); return (
    {tagsList} {userSearchResultList}
    ); } export default ChatThreadComposer; diff --git a/web/modals/threads/members/add-members-modal.react.js b/web/modals/threads/members/add-members-modal.react.js index bcee1392e..a26f57308 100644 --- a/web/modals/threads/members/add-members-modal.react.js +++ b/web/modals/threads/members/add-members-modal.react.js @@ -1,187 +1,182 @@ // @flow import * as React from 'react'; import { changeThreadSettingsActionTypes, useChangeThreadSettings, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; -import { - userSearchIndexForPotentialMembers, - userInfoSelectorForPotentialMembers, -} from 'lib/selectors/user-selectors.js'; +import { userInfoSelectorForPotentialMembers } from 'lib/selectors/user-selectors.js'; import { usePotentialMemberItems } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; import { useDispatchActionPromise } from 'lib/utils/action-utils.js'; import AddMembersListContent from './add-members-list-content.react.js'; import css from './members-modal.css'; import Button from '../../../components/button.react.js'; import Label from '../../../components/label.react.js'; import { useSelector } from '../../../redux/redux-utils.js'; import SearchModal from '../../search-modal.react.js'; type ContentProps = { +searchText: string, +threadID: string, +onClose: () => void, }; function AddMembersModalContent(props: ContentProps): React.Node { const { searchText, threadID, onClose } = props; const [pendingUsersToAdd, setPendingUsersToAdd] = React.useState< $ReadOnlySet, >(new Set()); 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 userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const excludeUserIDs = React.useMemo( () => threadActualMembers(threadInfo.members).concat( Array.from(pendingUsersToAdd), ), [pendingUsersToAdd, threadInfo.members], ); const userSearchResults = usePotentialMemberItems({ text: searchText, userInfos: otherUserInfos, - searchIndex: userSearchIndex, excludeUserIDs, inputParentThreadInfo: parentThreadInfo, inputCommunityThreadInfo: communityThreadInfo, threadType: threadInfo.type, }); const userSearchResultsWithENSNames = useENSNames(userSearchResults); const onSwitchUser = React.useCallback( (userID: string) => setPendingUsersToAdd(users => { const newUsers = new Set(users); if (newUsers.has(userID)) { newUsers.delete(userID); } else { newUsers.add(userID); } return newUsers; }), [], ); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useChangeThreadSettings(); const addUsers = React.useCallback(() => { void dispatchActionPromise( changeThreadSettingsActionTypes, callChangeThreadSettings({ threadID, changes: { newMemberIDs: Array.from(pendingUsersToAdd) }, }), ); onClose(); }, [ callChangeThreadSettings, dispatchActionPromise, onClose, pendingUsersToAdd, threadID, ]); const pendingUserInfos = React.useMemo( () => Array.from(pendingUsersToAdd) .map(userID => ({ id: userID, username: otherUserInfos[userID].username, })) .sort((a, b) => a.username.localeCompare(b.username)), [otherUserInfos, pendingUsersToAdd], ); const pendingUserInfosWithENSNames = useENSNames(pendingUserInfos); const labelItems = React.useMemo(() => { if (!pendingUserInfosWithENSNames.length) { return null; } return (
    {pendingUserInfosWithENSNames.map(userInfo => ( ))}
    ); }, [onSwitchUser, pendingUserInfosWithENSNames]); return (
    {labelItems}
    ); } type Props = { +threadID: string, +onClose: () => void, }; function AddMembersModal(props: Props): React.Node { const { threadID, onClose } = props; const addMembersModalContent = React.useCallback( (searchText: string) => ( ), [onClose, threadID], ); return ( {addMembersModalContent} ); } export { AddMembersModal, AddMembersModalContent };