diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index 0eeb105a6..40bdc3d58 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,60 +1,81 @@ // @flow import * as React from 'react'; import { type UserSurfacedPermissionOption, userSurfacedPermissions, userSurfacedPermissionOptions, } from '../types/thread-permission-types.js'; import type { ThreadType } from '../types/thread-types-enum.js'; import { threadTypes } from '../types/thread-types-enum.js'; function useFilterPermissionOptionsByThreadType( threadType: ThreadType, ): $ReadOnlySet { // If the thread is a community announcement root, we want to allow // the option to be voiced in the announcement channels. Otherwise, // we want to remove that option from being configured since this will // be guaranteed on the keyserver. const shouldFilterVoicedInAnnouncementChannel = threadType === threadTypes.COMMUNITY_ROOT; return React.useMemo(() => { if (!shouldFilterVoicedInAnnouncementChannel) { return userSurfacedPermissionOptions; } return new Set( [...userSurfacedPermissionOptions].filter( option => option.userSurfacedPermission !== userSurfacedPermissions.VOICED_IN_ANNOUNCEMENT_CHANNELS, ), ); }, [shouldFilterVoicedInAnnouncementChannel]); } function constructRoleDeletionMessagePrompt( defaultRoleName: string, memberCount: number, ): string { let message; if (memberCount === 0) { message = 'Are you sure you want to delete this role?'; } else { const messageNoun = memberCount === 1 ? 'member' : 'members'; const messageVerb = memberCount === 1 ? 'is' : 'are'; message = `There ${messageVerb} currently ${memberCount} ${messageNoun} with ` + `this role. Deleting the role will automatically assign the ` + `${messageNoun} affected to the ${defaultRoleName} role.`; } return message; } +type RoleDeletableAndEditableStatus = { + +isDeletable: boolean, + +isEditable: boolean, +}; +function useRoleDeletableAndEditableStatus( + roleName: string, + defaultRoleID: string, + existingRoleID: string, +): RoleDeletableAndEditableStatus { + return React.useMemo(() => { + const canDelete = roleName !== 'Admins' && defaultRoleID !== existingRoleID; + const canEdit = roleName !== 'Admins'; + + return { + isDeletable: canDelete, + isEditable: canEdit, + }; + }, [roleName, defaultRoleID, existingRoleID]); +} + export { useFilterPermissionOptionsByThreadType, constructRoleDeletionMessagePrompt, + useRoleDeletableAndEditableStatus, }; diff --git a/web/components/menu.css b/web/components/menu.css index 86efe8986..754a08ecd 100644 --- a/web/components/menu.css +++ b/web/components/menu.css @@ -1,87 +1,95 @@ button.menuButton { background-color: transparent; border: none; cursor: pointer; color: inherit; } div.menuActionList { position: absolute; z-index: 4; display: flex; flex-direction: column; background-color: var(--menu-bg); color: var(--menu-color); stroke: var(--menu-color); border-radius: 4px; padding: 4px 0; line-height: var(--line-height-text); min-width: max-content; } div.menuActionListThreadActions { font-size: var(--m-font-16); top: 40px; right: -20px; } div.menuActionListMemberActions { font-size: var(--xs-font-12); background-color: var(--menu-bg-light); color: var(--menu-color-light); stroke: var(--menu-color-light); top: 0; right: 5px; } div.menuActionListCommunityActions { font-size: var(--m-font-16); background-color: var(--menu-bg-light); color: var(--menu-color); stroke: var(--menu-color); top: 24px; } +div.menuActionListRoleActions { + font-size: var(--s-font-14); + background-color: var(--menu-bg-light); + color: var(--menu-color); + stroke: var(--menu-color); + margin-top: 20px; +} + button.menuAction { color: inherit; z-index: 1; padding: 12px 16px; line-height: 1.5; font-size: inherit; justify-content: start; } button.menuAction:hover { color: var(--menu-color-hover); stroke: var(--menu-color-hover); } div.menuActionIcon { display: flex; justify-content: center; margin-right: 8px; height: 24px; width: 24px; } div.menuActionListMemberActions div.menuActionIcon { height: 18px; width: 18px; } button.menuActionDangerous { color: var(--menu-color-dangerous); stroke: var(--menu-color-dangerous); } button.menuActionDangerous:hover { color: var(--menu-color-dangerous-hover); stroke: var(--menu-color-dangerous-hover); } hr.separator { height: 1px; background: var(--menu-separator-color); margin: 10px 16px; max-width: 130px; border: none; } diff --git a/web/components/menu.react.js b/web/components/menu.react.js index 18b516ea8..a56201af2 100644 --- a/web/components/menu.react.js +++ b/web/components/menu.react.js @@ -1,120 +1,125 @@ // @flow import classnames from 'classnames'; import * as React from 'react'; import css from './menu.css'; import { useRenderMenu } from '../menu-provider.react.js'; -type MenuVariant = 'thread-actions' | 'member-actions' | 'community-actions'; +type MenuVariant = + | 'thread-actions' + | 'member-actions' + | 'community-actions' + | 'role-actions'; type MenuProps = { +icon: React.Node, +children?: React.Node, +variant?: MenuVariant, +onChange?: boolean => void, }; function Menu(props: MenuProps): React.Node { const buttonRef = React.useRef(); const { renderMenu, setMenuPosition, closeMenu, setCurrentOpenMenu, currentOpenMenu, } = useRenderMenu(); const { icon, children, variant = 'thread-actions', onChange } = props; const ourSymbol = React.useRef(Symbol()); const menuActionListClasses = classnames(css.menuActionList, { [css.menuActionListThreadActions]: variant === 'thread-actions', [css.menuActionListMemberActions]: variant === 'member-actions', [css.menuActionListCommunityActions]: variant === 'community-actions', + [css.menuActionListRoleActions]: variant === 'role-actions', }); const menuActionList = React.useMemo( () =>
{children}
, [children, menuActionListClasses], ); const isOurMenuOpen = currentOpenMenu === ourSymbol.current; const updatePosition = React.useCallback(() => { if (buttonRef.current && isOurMenuOpen) { const { top, left } = buttonRef.current.getBoundingClientRect(); setMenuPosition({ top, left }); } }, [isOurMenuOpen, setMenuPosition]); React.useEffect(() => { if (!window) { return undefined; } window.addEventListener('resize', updatePosition); return () => window.removeEventListener('resize', updatePosition); }, [updatePosition]); React.useEffect(updatePosition, [updatePosition]); const closeMenuCallback = React.useCallback(() => { closeMenu(ourSymbol.current); }, [closeMenu]); React.useEffect(() => { onChange?.(isOurMenuOpen); }, [isOurMenuOpen, onChange]); React.useEffect(() => { if (!isOurMenuOpen) { return undefined; } document.addEventListener('click', closeMenuCallback); return () => { document.removeEventListener('click', closeMenuCallback); }; }, [closeMenuCallback, isOurMenuOpen]); const prevActionListRef = React.useRef(null); React.useEffect(() => { if (!isOurMenuOpen) { prevActionListRef.current = null; return; } if (prevActionListRef.current === menuActionList) { return; } renderMenu(menuActionList); prevActionListRef.current = menuActionList; }, [isOurMenuOpen, menuActionList, renderMenu]); React.useEffect(() => { const ourSymbolValue = ourSymbol.current; return () => closeMenu(ourSymbolValue); }, [closeMenu]); const onClickMenuCallback = React.useCallback( e => { e.stopPropagation(); setCurrentOpenMenu(ourSymbol.current); }, [setCurrentOpenMenu], ); if (React.Children.count(children) === 0) { return null; } return ( ); } export default Menu; diff --git a/web/roles/community-roles-modal.css b/web/roles/community-roles-modal.css index bb2205dbd..4cde69a65 100644 --- a/web/roles/community-roles-modal.css +++ b/web/roles/community-roles-modal.css @@ -1,52 +1,52 @@ .modalDescription { color: var(--modal-fg); padding: 16px 32px 0 32px; font-size: var(--m-font-16); } .rolePanelTitleContainer { display: flex; flex-direction: row; justify-content: space-between; margin: 32px 32px 16px 32px; } .rolePanelTitle { color: var(--community-roles-text-color); font-size: var(--l-font-18); } .rolePanelTitle:last-of-type { - margin-right: 32px; + margin-right: 90px; } .separator { border: 0; margin: 0 32px 8px 32px; width: 85%; align-self: center; height: 2px; border: none; border-top: var(--modal-separator) solid 1px; } .rolePanelList { display: flex; flex-direction: column; overflow-y: auto; max-height: 400px; margin: 0 32px 16px 24px; } .createRoleButtonContainer { width: 100%; display: flex; flex-direction: column; align-self: center; align-items: stretch; margin-bottom: 16px; } .createRoleButton { margin: 0 32px 0 32px; } diff --git a/web/roles/community-roles-modal.react.js b/web/roles/community-roles-modal.react.js index 77e37c60a..d45513949 100644 --- a/web/roles/community-roles-modal.react.js +++ b/web/roles/community-roles-modal.react.js @@ -1,96 +1,97 @@ // @flow import * as React from 'react'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { useRoleMemberCountsForCommunity } from 'lib/shared/thread-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import css from './community-roles-modal.css'; import CreateRolesModal from './create-roles-modal.react.js'; import RolePanelEntry from './role-panel-entry.react.js'; import Button, { buttonThemes } from '../components/button.react.js'; import Modal from '../modals/modal.react.js'; import { useSelector } from '../redux/redux-utils.js'; type CommunityRolesModalProps = { +community: ThreadInfo, }; function CommunityRolesModal(props: CommunityRolesModalProps): React.Node { const { popModal, pushModal } = useModalContext(); const { community } = props; const [threadInfo, setThreadInfo] = React.useState(community); const threadID = threadInfo.id; const reduxThreadInfo: ?ThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); React.useEffect(() => { if (reduxThreadInfo) { setThreadInfo(reduxThreadInfo); } }, [reduxThreadInfo]); const roleNamesToMembers = useRoleMemberCountsForCommunity(threadInfo); const rolePanelList = React.useMemo( () => Object.keys(roleNamesToMembers).map(roleName => ( )), - [roleNamesToMembers], + [roleNamesToMembers, threadInfo], ); const rolePermissionsForNewRole = React.useMemo(() => new Set(), []); const onClickCreateRole = React.useCallback( () => pushModal( , ), [pushModal, threadInfo, rolePermissionsForNewRole], ); return (
Roles help you group community members together and assign them certain permissions. When people join the community, they are automatically assigned the Members role.
Communities must always have the Admins and Members role.
Roles
Members

