From f217730b8452a531ddbd7454a50ed7d902b3f37f Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 14 Mar 2022 18:32:07 -0700 Subject: [PATCH] Support for people banned from joining groups via link --- _locales/en/messages.json | 28 +++ protos/Groups.proto | 17 ++ .../conversation/ContactModal.stories.tsx | 19 +- ts/components/conversation/ContactModal.tsx | 112 ++++++++--- .../ContactSpoofingReviewDialog.stories.tsx | 6 +- .../ContactSpoofingReviewDialog.tsx | 23 ++- .../RemoveGroupMemberConfirmationDialog.tsx | 54 +++--- .../conversation/Timeline.stories.tsx | 1 + ts/components/conversation/Timeline.tsx | 6 +- .../conversation-details/PendingInvites.tsx | 14 +- ts/groups.ts | 177 ++++++++++++++++-- ts/groups/joinViaLink.ts | 50 +++-- ts/groups/util.ts | 15 ++ ts/model-types.d.ts | 1 + ts/models/conversations.ts | 31 ++- ts/state/smart/ContactModal.tsx | 2 +- ts/state/smart/Timeline.tsx | 7 +- 17 files changed, 455 insertions(+), 108 deletions(-) create mode 100644 ts/groups/util.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 090b35dab..59968c74e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4059,6 +4059,14 @@ "message": "This group link is no longer valid.", "description": "Shown if you click a group link and we can't get information about it" }, + "GroupV2--join--link-forbidden--title": { + "message": "Can’t Join Group", + "description": "Shown if you click a group link and you have been forbidden from joining via the link" + }, + "GroupV2--join--link-forbidden": { + "message": "You can't join this group via the group link because an admin removed you.", + "description": "Shown if you click a group link and you have been forbidden from joining via the link" + }, "GroupV2--join--prompt-with-approval": { "message": "An admin of this group must approve your request before you can join this group. If approved, your name and photo will be shared with its members.", "description": "Shown when you click on a group link to confirm, if it requires admin approval" @@ -5688,6 +5696,16 @@ } } }, + "PendingRequests--deny-for--with-link": { + "message": "Deny request from \"$name$\"? They will not be able to request to join via the group link again.", + "description": "This is the modal content when confirming denying a group request to join", + "placeholders": { + "name": { + "content": "$1", + "example": "Meowsy Purrington" + } + } + }, "PendingInvites--invites": { "message": "Invited by you", "description": "This is the title list of all invites" @@ -6070,6 +6088,16 @@ } } }, + "RemoveGroupMemberConfirmation__description__with-link": { + "message": "Remove \"$name$\" from the group? They will not be able to rejoin via the group link.", + "description": "When confirming the removal of a group member, show this text in the dialog", + "placeholders": { + "name": { + "content": "$1", + "example": "Jane Doe" + } + } + }, "CaptchaDialog__title": { "message": "Verify to continue messaging", "description": "Header in the captcha dialog" diff --git a/protos/Groups.proto b/protos/Groups.proto index f3d04c3a9..262cb06a7 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -45,6 +45,10 @@ message MemberPendingAdminApproval { uint64 timestamp = 4; } +message MemberBanned { + bytes userId = 1; +} + message AccessControl { enum AccessRequired { UNKNOWN = 0; @@ -72,6 +76,8 @@ message Group { bytes inviteLinkPassword = 10; bytes descriptionBytes = 11; bool announcementsOnly = 12; + repeated MemberBanned membersBanned = 13; + // next: 14 } message GroupChange { @@ -121,6 +127,14 @@ message GroupChange { Member.Role role = 2; } + message AddMemberBannedAction { + MemberBanned added = 1; + } + + message DeleteMemberBannedAction { + bytes deletedUserId = 1; + } + message ModifyTitleAction { bytes title = 1; } @@ -183,6 +197,9 @@ message GroupChange { ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1 ModifyDescriptionAction modifyDescription = 20; // change epoch = 2 ModifyAnnouncementsOnlyAction modifyAnnouncementsOnly = 21; // change epoch = 3 + repeated AddMemberBannedAction addMembersBanned = 22; // change epoch = 4 + repeated DeleteMemberBannedAction deleteMembersBanned = 23; // change epoch = 4 + // next: 24 } bytes actions = 1; // The serialized actions diff --git a/ts/components/conversation/ContactModal.stories.tsx b/ts/components/conversation/ContactModal.stories.tsx index 11bb738c8..e4290444e 100644 --- a/ts/components/conversation/ContactModal.stories.tsx +++ b/ts/components/conversation/ContactModal.stories.tsx @@ -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 => ({ 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 ; }); +story.add('As admin with no group link', () => { + const props = createProps({ + areWeAdmin: true, + conversation: { + ...defaultGroup, + groupLink: undefined, + }, + }); + return ; +}); + story.add('As admin, viewing non-member of group', () => { const props = createProps({ isMember: false, diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index d462c390b..dcacdde65 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -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; 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.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 = ( + 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])} + + ); + break; + case SubModalState.MemberRemove: + if (!contact || !conversation?.id) { + log.warn( + 'ContactModal: MemberRemove state - missing contact or conversationId' + ); + modalNode = undefined; + break; + } + + modalNode = ( + { + 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 = ({ {i18n('showSafetyNumber')} )} - {!contact.isMe && areWeAdmin && isMember && conversationId && ( + {!contact.isMe && areWeAdmin && isMember && conversation?.id && ( <>