diff --git a/web/modals/threads/create/compose-subchannel-modal.react.js b/web/modals/threads/create/compose-subchannel-modal.react.js index 506d70043..b3e80b75e 100644 --- a/web/modals/threads/create/compose-subchannel-modal.react.js +++ b/web/modals/threads/create/compose-subchannel-modal.react.js @@ -1,289 +1,293 @@ // @flow import * as React from 'react'; import { newThreadActionTypes, useNewThinThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import { useDispatchActionPromise } from 'lib/utils/redux-promise-utils.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { trimText } from 'lib/utils/text-utils.js'; import css from './compose-subchannel-modal.css'; import SubchannelMembers from './steps/subchannel-members.react.js'; import type { VisibilityType } from './steps/subchannel-settings.react.js'; import SubchannelSettings from './steps/subchannel-settings.react.js'; import Button from '../../../components/button.react.js'; import { updateNavInfoActionType } from '../../../redux/action-types.js'; import { useSelector } from '../../../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../../../selectors/nav-selectors.js'; import { useAddUsersListContext, AddUsersListProvider, } from '../../../settings/relationship/add-users-list-provider.react.js'; import Modal from '../../modal.react.js'; type Props = { +onClose: () => void, +parentThreadInfo: ThreadInfo, }; const getThreadType = (visibility: VisibilityType, announcement: boolean) => { if (visibility === 'open') { return announcement ? threadTypes.COMMUNITY_OPEN_ANNOUNCEMENT_SUBTHREAD : threadTypes.COMMUNITY_OPEN_SUBTHREAD; } else { return announcement ? threadTypes.COMMUNITY_SECRET_ANNOUNCEMENT_SUBTHREAD : threadTypes.COMMUNITY_SECRET_SUBTHREAD; } }; type Steps = 'settings' | 'members'; type HeaderProps = { +parentThreadName: string, }; function ComposeSubchannelHeader(props: HeaderProps): React.Node { const { parentThreadName } = props; return (
{'within '}
{parentThreadName}
); } const createSubchannelLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); function ComposeSubchannelModal(props: Props): React.Node { const { parentThreadInfo, onClose } = props; const { uiName: parentThreadName } = useResolvedThreadInfo(parentThreadInfo); const { pendingUsersToAdd } = useAddUsersListContext(); const [activeStep, setActiveStep] = React.useState('settings'); const [channelName, setChannelName] = React.useState(''); const [visibilityType, setVisibilityType] = React.useState('open'); const [announcement, setAnnouncement] = React.useState(false); const loadingState = useSelector(createSubchannelLoadingStatusSelector); const [errorMessage, setErrorMessage] = React.useState(''); const calendarQuery = useSelector(nonThreadCalendarQuery); const callNewThinThread = useNewThinThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); + const threadType = getThreadType(visibilityType, announcement); + const createSubchannel = React.useCallback(async () => { try { - const threadType = getThreadType(visibilityType, announcement); - const query = calendarQuery(); const result = await callNewThinThread({ name: channelName, type: threadType, parentThreadID: parentThreadInfo.id, initialMemberIDs: Array.from(pendingUsersToAdd.keys()), calendarQuery: query, color: parentThreadInfo.color, }); return result; } catch (e) { await setErrorMessage('unknown error'); return null; } }, [ - visibilityType, - announcement, calendarQuery, callNewThinThread, channelName, + threadType, parentThreadInfo.id, parentThreadInfo.color, pendingUsersToAdd, ]); const dispatchCreateSubchannel = React.useCallback(async () => { await setErrorMessage(''); const response = createSubchannel(); await dispatchActionPromise(newThreadActionTypes, response); const result = await response; if (result) { const { newThreadID } = result; await dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: newThreadID, }, }); props.onClose(); } }, [dispatchActionPromise, createSubchannel, props, dispatch]); const onChangeChannelName = React.useCallback( (event: SyntheticEvent) => { const target = event.currentTarget; setChannelName(target.value); }, [], ); const onOpenVisibilityTypeSelected = React.useCallback( () => setVisibilityType('open'), [], ); const onSecretVisibilityTypeSelected = React.useCallback( () => setVisibilityType('secret'), [], ); const onAnnouncementSelected = React.useCallback( () => setAnnouncement(!announcement), [announcement], ); const subchannelSettings = React.useMemo( () => ( ), [ channelName, visibilityType, announcement, onChangeChannelName, onOpenVisibilityTypeSelected, onSecretVisibilityTypeSelected, onAnnouncementSelected, ], ); const subchannelMembers = React.useMemo( - () => , - [parentThreadInfo], + () => ( + + ), + [parentThreadInfo, threadType], ); const modalName = activeStep === 'members' ? `Create channel - ${trimText(channelName, 11)}` : 'Create channel'; const subheader = React.useMemo( () => , [parentThreadName], ); const modalContent = React.useMemo(() => { if (activeStep === 'settings') { return subchannelSettings; } return subchannelMembers; }, [activeStep, subchannelMembers, subchannelSettings]); const onClickNext = React.useCallback(() => { setErrorMessage(''); setChannelName(channelName.trim()); setActiveStep('members'); }, [channelName]); const primaryButton = React.useMemo(() => { if (activeStep === 'settings') { return ( ); } return ( ); }, [ activeStep, channelName, dispatchCreateSubchannel, loadingState, onClickNext, pendingUsersToAdd.size, ]); const onClickBack = React.useCallback(() => setActiveStep('settings'), []); const secondaryButton = React.useMemo(() => { if (activeStep !== 'members') { return null; } return ( ); }, [activeStep, onClickBack]); return (
{modalContent} {errorMessage}
); } function ComposeSubchannelModalWrapper(props: Props): React.Node { const composeSubchannelModalWrapper = React.useMemo( () => ( ), [props], ); return composeSubchannelModalWrapper; } export default ComposeSubchannelModalWrapper; diff --git a/web/modals/threads/create/steps/subchannel-members.react.js b/web/modals/threads/create/steps/subchannel-members.react.js index 87dde2ba3..7eef5efb0 100644 --- a/web/modals/threads/create/steps/subchannel-members.react.js +++ b/web/modals/threads/create/steps/subchannel-members.react.js @@ -1,45 +1,48 @@ // @flow import * as React from 'react'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; +import type { ThreadType } from 'lib/types/thread-types-enum.js'; import css from './subchannel-members.css'; import Search from '../../../../components/search.react.js'; import AddUsersList from '../../../../settings/relationship/add-users-list.react.js'; import { useSubchannelAddMembersListUserInfos } from '../../../../settings/relationship/add-users-utils.js'; type SubchannelMembersProps = { +parentThreadInfo: ThreadInfo, + +threadType: ThreadType, }; function SubchannelMembers(props: SubchannelMembersProps): React.Node { - const { parentThreadInfo } = props; + const { parentThreadInfo, threadType } = props; const [searchUserText, setSearchUserText] = React.useState(''); const { userInfos, sortedUsersWithENSNames } = useSubchannelAddMembersListUserInfos({ - parentThreadID: parentThreadInfo.id, + parentThreadInfo, searchText: searchUserText, + threadType, }); return ( <>
0} userInfos={userInfos} sortedUsersWithENSNames={sortedUsersWithENSNames} /> ); } export default SubchannelMembers; diff --git a/web/settings/relationship/add-users-list.react.js b/web/settings/relationship/add-users-list.react.js index a5adedc99..618cef610 100644 --- a/web/settings/relationship/add-users-list.react.js +++ b/web/settings/relationship/add-users-list.react.js @@ -1,191 +1,191 @@ // @flow import * as React from 'react'; import { useSortedENSResolvedUsers } from 'lib/hooks/ens-cache.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import AddUsersListItem, { type BaseAddUserInfo, } from './add-users-list-item.react.js'; import { useAddUsersListContext } from './add-users-list-provider.react.js'; import css from './add-users-list.css'; import Button from '../../components/button.react.js'; type Props = { +searchModeActive: boolean, +userInfos: { - [string]: T, + +[string]: T, }, +sortedUsersWithENSNames: $ReadOnlyArray, }; function AddUsersList(props: Props): React.Node { const { searchModeActive, userInfos, sortedUsersWithENSNames } = props; const { pendingUsersToAdd, setPendingUsersToAdd, previouslySelectedUsers, setPreviouslySelectedUsers, errorMessage, } = useAddUsersListContext(); React.useEffect(() => { setPreviouslySelectedUsers(pendingUsersToAdd); // We want this effect to run ONLY when searchModeActive changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchModeActive]); const previouslySelectedUsersList = React.useMemo( () => Array.from(previouslySelectedUsers.values()), [previouslySelectedUsers], ); const toggleUser = React.useCallback( (userID: string) => { setPendingUsersToAdd(pendingUsers => { const newPendingUsers = new Map(pendingUsers); if (newPendingUsers.delete(userID)) { return newPendingUsers; } if (!previouslySelectedUsers.has(userID)) { const newPendingUser: BaseAddUserInfo = { id: userID, username: stringForUser(userInfos[userID]), }; newPendingUsers.set(userID, newPendingUser); } const newPendingUser = previouslySelectedUsers.get(userID); if (newPendingUser) { newPendingUsers.set(userID, newPendingUser); } return newPendingUsers; }); }, [userInfos, setPendingUsersToAdd, previouslySelectedUsers], ); const userRows = React.useMemo( () => sortedUsersWithENSNames.map(userInfo => ( )), [sortedUsersWithENSNames, toggleUser, pendingUsersToAdd], ); const sortedPreviouslySelectedUsersWithENSNames = useSortedENSResolvedUsers( previouslySelectedUsersList, ); const previouslySelectedUserRows = React.useMemo(() => { if (searchModeActive || previouslySelectedUsers.size === 0) { return null; } const sortedPreviouslySelectedUserRows = sortedPreviouslySelectedUsersWithENSNames.map(userInfo => ( )); return (
{sortedPreviouslySelectedUserRows}
); }, [ searchModeActive, previouslySelectedUsers.size, sortedPreviouslySelectedUsersWithENSNames, toggleUser, pendingUsersToAdd, ]); const onClickClearAll = React.useCallback(() => { setPendingUsersToAdd(new Map()); }, [setPendingUsersToAdd]); const clearAllButtonColor = React.useMemo(() => { if (pendingUsersToAdd.size === 0) { return { color: 'var(--link-background-secondary-disabled)' }; } return { color: 'var(--link-background-secondary-default)' }; }, [pendingUsersToAdd.size]); const clearAllButton = React.useMemo(() => { if (searchModeActive) { return null; } return ( ); }, [ clearAllButtonColor, onClickClearAll, pendingUsersToAdd.size, searchModeActive, ]); const listHeader = React.useMemo(() => { let selectionText = 'Select users'; if (searchModeActive) { selectionText = 'Search results:'; } else if (pendingUsersToAdd.size > 0) { selectionText = `${pendingUsersToAdd.size} selected`; } return (
{selectionText}
{clearAllButton}
); }, [clearAllButton, pendingUsersToAdd.size, searchModeActive]); let errors; if (errorMessage) { errors =
{errorMessage}
; } const addUsersList = React.useMemo( () => (
{listHeader}
{previouslySelectedUserRows} {userRows}
{errors}
), [errors, listHeader, userRows, previouslySelectedUserRows], ); return addUsersList; } export default AddUsersList; diff --git a/web/settings/relationship/add-users-utils.js b/web/settings/relationship/add-users-utils.js index 903bb04a8..44effdbea 100644 --- a/web/settings/relationship/add-users-utils.js +++ b/web/settings/relationship/add-users-utils.js @@ -1,261 +1,242 @@ // @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 { useAncestorThreads } from 'lib/shared/ancestor-threads.js'; import { useSearchUsers, usePotentialMemberItems, } from 'lib/shared/search-utils.js'; import { threadActualMembers } from 'lib/shared/thread-utils.js'; -import type { RelativeMemberInfo } from 'lib/types/minimally-encoded-thread-permissions-types.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 excludeUserIDs = React.useMemo( () => threadActualMembers(threadInfo.members).concat( Array.from(previouslySelectedUsers.keys()), ), [previouslySelectedUsers, threadInfo.members], ); const userSearchResults = usePotentialMemberItems({ text: searchText, userInfos: otherUserInfos, 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 = { - +parentThreadID: string, + +parentThreadInfo: ThreadInfo, +searchText: string, + +threadType: ThreadType, }; function useSubchannelAddMembersListUserInfos( params: UseSubchannelAddMembersListUserInfosParams, ): { +userInfos: { - [string]: RelativeMemberInfo, + +[string]: UserListItem, }, - +sortedUsersWithENSNames: $ReadOnlyArray, + +sortedUsersWithENSNames: $ReadOnlyArray, } { - const { parentThreadID, searchText } = params; - - const { previouslySelectedUsers } = useAddUsersListContext(); - - const parentThreadInfo = useSelector( - state => threadInfoSelector(state)[parentThreadID], - ); - - const currentUserID = useSelector(state => state.currentUserInfo?.id); - - const ancestorThreads = useAncestorThreads(parentThreadInfo); - - const communityThreadInfo = ancestorThreads[0] ?? parentThreadInfo; - - const userInfos = React.useMemo(() => { - const infos: { [string]: RelativeMemberInfo } = {}; - - for (const member of communityThreadInfo.members) { - infos[member.id] = member; - } - - return infos; - }, [communityThreadInfo.members]); - - const userSearchIndex = useUserSearchIndex(communityThreadInfo.members); + const { searchText, parentThreadInfo, threadType } = params; - const searchResult = React.useMemo( - () => new Set(userSearchIndex.getSearchResults(searchText)), - [userSearchIndex, searchText], + const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); + const { community } = parentThreadInfo; + const communityThreadInfo = useSelector(state => + community ? threadInfoSelector(state)[community] : null, ); - const filterOutOtherMembersWithENSNames = React.useCallback( - (members: $ReadOnlyArray) => - members.filter( - user => - !previouslySelectedUsers.has(user.id) && - user.id !== currentUserID && - (searchResult.has(user.id) || searchText.length === 0), - ), - [currentUserID, previouslySelectedUsers, searchResult, searchText.length], + const { previouslySelectedUsers } = useAddUsersListContext(); + const previouslySelectedUserIDs = React.useMemo( + () => [...previouslySelectedUsers].map(([key]) => key), + [previouslySelectedUsers], ); - const otherMemberListWithoutENSNames = React.useMemo( - () => filterOutOtherMembersWithENSNames(communityThreadInfo.members), - [communityThreadInfo.members, filterOutOtherMembersWithENSNames], - ); + const userSearchResults = usePotentialMemberItems({ + text: searchText, + userInfos: otherUserInfos, + excludeUserIDs: previouslySelectedUserIDs, + inputParentThreadInfo: parentThreadInfo, + inputCommunityThreadInfo: communityThreadInfo, + threadType, + }); + const userSearchResultWithENSNames = + useSortedENSResolvedUsers(userSearchResults); - const sortedUsersWithENSNames = useSortedENSResolvedUsers( - otherMemberListWithoutENSNames, + const userResults: { [id: string]: UserListItem } = React.useMemo( + () => _keyBy('id')(userSearchResults), + [userSearchResults], ); return { - userInfos, - sortedUsersWithENSNames, + userInfos: userResults, + sortedUsersWithENSNames: userSearchResultWithENSNames, }; } export { useUserRelationshipUserInfos, useAddMembersListUserInfos, useSubchannelAddMembersListUserInfos, };