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

@ -4059,6 +4059,14 @@
"message": "This group link is no longer valid.", "message": "This group link is no longer valid.",
"description": "Shown if you click a group link and we can't get information about it" "description": "Shown if you click a group link and we can't get information about it"
}, },
"GroupV2--join--link-forbidden--title": {
"message": "Cant 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": { "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.", "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" "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": { "PendingInvites--invites": {
"message": "Invited by you", "message": "Invited by you",
"description": "This is the title list of all invites" "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": { "CaptchaDialog__title": {
"message": "Verify to continue messaging", "message": "Verify to continue messaging",
"description": "Header in the captcha dialog" "description": "Header in the captcha dialog"

View file

@ -45,6 +45,10 @@ message MemberPendingAdminApproval {
uint64 timestamp = 4; uint64 timestamp = 4;
} }
message MemberBanned {
bytes userId = 1;
}
message AccessControl { message AccessControl {
enum AccessRequired { enum AccessRequired {
UNKNOWN = 0; UNKNOWN = 0;
@ -72,6 +76,8 @@ message Group {
bytes inviteLinkPassword = 10; bytes inviteLinkPassword = 10;
bytes descriptionBytes = 11; bytes descriptionBytes = 11;
bool announcementsOnly = 12; bool announcementsOnly = 12;
repeated MemberBanned membersBanned = 13;
// next: 14
} }
message GroupChange { message GroupChange {
@ -121,6 +127,14 @@ message GroupChange {
Member.Role role = 2; Member.Role role = 2;
} }
message AddMemberBannedAction {
MemberBanned added = 1;
}
message DeleteMemberBannedAction {
bytes deletedUserId = 1;
}
message ModifyTitleAction { message ModifyTitleAction {
bytes title = 1; bytes title = 1;
} }
@ -183,6 +197,9 @@ message GroupChange {
ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1 ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; // change epoch = 1
ModifyDescriptionAction modifyDescription = 20; // change epoch = 2 ModifyDescriptionAction modifyDescription = 20; // change epoch = 2
ModifyAnnouncementsOnlyAction modifyAnnouncementsOnly = 21; // change epoch = 3 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 bytes actions = 1; // The serialized actions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -74,13 +74,15 @@ import { UUID, isValidUuid } from './types/UUID';
import type { UUIDStringType } from './types/UUID'; import type { UUIDStringType } from './types/UUID';
import * as Errors from './types/errors'; import * as Errors from './types/errors';
import { SignalService as Proto } from './protobuf'; import { SignalService as Proto } from './protobuf';
import { isNotNil } from './util/isNotNil';
import { isAccessControlEnabled } from './groups/util';
import { import {
conversationJobQueue, conversationJobQueue,
conversationQueueJobEnum, conversationQueueJobEnum,
} from './jobs/conversationJobQueue'; } from './jobs/conversationJobQueue';
import AccessRequiredEnum = Proto.AccessControl.AccessRequired; type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
export { joinViaLink } from './groups/joinViaLink'; export { joinViaLink } from './groups/joinViaLink';
@ -271,7 +273,7 @@ export const ID_LENGTH = 32;
const TEMPORAL_AUTH_REJECTED_CODE = 401; const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403; const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404; const GROUP_NONEXISTENT_CODE = 404;
const SUPPORTED_CHANGE_EPOCH = 2; const SUPPORTED_CHANGE_EPOCH = 4;
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR'; export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16; const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;
@ -582,7 +584,7 @@ function buildGroupProto(
export async function buildAddMembersChange( export async function buildAddMembersChange(
conversation: Pick< conversation: Pick<
ConversationAttributesType, ConversationAttributesType,
'id' | 'publicParams' | 'revision' | 'secretParams' 'bannedMembersV2' | 'id' | 'publicParams' | 'revision' | 'secretParams'
>, >,
conversationIds: ReadonlyArray<string> conversationIds: ReadonlyArray<string>
): Promise<undefined | Proto.GroupChange.Actions> { ): Promise<undefined | Proto.GroupChange.Actions> {
@ -621,6 +623,7 @@ export async function buildAddMembersChange(
const addMembers: Array<Proto.GroupChange.Actions.AddMemberAction> = []; const addMembers: Array<Proto.GroupChange.Actions.AddMemberAction> = [];
const addPendingMembers: Array<Proto.GroupChange.Actions.AddMemberPendingProfileKeyAction> = const addPendingMembers: Array<Proto.GroupChange.Actions.AddMemberPendingProfileKeyAction> =
[]; [];
const actions = new Proto.GroupChange.Actions();
await Promise.all( await Promise.all(
conversationIds.map(async conversationId => { conversationIds.map(async conversationId => {
@ -679,10 +682,22 @@ export async function buildAddMembersChange(
addPendingMembers.push(addPendingMemberAction); addPendingMembers.push(addPendingMemberAction);
} }
const doesMemberNeedUnban = conversation.bannedMembersV2?.includes(uuid);
if (doesMemberNeedUnban) {
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deleteMemberBannedAction =
new Proto.GroupChange.Actions.DeleteMemberBannedAction();
deleteMemberBannedAction.deletedUserId = uuidCipherTextBuffer;
actions.deleteMembersBanned = actions.deleteMembersBanned || [];
actions.deleteMembersBanned.push(deleteMemberBannedAction);
}
}) })
); );
const actions = new Proto.GroupChange.Actions();
if (!addMembers.length && !addPendingMembers.length) { if (!addMembers.length && !addPendingMembers.length) {
// This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
// will be logged. // will be logged.
@ -920,9 +935,11 @@ export function buildAccessControlMembersChange(
// TODO AND-1101 // TODO AND-1101
export function buildDeletePendingAdminApprovalMemberChange({ export function buildDeletePendingAdminApprovalMemberChange({
group, group,
ourUuid,
uuid, uuid,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
ourUuid: UUIDStringType;
uuid: UUIDStringType; uuid: UUIDStringType;
}): Proto.GroupChange.Actions { }): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions(); const actions = new Proto.GroupChange.Actions();
@ -944,6 +961,18 @@ export function buildDeletePendingAdminApprovalMemberChange({
deleteMemberPendingAdminApproval, deleteMemberPendingAdminApproval,
]; ];
const doesMemberNeedBan =
!group.bannedMembersV2?.includes(uuid) && uuid !== ourUuid;
if (doesMemberNeedBan) {
const addMemberBannedAction =
new Proto.GroupChange.Actions.AddMemberBannedAction();
addMemberBannedAction.added = new Proto.MemberBanned();
addMemberBannedAction.added.userId = uuidCipherTextBuffer;
actions.addMembersBanned = [addMemberBannedAction];
}
return actions; return actions;
} }
@ -990,11 +1019,13 @@ export function buildAddMember({
group, group,
profileKeyCredentialBase64, profileKeyCredentialBase64,
serverPublicParamsBase64, serverPublicParamsBase64,
uuid,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
profileKeyCredentialBase64: string; profileKeyCredentialBase64: string;
serverPublicParamsBase64: string; serverPublicParamsBase64: string;
joinFromInviteLink?: boolean; joinFromInviteLink?: boolean;
uuid: UUIDStringType;
}): Proto.GroupChange.Actions { }): Proto.GroupChange.Actions {
const MEMBER_ROLE_ENUM = Proto.Member.Role; const MEMBER_ROLE_ENUM = Proto.Member.Role;
@ -1023,6 +1054,18 @@ export function buildAddMember({
actions.version = (group.revision || 0) + 1; actions.version = (group.revision || 0) + 1;
actions.addMembers = [addMember]; actions.addMembers = [addMember];
const doesMemberNeedUnban = group.bannedMembersV2?.includes(uuid);
if (doesMemberNeedUnban) {
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
const deleteMemberBannedAction =
new Proto.GroupChange.Actions.DeleteMemberBannedAction();
deleteMemberBannedAction.deletedUserId = uuidCipherTextBuffer;
actions.deleteMembersBanned = [deleteMemberBannedAction];
}
return actions; return actions;
} }
@ -1057,11 +1100,13 @@ export function buildDeletePendingMemberChange({
} }
export function buildDeleteMemberChange({ export function buildDeleteMemberChange({
uuid,
group, group,
ourUuid,
uuid,
}: { }: {
uuid: UUIDStringType;
group: ConversationAttributesType; group: ConversationAttributesType;
ourUuid: UUIDStringType;
uuid: UUIDStringType;
}): Proto.GroupChange.Actions { }): Proto.GroupChange.Actions {
const actions = new Proto.GroupChange.Actions(); const actions = new Proto.GroupChange.Actions();
@ -1077,6 +1122,19 @@ export function buildDeleteMemberChange({
actions.version = (group.revision || 0) + 1; actions.version = (group.revision || 0) + 1;
actions.deleteMembers = [deleteMember]; actions.deleteMembers = [deleteMember];
const doesMemberNeedBan =
!group.bannedMembersV2?.includes(uuid) && uuid !== ourUuid;
if (doesMemberNeedBan) {
const addMemberBannedAction =
new Proto.GroupChange.Actions.AddMemberBannedAction();
addMemberBannedAction.added = new Proto.MemberBanned();
addMemberBannedAction.added.userId = uuidCipherTextBuffer;
actions.addMembersBanned = [addMemberBannedAction];
}
return actions; return actions;
} }
@ -3548,12 +3606,12 @@ function extractDiffs({
}); });
} }
const linkPreviouslyEnabled = const linkPreviouslyEnabled = isAccessControlEnabled(
old.accessControl?.addFromInviteLink === ACCESS_ENUM.ANY || old.accessControl?.addFromInviteLink
old.accessControl?.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR; );
const linkCurrentlyEnabled = const linkCurrentlyEnabled = isAccessControlEnabled(
current.accessControl?.addFromInviteLink === ACCESS_ENUM.ANY || current.accessControl?.addFromInviteLink
current.accessControl?.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR; );
if (!linkPreviouslyEnabled && linkCurrentlyEnabled) { if (!linkPreviouslyEnabled && linkCurrentlyEnabled) {
details.push({ details.push({
@ -3808,6 +3866,8 @@ function extractDiffs({
}); });
} }
// Note: currently no diff generated for bannedMembersV2 changes
// final processing // final processing
let message: MessageAttributesType | undefined; let message: MessageAttributesType | undefined;
@ -3967,6 +4027,9 @@ async function applyGroupChange({
> = fromPairs( > = fromPairs(
(result.pendingAdminApprovalV2 || []).map(member => [member.uuid, member]) (result.pendingAdminApprovalV2 || []).map(member => [member.uuid, member])
); );
const bannedMembers: Record<UUIDStringType, UUIDStringType> = fromPairs(
(result.bannedMembersV2 || []).map(uuid => [uuid, uuid])
);
// version?: number; // version?: number;
result.revision = version; result.revision = version;
@ -4388,6 +4451,32 @@ async function applyGroupChange({
result.announcementsOnly = announcementsOnly; result.announcementsOnly = announcementsOnly;
} }
if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
actions.addMembersBanned.forEach(uuid => {
if (bannedMembers[uuid]) {
log.warn(
`applyGroupChange/${logId}: Attempt to add banned member failed; was already in banned list.`
);
return;
}
bannedMembers[uuid] = uuid;
});
}
if (actions.deleteMembersBanned && actions.deleteMembersBanned.length > 0) {
actions.deleteMembersBanned.forEach(uuid => {
if (!bannedMembers[uuid]) {
log.warn(
`applyGroupChange/${logId}: Attempt to remove banned member failed; was not in banned list.`
);
return;
}
delete bannedMembers[uuid];
});
}
if (ourUuid) { if (ourUuid) {
result.left = !members[ourUuid]; result.left = !members[ourUuid];
} }
@ -4396,6 +4485,7 @@ async function applyGroupChange({
result.membersV2 = values(members); result.membersV2 = values(members);
result.pendingMembersV2 = values(pendingMembers); result.pendingMembersV2 = values(pendingMembers);
result.pendingAdminApprovalV2 = values(pendingAdminApprovalMembers); result.pendingAdminApprovalV2 = values(pendingAdminApprovalMembers);
result.bannedMembersV2 = values(bannedMembers);
return { return {
newAttributes: result, newAttributes: result,
@ -4650,6 +4740,9 @@ async function applyGroupState({
// announcementsOnly // announcementsOnly
result.announcementsOnly = groupState.announcementsOnly; result.announcementsOnly = groupState.announcementsOnly;
// membersBanned
result.bannedMembersV2 = groupState.membersBanned;
return { return {
newAttributes: result, newAttributes: result,
newProfileKeys, newProfileKeys,
@ -4759,6 +4852,8 @@ type DecryptedGroupChangeActions = {
modifyAnnouncementsOnly?: { modifyAnnouncementsOnly?: {
announcementsOnly: boolean; announcementsOnly: boolean;
}; };
addMembersBanned?: ReadonlyArray<UUIDStringType>;
deleteMembersBanned?: ReadonlyArray<UUIDStringType>;
} & Pick< } & Pick<
Proto.GroupChange.IActions, Proto.GroupChange.IActions,
| 'modifyAttributesAccess' | 'modifyAttributesAccess'
@ -5286,6 +5381,42 @@ function decryptGroupChange(
}; };
} }
// addMembersBanned
if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
result.addMembersBanned = actions.addMembersBanned
.map(item => {
if (!item.added || !item.added.userId) {
log.warn(
`decryptGroupChange/${logId}: addMembersBanned had a blank entry`
);
return null;
}
return normalizeUuid(
decryptUuid(clientZkGroupCipher, item.added.userId),
'addMembersBanned.added.userId'
);
})
.filter(isNotNil);
}
// deleteMembersBanned
if (actions.deleteMembersBanned && actions.deleteMembersBanned.length > 0) {
result.deleteMembersBanned = actions.deleteMembersBanned
.map(item => {
if (!item.deletedUserId) {
log.warn(
`decryptGroupChange/${logId}: deleteMembersBanned had a blank entry`
);
return null;
}
return normalizeUuid(
decryptUuid(clientZkGroupCipher, item.deletedUserId),
'deleteMembersBanned.deletedUserId'
);
})
.filter(isNotNil);
}
return result; return result;
} }
@ -5344,6 +5475,7 @@ type DecryptedGroupState = {
descriptionBytes?: Proto.GroupAttributeBlob; descriptionBytes?: Proto.GroupAttributeBlob;
avatar?: string; avatar?: string;
announcementsOnly?: boolean; announcementsOnly?: boolean;
membersBanned?: Array<UUIDStringType>;
}; };
function decryptGroupState( function decryptGroupState(
@ -5479,6 +5611,27 @@ function decryptGroupState(
const { announcementsOnly } = groupState; const { announcementsOnly } = groupState;
result.announcementsOnly = Boolean(announcementsOnly); result.announcementsOnly = Boolean(announcementsOnly);
// membersBanned
const { membersBanned } = groupState;
if (membersBanned && membersBanned.length > 0) {
result.membersBanned = membersBanned
.map(item => {
if (!item.userId) {
log.warn(
`decryptGroupState/${logId}: membersBanned had a blank entry`
);
return null;
}
return normalizeUuid(
decryptUuid(clientZkGroupCipher, item.userId),
'membersBanned.added.userId'
);
})
.filter(isNotNil);
} else {
result.membersBanned = [];
}
result.avatar = dropNull(groupState.avatar); result.avatar = dropNull(groupState.avatar);
return result; return result;

View file

@ -11,6 +11,7 @@ import {
LINK_VERSION_ERROR, LINK_VERSION_ERROR,
parseGroupLink, parseGroupLink,
} from '../groups'; } from '../groups';
import * as Errors from '../types/errors';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper'; import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
import { isGroupV1 } from '../util/whatTypeOfConversation'; import { isGroupV1 } from '../util/whatTypeOfConversation';
@ -24,16 +25,19 @@ import * as log from '../logging/log';
import { showToast } from '../util/showToast'; import { showToast } from '../util/showToast';
import { ToastAlreadyGroupMember } from '../components/ToastAlreadyGroupMember'; import { ToastAlreadyGroupMember } from '../components/ToastAlreadyGroupMember';
import { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin'; import { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin';
import { HTTPError } from '../textsecure/Errors';
import { isAccessControlEnabled } from './util';
export async function joinViaLink(hash: string): Promise<void> { export async function joinViaLink(hash: string): Promise<void> {
let inviteLinkPassword: string; let inviteLinkPassword: string;
let masterKey: string; let masterKey: string;
try { try {
({ inviteLinkPassword, masterKey } = parseGroupLink(hash)); ({ inviteLinkPassword, masterKey } = parseGroupLink(hash));
} catch (error) { } catch (error: unknown) {
const errorString = error && error.stack ? error.stack : error; const errorString = Errors.toLogFormat(error);
log.error(`joinViaLink: Failed to parse group link ${errorString}`); log.error(`joinViaLink: Failed to parse group link ${errorString}`);
if (error && error.name === LINK_VERSION_ERROR) {
if (error instanceof Error && error.name === LINK_VERSION_ERROR) {
showErrorDialog( showErrorDialog(
window.i18n('GroupV2--join--unknown-link-version'), window.i18n('GroupV2--join--unknown-link-version'),
window.i18n('GroupV2--join--unknown-link-version--title') window.i18n('GroupV2--join--unknown-link-version--title')
@ -84,28 +88,35 @@ export async function joinViaLink(hash: string): Promise<void> {
suppressErrorDialog: true, suppressErrorDialog: true,
task: () => getPreJoinGroupInfo(inviteLinkPassword, masterKey), task: () => getPreJoinGroupInfo(inviteLinkPassword, masterKey),
}); });
} catch (error) { } catch (error: unknown) {
const errorString = error && error.stack ? error.stack : error; const errorString = Errors.toLogFormat(error);
log.error( log.error(
`joinViaLink/${logId}: Failed to fetch group info - ${errorString}` `joinViaLink/${logId}: Failed to fetch group info - ${errorString}`
); );
showErrorDialog( if (
error.code && error.code === 403 error instanceof HTTPError &&
? window.i18n('GroupV2--join--link-revoked') error.responseHeaders['x-signal-forbidden-reason']
: window.i18n('GroupV2--join--general-join-failure'), ) {
error.code && error.code === 403 showErrorDialog(
? window.i18n('GroupV2--join--link-revoked--title') window.i18n('GroupV2--join--link-forbidden'),
: window.i18n('GroupV2--join--general-join-failure--title') window.i18n('GroupV2--join--link-forbidden--title')
); );
} else if (error instanceof HTTPError && error.code === 403) {
showErrorDialog(
window.i18n('GroupV2--join--link-revoked'),
window.i18n('GroupV2--join--link-revoked--title')
);
} else {
showErrorDialog(
window.i18n('GroupV2--join--general-join-failure'),
window.i18n('GroupV2--join--general-join-failure--title')
);
}
return; return;
} }
const ACCESS_ENUM = Proto.AccessControl.AccessRequired; if (!isAccessControlEnabled(result.addFromInviteLink)) {
if (
result.addFromInviteLink !== ACCESS_ENUM.ADMINISTRATOR &&
result.addFromInviteLink !== ACCESS_ENUM.ANY
) {
log.error( log.error(
`joinViaLink/${logId}: addFromInviteLink value of ${result.addFromInviteLink} is invalid` `joinViaLink/${logId}: addFromInviteLink value of ${result.addFromInviteLink} is invalid`
); );
@ -124,7 +135,8 @@ export async function joinViaLink(hash: string): Promise<void> {
| undefined = result.avatar ? { loading: true } : undefined; | undefined = result.avatar ? { loading: true } : undefined;
const memberCount = result.memberCount || 1; const memberCount = result.memberCount || 1;
const approvalRequired = const approvalRequired =
result.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR; result.addFromInviteLink ===
Proto.AccessControl.AccessRequired.ADMINISTRATOR;
const title = const title =
decryptGroupTitle(result.title, secretParams) || decryptGroupTitle(result.title, secretParams) ||
window.i18n('unknownGroup'); window.i18n('unknownGroup');

15
ts/groups/util.ts Normal file
View file

@ -0,0 +1,15 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { SignalService as Proto } from '../protobuf';
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
export function isAccessControlEnabled(
accessControl: number | undefined
): boolean {
return (
accessControl === ACCESS_ENUM.ANY ||
accessControl === ACCESS_ENUM.ADMINISTRATOR
);
}

1
ts/model-types.d.ts vendored
View file

@ -344,6 +344,7 @@ export type ConversationAttributesType = {
membersV2?: Array<GroupV2MemberType>; membersV2?: Array<GroupV2MemberType>;
pendingMembersV2?: Array<GroupV2PendingMemberType>; pendingMembersV2?: Array<GroupV2PendingMemberType>;
pendingAdminApprovalV2?: Array<GroupV2PendingAdminApprovalType>; pendingAdminApprovalV2?: Array<GroupV2PendingAdminApprovalType>;
bannedMembersV2?: Array<UUIDStringType>;
groupInviteLinkPassword?: string; groupInviteLinkPassword?: string;
previousGroupV1Id?: string; previousGroupV1Id?: string;
previousGroupV1Members?: Array<string>; previousGroupV1Members?: Array<string>;

View file

@ -52,7 +52,7 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { isValidE164 } from '../util/isValidE164'; import { isValidE164 } from '../util/isValidE164';
import type { MIMEType } from '../types/MIME'; import type { MIMEType } from '../types/MIME';
import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME'; import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME';
import { UUID, UUIDKind, isValidUuid } from '../types/UUID'; import { UUID, isValidUuid, UUIDKind } from '../types/UUID';
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import { deriveAccessKey, decryptProfileName, decryptProfile } from '../Crypto'; import { deriveAccessKey, decryptProfileName, decryptProfile } from '../Crypto';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
@ -582,8 +582,13 @@ export class ConversationModel extends window.Backbone
); );
} }
const ourUuid = window.textsecure.storage.user
.getCheckedUuid(UUIDKind.ACI)
.toString();
return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({ return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({
group: this.attributes, group: this.attributes,
ourUuid,
uuid, uuid,
}); });
} }
@ -660,10 +665,19 @@ export class ConversationModel extends window.Backbone
this.get('announcementsOnly') && this.get('announcementsOnly') &&
!toRequest.get('capabilities')?.announcementGroup !toRequest.get('capabilities')?.announcementGroup
) { ) {
log.warn(`addMember/${idLog}: ${conversationId} needs to upgrade.`); log.warn(
`addMember/${idLog}: ${toRequest.idForLogging()} needs to upgrade.`
);
return undefined; return undefined;
} }
const uuid = toRequest.get('uuid');
if (!uuid) {
throw new Error(
`addMember/${idLog}: ${toRequest.idForLogging()} is missing a uuid!`
);
}
// We need the user's profileKeyCredential, which requires a roundtrip with the // We need the user's profileKeyCredential, which requires a roundtrip with the
// server, and most definitely their profileKey. A getProfiles() call will // server, and most definitely their profileKey. A getProfiles() call will
// ensure that we have as much as we can get with the data we have. // ensure that we have as much as we can get with the data we have.
@ -691,6 +705,7 @@ export class ConversationModel extends window.Backbone
group: this.attributes, group: this.attributes,
profileKeyCredentialBase64, profileKeyCredentialBase64,
serverPublicParamsBase64: window.getServerPublicParams(), serverPublicParamsBase64: window.getServerPublicParams(),
uuid,
}); });
} }
@ -769,8 +784,13 @@ export class ConversationModel extends window.Backbone
); );
} }
const ourUuid = window.textsecure.storage.user
.getCheckedUuid(UUIDKind.ACI)
.toString();
return window.Signal.Groups.buildDeleteMemberChange({ return window.Signal.Groups.buildDeleteMemberChange({
group: this.attributes, group: this.attributes,
ourUuid,
uuid, uuid,
}); });
} }
@ -2260,12 +2280,7 @@ export class ConversationModel extends window.Backbone
name: 'addMembersV2', name: 'addMembersV2',
createGroupChange: () => createGroupChange: () =>
window.Signal.Groups.buildAddMembersChange( window.Signal.Groups.buildAddMembersChange(
{ this.attributes,
id: this.id,
publicParams: this.get('publicParams'),
revision: this.get('revision'),
secretParams: this.get('secretParams'),
},
conversationIds conversationIds
), ),
}); });

View file

@ -40,7 +40,7 @@ const mapStateToProps = (state: StateType): PropsDataType => {
areWeAdmin, areWeAdmin,
badges: getBadgesSelector(state)(contact.badges), badges: getBadgesSelector(state)(contact.badges),
contact, contact,
conversationId, conversation: currentConversation,
i18n: getIntl(state), i18n: getIntl(state),
isAdmin, isAdmin,
isMember, isMember,

View file

@ -284,11 +284,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
id, id,
...pick(conversation, [ ...pick(conversation, ['unreadCount', 'isGroupV1AndDisabled']),
'areWeAdmin', conversation,
'unreadCount',
'isGroupV1AndDisabled',
]),
isConversationSelected: state.conversations.selectedConversationId === id, isConversationSelected: state.conversations.selectedConversationId === id,
isIncomingMessageRequest: Boolean( isIncomingMessageRequest: Boolean(
conversation.messageRequestsEnabled && conversation.messageRequestsEnabled &&