diff --git a/web/chat/thread-menu.react.js b/web/chat/thread-menu.react.js --- a/web/chat/thread-menu.react.js +++ b/web/chat/thread-menu.react.js @@ -28,6 +28,7 @@ import SidebarPromoteModal from '../modals/chat/sidebar-promote-modal.react'; import { useModalContext } from '../modals/modal-provider.react'; import ConfirmLeaveThreadModal from '../modals/threads/confirm-leave-thread-modal.react'; +import ComposeSubchannelModal from '../modals/threads/create/compose-subchannel-modal.react'; import ThreadMembersModal from '../modals/threads/members/members-modal.react'; import ThreadNotificationsModal from '../modals/threads/notifications/notifications-modal.react'; import ThreadSettingsModal from '../modals/threads/settings/thread-settings-modal.react'; @@ -144,6 +145,17 @@ ); }, [hasSubchannels, onClickViewSubchannels]); + const onClickCreateSubchannel = React.useCallback( + () => + pushModal( + , + ), + [popModal, pushModal, threadInfo], + ); + const createSubchannelsItem = React.useMemo(() => { if (!canCreateSubchannels) { return null; @@ -153,9 +165,10 @@ key="newSubchannel" text="Create new subchannel" icon="plus-circle" + onClick={onClickCreateSubchannel} /> ); - }, [canCreateSubchannels]); + }, [canCreateSubchannels, onClickCreateSubchannel]); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useServerCall(leaveThread); @@ -245,16 +258,13 @@ const menuItems = React.useMemo(() => { const separator =
; - // TODO: Enable menu items when the modals are implemented - const SHOW_CREATE_SUBCHANNELS = false; - const items = [ settingsItem, notificationsItem, membersItem, sidebarItem, viewSubchannelsItem, - SHOW_CREATE_SUBCHANNELS && createSubchannelsItem, + createSubchannelsItem, leaveThreadItem && separator, canPromoteSidebar && promoteSidebar, leaveThreadItem, diff --git a/web/modals/threads/create/compose-subchannel-modal.css b/web/modals/threads/create/compose-subchannel-modal.css new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/compose-subchannel-modal.css @@ -0,0 +1,35 @@ +div.modalHeader { + padding: 15px; + + font-weight: 500; + + color: var(--shades-black-60); + background-color: var(--shades-black-80); + + display: flex; + align-items: center; + justify-content: center; + + margin-top: 30px; +} + +div.modalHeaderParentName { + color: var(--violet-light-100); + display: inline; +} + +div.container { + width: 383px; + max-height: 100vh; + overflow-y: auto; +} + +div.stepContainer { + position: relative; + height: 100vh; + max-height: min(533px, 100vh); +} + +div.stepItem { + padding: 20px; +} diff --git a/web/modals/threads/create/compose-subchannel-modal.react.js b/web/modals/threads/create/compose-subchannel-modal.react.js new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/compose-subchannel-modal.react.js @@ -0,0 +1,211 @@ +// @flow +import * as React from 'react'; + +import type { ThreadInfo } from 'lib/types/thread-types'; +import { trimText } from 'lib/utils/text-utils'; + +import Stepper from '../../../components/stepper.react'; +import Modal from '../../modal.react'; +import css from './compose-subchannel-modal.css'; +import SubchannelMembers from './steps/subchannel-members.react'; +import SubchannelSettings from './steps/subchannel-settings.react'; + +type Props = { + +onClose: () => any, + +parentThreadInfo: ThreadInfo, +}; + +type Pages = 'settings' | 'members'; +type VisibilityType = 'open' | 'closed'; + +type HeaderProps = { + +parentThreadName: string, +}; + +function ComposeSubchannelHeader(props: HeaderProps): React.Node { + const { parentThreadName } = props; + return ( +
+
+ {'within '} +
{parentThreadName}
+
+
+ ); +} + +function ComposeSubchannelModal(props: Props): React.Node { + const { parentThreadInfo } = props; + const { uiName: parentThreadName } = parentThreadInfo; + + 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>( + new Set(), + ); + const [searchUserText, setSearchUserText] = React.useState(''); + + const handleChanges = React.useCallback( + ( + event: SyntheticEvent, + setValue: (newValue: any) => any, + ) => { + const target = event.currentTarget; + setValue(target.value); + }, + [], + ); + + const onChangeChannelName = React.useCallback( + (event: SyntheticEvent) => { + handleChanges(event, setChannelName); + }, + [handleChanges], + ); + + const onOpenVisibilityTypeSelected = React.useCallback( + () => setVisibilityType('open'), + [], + ); + + const onClosedVisibilityTypeSelected = React.useCallback( + () => setVisibilityType('closed'), + [], + ); + + const onAnnouncementSelected = React.useCallback( + () => setAnnouncement(!announcement), + [announcement], + ); + + const switchUser = React.useCallback((userID: string) => { + setSelectedUsers((users: Set) => { + const newUsers = new Set(users); + if (newUsers.has(userID)) { + newUsers.delete(userID); + } else { + newUsers.add(userID); + } + return newUsers; + }); + }, []); + + const steps = []; + + const subchannelSettings = React.useMemo( + () => ( + + ), + [ + channelName, + visibilityType, + announcement, + onChangeChannelName, + onOpenVisibilityTypeSelected, + onClosedVisibilityTypeSelected, + onAnnouncementSelected, + ], + ); + + const stepperButtons = React.useMemo( + () => ({ + settings: { + nextProps: { + content: 'Next', + disabled: !channelName.trim(), + onClick: () => { + setChannelName(channelName.trim()); + setActiveStep('members'); + }, + }, + }, + members: { + prevProps: { + content: 'Back', + onClick: () => setActiveStep('settings'), + }, + nextProps: { + content: 'Create', + onClick: () => { + /// TODO: make form logic + }, + }, + }, + }), + [channelName], + ); + + const subchannelMembers = React.useMemo( + () => ( + + ), + [ + selectedUsers, + switchUser, + parentThreadInfo, + searchUserText, + setSearchUserText, + ], + ); + + steps.push( + , + ); + steps.push( + , + ); + + const modalName = React.useMemo(() => { + if (activeStep !== 'settings') { + return `Create channel - ${trimText(channelName || '', 11)}`; + } else { + return 'Create channel'; + } + }, [activeStep, channelName]); + + return ( + + +
+
+ + {steps} + +
+
+
+ ); +} + +export default ComposeSubchannelModal; diff --git a/web/modals/threads/create/steps/subchannel-members-list.react.js b/web/modals/threads/create/steps/subchannel-members-list.react.js new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/steps/subchannel-members-list.react.js @@ -0,0 +1,86 @@ +// @flow + +import * as React from 'react'; +import { useSelector } from 'react-redux'; + +import type { ThreadInfo } from 'lib/types/thread-types'; +import { trimText } from 'lib/utils/text-utils'; + +import AddMembersList from '../../../components/add-members-list.react'; + +type Props = { + +searchText: string, + +searchResult: $ReadOnlySet, + +communityThreadInfo: ThreadInfo, + +parentThreadInfo: ThreadInfo, + +selectedUsers: $ReadOnlySet, + +switchUser: (userID: string) => void, +}; + +function Memberlist(props: Props): React.Node { + const { + searchText, + searchResult, + communityThreadInfo, + parentThreadInfo, + selectedUsers, + switchUser, + } = props; + + const { + members: communityMembers, + name: communityName, + } = communityThreadInfo; + + const currentUserId = useSelector(state => state.currentUserInfo.id); + + const parentMembers = React.useMemo( + () => new Set(parentThreadInfo.members.map(user => user.id)), + [parentThreadInfo], + ); + + const parentMemberList = React.useMemo( + () => + communityMembers.filter( + user => + parentMembers.has(user.id) && + user.id !== currentUserId && + (searchResult.has(user.id) || searchText.length === 0), + ), + [communityMembers, parentMembers, currentUserId, searchResult, searchText], + ); + + const otherMemberList = React.useMemo( + () => + communityMembers.filter( + user => + !parentMembers.has(user.id) && + (searchResult.has(user.id) || searchText.length === 0), + ), + [communityMembers, parentMembers, searchResult, searchText], + ); + + const sortedGroupedUserList = React.useMemo( + () => + [ + { header: 'Users in parent channel', userInfos: parentMemberList }, + { + header: `All users in ${ + trimText(communityName || '', 22) ?? '' + }`, + userInfos: otherMemberList, + }, + ].filter(item => item.userInfos.length), + [parentMemberList, otherMemberList, communityName], + ); + + return ( + + ); +} + +export default Memberlist; diff --git a/web/modals/threads/create/steps/subchannel-members.css b/web/modals/threads/create/steps/subchannel-members.css new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/steps/subchannel-members.css @@ -0,0 +1,10 @@ +.members { + overflow-y: auto; +} + +.searchBar { + background-color: var(--modal-bg); + position: sticky; + padding: 2.5px 0; + top: 0; +} diff --git a/web/modals/threads/create/steps/subchannel-members.react.js b/web/modals/threads/create/steps/subchannel-members.react.js new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/steps/subchannel-members.react.js @@ -0,0 +1,68 @@ +// @flow + +import * as React from 'react'; +import { useSelector } from 'react-redux'; + +import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import { userStoreSearchIndex } from 'lib/selectors/user-selectors'; +import { useAncestorThreads } from 'lib/shared/ancestor-threads'; +import type { ThreadInfo } from 'lib/types/thread-types'; + +import Search from '../../../../components/search.react'; +import MembersList from './subchannel-members-list.react'; +import css from './subchannel-members.css'; + +type SubchannelMembersProps = { + +parentThreadInfo: ThreadInfo, + +selectedUsers: $ReadOnlySet, + +searchText: string, + +setSearchText: string => void, + +switchUser: (userID: string) => void, +}; + +function SubchannelMembers(props: SubchannelMembersProps): React.Node { + const { + switchUser, + searchText, + setSearchText, + parentThreadInfo, + selectedUsers, + } = props; + + const ancestorThreads = useAncestorThreads(parentThreadInfo); + const { id: threadID } = ancestorThreads[0] ?? parentThreadInfo; + + const communityThread = useSelector( + state => threadInfoSelector(state)[threadID], + ); + + const userSearchIndex = useSelector(userStoreSearchIndex); + const searchResult = React.useMemo( + () => new Set(userSearchIndex.getSearchResults(searchText)), + [userSearchIndex, searchText], + ); + + return ( + <> +
+ +
+
+ +
+ + ); +} + +export default SubchannelMembers; diff --git a/web/modals/threads/create/steps/subchannel-settings.css b/web/modals/threads/create/steps/subchannel-settings.css new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/steps/subchannel-settings.css @@ -0,0 +1,8 @@ +.wrapper { + color: var(--violet-dark-100); +} + +.label { + padding: 20px 0; + color: var(--shades-black-60); +} diff --git a/web/modals/threads/create/steps/subchannel-settings.react.js b/web/modals/threads/create/steps/subchannel-settings.react.js new file mode 100644 --- /dev/null +++ b/web/modals/threads/create/steps/subchannel-settings.react.js @@ -0,0 +1,121 @@ +// @flow +import * as React from 'react'; + +import { threadTypeDescriptions } from 'lib/shared/thread-utils'; +import { threadTypes } from 'lib/types/thread-types'; + +import EnumSettingsOption from '../../../../components/enum-settings-option.react'; +import SWMansionIcon from '../../../../SWMansionIcon.react'; +import Input from '../../../input.react'; +import css from './subchannel-settings.css'; + +const { COMMUNITY_OPEN_SUBTHREAD, COMMUNITY_SECRET_SUBTHREAD } = threadTypes; + +type Visibilities = 'open' | 'closed'; + +type Props = { + +channelName: string, + +onChangeChannelName: (SyntheticEvent) => void, + +visibilityType: Visibilities, + +onOpenTypeSelect: () => void, + +onClosedTypeSelect: () => void, + +announcement: boolean, + +onAnnouncementSelected: () => void, +}; + +const openStatements = [ + { + statement: threadTypeDescriptions[COMMUNITY_OPEN_SUBTHREAD], + isStatementValid: true, + styleStatementBasedOnValidity: false, + }, +]; + +const secretStatements = [ + { + statement: threadTypeDescriptions[COMMUNITY_SECRET_SUBTHREAD], + isStatementValid: true, + styleStatementBasedOnValidity: false, + }, +]; + +const announcementStatements = [ + { + statement: 'Admins can create Announcement channels.', + isStatementValid: true, + styleStatementBasedOnValidity: false, + }, +]; + +function SubchannelSettings(props: Props): React.Node { + const { + channelName, + onChangeChannelName, + visibilityType, + onOpenTypeSelect, + onClosedTypeSelect, + announcement, + onAnnouncementSelected, + } = props; + + const globeIcon = React.useMemo( + () => , + [], + ); + + const lockIcon = React.useMemo( + () => , + [], + ); + + const flagIcon = React.useMemo( + () => , + [], + ); + + return ( + <> + + +
+
Visibility
+ + +
+ +
+
Optional settings
+ +
+ + ); +} + +export default SubchannelSettings;