Support for people banned from joining groups via link

This commit is contained in:
Scott Nonnenberg 2022-03-14 18:32:07 -07:00 committed by GitHub
parent 1b7496399b
commit f217730b84
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 455 additions and 108 deletions

View file

@ -22,17 +22,23 @@ const story = storiesOf('Components/Conversation/ContactModal', module);
const defaultContact: ConversationType = getDefaultConversation({
id: 'abcdef',
areWeAdmin: false,
title: 'Pauline Oliveros',
phoneNumber: '(333) 444-5515',
about: '👍 Free to chat',
});
const defaultGroup: ConversationType = getDefaultConversation({
id: 'abcdef',
areWeAdmin: true,
title: "It's a group",
groupLink: 'something',
});
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
areWeASubscriber: false,
areWeAdmin: boolean('areWeAdmin', overrideProps.areWeAdmin || false),
badges: overrideProps.badges || [],
contact: overrideProps.contact || defaultContact,
conversation: overrideProps.conversation || defaultGroup,
hideContactModal: action('hideContactModal'),
i18n,
isAdmin: boolean('isAdmin', overrideProps.isAdmin || false),
@ -62,6 +68,17 @@ story.add('As admin', () => {
return <ContactModal {...props} />;
});
story.add('As admin with no group link', () => {
const props = createProps({
areWeAdmin: true,
conversation: {
...defaultGroup,
groupLink: undefined,
},
});
return <ContactModal {...props} />;
});
story.add('As admin, viewing non-member of group', () => {
const props = createProps({
isMember: false,

View file

@ -2,7 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import * as log from '../../logging/log';
import { missingCaseError } from '../../util/missingCaseError';
import { About } from './About';
import { Avatar } from '../Avatar';
@ -14,13 +16,14 @@ import { BadgeDialog } from '../BadgeDialog';
import type { BadgeType } from '../../badges/types';
import { SharedGroupNames } from '../SharedGroupNames';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog';
export type PropsDataType = {
areWeASubscriber: boolean;
areWeAdmin: boolean;
badges: ReadonlyArray<BadgeType>;
contact?: ConversationType;
conversationId?: string;
conversation?: ConversationType;
readonly i18n: LocalizerType;
isAdmin: boolean;
isMember: boolean;
@ -50,12 +53,18 @@ enum ContactModalView {
ShowingBadges,
}
enum SubModalState {
None = 'None',
ToggleAdmin = 'ToggleAdmin',
MemberRemove = 'MemberRemove',
}
export const ContactModal = ({
areWeASubscriber,
areWeAdmin,
badges,
contact,
conversationId,
conversation,
hideContactModal,
i18n,
isAdmin,
@ -72,14 +81,78 @@ export const ContactModal = ({
}
const [view, setView] = useState(ContactModalView.Default);
const [confirmToggleAdmin, setConfirmToggleAdmin] = useState(false);
const [subModalState, setSubModalState] = useState<SubModalState>(
SubModalState.None
);
useEffect(() => {
if (conversationId) {
if (conversation?.id) {
// Kick off the expensive hydration of the current sharedGroupNames
updateConversationModelSharedGroups(conversationId);
updateConversationModelSharedGroups(conversation.id);
}
}, [conversationId, updateConversationModelSharedGroups]);
}, [conversation?.id, updateConversationModelSharedGroups]);
let modalNode: ReactNode;
switch (subModalState) {
case SubModalState.None:
modalNode = undefined;
break;
case SubModalState.ToggleAdmin:
if (!conversation?.id) {
log.warn('ContactModal: ToggleAdmin state - missing conversationId');
modalNode = undefined;
break;
}
modalNode = (
<ConfirmationDialog
actions={[
{
action: () => toggleAdmin(conversation.id, contact.id),
text: isAdmin
? i18n('ContactModal--rm-admin')
: i18n('ContactModal--make-admin'),
},
]}
i18n={i18n}
onClose={() => setSubModalState(SubModalState.None)}
>
{isAdmin
? i18n('ContactModal--rm-admin-info', [contact.title])
: i18n('ContactModal--make-admin-info', [contact.title])}
</ConfirmationDialog>
);
break;
case SubModalState.MemberRemove:
if (!contact || !conversation?.id) {
log.warn(
'ContactModal: MemberRemove state - missing contact or conversationId'
);
modalNode = undefined;
break;
}
modalNode = (
<RemoveGroupMemberConfirmationDialog
conversation={contact}
group={conversation}
i18n={i18n}
onClose={() => {
setSubModalState(SubModalState.None);
}}
onRemove={() => {
removeMemberFromGroup(conversation?.id, contact.id);
}}
/>
);
break;
default: {
const state: never = subModalState;
log.warn(`ContactModal: unexpected ${state}!`);
modalNode = undefined;
break;
}
}
switch (view) {
case ContactModalView.Default: {
@ -155,12 +228,12 @@ export const ContactModal = ({
<span>{i18n('showSafetyNumber')}</span>
</button>
)}
{!contact.isMe && areWeAdmin && isMember && conversationId && (
{!contact.isMe && areWeAdmin && isMember && conversation?.id && (
<>
<button
type="button"
className="ContactModal__button ContactModal__make-admin"
onClick={() => setConfirmToggleAdmin(true)}
onClick={() => setSubModalState(SubModalState.ToggleAdmin)}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__make-admin__bubble-icon" />
@ -174,9 +247,7 @@ export const ContactModal = ({
<button
type="button"
className="ContactModal__button ContactModal__remove-from-group"
onClick={() =>
removeMemberFromGroup(conversationId, contact.id)
}
onClick={() => setSubModalState(SubModalState.MemberRemove)}
>
<div className="ContactModal__bubble-icon">
<div className="ContactModal__remove-from-group__bubble-icon" />
@ -186,24 +257,7 @@ export const ContactModal = ({
</>
)}
</div>
{confirmToggleAdmin && conversationId && (
<ConfirmationDialog
actions={[
{
action: () => toggleAdmin(conversationId, contact.id),
text: isAdmin
? i18n('ContactModal--rm-admin')
: i18n('ContactModal--make-admin'),
},
]}
i18n={i18n}
onClose={() => setConfirmToggleAdmin(false)}
>
{isAdmin
? i18n('ContactModal--rm-admin-info', [contact.title])
: i18n('ContactModal--make-admin-info', [contact.title])}
</ConfirmationDialog>
)}
{modalNode}
</div>
</Modal>
);

View file

@ -23,6 +23,7 @@ const story = storiesOf(
const getCommonProps = () => ({
getPreferredBadge: () => undefined,
i18n,
group: getDefaultConversation(),
onBlock: action('onBlock'),
onBlockAndReportSpam: action('onBlockAndReportSpam'),
onClose: action('onClose'),
@ -51,7 +52,10 @@ story.add('Direct conversations with same title', () => (
<ContactSpoofingReviewDialog
{...getCommonProps()}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
areWeAdmin={areWeAdmin}
group={{
...getDefaultConversation(),
areWeAdmin,
}}
collisionInfoByTitle={{
Alice: times(2, () => ({
oldName: 'Alicia',

View file

@ -43,7 +43,7 @@ type PropsType = {
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
areWeAdmin: boolean;
group: ConversationType;
collisionInfoByTitle: Record<
string,
Array<{
@ -78,13 +78,20 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
const [confirmationState, setConfirmationState] = useState<
| undefined
| {
type: ConfirmationStateType;
type: ConfirmationStateType.ConfirmingGroupRemoval;
affectedConversation: ConversationType;
group: ConversationType;
}
| {
type:
| ConfirmationStateType.ConfirmingDelete
| ConfirmationStateType.ConfirmingBlock;
affectedConversation: ConversationType;
}
>();
if (confirmationState) {
const { affectedConversation, type } = confirmationState;
const { type, affectedConversation } = confirmationState;
switch (type) {
case ConfirmationStateType.ConfirmingDelete:
case ConfirmationStateType.ConfirmingBlock:
@ -140,10 +147,12 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
}}
/>
);
case ConfirmationStateType.ConfirmingGroupRemoval:
case ConfirmationStateType.ConfirmingGroupRemoval: {
const { group } = confirmationState;
return (
<RemoveGroupMemberConfirmationDialog
conversation={affectedConversation}
group={group}
i18n={i18n}
onClose={() => {
setConfirmationState(undefined);
@ -153,6 +162,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
}}
/>
);
}
default:
throw missingCaseError(type);
}
@ -227,7 +237,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
break;
}
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
const { areWeAdmin, collisionInfoByTitle } = props;
const { group, collisionInfoByTitle } = props;
const unsortedConversationInfos = concat(
// This empty array exists to appease Lodash's type definitions.
@ -254,7 +264,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
</h2>
{conversationInfos.map((conversationInfo, index) => {
let button: ReactNode;
if (areWeAdmin) {
if (group.areWeAdmin) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
@ -262,6 +272,7 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> =
setConfirmationState({
type: ConfirmationStateType.ConfirmingGroupRemoval,
affectedConversation: conversationInfo.conversation,
group,
});
}}
>

View file

@ -6,12 +6,14 @@ import React from 'react';
import type { ConversationType } from '../../state/ducks/conversations';
import type { LocalizerType } from '../../types/Util';
import { isAccessControlEnabled } from '../../groups/util';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Intl } from '../Intl';
import { ContactName } from './ContactName';
type PropsType = {
group: ConversationType;
conversation: ConversationType;
i18n: LocalizerType;
onClose: () => void;
@ -19,25 +21,33 @@ type PropsType = {
};
export const RemoveGroupMemberConfirmationDialog: FunctionComponent<PropsType> =
({ conversation, i18n, onClose, onRemove }) => (
<ConfirmationDialog
actions={[
{
action: onRemove,
text: i18n('RemoveGroupMemberConfirmation__remove-button'),
style: 'negative',
},
]}
i18n={i18n}
onClose={onClose}
title={
<Intl
i18n={i18n}
id="RemoveGroupMemberConfirmation__description"
components={{
name: <ContactName title={conversation.title} />,
}}
/>
}
/>
);
({ conversation, group, i18n, onClose, onRemove }) => {
const descriptionKey = isAccessControlEnabled(
group.accessControlAddFromInviteLink
)
? 'RemoveGroupMemberConfirmation__description__with-link'
: 'RemoveGroupMemberConfirmation__description';
return (
<ConfirmationDialog
actions={[
{
action: onRemove,
text: i18n('RemoveGroupMemberConfirmation__remove-button'),
style: 'negative',
},
]}
i18n={i18n}
onClose={onClose}
title={
<Intl
i18n={i18n}
id={descriptionKey}
components={{
name: <ContactName title={conversation.title} />,
}}
/>
}
/>
);
};

View file

@ -492,6 +492,7 @@ const renderTypingBubble = () => (
);
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
conversation: overrideProps.conversation || getDefaultConversation(),
discardMessages: action('discardMessages'),
getPreferredBadge: () => undefined,
i18n,

View file

@ -94,7 +94,7 @@ export type PropsDataType = {
type PropsHousekeepingType = {
id: string;
areWeAdmin?: boolean;
conversation: ConversationType;
isConversationSelected: boolean;
isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean;
@ -734,10 +734,10 @@ export class Timeline extends React.Component<
public override render(): JSX.Element | null {
const {
acknowledgeGroupMemberNameCollisions,
areWeAdmin,
clearInvitedUuidsForNewlyCreatedGroup,
closeContactSpoofingReview,
contactSpoofingReview,
conversation,
getPreferredBadge,
getTimestampForMessage,
haveNewest,
@ -1018,7 +1018,7 @@ export class Timeline extends React.Component<
<ContactSpoofingReviewDialog
{...commonProps}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
areWeAdmin={Boolean(areWeAdmin)}
group={conversation}
collisionInfoByTitle={contactSpoofingReview.collisionInfoByTitle}
/>
);

View file

@ -14,6 +14,7 @@ import { ConfirmationDialog } from '../../ConfirmationDialog';
import { PanelSection } from './PanelSection';
import { PanelRow } from './PanelRow';
import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon';
import { isAccessControlEnabled } from '../../../groups/util';
export type PropsType = {
readonly conversation?: ConversationType;
@ -147,6 +148,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
{stagedMemberships && stagedMemberships.length && (
<MembershipActionConfirmation
approvePendingMembership={approvePendingMembership}
conversation={conversation}
i18n={i18n}
members={conversation.sortedGroupMembers || []}
onClose={() => setStagedMemberships(null)}
@ -161,6 +163,7 @@ export const PendingInvites: React.ComponentType<PropsType> = ({
function MembershipActionConfirmation({
approvePendingMembership,
conversation,
i18n,
members,
onClose,
@ -169,6 +172,7 @@ function MembershipActionConfirmation({
stagedMemberships,
}: {
approvePendingMembership: (conversationId: string) => void;
conversation: ConversationType;
i18n: LocalizerType;
members: Array<ConversationType>;
onClose: () => void;
@ -222,6 +226,7 @@ function MembershipActionConfirmation({
onClose={onClose}
>
{getConfirmationMessage({
conversation,
i18n,
members,
ourUuid,
@ -232,11 +237,13 @@ function MembershipActionConfirmation({
}
function getConfirmationMessage({
conversation,
i18n,
members,
ourUuid,
stagedMemberships,
}: Readonly<{
conversation: ConversationType;
i18n: LocalizerType;
members: ReadonlyArray<ConversationType>;
ourUuid: string;
@ -251,7 +258,12 @@ function getConfirmationMessage({
// Requesting a membership since they weren't added by anyone
if (membershipType === StageType.DENY_REQUEST) {
return i18n('PendingRequests--deny-for', {
const key = isAccessControlEnabled(
conversation.accessControlAddFromInviteLink
)
? 'PendingRequests--deny-for--with-link'
: 'PendingRequests--deny-for';
return i18n(key, {
name: firstMembership.member.title,
});
}