{rolePanelList}
); } export default CommunityRolesModal; diff --git a/web/roles/role-actions-menu.css b/web/roles/role-actions-menu.css new file mode 100644 index 000000000..b00c21d80 --- /dev/null +++ b/web/roles/role-actions-menu.css @@ -0,0 +1,4 @@ +.menuContainer { + color: var(--menu-color-light); + width: 10px; +} diff --git a/web/roles/role-actions-menu.react.js b/web/roles/role-actions-menu.react.js new file mode 100644 index 000000000..8a3cdd313 --- /dev/null +++ b/web/roles/role-actions-menu.react.js @@ -0,0 +1,84 @@ +// @flow + +import invariant from 'invariant'; +import * as React from 'react'; + +import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; +import { useRoleDeletableAndEditableStatus } from 'lib/utils/role-utils.js'; + +import css from './role-actions-menu.css'; +import MenuItem from '../components/menu-item.react.js'; +import Menu from '../components/menu.react.js'; + +const menuIcon = ; + +type RoleActionsMenuProps = { + +threadInfo: ThreadInfo, + +roleName: string, +}; + +function RoleActionsMenu(props: RoleActionsMenuProps): React.Node { + const { threadInfo, roleName } = props; + + const defaultRoleID = Object.keys(threadInfo.roles).find( + roleID => threadInfo.roles[roleID].isDefault, + ); + invariant(defaultRoleID, 'default role should exist'); + + const existingRoleID = Object.keys(threadInfo.roles).find( + roleID => threadInfo.roles[roleID].name === roleName, + ); + invariant(existingRoleID, 'existing role should exist'); + + const roleOptions = useRoleDeletableAndEditableStatus( + roleName, + defaultRoleID, + existingRoleID, + ); + + // TODO: Implement in following diffs + const openEditRoleModal = React.useCallback(() => {}, []); + const openDeleteRoleModal = React.useCallback(() => {}, []); + + const menuItems = React.useMemo(() => { + const availableOptions = []; + const { isDeletable, isEditable } = roleOptions; + + if (isEditable) { + availableOptions.push( + , + ); + } + + if (isDeletable) { + availableOptions.push( + , + ); + } + + return availableOptions; + }, [roleOptions, openDeleteRoleModal, openEditRoleModal]); + + return ( +
+ + {menuItems} + +
+ ); +} + +export default RoleActionsMenu; diff --git a/web/roles/role-panel-entry.css b/web/roles/role-panel-entry.css index 9ec90fb78..c317d5d1b 100644 --- a/web/roles/role-panel-entry.css +++ b/web/roles/role-panel-entry.css @@ -1,24 +1,29 @@ .rolePanelEntry { - display: flex; - flex-direction: row; - justify-content: space-between; + display: grid; align-items: center; padding: 12px; color: var(--community-roles-text-color); font-weight: 500; } .rolePanelNameEntry { font-size: var(--s-font-14); } .rolePanelCountEntryContainer { - margin-right: 40px; display: flex; - align-items: flex-end; + align-items: center; + justify-content: flex-end; + grid-column: 3; +} + +.rolePanelCountAndIcon { + display: flex; + align-items: center; + margin-right: 90px; } .rolePanelCountEntry { font-size: var(--s-font-14); margin-right: 2px; } diff --git a/web/roles/role-panel-entry.react.js b/web/roles/role-panel-entry.react.js index 64053c0de..66fdfafe7 100644 --- a/web/roles/role-panel-entry.react.js +++ b/web/roles/role-panel-entry.react.js @@ -1,26 +1,33 @@ // @flow import * as React from 'react'; +import type { ThreadInfo } from 'lib/types/thread-types.js'; + +import RoleActionsMenu from './role-actions-menu.react.js'; import css from './role-panel-entry.css'; import CommIcon from '../CommIcon.react.js'; type RolePanelEntryProps = { + +threadInfo: ThreadInfo, +roleName: string, +memberCount: number, }; function RolePanelEntry(props: RolePanelEntryProps): React.Node { - const { roleName, memberCount } = props; + const { threadInfo, roleName, memberCount } = props; return (
{roleName}
-
{memberCount}
- +
+
{memberCount}
+ +
+
); } export default RolePanelEntry;