diff --git a/web/modals/threads/create/compose-subchannel-modal.react.js b/web/modals/threads/create/compose-subchannel-modal.react.js index e8a335564..af035e9b9 100644 --- a/web/modals/threads/create/compose-subchannel-modal.react.js +++ b/web/modals/threads/create/compose-subchannel-modal.react.js @@ -1,276 +1,271 @@ // @flow import * as React from 'react'; import { newThreadActionTypes, useNewThread, } 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 Stepper from '../../../components/stepper.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 [selectedUsers, setSelectedUsers] = React.useState< - $ReadOnlySet, - >(new Set()); - const [searchUserText, setSearchUserText] = React.useState(''); const loadingState = useSelector(createSubchannelLoadingStatusSelector); const [errorMessage, setErrorMessage] = React.useState(''); const calendarQuery = useSelector(nonThreadCalendarQuery); const callNewThread = useNewThread(); const dispatchActionPromise = useDispatchActionPromise(); const dispatch = useDispatch(); const createSubchannel = React.useCallback(async () => { try { const threadType = getThreadType(visibilityType, announcement); const query = calendarQuery(); const result = await callNewThread({ name: channelName, type: threadType, parentThreadID: parentThreadInfo.id, - initialMemberIDs: Array.from(selectedUsers), + initialMemberIDs: Array.from(pendingUsersToAdd.keys()), calendarQuery: query, color: parentThreadInfo.color, }); return result; } catch (e) { await setErrorMessage('unknown error'); return null; } }, [ - parentThreadInfo, - selectedUsers, visibilityType, announcement, - callNewThread, calendarQuery, + callNewThread, channelName, + 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 toggleUserSelection = React.useCallback((userID: string) => { - setSelectedUsers((users: $ReadOnlySet) => { - const newUsers = new Set(users); - if (newUsers.has(userID)) { - newUsers.delete(userID); - } else { - newUsers.add(userID); - } - return newUsers; - }); - }, []); - const subchannelSettings = React.useMemo( () => ( ), [ channelName, visibilityType, announcement, onChangeChannelName, onOpenVisibilityTypeSelected, onSecretVisibilityTypeSelected, onAnnouncementSelected, ], ); const stepperButtons = React.useMemo( () => ({ settings: { nextProps: { content: 'Next', disabled: !channelName.trim(), onClick: () => { setErrorMessage(''); setChannelName(channelName.trim()); setActiveStep('members'); }, }, }, members: { prevProps: { content: 'Back', onClick: () => setActiveStep('settings'), }, nextProps: { content: 'Create', loading: loadingState === 'loading', - disabled: selectedUsers.size === 0, + disabled: pendingUsersToAdd.size === 0, onClick: dispatchCreateSubchannel, }, }, }), - [channelName, dispatchCreateSubchannel, loadingState, selectedUsers], + [ + channelName, + dispatchCreateSubchannel, + loadingState, + pendingUsersToAdd.size, + ], ); const subchannelMembers = React.useMemo( - () => ( - - ), - [ - selectedUsers, - toggleUserSelection, - parentThreadInfo, - searchUserText, - setSearchUserText, - ], + () => , + [parentThreadInfo], ); const modalName = activeStep === 'members' ? `Create channel - ${trimText(channelName, 11)}` : 'Create channel'; return (
); } -export default ComposeSubchannelModal; +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-list.react.js b/web/modals/threads/create/steps/subchannel-members-list.react.js deleted file mode 100644 index c08294c8f..000000000 --- a/web/modals/threads/create/steps/subchannel-members-list.react.js +++ /dev/null @@ -1,108 +0,0 @@ -// @flow - -import * as React from 'react'; - -import { useENSNames } from 'lib/hooks/ens-cache.js'; -import { stringForUser } from 'lib/shared/user-utils.js'; -import type { - RelativeMemberInfo, - ThreadInfo, -} from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import type { UserListItem } from 'lib/types/user-types.js'; - -import { useSelector } from '../../../../redux/redux-utils.js'; -import AddMembersList from '../../../components/add-members-list.react.js'; - -type Props = { - +searchText: string, - +searchResult: $ReadOnlySet, - +communityThreadInfo: ThreadInfo, - +parentThreadInfo: ThreadInfo, - +selectedUsers: $ReadOnlySet, - +toggleUserSelection: (userID: string) => void, -}; - -function SubchannelMembersList(props: Props): React.Node { - const { - searchText, - searchResult, - communityThreadInfo, - parentThreadInfo, - selectedUsers, - toggleUserSelection, - } = props; - - const { name: communityName } = communityThreadInfo; - - const currentUserId = useSelector(state => state.currentUserInfo?.id); - - const parentMembersSet = React.useMemo( - () => new Set(parentThreadInfo.members.map(user => user.id)), - [parentThreadInfo], - ); - - const filterOutParentMembersWithENSNames = React.useCallback( - (members: $ReadOnlyArray) => - members - .filter( - user => - user.id !== currentUserId && - (searchResult.has(user.id) || searchText.length === 0), - ) - .map(user => ({ id: user.id, username: stringForUser(user) })), - [currentUserId, searchResult, searchText.length], - ); - - const parentMemberListWithoutENSNames = React.useMemo( - () => filterOutParentMembersWithENSNames(parentThreadInfo.members), - [filterOutParentMembersWithENSNames, parentThreadInfo.members], - ); - - const parentMemberList = useENSNames( - parentMemberListWithoutENSNames, - ); - - const filterOutOtherMembersWithENSNames = React.useCallback( - (members: $ReadOnlyArray) => - members - .filter( - user => - !parentMembersSet.has(user.id) && - user.id !== currentUserId && - (searchResult.has(user.id) || searchText.length === 0), - ) - .map(user => ({ id: user.id, username: stringForUser(user) })), - [currentUserId, parentMembersSet, searchResult, searchText.length], - ); - - const otherMemberListWithoutENSNames = React.useMemo( - () => filterOutOtherMembersWithENSNames(communityThreadInfo.members), - [communityThreadInfo.members, filterOutOtherMembersWithENSNames], - ); - - const otherMemberList = useENSNames( - otherMemberListWithoutENSNames, - ); - - const sortedGroupedUserList = React.useMemo( - () => - [ - { header: 'Users in parent channel', userInfos: parentMemberList }, - { - header: `All users in ${communityName ?? 'community'}`, - userInfos: otherMemberList, - }, - ].filter(item => item.userInfos.length), - [parentMemberList, otherMemberList, communityName], - ); - - return ( - - ); -} - -export default SubchannelMembersList; diff --git a/web/modals/threads/create/steps/subchannel-members.css b/web/modals/threads/create/steps/subchannel-members.css index 3dcd4c365..46f36c541 100644 --- a/web/modals/threads/create/steps/subchannel-members.css +++ b/web/modals/threads/create/steps/subchannel-members.css @@ -1,10 +1,5 @@ -.members { - overflow-y: auto; -} - .searchBar { - background-color: var(--modal-bg); position: sticky; - padding: 2.5px 0; top: 0; + z-index: 1; } diff --git a/web/modals/threads/create/steps/subchannel-members.react.js b/web/modals/threads/create/steps/subchannel-members.react.js index a65940884..87dde2ba3 100644 --- a/web/modals/threads/create/steps/subchannel-members.react.js +++ b/web/modals/threads/create/steps/subchannel-members.react.js @@ -1,63 +1,45 @@ // @flow import * as React from 'react'; -import { useUserSearchIndex } from 'lib/selectors/nav-selectors.js'; -import { useAncestorThreads } from 'lib/shared/ancestor-threads.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; -import MembersList from './subchannel-members-list.react.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, - +selectedUsers: $ReadOnlySet, - +searchText: string, - +setSearchText: string => void, - +toggleUserSelection: (userID: string) => void, }; function SubchannelMembers(props: SubchannelMembersProps): React.Node { - const { - toggleUserSelection, - searchText, - setSearchText, - parentThreadInfo, - selectedUsers, - } = props; - - const ancestorThreads = useAncestorThreads(parentThreadInfo); - - const communityThread = ancestorThreads[0] ?? parentThreadInfo; - - const userSearchIndex = useUserSearchIndex(communityThread.members); - const searchResult = React.useMemo( - () => new Set(userSearchIndex.getSearchResults(searchText)), - [userSearchIndex, searchText], - ); + const { parentThreadInfo } = props; + + const [searchUserText, setSearchUserText] = React.useState(''); + + const { userInfos, sortedUsersWithENSNames } = + useSubchannelAddMembersListUserInfos({ + parentThreadID: parentThreadInfo.id, + searchText: searchUserText, + }); return ( <>
-
- -
+ 0} + userInfos={userInfos} + sortedUsersWithENSNames={sortedUsersWithENSNames} + /> ); } export default SubchannelMembers; diff --git a/web/settings/relationship/add-users-utils.js b/web/settings/relationship/add-users-utils.js index 274eb6057..903bb04a8 100644 --- a/web/settings/relationship/add-users-utils.js +++ b/web/settings/relationship/add-users-utils.js @@ -1,185 +1,261 @@ // @flow 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 { UserRelationshipStatus } from 'lib/types/relationship-types.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; } -export { useUserRelationshipUserInfos, useAddMembersListUserInfos }; +type UseSubchannelAddMembersListUserInfosParams = { + +parentThreadID: string, + +searchText: string, +}; + +function useSubchannelAddMembersListUserInfos( + params: UseSubchannelAddMembersListUserInfosParams, +): { + +userInfos: { + [string]: RelativeMemberInfo, + }, + +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 searchResult = React.useMemo( + () => new Set(userSearchIndex.getSearchResults(searchText)), + [userSearchIndex, searchText], + ); + + 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 otherMemberListWithoutENSNames = React.useMemo( + () => filterOutOtherMembersWithENSNames(communityThreadInfo.members), + [communityThreadInfo.members, filterOutOtherMembersWithENSNames], + ); + + const sortedUsersWithENSNames = useSortedENSResolvedUsers( + otherMemberListWithoutENSNames, + ); + + return { + userInfos, + sortedUsersWithENSNames, + }; +} + +export { + useUserRelationshipUserInfos, + useAddMembersListUserInfos, + useSubchannelAddMembersListUserInfos, +};