Page MenuHomePhabricator

D5187.id17120.diff
No OneTemporary

D5187.id17120.diff

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(--enum-option-icon-color);
+ display: inline;
+}
+
+div.container {
+ width: 383px;
+ 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,197 @@
+// @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 steps = React.useMemo(
+ () => [
+ <Stepper.Item
+ content={subchannelSettings}
+ key="settings"
+ nextProps={stepperButtons.settings.nextProps}
+ />,
+ <Stepper.Item
+ content={subchannelMembers}
+ key="members"
+ prevProps={stepperButtons.members.prevProps}
+ nextProps={stepperButtons.members.nextProps}
+ />,
+ ],
+ [subchannelSettings, subchannelMembers, stepperButtons],
+ );
+
+ 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}
+ >
+ {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,96 @@
+// @flow
+
+import * as React from 'react';
+import { useSelector } from 'react-redux';
+
+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(({ id, username }) => ({ id, username: username ?? '' })),
+
+ [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(({ id, username }) => ({ id, username: username ?? '' })),
+ [
+ communityMembers,
+ parentMembersSet,
+ currentUserId,
+ searchResult,
+ searchText,
+ ],
+ );
+
+ const sortedGroupedUserList = React.useMemo(
+ () =>
+ [
+ { header: 'Users in parent channel', userInfos: parentMemberList },
+ {
+ header: `All users in ${communityName ?? '<not specified>'}`,
+ 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(--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,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,7 @@
--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);
+ --enum-option-icon-color: var(--violet-light-100);
}

File Metadata

Mime Type
text/plain
Expires
Sun, Dec 29, 12:09 AM (7 h, 25 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2729692
Default Alt Text
D5187.id17120.diff (17 KB)

Event Timeline