diff --git a/native/profile/relationship-list.react.js b/native/profile/relationship-list.react.js index f8067bfe0..aba637fdd 100644 --- a/native/profile/relationship-list.react.js +++ b/native/profile/relationship-list.react.js @@ -1,501 +1,486 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import { FlatList } from 'react-native-gesture-handler'; import { updateRelationshipsActionTypes, updateRelationships, } from 'lib/actions/relationship-actions.js'; -import { - searchUsersActionTypes, - searchUsers, -} from 'lib/actions/user-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { userRelationshipsSelector } from 'lib/selectors/relationship-selectors.js'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors.js'; +import { useSearchUsers } from 'lib/shared/search-utils.js'; import { userRelationshipStatus, relationshipActions, } from 'lib/types/relationship-types.js'; import type { GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types.js'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import RelationshipListItem from './relationship-list-item.react.js'; import LinkButton from '../components/link-button.react.js'; import { createTagInput, BaseTagInput } from '../components/tag-input.react.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { FriendListRouteName, BlockListRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { useStyles, useIndicatorStyle } from '../themes/colors.js'; import type { VerticalBounds } from '../types/layout-types.js'; import Alert from '../utils/alert.js'; const TagInput = createTagInput(); export type RelationshipListNavigate = $PropertyType< ProfileNavigationProp<'FriendList' | 'BlockList'>, 'navigate', >; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; type ListItem = | { +type: 'empty', +because: 'no-relationships' | 'no-results' } | { +type: 'header' } | { +type: 'footer' } | { +type: 'user', +userInfo: AccountUserInfo, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, }; // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function keyExtractor(item: ListItem) { if (item.userInfo) { return item.userInfo.id; } else if (item.type === 'empty') { return 'empty'; } else if (item.type === 'header') { return 'header'; } else if (item.type === 'footer') { return 'footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); } const tagDataLabelExtractor = (userInfo: GlobalAccountUserInfo) => userInfo.username; type Props = { +navigation: ProfileNavigationProp<'FriendList' | 'BlockList'>, +route: NavigationRoute<'FriendList' | 'BlockList'>, }; function RelationshipList(props: Props): React.Node { - const callSearchUsers = useServerCall(searchUsers); const userInfos = useSelector(state => state.userStore.userInfos); - const searchUsersOnServer = React.useCallback( - async ( - usernamePrefix: string, - ): Promise<$ReadOnlyArray> => { - if (usernamePrefix.length === 0) { - return []; - } - - const userInfosResult = await callSearchUsers(usernamePrefix); - return userInfosResult.userInfos; - }, - [callSearchUsers], - ); const [searchInputText, setSearchInputText] = React.useState(''); const [userStoreSearchResults, setUserStoreSearchResults] = React.useState< $ReadOnlySet, >(new Set()); - const [serverSearchResults, setServerSearchResults] = React.useState< - $ReadOnlyArray, - >([]); const { route } = props; const routeName = route.name; - const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); - const onChangeSearchText = React.useCallback( - async (searchText: string) => { - setSearchInputText(searchText); - - const excludeStatuses = { + const excludeStatuses = React.useMemo( + () => + ({ [FriendListRouteName]: [ userRelationshipStatus.BLOCKED_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], [BlockListRouteName]: [], - }[routeName]; + }[routeName]), + [routeName], + ); + + const serverSearchResults = useSearchUsers(searchInputText); + const filteredServerSearchResults = React.useMemo( + () => + serverSearchResults.filter(searchUserInfo => { + const userInfo = userInfos[searchUserInfo.id]; + return ( + !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) + ); + }), + [serverSearchResults, userInfos, excludeStatuses], + ); + + const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); + const onChangeSearchText = React.useCallback( + async (searchText: string) => { + setSearchInputText(searchText); + const results = userStoreSearchIndex .getSearchResults(searchText) .filter(userID => { const relationship = userInfos[userID].relationshipStatus; return !excludeStatuses.includes(relationship); }); setUserStoreSearchResults(new Set(results)); - - const searchResultsFromServer = await searchUsersOnServer(searchText); - const filteredServerSearchResults = searchResultsFromServer.filter( - searchUserInfo => { - const userInfo = userInfos[searchUserInfo.id]; - return ( - !userInfo || !excludeStatuses.includes(userInfo.relationshipStatus) - ); - }, - ); - setServerSearchResults(filteredServerSearchResults); }, - [routeName, userStoreSearchIndex, userInfos, searchUsersOnServer], + [userStoreSearchIndex, userInfos, excludeStatuses], ); const overlayContext = React.useContext(OverlayContext); invariant(overlayContext, 'RelationshipList should have OverlayContext'); const scrollEnabled = overlayContext.scrollBlockingModalStatus === 'closed'; const tagInputRef = React.useRef>(); const flatListContainerRef = React.useRef>(); const keyboardState = React.useContext(KeyboardContext); const keyboardNotShowing = !!( keyboardState && !keyboardState.keyboardShowing ); const [verticalBounds, setVerticalBounds] = React.useState(null); const onFlatListContainerLayout = React.useCallback(() => { if (!flatListContainerRef.current) { return; } if (!keyboardNotShowing) { return; } flatListContainerRef.current.measure( (x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } setVerticalBounds({ height, y: pageY }); }, ); }, [keyboardNotShowing]); const [currentTags, setCurrentTags] = React.useState< $ReadOnlyArray, >([]); const onSelect = React.useCallback( (selectedUser: GlobalAccountUserInfo) => { if (currentTags.find(o => o.id === selectedUser.id)) { return; } setSearchInputText(''); setCurrentTags(prevCurrentTags => prevCurrentTags.concat(selectedUser)); }, [currentTags], ); const onUnknownErrorAlertAcknowledged = React.useCallback(() => { setCurrentTags([]); setSearchInputText(''); tagInputRef.current?.focus(); }, []); const callUpdateRelationships = useServerCall(updateRelationships); const updateRelationshipsOnServer = React.useCallback(async () => { const action = { [FriendListRouteName]: relationshipActions.FRIEND, [BlockListRouteName]: relationshipActions.BLOCK, }[routeName]; const userIDs = currentTags.map(userInfo => userInfo.id); try { const result = await callUpdateRelationships({ action, userIDs, }); setCurrentTags([]); setSearchInputText(''); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: onUnknownErrorAlertAcknowledged }], { cancelable: true, onDismiss: onUnknownErrorAlertAcknowledged }, ); throw e; } }, [ routeName, currentTags, callUpdateRelationships, onUnknownErrorAlertAcknowledged, ]); const dispatchActionPromise = useDispatchActionPromise(); const noCurrentTags = currentTags.length === 0; const onPressAdd = React.useCallback(() => { if (noCurrentTags) { return; } dispatchActionPromise( updateRelationshipsActionTypes, updateRelationshipsOnServer(), ); }, [noCurrentTags, dispatchActionPromise, updateRelationshipsOnServer]); const inputProps = React.useMemo( () => ({ ...tagInputProps, onSubmitEditing: onPressAdd, }), [onPressAdd], ); const { navigation } = props; const { navigate } = navigation; const styles = useStyles(unboundStyles); const renderItem = React.useCallback( // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return ({ item }: { item: ListItem, ... }) => { if (item.type === 'empty') { const action = { [FriendListRouteName]: 'added', [BlockListRouteName]: 'blocked', }[routeName]; const emptyMessage = item.because === 'no-relationships' ? `You haven't ${action} any users yet` : 'No results'; return {emptyMessage}; } else if (item.type === 'header' || item.type === 'footer') { return ; } else if (item.type === 'user') { return ( ); } else { invariant(false, `unexpected RelationshipList item type ${item.type}`); } }, [routeName, navigate, route, onSelect, styles.emptyText, styles.separator], ); const { setOptions } = navigation; const prevNoCurrentTags = React.useRef(noCurrentTags); React.useEffect(() => { let setSaveButtonDisabled; if (!prevNoCurrentTags.current && noCurrentTags) { setSaveButtonDisabled = true; } else if (prevNoCurrentTags.current && !noCurrentTags) { setSaveButtonDisabled = false; } prevNoCurrentTags.current = noCurrentTags; if (setSaveButtonDisabled === undefined) { return; } setOptions({ // eslint-disable-next-line react/display-name headerRight: () => ( ), }); }, [setOptions, noCurrentTags, onPressAdd]); const relationships = useSelector(userRelationshipsSelector); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const usersWithoutENSNames = React.useMemo(() => { if (searchInputText === '') { return { [FriendListRouteName]: relationships.friends, [BlockListRouteName]: relationships.blocked, }[routeName]; } const mergedUserInfos: { [id: string]: AccountUserInfo } = {}; - for (const userInfo of serverSearchResults) { + for (const userInfo of filteredServerSearchResults) { mergedUserInfos[userInfo.id] = userInfo; } for (const id of userStoreSearchResults) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedUserInfos[id] = { id, username, relationshipStatus }; } } const excludeUserIDsArray = currentTags .map(userInfo => userInfo.id) .concat(viewerID || []); const excludeUserIDs = new Set(excludeUserIDsArray); const sortToEnd = []; const userSearchResults = []; const sortRelationshipTypesToEnd = { [FriendListRouteName]: [userRelationshipStatus.FRIEND], [BlockListRouteName]: [ userRelationshipStatus.BLOCKED_BY_VIEWER, userRelationshipStatus.BOTH_BLOCKED, ], }[routeName]; for (const userID in mergedUserInfos) { if (excludeUserIDs.has(userID)) { continue; } const userInfo = mergedUserInfos[userID]; if (sortRelationshipTypesToEnd.includes(userInfo.relationshipStatus)) { sortToEnd.push(userInfo); } else { userSearchResults.push(userInfo); } } return userSearchResults.concat(sortToEnd); }, [ searchInputText, relationships, routeName, viewerID, currentTags, - serverSearchResults, + filteredServerSearchResults, userStoreSearchResults, userInfos, ]); const displayUsers = useENSNames(usersWithoutENSNames); const listData = React.useMemo(() => { let emptyItem; if (displayUsers.length === 0 && searchInputText === '') { emptyItem = { type: 'empty', because: 'no-relationships' }; } else if (displayUsers.length === 0) { emptyItem = { type: 'empty', because: 'no-results' }; } const mappedUsers = displayUsers.map((userInfo, index) => ({ type: 'user', userInfo, lastListItem: displayUsers.length - 1 === index, verticalBounds, })); return [] .concat(emptyItem ? emptyItem : []) .concat(emptyItem ? [] : { type: 'header' }) .concat(mappedUsers) .concat(emptyItem ? [] : { type: 'footer' }); }, [displayUsers, verticalBounds, searchInputText]); const indicatorStyle = useIndicatorStyle(); const currentTagsWithENSNames = useENSNames(currentTags); return ( Search: ); } const unboundStyles = { container: { flex: 1, backgroundColor: 'panelBackground', }, contentContainer: { paddingTop: 12, paddingBottom: 24, }, separator: { backgroundColor: 'panelForegroundBorder', height: Platform.OS === 'android' ? 1.5 : 1, }, emptyText: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, textAlign: 'center', paddingHorizontal: 12, paddingVertical: 10, marginHorizontal: 12, }, tagInput: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingLeft: 12, }, tagInputContainer: { alignItems: 'center', backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; -registerFetchKey(searchUsersActionTypes); registerFetchKey(updateRelationshipsActionTypes); const MemoizedRelationshipList: React.ComponentType = React.memo(RelationshipList); MemoizedRelationshipList.displayName = 'RelationshipList'; export default MemoizedRelationshipList; diff --git a/web/settings/relationship/add-users-list.react.js b/web/settings/relationship/add-users-list.react.js index 381aa94de..e8d12d9e8 100644 --- a/web/settings/relationship/add-users-list.react.js +++ b/web/settings/relationship/add-users-list.react.js @@ -1,264 +1,251 @@ // @flow import * as React from 'react'; import { updateRelationships, updateRelationshipsActionTypes, } from 'lib/actions/relationship-actions.js'; -import { searchUsers } from 'lib/actions/user-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { userStoreSearchIndex as userStoreSearchIndexSelector } from 'lib/selectors/user-selectors.js'; +import { useSearchUsers } from 'lib/shared/search-utils.js'; import type { UserRelationshipStatus, RelationshipAction, } from 'lib/types/relationship-types.js'; import type { GlobalAccountUserInfo, AccountUserInfo, } from 'lib/types/user-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; import AddUsersListItem from './add-users-list-item.react.js'; import css from './add-users-list.css'; import Button from '../../components/button.react.js'; import type { ButtonColor } from '../../components/button.react.js'; import Label from '../../components/label.react.js'; import LoadingIndicator from '../../loading-indicator.react.js'; import { useSelector } from '../../redux/redux-utils.js'; const loadingStatusSelector = createLoadingStatusSelector( updateRelationshipsActionTypes, ); type Props = { +searchText: string, +excludedStatuses?: $ReadOnlySet, +closeModal: () => void, +confirmButtonContent: React.Node, +confirmButtonColor?: ButtonColor, +relationshipAction: RelationshipAction, }; function AddUsersList(props: Props): React.Node { const { searchText, excludedStatuses = new Set(), closeModal, confirmButtonContent, confirmButtonColor, relationshipAction, } = props; const viewerID = useSelector(state => state.currentUserInfo?.id); const userStoreSearchIndex = useSelector(userStoreSearchIndexSelector); const [userStoreSearchResults, setUserStoreSearchResults] = React.useState< $ReadOnlySet, >(new Set(userStoreSearchIndex.getSearchResults(searchText))); React.useEffect(() => { setUserStoreSearchResults( new Set(userStoreSearchIndex.getSearchResults(searchText)), ); }, [searchText, userStoreSearchIndex]); - const [serverSearchResults, setServerSearchResults] = React.useState< - $ReadOnlyArray, - >([]); - const callSearchUsers = useServerCall(searchUsers); - React.useEffect(() => { - (async () => { - if (searchText.length === 0) { - setServerSearchResults([]); - } else { - const { userInfos } = await callSearchUsers(searchText); - setServerSearchResults(userInfos); - } - })(); - }, [callSearchUsers, searchText]); + const serverSearchResults = useSearchUsers(searchText); const searchTextPresent = searchText.length > 0; const userInfos = useSelector(state => state.userStore.userInfos); const mergedUserInfos = React.useMemo(() => { const mergedInfos: { [string]: GlobalAccountUserInfo | AccountUserInfo } = {}; for (const userInfo of serverSearchResults) { mergedInfos[userInfo.id] = userInfo; } const userStoreUserIDs = searchTextPresent ? userStoreSearchResults : Object.keys(userInfos); for (const id of userStoreUserIDs) { const { username, relationshipStatus } = userInfos[id]; if (username) { mergedInfos[id] = { id, username, relationshipStatus }; } } return mergedInfos; }, [ searchTextPresent, serverSearchResults, userInfos, userStoreSearchResults, ]); const sortedUsers = React.useMemo( () => Object.keys(mergedUserInfos) .map(userID => mergedUserInfos[userID]) .filter( user => user.id !== viewerID && (!user.relationshipStatus || !excludedStatuses.has(user.relationshipStatus)), ) .sort((user1, user2) => user1.username.localeCompare(user2.username)), [excludedStatuses, mergedUserInfos, viewerID], ); const [pendingUsersToAdd, setPendingUsersToAdd] = React.useState< $ReadOnlyArray, >([]); const selectUser = React.useCallback( (userID: string) => { setPendingUsersToAdd(pendingUsers => { const username = mergedUserInfos[userID]?.username; if (!username || pendingUsers.some(user => user.id === userID)) { return pendingUsers; } const newPendingUser = { id: userID, username, }; let targetIndex = 0; while ( targetIndex < pendingUsers.length && newPendingUser.username.localeCompare( pendingUsers[targetIndex].username, ) > 0 ) { targetIndex++; } return [ ...pendingUsers.slice(0, targetIndex), newPendingUser, ...pendingUsers.slice(targetIndex), ]; }); }, [mergedUserInfos], ); const deselectUser = React.useCallback( (userID: string) => setPendingUsersToAdd(pendingUsers => pendingUsers.filter(userInfo => userInfo.id !== userID), ), [], ); const pendingUserIDs = React.useMemo( () => new Set(pendingUsersToAdd.map(userInfo => userInfo.id)), [pendingUsersToAdd], ); const pendingUsersWithENSNames = useENSNames(pendingUsersToAdd); const userTags = React.useMemo(() => { if (pendingUsersWithENSNames.length === 0) { return null; } const tags = pendingUsersWithENSNames.map(userInfo => ( )); return
{tags}
; }, [deselectUser, pendingUsersWithENSNames]); const filteredUsers = React.useMemo( () => sortedUsers.filter(userInfo => !pendingUserIDs.has(userInfo.id)), [pendingUserIDs, sortedUsers], ); const filteredUsersWithENSNames = useENSNames(filteredUsers); const userRows = React.useMemo( () => filteredUsersWithENSNames.map(userInfo => ( )), [filteredUsersWithENSNames, selectUser], ); const [errorMessage, setErrorMessage] = React.useState(''); const callUpdateRelationships = useServerCall(updateRelationships); const dispatchActionPromise = useDispatchActionPromise(); const updateRelationshipsPromiseCreator = React.useCallback(async () => { try { setErrorMessage(''); const result = await callUpdateRelationships({ action: relationshipAction, userIDs: Array.from(pendingUserIDs), }); closeModal(); return result; } catch (e) { setErrorMessage('unknown error'); throw e; } }, [callUpdateRelationships, closeModal, pendingUserIDs, relationshipAction]); const confirmSelection = React.useCallback( () => dispatchActionPromise( updateRelationshipsActionTypes, updateRelationshipsPromiseCreator(), ), [dispatchActionPromise, updateRelationshipsPromiseCreator], ); const loadingStatus = useSelector(loadingStatusSelector); let buttonContent = confirmButtonContent; if (loadingStatus === 'loading') { buttonContent = ( <>
{confirmButtonContent}
); } let errors; if (errorMessage) { errors =
{errorMessage}
; } return (
{userTags}
{userRows}
{errors}
); } export default AddUsersList;