Don't let users leave groups if they're the only admin

This commit is contained in:
Evan Hahn 2021-04-05 12:44:13 -05:00 committed by Josh Perez
parent a7c78b3b23
commit c8dc8a7398
8 changed files with 158 additions and 35 deletions

View File

@ -4755,6 +4755,10 @@
"message": "Block group",
"description": "This is a button to block a group"
},
"ConversationDetailsActions--leave-group-must-choose-new-admin": {
"message": "Before you leave, you must choose at least one new admin for this group.",
"description": "Shown if, before leaving a group, you need to choose an admin"
},
"ConversationDetailsActions--leave-group-modal-title": {
"message": "Do you really want to leave?",
"description": "This is the modal title for confirming leaving a group"

View File

@ -2966,6 +2966,16 @@ button.module-conversation-details__action-button {
&__leave-group {
color: $color-accent-red;
&--disabled {
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
}
&__block-group {
@ -3180,6 +3190,16 @@ button.module-conversation-details__action-button {
-webkit-mask: url(../images/icons/v2/leave-24.svg) no-repeat center;
background-color: $color-accent-red;
}
&--disabled::after {
@include light-theme {
background-color: $color-gray-60;
}
@include dark-theme {
background-color: $color-gray-25;
}
}
}
&--block {
@ -3248,19 +3268,19 @@ button.module-conversation-details__action-button {
&--button {
color: inherit;
background: none;
}
&:hover {
@include light-theme {
background-color: $color-gray-02;
}
&:hover:not(:disabled) {
@include light-theme {
background-color: $color-gray-02;
}
@include dark-theme {
background-color: $color-gray-90;
}
@include dark-theme {
background-color: $color-gray-90;
}
& .module-conversation-details-panel-row__actions {
opacity: 1;
& .module-conversation-details-panel-row__actions {
opacity: 1;
}
}
}

View File

@ -25,7 +25,7 @@ const conversation: ConversationType = {
lastUpdated: 0,
markedUnread: false,
memberships: Array.from(Array(32)).map((_, i) => ({
isAdmin: false,
isAdmin: i === 1,
member: getDefaultConversation({
isMe: i === 2,
}),
@ -84,6 +84,44 @@ story.add('as Admin', () => {
return <ConversationDetails {...props} isAdmin />;
});
story.add('as last admin', () => {
const props = createProps();
return (
<ConversationDetails
{...props}
isAdmin
conversation={{
...conversation,
memberships: conversation.memberships?.map(membership => ({
...membership,
isAdmin: Boolean(membership.member.isMe),
})),
}}
/>
);
});
story.add('as only admin', () => {
const props = createProps();
return (
<ConversationDetails
{...props}
isAdmin
conversation={{
...conversation,
memberships: conversation.memberships
?.filter(membership => membership.member.isMe)
.map(membership => ({
...membership,
isAdmin: true,
})),
}}
/>
);
});
story.add('Group Editable', () => {
const props = createProps();

View File

@ -101,12 +101,21 @@ export const ConversationDetails: React.ComponentType<Props> = ({
throw new Error('ConversationDetails rendered without a conversation');
}
const memberships = conversation.memberships || [];
const pendingMemberships = conversation.pendingMemberships || [];
const pendingApprovalMemberships =
conversation.pendingApprovalMemberships || [];
const invitesCount =
pendingMemberships.length + pendingApprovalMemberships.length;
const otherMemberships = memberships.filter(({ member }) => !member.isMe);
const isJustMe = otherMemberships.length === 0;
const isAnyoneElseAnAdmin = otherMemberships.some(
membership => membership.isAdmin
);
const cannotLeaveBecauseYouAreLastAdmin =
isAdmin && !isJustMe && !isAnyoneElseAnAdmin;
let modalNode: ReactNode;
switch (modalState) {
case ModalState.NothingOpen:
@ -158,11 +167,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
});
}}
conversationIdsAlreadyInGroup={
new Set(
(conversation.memberships || []).map(
membership => membership.member.id
)
)
new Set(memberships.map(membership => membership.member.id))
}
groupTitle={conversation.title}
i18n={i18n}
@ -238,7 +243,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
<ConversationDetailsMembershipList
canAddNewMembers={canEditGroupInfo}
i18n={i18n}
memberships={conversation.memberships || []}
memberships={memberships}
showContactModal={showContactModal}
startAddingNewMembers={() => {
setModalState(ModalState.AddingGroupMembers);
@ -294,6 +299,7 @@ export const ConversationDetails: React.ComponentType<Props> = ({
<ConversationDetailsActions
i18n={i18n}
cannotLeaveBecauseYouAreLastAdmin={cannotLeaveBecauseYouAreLastAdmin}
conversationTitle={conversation.title}
onDelete={onDelete}
onBlockAndDelete={onBlockAndDelete}

View File

@ -1,7 +1,8 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { isBoolean } from 'lodash';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
@ -21,6 +22,11 @@ const story = storiesOf(
);
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
cannotLeaveBecauseYouAreLastAdmin: isBoolean(
overrideProps.cannotLeaveBecauseYouAreLastAdmin
)
? overrideProps.cannotLeaveBecauseYouAreLastAdmin
: false,
conversationTitle: overrideProps.conversationTitle || '',
onBlockAndDelete: action('onBlockAndDelete'),
onDelete: action('onDelete'),
@ -32,3 +38,9 @@ story.add('Basic', () => {
return <ConversationDetailsActions {...props} />;
});
story.add('Cannot leave because you are the last admin', () => {
const props = createProps({ cannotLeaveBecauseYouAreLastAdmin: true });
return <ConversationDetailsActions {...props} />;
});

View File

@ -1,16 +1,19 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { LocalizerType } from '../../../types/Util';
import { ConfirmationModal } from '../../ConfirmationModal';
import { Tooltip, TooltipPlacement } from '../../Tooltip';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
import { ConversationDetailsIcon } from './ConversationDetailsIcon';
export type Props = {
cannotLeaveBecauseYouAreLastAdmin: boolean;
conversationTitle: string;
onBlockAndDelete: () => void;
onDelete: () => void;
@ -18,6 +21,7 @@ export type Props = {
};
export const ConversationDetailsActions: React.ComponentType<Props> = ({
cannotLeaveBecauseYouAreLastAdmin,
conversationTitle,
onBlockAndDelete,
onDelete,
@ -26,23 +30,47 @@ export const ConversationDetailsActions: React.ComponentType<Props> = ({
const [confirmingLeave, setConfirmingLeave] = React.useState<boolean>(false);
const [confirmingBlock, setConfirmingBlock] = React.useState<boolean>(false);
let leaveGroupNode = (
<PanelRow
disabled={cannotLeaveBecauseYouAreLastAdmin}
onClick={() => setConfirmingLeave(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsActions--leave-group')}
disabled={cannotLeaveBecauseYouAreLastAdmin}
icon="leave"
/>
}
label={
<div
className={classNames(
'module-conversation-details__leave-group',
cannotLeaveBecauseYouAreLastAdmin &&
'module-conversation-details__leave-group--disabled'
)}
>
{i18n('ConversationDetailsActions--leave-group')}
</div>
}
/>
);
if (cannotLeaveBecauseYouAreLastAdmin) {
leaveGroupNode = (
<Tooltip
content={i18n(
'ConversationDetailsActions--leave-group-must-choose-new-admin'
)}
direction={TooltipPlacement.Top}
>
{leaveGroupNode}
</Tooltip>
);
}
return (
<>
<PanelSection>
<PanelRow
onClick={() => setConfirmingLeave(true)}
icon={
<ConversationDetailsIcon
ariaLabel={i18n('ConversationDetailsActions--leave-group')}
icon="leave"
/>
}
label={
<div className="module-conversation-details__leave-group">
{i18n('ConversationDetailsActions--leave-group')}
</div>
}
/>
{leaveGroupNode}
<PanelRow
onClick={() => setConfirmingBlock(true)}
icon={

View File

@ -1,12 +1,14 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { bemGenerator } from './util';
export type Props = {
ariaLabel: string;
disabled?: boolean;
icon: string;
onClick?: () => void;
};
@ -15,16 +17,26 @@ const bem = bemGenerator('module-conversation-details-icon');
export const ConversationDetailsIcon: React.ComponentType<Props> = ({
ariaLabel,
disabled,
icon,
onClick,
}) => {
const content = <div className={bem('icon', icon)} />;
const iconClassName = bem('icon', icon);
const content = (
<div
className={classNames(
iconClassName,
disabled && `${iconClassName}--disabled`
)}
/>
);
if (onClick) {
return (
<button
aria-label={ariaLabel}
className={bem('button')}
disabled={disabled}
type="button"
onClick={onClick}
>

View File

@ -8,6 +8,7 @@ import { bemGenerator } from './util';
export type Props = {
alwaysShowActions?: boolean;
className?: string;
disabled?: boolean;
icon?: React.ReactNode;
label: string | React.ReactNode;
info?: string;
@ -21,6 +22,7 @@ const bem = bemGenerator('module-conversation-details-panel-row');
export const PanelRow: React.ComponentType<Props> = ({
alwaysShowActions,
className,
disabled,
icon,
label,
info,
@ -45,6 +47,7 @@ export const PanelRow: React.ComponentType<Props> = ({
if (onClick) {
return (
<button
disabled={disabled}
type="button"
className={classNames(bem('root', 'button'), className)}
onClick={onClick}