diff --git a/lib/utils/role-utils.js b/lib/utils/role-utils.js index 40bdc3d58..fa7234959 100644 --- a/lib/utils/role-utils.js +++ b/lib/utils/role-utils.js @@ -1,81 +1,126 @@ // @flow import * as React from 'react'; +import { useSelector } from './redux-utils.js'; +import { threadInfoSelector } from '../selectors/thread-selectors.js'; 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'; +import { type ThreadType, threadTypes } from '../types/thread-types-enum.js'; +import type { + ThreadInfo, + RelativeMemberInfo, + RoleInfo, +} from '../types/thread-types.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]); } +function useRolesFromCommunityThreadInfo( + threadInfo: ThreadInfo, + memberInfos: $ReadOnlyArray, +): $ReadOnlyMap { + // Our in-code system has chat-specific roles, while the + // user-surfaced system has roles only for communities. We retrieve roles + // from the top-level community thread for accuracy, with a rare fallback + // for potential issues reading memberInfos, primarily in GENESIS threads. + // The special case is GENESIS threads, since per prior discussion + // (see context: https://linear.app/comm/issue/ENG-4077/), we don't really + // support roles for it. Also with GENESIS, the list of members are not + // populated in the community root. So in this case to prevent crashing, we + // should just return the role name from the current thread info. + const { community } = threadInfo; + const communityThreadInfo = useSelector(state => + community ? threadInfoSelector(state)[community] : null, + ); + const topMostThreadInfo = communityThreadInfo || threadInfo; + const roleMap = new Map(); + + if (topMostThreadInfo.type === threadTypes.GENESIS) { + memberInfos.forEach(memberInfo => + roleMap.set( + memberInfo.id, + memberInfo.role ? threadInfo.roles[memberInfo.role] : null, + ), + ); + return roleMap; + } + + const { members: memberInfosFromTopMostThreadInfo, roles } = + topMostThreadInfo; + memberInfosFromTopMostThreadInfo.forEach(memberInfo => { + roleMap.set(memberInfo.id, memberInfo.role ? roles[memberInfo.role] : null); + }); + return roleMap; +} + export { useFilterPermissionOptionsByThreadType, constructRoleDeletionMessagePrompt, useRoleDeletableAndEditableStatus, + useRolesFromCommunityThreadInfo, }; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index c27d302ff..674e313fe 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,298 +1,302 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View, Text, Platform, ActivityIndicator, TouchableOpacity, } from 'react-native'; import { removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions.js'; import { useENSNames } from 'lib/hooks/ens-cache.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { getAvailableThreadMemberActions } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { type ThreadInfo, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; +import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js'; import type { ThreadSettingsNavigate } from './thread-settings.react.js'; import UserAvatar from '../../avatars/user-avatar.react.js'; import PencilIcon from '../../components/pencil-icon.react.js'; import SingleLine from '../../components/single-line.react.js'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state.js'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context.js'; import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; import type { VerticalBounds } from '../../types/layout-types.js'; import { useNavigateToUserProfileBottomSheet } from '../../user-profile/user-profile-utils.js'; type BaseProps = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, }; type Props = { ...BaseProps, // Redux state + +roleName: ?string, +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, +navigateToUserProfileBottomSheet: (userID: string) => mixed, }; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; render() { const userText = stringForUser(this.props.memberInfo); let usernameInfo = null; if (this.props.memberInfo.username) { usernameInfo = ( {userText} ); } else { usernameInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if ( getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ).length !== 0 ) { editButton = ( ); } - const roleName = - this.props.memberInfo.role && - this.props.threadInfo.roles[this.props.memberInfo.role].name; - const roleInfo = ( - {roleName} + {this.props.roleName} ); const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastContainer : null; return ( {usernameInfo} {editButton} {roleInfo} ); } onPressUser = () => { this.props.navigateToUserProfileBottomSheet(this.props.memberInfo.id); }; editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate<'ThreadSettingsMemberTooltipModal'>({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: getAvailableThreadMemberActions( this.props.memberInfo, this.props.threadInfo, this.props.canEdit, ), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 24, paddingVertical: 8, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, userInfoContainer: { flex: 1, flexDirection: 'row', alignItems: 'center', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, marginLeft: 8, }, anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, }; const ConnectedThreadSettingsMember: React.ComponentType = React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector(state => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector(state => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const [memberInfo] = useENSNames([props.memberInfo]); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); + const roles = useRolesFromCommunityThreadInfo(props.threadInfo, [ + props.memberInfo, + ]); + const roleName = roles.get(props.memberInfo.id)?.name; + return ( ); }); export default ConnectedThreadSettingsMember; diff --git a/web/modals/threads/members/member.react.js b/web/modals/threads/members/member.react.js index d09e8e4b0..ea3b74c5e 100644 --- a/web/modals/threads/members/member.react.js +++ b/web/modals/threads/members/member.react.js @@ -1,141 +1,141 @@ // @flow import * as React from 'react'; import { removeUsersFromThread } from 'lib/actions/thread-actions.js'; import { useModalContext } from 'lib/components/modal-provider.react.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { removeMemberFromThread, getAvailableThreadMemberActions, } from 'lib/shared/thread-utils.js'; import { stringForUser } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; import { type RelativeMemberInfo, type ThreadInfo, } from 'lib/types/thread-types.js'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils.js'; +import { useRolesFromCommunityThreadInfo } from 'lib/utils/role-utils.js'; import ChangeMemberRoleModal from './change-member-role-modal.react.js'; import css from './members-modal.css'; import UserAvatar from '../../../avatars/user-avatar.react.js'; import CommIcon from '../../../CommIcon.react.js'; import Label from '../../../components/label.react.js'; import MenuItem from '../../../components/menu-item.react.js'; import Menu from '../../../components/menu.react.js'; import { usePushUserProfileModal } from '../../user-profile/user-profile-utils.js'; const commIconComponent = ; type Props = { +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +setOpenMenu: SetState, }; function ThreadMember(props: Props): React.Node { const { memberInfo, threadInfo, setOpenMenu } = props; const { pushModal } = useModalContext(); const userName = stringForUser(memberInfo); - const { roles } = threadInfo; - const { role } = memberInfo; + + const roles = useRolesFromCommunityThreadInfo(threadInfo, [memberInfo]); + const roleName = roles.get(memberInfo.id)?.name; const onMenuChange = React.useCallback( menuOpen => { if (menuOpen) { setOpenMenu(() => memberInfo.id); } else { setOpenMenu(menu => (menu === memberInfo.id ? null : menu)); } }, [memberInfo.id, setOpenMenu], ); const dispatchActionPromise = useDispatchActionPromise(); const boundRemoveUsersFromThread = useServerCall(removeUsersFromThread); const onClickRemoveUser = React.useCallback( () => removeMemberFromThread( threadInfo, memberInfo, dispatchActionPromise, boundRemoveUsersFromThread, ), [boundRemoveUsersFromThread, dispatchActionPromise, memberInfo, threadInfo], ); const onClickChangeRole = React.useCallback(() => { pushModal( , ); }, [memberInfo, pushModal, threadInfo]); const menuItems = React.useMemo( () => getAvailableThreadMemberActions(memberInfo, threadInfo).map(action => { if (action === 'change_role') { return ( ); } if (action === 'remove_user') { return ( ); } return null; }), [memberInfo, onClickRemoveUser, onClickChangeRole, threadInfo], ); const userSettingsIcon = React.useMemo( () => , [], ); - const roleName = role && roles[role].name; - const label = React.useMemo( () => , [roleName], ); const pushUserProfileModal = usePushUserProfileModal(memberInfo.id); return (
{userName} {label}
{menuItems}
); } export default ThreadMember;