Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F3351389
D5187.id17202.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.id17202.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,30 @@
+div.modalHeader {
+ padding: 15px;
+ font-weight: var(--semi-bold);
+ color: var(--compose-subchannel-header-fg);
+ background-color: var(--compose-subchannel-header-bg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 30px;
+}
+
+div.modalHeaderParentName {
+ color: var(--compose-subchannel-mark-color);
+ display: inline;
+}
+
+div.container {
+ width: 383px;
+ overflow-y: auto;
+}
+
+div.stepContainer {
+ position: relative;
+ height: 100vh;
+ max-height: 533px;
+}
+
+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,192 @@
+// @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';
+import type { VisibilityType } from './steps/subchannel-settings.react';
+
+type Props = {
+ +onClose: () => void,
+ +parentThreadInfo: ThreadInfo,
+};
+
+type Steps = 'settings' | 'members';
+
+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, onClose } = props;
+ const { uiName: parentThreadName } = parentThreadInfo;
+
+ const [activeStep, setActiveStep] = React.useState<Steps>('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<
+ $ReadOnlySet<string>,
+ >(new Set());
+ const [searchUserText, setSearchUserText] = React.useState<string>('');
+
+ const onChangeChannelName = React.useCallback(
+ (event: SyntheticEvent<HTMLInputElement>) => {
+ const target = event.currentTarget;
+ setChannelName(target.value);
+ },
+ [],
+ );
+
+ const onOpenVisibilityTypeSelected = React.useCallback(
+ () => setVisibilityType('open'),
+ [],
+ );
+
+ const onClosedVisibilityTypeSelected = React.useCallback(
+ () => setVisibilityType('closed'),
+ [],
+ );
+
+ const onAnnouncementSelected = React.useCallback(
+ () => setAnnouncement(!announcement),
+ [announcement],
+ );
+
+ const toggleUserSelection = React.useCallback((userID: string) => {
+ setSelectedUsers((users: $ReadOnlySet<string>) => {
+ const newUsers = new Set(users);
+ if (newUsers.has(userID)) {
+ newUsers.delete(userID);
+ } else {
+ newUsers.add(userID);
+ }
+ return newUsers;
+ });
+ }, []);
+
+ 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}
+ toggleUserSelection={toggleUserSelection}
+ />
+ ),
+ [
+ selectedUsers,
+ toggleUserSelection,
+ parentThreadInfo,
+ searchUserText,
+ setSearchUserText,
+ ],
+ );
+
+ const modalName =
+ activeStep === 'members'
+ ? `Create channel - ${trimText(channelName, 11)}`
+ : 'Create channel';
+
+ return (
+ <Modal name={modalName} onClose={onClose} size="fit-content">
+ <ComposeSubchannelHeader parentThreadName={parentThreadName} />
+ <div className={css.container}>
+ <div className={css.stepItem}>
+ <Stepper.Container
+ className={css.stepContainer}
+ activeStep={activeStep}
+ >
+ <Stepper.Item
+ content={subchannelSettings}
+ key="settings"
+ name="settings"
+ nextProps={stepperButtons.settings.nextProps}
+ />
+ <Stepper.Item
+ content={subchannelMembers}
+ key="members"
+ name="members"
+ prevProps={stepperButtons.members.prevProps}
+ nextProps={stepperButtons.members.nextProps}
+ />
+ </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,97 @@
+// @flow
+
+import * as React from 'react';
+import { useSelector } from 'react-redux';
+
+import { stringForUser } from 'lib/shared/user-utils';
+import type { ThreadInfo } from 'lib/types/thread-types';
+
+import AddMembersList from '../../../components/add-members-list.react';
+
+type Props = {
+ +searchText: string,
+ +searchResult: $ReadOnlySet<string>,
+ +communityThreadInfo: ThreadInfo,
+ +parentThreadInfo: ThreadInfo,
+ +selectedUsers: $ReadOnlySet<string>,
+ +toggleUserSelection: (userID: string) => void,
+};
+
+function Memberlist(props: Props): React.Node {
+ const {
+ searchText,
+ searchResult,
+ communityThreadInfo,
+ parentThreadInfo,
+ selectedUsers,
+ toggleUserSelection,
+ } = props;
+
+ const { members: parentMembers } = parentThreadInfo;
+
+ const {
+ members: communityMembers,
+ name: communityName,
+ } = communityThreadInfo;
+
+ const currentUserId = useSelector(state => state.currentUserInfo.id);
+
+ const parentMembersSet = React.useMemo(
+ () => new Set(parentThreadInfo.members.map(user => user.id)),
+ [parentThreadInfo],
+ );
+
+ const parentMemberList = React.useMemo(
+ () =>
+ parentMembers
+ .filter(
+ user =>
+ user.id !== currentUserId &&
+ (searchResult.has(user.id) || searchText.length === 0),
+ )
+ .map(user => ({ id: user.id, username: stringForUser(user) })),
+
+ [parentMembers, currentUserId, searchResult, searchText],
+ );
+
+ const otherMemberList = React.useMemo(
+ () =>
+ communityMembers
+ .filter(
+ user =>
+ !parentMembersSet.has(user.id) &&
+ user.id !== currentUserId &&
+ (searchResult.has(user.id) || searchText.length === 0),
+ )
+ .map(user => ({ id: user.id, username: stringForUser(user) })),
+ [
+ communityMembers,
+ parentMembersSet,
+ currentUserId,
+ searchResult,
+ searchText,
+ ],
+ );
+
+ 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 (
+ <AddMembersList
+ switchUser={toggleUserSelection}
+ 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,64 @@
+// @flow
+
+import * as React from 'react';
+import { useSelector } from 'react-redux';
+
+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,
+ +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 = 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}
+ toggleUserSelection={toggleUserSelection}
+ />
+ </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(--enum-option-icon-color);
+}
+
+.label {
+ padding: 20px 0;
+ color: var(--compose-subchannel-label-color);
+}
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,123 @@
+// @flow
+
+import * as React from 'react';
+
+import { threadTypeDescriptions } from 'lib/shared/thread-utils';
+import { threadTypes } from 'lib/types/thread-types';
+
+import CommIcon from '../../../../CommIcon.react';
+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;
+
+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,
+ },
+];
+
+export type VisibilityType = 'open' | 'closed';
+
+type Props = {
+ +channelName: string,
+ +onChangeChannelName: (SyntheticEvent<HTMLInputElement>) => void,
+ +visibilityType: VisibilityType,
+ +onOpenTypeSelect: () => void,
+ +onClosedTypeSelect: () => void,
+ +announcement: boolean,
+ +onAnnouncementSelected: () => void,
+};
+
+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(
+ () => <CommIcon icon="megaphone" 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;
diff --git a/web/theme.css b/web/theme.css
--- a/web/theme.css
+++ b/web/theme.css
@@ -182,4 +182,9 @@
--inline-sidebar-bg: var(--shades-black-70);
--inline-sidebar-bg-hover: var(--shades-black-80);
--inline-sidebar-color: var(--fg);
+ --compose-subchannel-header-fg: var(--shades-black-60);
+ --compose-subchannel-header-bg: var(--shades-black-80);
+ --compose-subchannel-label-color: var(--shades-black-60);
+ --compose-subchannel-mark-color: var(--violet-light-100);
+ --enum-option-icon-color: var(--violet-dark-100);
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Nov 24, 1:19 AM (17 h, 56 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2573398
Default Alt Text
D5187.id17202.diff (17 KB)
Attached To
Mode
D5187: [web] Introduce `ComposeSubchannel` modal
Attached
Detach File
Event Timeline
Log In to Comment