Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F3561251
D5187.id16926.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
17 KB
Referenced Files
None
Subscribers
None
D5187.id16926.diff
View Options
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(
+ <ComposeSubchannelModal
+ parentThreadInfo={threadInfo}
+ onClose={popModal}
+ />,
+ ),
+ [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 = <hr key="separator" className={css.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 (
+ <div className={css.modalHeader}>
+ <div>
+ {'within '}
+ <div className={css.modalHeaderParentName}>{parentThreadName}</div>
+ </div>
+ </div>
+ );
+}
+
+function ComposeSubchannelModal(props: Props): React.Node {
+ const { parentThreadInfo } = props;
+ const { uiName: parentThreadName } = parentThreadInfo;
+
+ const [activeStep, setActiveStep] = React.useState<Pages>('settings');
+
+ const [channelName, setChannelName] = React.useState<string>('');
+ const [visibilityType, setVisibilityType] = React.useState<VisibilityType>(
+ 'open',
+ );
+ const [announcement, setAnnouncement] = React.useState<boolean>(false);
+ const [selectedUsers, setSelectedUsers] = React.useState<Set<string>>(
+ new Set(),
+ );
+ const [searchUserText, setSearchUserText] = React.useState<string>('');
+
+ const handleChanges = React.useCallback(
+ (
+ event: SyntheticEvent<HTMLInputElement>,
+ setValue: (newValue: any) => any,
+ ) => {
+ const target = event.currentTarget;
+ setValue(target.value);
+ },
+ [],
+ );
+
+ const onChangeChannelName = React.useCallback(
+ (event: SyntheticEvent<HTMLInputElement>) => {
+ 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<string>) => {
+ const newUsers = new Set(users);
+ if (newUsers.has(userID)) {
+ newUsers.delete(userID);
+ } else {
+ newUsers.add(userID);
+ }
+ return newUsers;
+ });
+ }, []);
+
+ const steps = [];
+
+ const subchannelSettings = React.useMemo(
+ () => (
+ <SubchannelSettings
+ channelName={channelName}
+ visibilityType={visibilityType}
+ announcement={announcement}
+ onChangeChannelName={onChangeChannelName}
+ onOpenTypeSelect={onOpenVisibilityTypeSelected}
+ onClosedTypeSelect={onClosedVisibilityTypeSelected}
+ onAnnouncementSelected={onAnnouncementSelected}
+ />
+ ),
+ [
+ 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(
+ () => (
+ <SubchannelMembers
+ parentThreadInfo={parentThreadInfo}
+ selectedUsers={selectedUsers}
+ searchText={searchUserText}
+ setSearchText={setSearchUserText}
+ switchUser={switchUser}
+ />
+ ),
+ [
+ selectedUsers,
+ switchUser,
+ parentThreadInfo,
+ searchUserText,
+ setSearchUserText,
+ ],
+ );
+
+ steps.push(
+ <Stepper.Item
+ content={subchannelSettings}
+ key="settings"
+ nextProps={stepperButtons.settings.nextProps}
+ />,
+ );
+ steps.push(
+ <Stepper.Item
+ content={subchannelMembers}
+ key="members"
+ prevProps={stepperButtons.members.prevProps}
+ nextProps={stepperButtons.members.nextProps}
+ />,
+ );
+
+ const modalName = React.useMemo(() => {
+ if (activeStep !== 'settings') {
+ return `Create channel - ${trimText(channelName || '', 11)}`;
+ } else {
+ return 'Create channel';
+ }
+ }, [activeStep, channelName]);
+
+ return (
+ <Modal name={modalName} onClose={props.onClose} size="fit-content">
+ <ComposeSubchannelHeader parentThreadName={parentThreadName} />
+ <div className={css.container}>
+ <div className={css.stepItem}>
+ <Stepper.Container
+ className={css.stepContainer}
+ activeStep={activeStep}
+ >
+ {steps}
+ </Stepper.Container>
+ </div>
+ </div>
+ </Modal>
+ );
+}
+
+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<string>,
+ +communityThreadInfo: ThreadInfo,
+ +parentThreadInfo: ThreadInfo,
+ +selectedUsers: $ReadOnlySet<string>,
+ +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) ?? '<not specified>'
+ }`,
+ userInfos: otherMemberList,
+ },
+ ].filter(item => item.userInfos.length),
+ [parentMemberList, otherMemberList, communityName],
+ );
+
+ return (
+ <AddMembersList
+ switchUser={switchUser}
+ pendingUsersToAdd={selectedUsers}
+ sortedGroupedUsersList={sortedGroupedUserList}
+ />
+ );
+}
+
+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<string>,
+ +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 (
+ <>
+ <div className={css.searchBar}>
+ <Search
+ searchText={searchText}
+ onChangeText={setSearchText}
+ placeholder="Search"
+ />
+ </div>
+ <div className={css.members}>
+ <MembersList
+ communityThreadInfo={communityThread}
+ parentThreadInfo={parentThreadInfo}
+ selectedUsers={selectedUsers}
+ searchResult={searchResult}
+ searchText={searchText}
+ switchUser={switchUser}
+ />
+ </div>
+ </>
+ );
+}
+
+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<HTMLInputElement>) => 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(
+ () => <SWMansionIcon icon="globe-1" size={24} />,
+ [],
+ );
+
+ const lockIcon = React.useMemo(
+ () => <SWMansionIcon icon="lock-on" size={24} />,
+ [],
+ );
+
+ const flagIcon = React.useMemo(
+ () => <SWMansionIcon icon="flag-2" size={24} />,
+ [],
+ );
+
+ return (
+ <>
+ <Input
+ type="text"
+ onChange={onChangeChannelName}
+ placeholder="Channel name"
+ value={channelName}
+ />
+
+ <div className={css.wrapper}>
+ <div className={css.label}> Visibility </div>
+ <EnumSettingsOption
+ title="Open"
+ statements={openStatements}
+ onSelect={onOpenTypeSelect}
+ selected={visibilityType === 'open'}
+ icon={globeIcon}
+ iconPosition="top"
+ />
+ <EnumSettingsOption
+ title="Closed"
+ statements={secretStatements}
+ onSelect={onClosedTypeSelect}
+ selected={visibilityType === 'closed'}
+ icon={lockIcon}
+ iconPosition="top"
+ />
+ </div>
+
+ <div className={css.wrapper}>
+ <div className={css.label}>Optional settings</div>
+ <EnumSettingsOption
+ title="Announcement"
+ statements={announcementStatements}
+ onSelect={onAnnouncementSelected}
+ selected={announcement}
+ icon={flagIcon}
+ iconPosition="top"
+ type="checkbox"
+ />
+ </div>
+ </>
+ );
+}
+
+export default SubchannelSettings;
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Dec 28, 8:41 AM (6 h, 44 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2725119
Default Alt Text
D5187.id16926.diff (17 KB)
Attached To
Mode
D5187: [web] Introduce `ComposeSubchannel` modal
Attached
Detach File
Event Timeline
Log In to Comment