diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0e542fc05..bec11218c 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3405,6 +3405,42 @@ "message": "An admin changed who can edit group membership to \"All members.\"", "description": "Shown in timeline or conversation preview when v2 group changes" }, + "GroupV2--access-invite-link--disabled--you": { + "message": "You disabled admin approval for the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-invite-link--disabled--other": { + "message": "$adminName$ disabled admin approval for the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--access-invite-link--disabled--unknown": { + "message": "Admin approval for the group link has been disabled.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-invite-link--enabled--you": { + "message": "You enabled admin approval for the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--access-invite-link--enabled--other": { + "message": "$adminName$ enabled admin approval for the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--access-invite-link--enabled--unknown": { + "message": "Admin approval for the group link has been disabled.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, "GroupV2--member-add--invited--you": { "message": "You added invited member $inviteeName$.", "description": "Shown in timeline or conversation preview when v2 group changes", @@ -3539,6 +3575,72 @@ "message": "You were added to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, + + "GroupV2--member-add-from-link--you--you": { + "message": "You joined the group via the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--member-add-from-link--other": { + "message": "$memberName$ joined the group via the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "memberName": { + "content": "$1", + "example": "Alice" + } + } + }, + + "GroupV2--member-add-from-admin-approval--you--other": { + "message": "$adminName$ approved your request to join the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--member-add-from-admin-approval--you--unknown": { + "message": "Your request to join the group has been approved.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--member-add-from-admin-approval--other--you": { + "message": "You approved a request to join the group from $joinerName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "joinerName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--member-add-from-admin-approval--other--other": { + "message": "$adminName$ approved a request to join the group from $joinerName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "joinerName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--member-add-from-admin-approval--other--unknown": { + "message": "A request to join the group from $joinerName$ has been approved.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "joinerName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--member-remove--other--other": { "message": "$adminName$ removed $memberName$.", "description": "Shown in timeline or conversation preview when v2 group changes", @@ -4030,6 +4132,138 @@ } } }, + "GroupV2--admin-approval-add-one--you": { + "message": "You sent a request to join the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--admin-approval-add-one--other": { + "message": "$joinerName$ requested to join via the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "joinerName": { + "content": "$1", + "example": "Alice" + } + } + }, + + "GroupV2--admin-approval-remove-one--you--you": { + "message": "You canceled your request to join the group.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--admin-approval-remove-one--you--unknown": { + "message": "Your request to join the group has been denied by an admin.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + + "GroupV2--admin-approval-remove-one--other--you": { + "message": "You denied a request to join the group from $joinerName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "joinerName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--admin-approval-remove-one--other--own": { + "message": "$joinerName$ canceled their request to join the group.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "joinerName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--admin-approval-remove-one--other--other": { + "message": "$adminName$ denied a request to join the group from $joinerName$.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Bob" + }, + "joinerName": { + "content": "$2", + "example": "Alice" + } + } + }, + + "GroupV2--group-link-add--disabled--you": { + "message": "You turned on the group link with admin approval disabled.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-add--disabled--other": { + "message": "$adminName$ turned on the group link with admin approval disabled.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--group-link-add--disabled--unknown": { + "message": "The group link has been turned on with admin approval disabled.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-add--enabled--you": { + "message": "You turned on the group link with admin approval enabled.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-add--enabled--other": { + "message": "$adminName$ turned on the group link with admin approval enabled.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--group-link-add--enabled--unknown": { + "message": "The group link has been turned on with admin approval enabled.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-remove--you": { + "message": "You turned off the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-remove--other": { + "message": "$adminName$ turned off the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--group-link-remove--unknown": { + "message": "The group link has been turned off.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-reset--you": { + "message": "You reset the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV2--group-link-reset--other": { + "message": "$adminName$ reset the group link.", + "description": "Shown in timeline or conversation preview when v2 group changes", + "placeholders": { + "adminName": { + "content": "$1", + "example": "Alice" + } + } + }, + "GroupV2--group-link-reset--unknown": { + "message": "The group link has been reset.", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "GroupV1--Migration--disabled": { "message": "Upgrade this group to activate new features like @mentions and admins. Members who have not shared their name or photo in this group will be invited to join. $learnMore$", "description": "Shown instead of composition area when user is forced to migrate a legacy group (GV1).", diff --git a/preload.js b/preload.js index 813ebae78..984c56c8a 100644 --- a/preload.js +++ b/preload.js @@ -27,6 +27,11 @@ try { title += ` - ${config.appInstance}`; } + // Flags for testing + window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING = true; + window.GV2_ENABLE_CHANGE_PROCESSING = true; + window.GV2_ENABLE_STATE_PROCESSING = true; + window.platform = process.platform; window.getTitle = () => title; window.getEnvironment = () => config.environment; diff --git a/protos/Groups.proto b/protos/Groups.proto index e7c29d93b..c5272019f 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -29,32 +29,44 @@ message Member { uint32 joinedAtVersion = 5; // The Group.version this member joined at } -message PendingMember { +message MemberPendingProfileKey { Member member = 1; // The “invited” member bytes addedByUserId = 2; // The UID who invited this member uint64 timestamp = 3; // The time the invitation occurred } +message MemberPendingAdminApproval { + bytes userId = 1; + bytes profileKey = 2; + bytes presentation = 3; + uint64 timestamp = 4; +} + message AccessControl { enum AccessRequired { UNKNOWN = 0; + ANY = 1; MEMBER = 2; // Any group member can make the modification ADMINISTRATOR = 3; // Only administrators can make the modification + UNSATISFIABLE = 4; } - AccessRequired attributes = 1; // Who can modify the group title, avatar, disappearing messages timer - AccessRequired members = 2; // Who can add people to the group + AccessRequired attributes = 1; // Who can modify the group title, avatar, disappearing messages timer + AccessRequired members = 2; // Who can add people to the group + AccessRequired addFromInviteLink = 3; } message Group { - bytes publicKey = 1; // GroupPublicParams - bytes title = 2; // Encrypted title - string avatar = 3; // Pointer to encrypted avatar (‘key’ from AvatarUploadAttributes) - bytes disappearingMessagesTimer = 4; // Encrypted timer - AccessControl accessControl = 5; - uint32 version = 6; // Current group version number - repeated Member members = 7; - repeated PendingMember pendingMembers = 8; + bytes publicKey = 1; // GroupPublicParams + bytes title = 2; // Encrypted title + string avatar = 3; // Pointer to encrypted avatar (‘key’ from AvatarUploadAttributes) + bytes disappearingMessagesTimer = 4; // Encrypted timer + AccessControl accessControl = 5; + uint32 version = 6; // Current group version number + repeated Member members = 7; + repeated MemberPendingProfileKey membersPendingProfileKey = 8; + repeated MemberPendingAdminApproval membersPendingAdminApproval = 9; + bytes inviteLinkPassword = 10; } message GroupChange { @@ -62,7 +74,8 @@ message GroupChange { message Actions { message AddMemberAction { - Member added = 1; + Member added = 1; + bool joinFromInviteLink = 2; } message DeleteMemberAction { @@ -78,18 +91,32 @@ message GroupChange { bytes presentation = 1; } - message AddPendingMemberAction { - PendingMember added = 1; + message AddMemberPendingProfileKeyAction { + MemberPendingProfileKey added = 1; } - message DeletePendingMemberAction { + message DeleteMemberPendingProfileKeyAction { bytes deletedUserId = 1; } - message PromotePendingMemberAction { + message PromoteMemberPendingProfileKeyAction { bytes presentation = 1; } + message AddMemberPendingAdminApprovalAction { + MemberPendingAdminApproval added = 1; + } + + message DeleteMemberPendingAdminApprovalAction { + bytes deletedUserId = 1; + } + + message PromoteMemberPendingAdminApprovalAction { + bytes userId = 1; + Member.Role role = 2; + } + + message ModifyTitleAction { bytes title = 1; } @@ -114,20 +141,34 @@ message GroupChange { AccessControl.AccessRequired membersAccess = 1; } - bytes sourceUuid = 1; // Who made the change - uint32 version = 2; // The change version number - repeated AddMemberAction addMembers = 3; // Members added - repeated DeleteMemberAction deleteMembers = 4; // Members deleted - repeated ModifyMemberRoleAction modifyMemberRoles = 5; // Modified member roles - repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; // Modified member profile keys - repeated AddPendingMemberAction addPendingMembers = 7; // Pending members added - repeated DeletePendingMemberAction deletePendingMembers = 8; // Pending members deleted - repeated PromotePendingMemberAction promotePendingMembers = 9; // Pending invitations accepted - ModifyTitleAction modifyTitle = 10; // Changed title - ModifyAvatarAction modifyAvatar = 11; // Changed avatar - ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer - ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control - ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control + message ModifyAddFromInviteLinkAccessControlAction { + AccessControl.AccessRequired addFromInviteLinkAccess = 1; + } + + message ModifyInviteLinkPasswordAction { + bytes inviteLinkPassword = 1; + } + + + bytes sourceUuid = 1; // Who made the change + uint32 version = 2; // The change version number + repeated AddMemberAction addMembers = 3; // Members added + repeated DeleteMemberAction deleteMembers = 4; // Members deleted + repeated ModifyMemberRoleAction modifyMemberRoles = 5; // Modified member roles + repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; // Modified member profile keys + repeated AddMemberPendingProfileKeyAction addPendingMembers = 7; // Pending members added + repeated DeleteMemberPendingProfileKeyAction deletePendingMembers = 8; // Pending members deleted + repeated PromoteMemberPendingProfileKeyAction promotePendingMembers = 9; // Pending invitations accepted + ModifyTitleAction modifyTitle = 10; // Changed title + ModifyAvatarAction modifyAvatar = 11; // Changed avatar + ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer + ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control + ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control + ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; + repeated AddMemberPendingAdminApprovalAction addMemberPendingAdminApprovals = 16; + repeated DeleteMemberPendingAdminApprovalAction deleteMemberPendingAdminApprovals = 17; + repeated PromoteMemberPendingAdminApprovalAction promoteMemberPendingAdminApprovals = 18; + ModifyInviteLinkPasswordAction modifyInviteLinkPassword = 19; } bytes actions = 1; // The serialized actions @@ -155,3 +196,14 @@ message GroupAttributeBlob { message GroupExternalCredential { string token = 1; } + +message GroupInviteLink { + message GroupInviteLinkContentsV1 { + bytes groupMasterKey = 1; + bytes inviteLinkPassword = 2; + } + + oneof contents { + GroupInviteLinkContentsV1 v1Contents = 1; + } +} diff --git a/ts/components/conversation/GroupV2Change.stories.tsx b/ts/components/conversation/GroupV2Change.stories.tsx index 1a115095e..979424c5c 100644 --- a/ts/components/conversation/GroupV2Change.stories.tsx +++ b/ts/components/conversation/GroupV2Change.stories.tsx @@ -23,11 +23,13 @@ const INVITEE_A = 'INVITEE_A'; class AccessControlEnum { static UNKNOWN = 0; - static ADMINISTRATOR = 1; + static ANY = 1; - static ANY = 2; + static MEMBER = 2; - static MEMBER = 3; + static ADMINISTRATOR = 3; + + static UNSATISFIABLE = 4; } class RoleEnum { @@ -342,6 +344,64 @@ storiesOf('Components/Conversation/GroupV2Change', module) ); }) + .add('Access (Invite Link)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ANY, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ANY, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ANY, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + + ); + }) .add('Member Add', () => { return ( <> @@ -400,7 +460,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) ); }) - .add('Member Add - add invited', () => { + .add('Member Add (from invited)', () => { return ( <> {/* the strings where someone added you - shown like a normal add */} @@ -505,6 +565,87 @@ storiesOf('Components/Conversation/GroupV2Change', module) ); }) + .add('Member Add (from link)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-add-from-link', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-link', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-add-from-link', + conversationId: CONTACT_A, + }, + ], + })} + + ); + }) + .add('Member Add (from admin approval)', () => { + return ( + <> + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'member-add-from-admin-approval', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-add-from-admin-approval', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'member-add-from-admin-approval', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'member-add-from-admin-approval', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'member-add-from-admin-approval', + conversationId: CONTACT_A, + }, + ], + })} + + ); + }) .add('Member Remove', () => { return ( <> @@ -987,4 +1128,200 @@ storiesOf('Components/Conversation/GroupV2Change', module) })} ); + }) + .add('Admin Approval (Add)', () => { + return ( + <> + {renderChange({ + details: [ + { + type: 'admin-approval-add-one', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'admin-approval-add-one', + conversationId: CONTACT_A, + }, + ], + })} + + ); + }) + .add('Admin Approval (Remove)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'admin-approval-remove-one', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'admin-approval-remove-one', + conversationId: OUR_ID, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'admin-approval-remove-one', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'admin-approval-remove-one', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'admin-approval-remove-one', + conversationId: CONTACT_A, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'admin-approval-remove-one', + conversationId: CONTACT_A, + }, + ], + })} + + ); + }) + .add('Group Link (Add)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ANY, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ANY, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ANY, + }, + ], + })} + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + })} + + ); + }) + .add('Group Link (Reset)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'group-link-reset', + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'group-link-reset', + }, + ], + })} + {renderChange({ + details: [ + { + type: 'group-link-reset', + }, + ], + })} + + ); + }) + .add('Group Link (Remove)', () => { + return ( + <> + {renderChange({ + from: OUR_ID, + details: [ + { + type: 'group-link-remove', + }, + ], + })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'group-link-remove', + }, + ], + })} + {renderChange({ + details: [ + { + type: 'group-link-remove', + }, + ], + })} + + ); }); diff --git a/ts/groupChange.ts b/ts/groupChange.ts index 10a2c051b..9767a4c47 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -138,10 +138,12 @@ export function renderChangeDetail( } return renderString('GroupV2--access-attributes--all--unknown', i18n); } - throw new Error( + window.log.warn( `access-attributes change type, privilege ${newPrivilege} is unknown` ); - } else if (detail.type === 'access-members') { + return ''; + } + if (detail.type === 'access-members') { const { newPrivilege } = detail; if (newPrivilege === AccessControlEnum.ADMINISTRATOR) { @@ -166,10 +168,52 @@ export function renderChangeDetail( } return renderString('GroupV2--access-members--all--unknown', i18n); } - throw new Error( + window.log.warn( `access-members change type, privilege ${newPrivilege} is unknown` ); - } else if (detail.type === 'member-add') { + return ''; + } + if (detail.type === 'access-invite-link') { + const { newPrivilege } = detail; + + if (newPrivilege === AccessControlEnum.ADMINISTRATOR) { + if (fromYou) { + return renderString('GroupV2--access-invite-link--enabled--you', i18n); + } + if (from) { + return renderString( + 'GroupV2--access-invite-link--enabled--other', + i18n, + [renderContact(from)] + ); + } + return renderString( + 'GroupV2--access-invite-link--enabled--unknown', + i18n + ); + } + if (newPrivilege === AccessControlEnum.ANY) { + if (fromYou) { + return renderString('GroupV2--access-invite-link--disabled--you', i18n); + } + if (from) { + return renderString( + 'GroupV2--access-invite-link--disabled--other', + i18n, + [renderContact(from)] + ); + } + return renderString( + 'GroupV2--access-invite-link--disabled--unknown', + i18n + ); + } + window.log.warn( + `access-invite-link change type, privilege ${newPrivilege} is unknown` + ); + return ''; + } + if (detail.type === 'member-add') { const { conversationId } = detail; const weAreJoiner = conversationId === ourConversationId; @@ -198,7 +242,8 @@ export function renderChangeDetail( return renderString('GroupV2--member-add--other--unknown', i18n, [ renderContact(conversationId), ]); - } else if (detail.type === 'member-add-from-invite') { + } + if (detail.type === 'member-add-from-invite') { const { conversationId, inviter } = detail; const weAreJoiner = conversationId === ourConversationId; const weAreInviter = Boolean(inviter && inviter === ourConversationId); @@ -259,7 +304,80 @@ export function renderChangeDetail( inviteeName: renderContact(conversationId), } ); - } else if (detail.type === 'member-remove') { + } + if (detail.type === 'member-add-from-link') { + const { conversationId } = detail; + + if (fromYou && conversationId === ourConversationId) { + return renderString('GroupV2--member-add-from-link--you--you', i18n); + } + if (from && conversationId === from) { + return renderString('GroupV2--member-add-from-link--other', i18n, [ + renderContact(from), + ]); + } + + // Note: this shouldn't happen, because we only capture 'add-from-link' status + // from group change events, which always have a sender. + window.log.warn('member-add-from-link change type; we have no from!'); + return renderString('GroupV2--member-add--other--unknown', i18n, [ + renderContact(conversationId), + ]); + } + if (detail.type === 'member-add-from-admin-approval') { + const { conversationId } = detail; + const weAreJoiner = conversationId === ourConversationId; + + if (weAreJoiner) { + if (from) { + return renderString( + 'GroupV2--member-add-from-admin-approval--you--other', + i18n, + [renderContact(from)] + ); + } + + // Note: this shouldn't happen, because we only capture 'add-from-admin-approval' + // status from group change events, which always have a sender. + window.log.warn( + 'member-add-from-admin-approval change type; we have no from, and we are joiner!' + ); + return renderString( + 'GroupV2--member-add-from-admin-approval--you--unknown', + i18n + ); + } + + if (fromYou) { + return renderString( + 'GroupV2--member-add-from-admin-approval--other--you', + i18n, + [renderContact(conversationId)] + ); + } + if (from) { + return renderString( + 'GroupV2--member-add-from-admin-approval--other--other', + i18n, + { + adminName: renderContact(from), + joinerName: renderContact(conversationId), + } + ); + } + + // Note: this shouldn't happen, because we only capture 'add-from-admin-approval' + // status from group change events, which always have a sender. + window.log.warn( + 'member-add-from-admin-approval change type; we have no from' + ); + return renderString( + 'GroupV2--member-add-from-admin-approval--other--unknown', + i18n, + [renderContact(conversationId)] + ); + } + if (detail.type === 'member-remove') { const { conversationId } = detail; const weAreLeaver = conversationId === ourConversationId; @@ -294,7 +412,8 @@ export function renderChangeDetail( return renderString('GroupV2--member-remove--other--unknown', i18n, [ renderContact(conversationId), ]); - } else if (detail.type === 'member-privilege') { + } + if (detail.type === 'member-privilege') { const { conversationId, newPrivilege } = detail; const weAreMember = conversationId === ourConversationId; @@ -375,10 +494,12 @@ export function renderChangeDetail( [renderContact(conversationId)] ); } - throw new Error( + window.log.warn( `member-privilege change type, privilege ${newPrivilege} is unknown` ); - } else if (detail.type === 'pending-add-one') { + return ''; + } + if (detail.type === 'pending-add-one') { const { conversationId } = detail; const weAreInvited = conversationId === ourConversationId; if (weAreInvited) { @@ -400,7 +521,8 @@ export function renderChangeDetail( ]); } return renderString('GroupV2--pending-add--one--other--unknown', i18n); - } else if (detail.type === 'pending-add-many') { + } + if (detail.type === 'pending-add-many') { const { count } = detail; if (fromYou) { @@ -417,7 +539,8 @@ export function renderChangeDetail( return renderString('GroupV2--pending-add--many--unknown', i18n, [ count.toString(), ]); - } else if (detail.type === 'pending-remove-one') { + } + if (detail.type === 'pending-remove-one') { const { inviter, conversationId } = detail; const weAreInviter = Boolean(inviter && inviter === ourConversationId); const weAreInvited = conversationId === ourConversationId; @@ -511,7 +634,8 @@ export function renderChangeDetail( ]); } return renderString('GroupV2--pending-remove--revoke--one--unknown', i18n); - } else if (detail.type === 'pending-remove-many') { + } + if (detail.type === 'pending-remove-many') { const { count, inviter } = detail; const weAreInviter = Boolean(inviter && inviter === ourConversationId); @@ -590,7 +714,120 @@ export function renderChangeDetail( i18n, [count.toString()] ); - } else { - throw missingCaseError(detail); } + if (detail.type === 'admin-approval-add-one') { + const { conversationId } = detail; + const weAreJoiner = conversationId === ourConversationId; + + if (weAreJoiner) { + return renderString('GroupV2--admin-approval-add-one--you', i18n); + } + return renderString('GroupV2--admin-approval-add-one--other', i18n, [ + renderContact(conversationId), + ]); + } + if (detail.type === 'admin-approval-remove-one') { + const { conversationId } = detail; + const weAreJoiner = conversationId === ourConversationId; + + if (weAreJoiner) { + if (fromYou) { + return renderString( + 'GroupV2--admin-approval-remove-one--you--you', + i18n + ); + } + return renderString( + 'GroupV2--admin-approval-remove-one--you--unknown', + i18n + ); + } + + if (fromYou) { + return renderString( + 'GroupV2--admin-approval-remove-one--other--you', + i18n, + [renderContact(conversationId)] + ); + } + if (from && from === conversationId) { + return renderString( + 'GroupV2--admin-approval-remove-one--other--own', + i18n, + [renderContact(conversationId)] + ); + } + if (from) { + return renderString( + 'GroupV2--admin-approval-remove-one--other--other', + i18n, + { + adminName: renderContact(from), + joinerName: renderContact(conversationId), + } + ); + } + + // We default to the user canceling their request, because it is far more likely that + // if an admin does the denial, we'll get a change event from them. + return renderString( + 'GroupV2--admin-approval-remove-one--other--own', + i18n, + [renderContact(conversationId)] + ); + } + if (detail.type === 'group-link-add') { + const { privilege } = detail; + + if (privilege === AccessControlEnum.ADMINISTRATOR) { + if (fromYou) { + return renderString('GroupV2--group-link-add--enabled--you', i18n); + } + if (from) { + return renderString('GroupV2--group-link-add--enabled--other', i18n, [ + renderContact(from), + ]); + } + return renderString('GroupV2--group-link-add--enabled--unknown', i18n); + } + if (privilege === AccessControlEnum.ANY) { + if (fromYou) { + return renderString('GroupV2--group-link-add--disabled--you', i18n); + } + if (from) { + return renderString('GroupV2--group-link-add--disabled--other', i18n, [ + renderContact(from), + ]); + } + return renderString('GroupV2--group-link-add--disabled--unknown', i18n); + } + window.log.warn( + `group-link-add change type, privilege ${privilege} is unknown` + ); + return ''; + } + if (detail.type === 'group-link-reset') { + if (fromYou) { + return renderString('GroupV2--group-link-reset--you', i18n); + } + if (from) { + return renderString('GroupV2--group-link-reset--other', i18n, [ + renderContact(from), + ]); + } + return renderString('GroupV2--group-link-reset--unknown', i18n); + } + if (detail.type === 'group-link-remove') { + if (fromYou) { + return renderString('GroupV2--group-link-remove--you', i18n); + } + if (from) { + return renderString('GroupV2--group-link-remove--other', i18n, [ + renderContact(from), + ]); + } + return renderString('GroupV2--group-link-remove--unknown', i18n); + } + + throw missingCaseError(detail); } diff --git a/ts/groups.ts b/ts/groups.ts index 72184f985..d444a8798 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -22,6 +22,7 @@ import dataInterface from './sql/Client'; import { ConversationAttributesType, GroupV2MemberType, + GroupV2PendingAdminApprovalType, GroupV2PendingMemberType, MessageAttributesType, } from './model-types.d'; @@ -55,7 +56,9 @@ import { GroupChangesClass, GroupClass, MemberClass, - PendingMemberClass, + MemberPendingAdminApprovalClass, + MemberPendingProfileKeyClass, + ProtoBigNumberType, ProtoBinaryType, } from './textsecure.d'; import { @@ -77,6 +80,10 @@ export type GroupV2AccessMembersChangeType = { type: 'access-members'; newPrivilege: number; }; +export type GroupV2AccessInviteLinkChangeType = { + type: 'access-invite-link'; + newPrivilege: number; +}; export type GroupV2AvatarChangeType = { type: 'avatar'; removed: boolean; @@ -86,6 +93,16 @@ export type GroupV2TitleChangeType = { // Allow for null, because the title could be removed entirely newTitle?: string; }; +export type GroupV2GroupLinkAddChangeType = { + type: 'group-link-add'; + privilege: number; +}; +export type GroupV2GroupLinkResetChangeType = { + type: 'group-link-reset'; +}; +export type GroupV2GroupLinkRemoveChangeType = { + type: 'group-link-remove'; +}; // No disappearing messages timer change type - message.expirationTimerUpdate used instead @@ -98,6 +115,14 @@ export type GroupV2MemberAddFromInviteChangeType = { conversationId: string; inviter?: string; }; +export type GroupV2MemberAddFromLinkChangeType = { + type: 'member-add-from-link'; + conversationId: string; +}; +export type GroupV2MemberAddFromAdminApprovalChangeType = { + type: 'member-add-from-admin-approval'; + conversationId: string; +}; export type GroupV2MemberPrivilegeChangeType = { type: 'member-privilege'; conversationId: string; @@ -129,20 +154,40 @@ export type GroupV2PendingRemoveManyChangeType = { inviter?: string; }; +export type GroupV2AdminApprovalAddOneChangeType = { + type: 'admin-approval-add-one'; + conversationId: string; +}; +// Note: admin-approval-remove-one is only used if user didn't also join the group at +// the same time +export type GroupV2AdminApprovalRemoveOneChangeType = { + type: 'admin-approval-remove-one'; + conversationId: string; + inviter?: string; +}; + export type GroupV2ChangeDetailType = - | GroupV2AccessCreateChangeType - | GroupV2TitleChangeType - | GroupV2AvatarChangeType | GroupV2AccessAttributesChangeType + | GroupV2AccessCreateChangeType + | GroupV2AccessInviteLinkChangeType | GroupV2AccessMembersChangeType + | GroupV2AdminApprovalAddOneChangeType + | GroupV2AdminApprovalRemoveOneChangeType + | GroupV2AvatarChangeType + | GroupV2GroupLinkAddChangeType + | GroupV2GroupLinkResetChangeType + | GroupV2GroupLinkRemoveChangeType | GroupV2MemberAddChangeType + | GroupV2MemberAddFromAdminApprovalChangeType | GroupV2MemberAddFromInviteChangeType - | GroupV2MemberRemoveChangeType + | GroupV2MemberAddFromLinkChangeType | GroupV2MemberPrivilegeChangeType - | GroupV2PendingAddOneChangeType + | GroupV2MemberRemoveChangeType | GroupV2PendingAddManyChangeType + | GroupV2PendingAddOneChangeType + | GroupV2PendingRemoveManyChangeType | GroupV2PendingRemoveOneChangeType - | GroupV2PendingRemoveManyChangeType; + | GroupV2TitleChangeType; export type GroupV2ChangeType = { from?: string; @@ -179,7 +224,7 @@ export const ID_LENGTH = 32; const TEMPORAL_AUTH_REJECTED_CODE = 401; const GROUP_ACCESS_DENIED_CODE = 403; const GROUP_NONEXISTENT_CODE = 404; -const SUPPORTED_CHANGE_EPOCH = 0; +const SUPPORTED_CHANGE_EPOCH = 1; // Group Modifications @@ -349,30 +394,34 @@ async function buildGroupProto({ const ourUuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, ourUuid); - proto.pendingMembers = (attributes.pendingMembersV2 || []).map(item => { - const pendingMember = new window.textsecure.protobuf.PendingMember(); - const member = new window.textsecure.protobuf.Member(); + proto.membersPendingProfileKey = (attributes.pendingMembersV2 || []).map( + item => { + const pendingMember = new window.textsecure.protobuf.MemberPendingProfileKey(); + const member = new window.textsecure.protobuf.Member(); - const conversation = window.ConversationController.get(item.conversationId); - if (!conversation) { - throw new Error('buildGroupProto: no conversation for pending member!'); + const conversation = window.ConversationController.get( + item.conversationId + ); + if (!conversation) { + throw new Error('buildGroupProto: no conversation for pending member!'); + } + + const uuid = conversation.get('uuid'); + if (!uuid) { + throw new Error('buildGroupProto: pending member was missing uuid!'); + } + + const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); + member.userId = uuidCipherTextBuffer; + member.role = item.role || MEMBER_ROLE_ENUM.DEFAULT; + + pendingMember.member = member; + pendingMember.timestamp = item.timestamp; + pendingMember.addedByUserId = ourUuidCipherTextBuffer; + + return pendingMember; } - - const uuid = conversation.get('uuid'); - if (!uuid) { - throw new Error('buildGroupProto: pending member was missing uuid!'); - } - - const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); - member.userId = uuidCipherTextBuffer; - member.role = item.role || MEMBER_ROLE_ENUM.DEFAULT; - - pendingMember.member = member; - pendingMember.timestamp = item.timestamp; - pendingMember.addedByUserId = ourUuidCipherTextBuffer; - - return pendingMember; - }); + ); return proto; } @@ -425,7 +474,7 @@ export function buildDeletePendingMemberChange({ const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid); - const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeletePendingMemberAction(); + const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberPendingProfileKeyAction(); deletePendingMember.deletedUserId = uuidCipherTextBuffer; actions.version = (group.revision || 0) + 1; @@ -484,7 +533,7 @@ export function buildPromoteMemberChange({ group.secretParams ); - const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromotePendingMemberAction(); + const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromoteMemberPendingProfileKeyAction(); promotePendingMember.presentation = presentation; actions.version = (group.revision || 0) + 1; @@ -1551,6 +1600,7 @@ async function getGroupUpdates({ newRevision === currentRevision + 1; if ( + window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING && groupChangeBase64 && isNumber(newRevision) && (isInitialCreationMessage || isOneVersionUp) @@ -1573,7 +1623,7 @@ async function getGroupUpdates({ ); } - if (isNumber(newRevision)) { + if (isNumber(newRevision) && window.GV2_ENABLE_CHANGE_PROCESSING) { try { const result = await updateGroupViaLogs({ group, @@ -1599,11 +1649,22 @@ async function getGroupUpdates({ } } - return updateGroupViaState({ - dropInitialJoinMessage, - group, - serverPublicParamsBase64, - }); + if (window.GV2_ENABLE_STATE_PROCESSING) { + return updateGroupViaState({ + dropInitialJoinMessage, + group, + serverPublicParamsBase64, + }); + } + + window.log.warn( + `getGroupUpdates/${logId}: No processing was legal! Returning empty changeset.` + ); + return { + newAttributes: group, + groupChangeMessages: [], + members: [], + }; } async function updateGroupViaState({ @@ -2110,15 +2171,19 @@ function extractDiffs({ const logId = idForLogging(old); const details: Array = []; const ourConversationId = window.ConversationController.getOurConversationId(); + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; let areWeInGroup = false; let areWeInvitedToGroup = false; let whoInvitedUsUserId = null; + // access control + if ( current.accessControl && - (!old.accessControl || - old.accessControl.attributes !== current.accessControl.attributes) + old.accessControl && + old.accessControl.attributes !== undefined && + old.accessControl.attributes !== current.accessControl.attributes ) { details.push({ type: 'access-attributes', @@ -2127,14 +2192,46 @@ function extractDiffs({ } if ( current.accessControl && - (!old.accessControl || - old.accessControl.members !== current.accessControl.members) + old.accessControl && + old.accessControl.members !== undefined && + old.accessControl.members !== current.accessControl.members ) { details.push({ type: 'access-members', newPrivilege: current.accessControl.members, }); } + + const linkPreviouslyEnabled = + old.accessControl?.addFromInviteLink === ACCESS_ENUM.ANY || + old.accessControl?.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR; + const linkCurrentlyEnabled = + current.accessControl?.addFromInviteLink === ACCESS_ENUM.ANY || + current.accessControl?.addFromInviteLink === ACCESS_ENUM.ADMINISTRATOR; + + if (!linkPreviouslyEnabled && linkCurrentlyEnabled) { + details.push({ + type: 'group-link-add', + privilege: current.accessControl?.addFromInviteLink || ACCESS_ENUM.ANY, + }); + } else if (linkPreviouslyEnabled && !linkCurrentlyEnabled) { + details.push({ + type: 'group-link-remove', + }); + } else if ( + linkPreviouslyEnabled && + linkCurrentlyEnabled && + old.accessControl?.addFromInviteLink !== + current.accessControl?.addFromInviteLink + ) { + details.push({ + type: 'access-invite-link', + newPrivilege: current.accessControl?.addFromInviteLink || ACCESS_ENUM.ANY, + }); + } + + // avatar + if ( Boolean(old.avatar) !== Boolean(current.avatar) || old.avatar?.hash !== current.avatar?.hash @@ -2144,6 +2241,9 @@ function extractDiffs({ removed: !current.avatar, }); } + + // name + if (old.name !== current.name) { details.push({ type: 'title', @@ -2151,14 +2251,36 @@ function extractDiffs({ }); } + // groupInviteLinkPassword + + // Note: we only capture link resets here. Enable/disable are controlled by the + // accessControl.addFromInviteLink + if ( + old.groupInviteLinkPassword && + current.groupInviteLinkPassword && + old.groupInviteLinkPassword !== current.groupInviteLinkPassword + ) { + details.push({ + type: 'group-link-reset', + }); + } + // No disappearing message timer check here - see below + // membersV2 + const oldMemberLookup: Dictionary = fromPairs( (old.membersV2 || []).map(member => [member.conversationId, member]) ); const oldPendingMemberLookup: Dictionary = fromPairs( (old.pendingMembersV2 || []).map(member => [member.conversationId, member]) ); + const oldPendingAdminApprovalLookup: Dictionary = fromPairs( + (old.pendingAdminApprovalV2 || []).map(member => [ + member.conversationId, + member, + ]) + ); (current.membersV2 || []).forEach(currentMember => { const { conversationId } = currentMember; @@ -2170,23 +2292,28 @@ function extractDiffs({ const oldMember = oldMemberLookup[conversationId]; if (!oldMember) { const pendingMember = oldPendingMemberLookup[conversationId]; - if (pendingMember) { details.push({ type: 'member-add-from-invite', conversationId, inviter: pendingMember.addedByUserId, }); + } else if (currentMember.joinedFromLink) { + details.push({ + type: 'member-add-from-link', + conversationId, + }); + } else if (currentMember.approvedByAdmin) { + details.push({ + type: 'member-add-from-admin-approval', + conversationId, + }); } else { details.push({ type: 'member-add', conversationId, }); } - - // If we capture a pending remove here, it's an 'accept invitation', and we don't - // want to generate a generic pending-remove event for it - delete oldPendingMemberLookup[conversationId]; } else if (oldMember.role !== currentMember.role) { details.push({ type: 'member-privilege', @@ -2195,6 +2322,15 @@ function extractDiffs({ }); } + // We don't want to generate an admin-approval-remove event for this newly-added + // member. But we don't know for sure if this is an admin approval; for that we + // consulted the approvedByAdmin flag saved on the member. + delete oldPendingAdminApprovalLookup[conversationId]; + + // If we capture a pending remove here, it's an 'accept invitation', and we don't + // want to generate a pending-remove event for it + delete oldPendingMemberLookup[conversationId]; + // This deletion makes it easier to capture removals delete oldMemberLookup[conversationId]; }); @@ -2207,8 +2343,10 @@ function extractDiffs({ }); }); + // pendingMembersV2 + let lastPendingConversationId: string | undefined; - let count = 0; + let pendingCount = 0; (current.pendingMembersV2 || []).forEach(currentPendingMember => { const { conversationId } = currentPendingMember; const oldPendingMember = oldPendingMemberLookup[conversationId]; @@ -2220,19 +2358,19 @@ function extractDiffs({ if (!oldPendingMember) { lastPendingConversationId = conversationId; - count += 1; + pendingCount += 1; } // This deletion makes it easier to capture removals delete oldPendingMemberLookup[conversationId]; }); - if (count > 1) { + if (pendingCount > 1) { details.push({ type: 'pending-add-many', - count, + count: pendingCount, }); - } else if (count === 1) { + } else if (pendingCount === 1) { if (lastPendingConversationId) { details.push({ type: 'pending-add-one', @@ -2240,7 +2378,7 @@ function extractDiffs({ }); } else { window.log.warn( - `extractDiffs/${logId}: pending-add count was 1, no last conversationId available` + `extractDiffs/${logId}: pendingCount was 1, no last conversationId available` ); } } @@ -2271,6 +2409,39 @@ function extractDiffs({ }); } + // pendingAdminApprovalV2 + + (current.pendingAdminApprovalV2 || []).forEach( + currentPendingAdminAprovalMember => { + const { conversationId } = currentPendingAdminAprovalMember; + const oldPendingMember = oldPendingAdminApprovalLookup[conversationId]; + + if (!oldPendingMember) { + details.push({ + type: 'admin-approval-add-one', + conversationId, + }); + } + + // This deletion makes it easier to capture removals + delete oldPendingAdminApprovalLookup[conversationId]; + } + ); + + // Note: The only members left over here should be people who were moved from the + // pendingAdminApproval list but also not added to the group at the same time. + const removedPendingAdminApprovalIds = Object.keys( + oldPendingAdminApprovalLookup + ); + removedPendingAdminApprovalIds.forEach(conversationId => { + details.push({ + type: 'admin-approval-remove-one', + conversationId, + }); + }); + + // final processing + let message: MessageAttributesType | undefined; let timerNotification: MessageAttributesType | undefined; const conversation = sourceConversationId @@ -2438,6 +2609,12 @@ async function applyGroupChange({ member, ]) ); + const pendingAdminApprovalMembers: Dictionary = fromPairs( + (result.pendingAdminApprovalV2 || []).map(member => [ + member.conversationId, + member, + ]) + ); // version?: number; result.revision = version; @@ -2470,6 +2647,7 @@ async function applyGroupChange({ conversationId: conversation.id, role: added.role || MEMBER_ROLE_ENUM.DEFAULT, joinedAtVersion: version, + joinedFromLink: addMember.joinFromInviteLink || false, }; if (pendingMembers[conversation.id]) { @@ -2559,12 +2737,14 @@ async function applyGroupChange({ }); }); - // addPendingMembers?: Array; + // addPendingMembers?: Array< + // GroupChangeClass.Actions.AddMemberPendingProfileKeyAction + // >; (actions.addPendingMembers || []).forEach(addPendingMember => { const { added } = addPendingMember; if (!added || !added.member) { throw new Error( - 'applyGroupChange: modifyMemberProfileKey had a missing value' + 'applyGroupChange: addPendingMembers had a missing value' ); } @@ -2601,7 +2781,9 @@ async function applyGroupChange({ } }); - // deletePendingMembers?: Array; + // deletePendingMembers?: Array< + // GroupChangeClass.Actions.DeleteMemberPendingProfileKeyAction + // >; (actions.deletePendingMembers || []).forEach(deletePendingMember => { const { deletedUserId } = deletePendingMember; if (!deletedUserId) { @@ -2624,7 +2806,9 @@ async function applyGroupChange({ } }); - // promotePendingMembers?: Array; + // promotePendingMembers?: Array< + // GroupChangeClass.Actions.PromoteMemberPendingProfileKeyAction + // >; (actions.promotePendingMembers || []).forEach(promotePendingMember => { const { profileKey, uuid } = promotePendingMember; if (!profileKey || !uuid) { @@ -2690,7 +2874,7 @@ async function applyGroupChange({ } // modifyDisappearingMessagesTimer?: - // GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; + // GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; if (actions.modifyDisappearingMessagesTimer) { const disappearingMessagesTimer: GroupAttributeBlobClass | undefined = actions.modifyDisappearingMessagesTimer.timer; @@ -2711,6 +2895,7 @@ async function applyGroupChange({ result.accessControl = result.accessControl || { members: ACCESS_ENUM.MEMBER, attributes: ACCESS_ENUM.MEMBER, + addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE, }; // modifyAttributesAccess?: @@ -2731,6 +2916,151 @@ async function applyGroupChange({ }; } + // modifyAddFromInviteLinkAccess?: + // GroupChangeClass.Actions.ModifyAddFromInviteLinkAccessControlAction; + if (actions.modifyAddFromInviteLinkAccess) { + result.accessControl = { + ...result.accessControl, + addFromInviteLink: + actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess || + ACCESS_ENUM.UNSATISFIABLE, + }; + } + + // addMemberPendingAdminApprovals?: Array< + // GroupChangeClass.Actions.AddMemberPendingAdminApprovalAction + // >; + (actions.addMemberPendingAdminApprovals || []).forEach( + pendingAdminApproval => { + const { added } = pendingAdminApproval; + if (!added) { + throw new Error( + 'applyGroupChange: modifyMemberProfileKey had a missing value' + ); + } + + const conversation = window.ConversationController.getOrCreate( + added.userId, + 'private' + ); + + if (members[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in members.` + ); + return; + } + if (pendingMembers[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in pendingMembers.` + ); + return; + } + if (pendingAdminApprovalMembers[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in pendingAdminApprovalMembers.` + ); + return; + } + + pendingAdminApprovalMembers[conversation.id] = { + conversationId: conversation.id, + timestamp: added.timestamp, + }; + + if (added.profileKey) { + newProfileKeys.push({ + profileKey: added.profileKey, + uuid: added.userId, + }); + } + } + ); + + // deleteMemberPendingAdminApprovals?: Array< + // GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction + // >; + (actions.deleteMemberPendingAdminApprovals || []).forEach( + deleteAdminApproval => { + const { deletedUserId } = deleteAdminApproval; + if (!deletedUserId) { + throw new Error( + 'applyGroupChange: deleteAdminApproval.deletedUserId is null!' + ); + } + + const conversation = window.ConversationController.getOrCreate( + deletedUserId, + 'private' + ); + + if (pendingAdminApprovalMembers[conversation.id]) { + delete pendingAdminApprovalMembers[conversation.id]; + } else { + window.log.warn( + `applyGroupChange/${logId}: Attempt to remove pendingAdminApproval failed; was not in pendingAdminApprovalMembers.` + ); + } + } + ); + + // promoteMemberPendingAdminApprovals?: Array< + // GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction + // >; + (actions.promoteMemberPendingAdminApprovals || []).forEach( + promoteAdminApproval => { + const { userId, role } = promoteAdminApproval; + if (!userId) { + throw new Error( + 'applyGroupChange: promoteAdminApproval had a missing value' + ); + } + + const conversation = window.ConversationController.getOrCreate( + userId, + 'private' + ); + + if (pendingAdminApprovalMembers[conversation.id]) { + delete pendingAdminApprovalMembers[conversation.id]; + } else { + window.log.warn( + `applyGroupChange/${logId}: Attempt to promote pendingAdminApproval failed; was not in pendingAdminApprovalMembers.` + ); + } + if (pendingMembers[conversation.id]) { + delete pendingAdminApprovalMembers[conversation.id]; + window.log.warn( + `applyGroupChange/${logId}: Deleted pendingAdminApproval from pendingMembers.` + ); + } + + if (members[conversation.id]) { + window.log.warn( + `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.` + ); + return; + } + + members[conversation.id] = { + conversationId: conversation.id, + joinedAtVersion: version, + role: role || MEMBER_ROLE_ENUM.DEFAULT, + approvedByAdmin: true, + }; + } + ); + + // modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction; + if (actions.modifyInviteLinkPassword) { + const { inviteLinkPassword } = actions.modifyInviteLinkPassword; + if (inviteLinkPassword) { + result.groupInviteLinkPassword = inviteLinkPassword; + } else { + result.groupInviteLinkPassword = undefined; + } + } + if (ourConversationId) { result.left = !members[ourConversationId]; } @@ -2738,6 +3068,7 @@ async function applyGroupChange({ // Go from lookups back to arrays result.membersV2 = values(members); result.pendingMembersV2 = values(pendingMembers); + result.pendingAdminApprovalV2 = values(pendingAdminApprovalMembers); return { newAttributes: result, @@ -2864,6 +3195,9 @@ async function applyGroupState({ attributes: (accessControl && accessControl.attributes) || ACCESS_ENUM.MEMBER, members: (accessControl && accessControl.members) || ACCESS_ENUM.MEMBER, + addFromInviteLink: + (accessControl && accessControl.addFromInviteLink) || + ACCESS_ENUM.UNSATISFIABLE, }; // Optimization: we assume we have left the group unless we are found in members @@ -2898,7 +3232,9 @@ async function applyGroupState({ } if (!isValidRole(member.role)) { - throw new Error('applyGroupState: Member had invalid role'); + throw new Error( + `applyGroupState: Member had invalid role ${member.role}` + ); } return { @@ -2909,10 +3245,10 @@ async function applyGroupState({ }); } - // pendingMembers - if (groupState.pendingMembers) { - result.pendingMembersV2 = groupState.pendingMembers.map( - (member: PendingMemberClass) => { + // membersPendingProfileKey + if (groupState.membersPendingProfileKey) { + result.pendingMembersV2 = groupState.membersPendingProfileKey.map( + (member: MemberPendingProfileKeyClass) => { let pending; let invitedBy; @@ -2928,7 +3264,7 @@ async function applyGroupState({ ); } else { throw new Error( - 'applyGroupState: Pending member did not have an associated userId' + 'applyGroupState: Member pending profile key did not have an associated userId' ); } @@ -2939,12 +3275,14 @@ async function applyGroupState({ ); } else { throw new Error( - 'applyGroupState: Pending member did not have an addedByUserID' + 'applyGroupState: Member pending profile key did not have an addedByUserID' ); } if (!isValidRole(member.member.role)) { - throw new Error('applyGroupState: Pending member had invalid role'); + throw new Error( + `applyGroupState: Member pending profile key had invalid role ${member.member.role}` + ); } return { @@ -2957,6 +3295,44 @@ async function applyGroupState({ ); } + // membersPendingAdminApproval + if (groupState.membersPendingAdminApproval) { + result.pendingAdminApprovalV2 = groupState.membersPendingAdminApproval.map( + (member: MemberPendingAdminApprovalClass) => { + let pending; + + if (member.userId) { + pending = window.ConversationController.getOrCreate( + member.userId, + 'private', + { + profileKey: member.profileKey + ? arrayBufferToBase64(member.profileKey) + : undefined, + } + ); + } else { + throw new Error( + 'applyGroupState: Pending admin approval did not have an associated userId' + ); + } + + return { + conversationId: pending.id, + timestamp: member.timestamp, + }; + } + ); + } + + // inviteLinkPassword + const { inviteLinkPassword } = groupState; + if (inviteLinkPassword) { + result.groupInviteLinkPassword = inviteLinkPassword; + } else { + result.groupInviteLinkPassword = undefined; + } + return result; } @@ -2968,12 +3344,23 @@ function isValidRole(role?: number): role is number { ); } -function isValidAccess(access?: number): boolean { +function isValidAccess(access?: number): access is number { const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; return access === ACCESS_ENUM.ADMINISTRATOR || access === ACCESS_ENUM.MEMBER; } +function isValidLinkAccess(access?: number): access is number { + const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; + + return ( + access === ACCESS_ENUM.UNKNOWN || + access === ACCESS_ENUM.ANY || + access === ACCESS_ENUM.ADMINISTRATOR || + access === ACCESS_ENUM.UNSATISFIABLE + ); +} + function isValidProfileKey(buffer?: ArrayBuffer): boolean { return Boolean(buffer && buffer.byteLength === 32); } @@ -2982,13 +3369,31 @@ function hasData(data: ProtoBinaryType): boolean { return data && data.limit > 0; } +function normalizeTimestamp( + timestamp: ProtoBigNumberType +): number | ProtoBigNumberType { + if (!timestamp) { + return timestamp; + } + + const asNumber = timestamp.toNumber(); + + const now = Date.now(); + if (!asNumber || asNumber > now) { + return now; + } + + return asNumber; +} + +/* eslint-disable no-param-reassign */ + function decryptGroupChange( - _actions: GroupChangeClass.Actions, + actions: GroupChangeClass.Actions, groupSecretParams: string, logId: string ): GroupChangeClass.Actions { const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams); - const actions = _actions; if (hasData(actions.sourceUuid)) { try { @@ -3018,9 +3423,7 @@ function decryptGroupChange( // addMembers?: Array; actions.addMembers = compact( - (actions.addMembers || []).map(_addMember => { - const addMember = _addMember; - + (actions.addMembers || []).map(addMember => { if (addMember.added) { const decrypted = decryptMember( clientZkGroupCipher, @@ -3040,9 +3443,7 @@ function decryptGroupChange( // deleteMembers?: Array; actions.deleteMembers = compact( - (actions.deleteMembers || []).map(_deleteMember => { - const deleteMember = _deleteMember; - + (actions.deleteMembers || []).map(deleteMember => { if (hasData(deleteMember.deletedUserId)) { try { deleteMember.deletedUserId = decryptUuid( @@ -3082,9 +3483,7 @@ function decryptGroupChange( // modifyMemberRoles?: Array; actions.modifyMemberRoles = compact( - (actions.modifyMemberRoles || []).map(_modifyMember => { - const modifyMember = _modifyMember; - + (actions.modifyMemberRoles || []).map(modifyMember => { if (hasData(modifyMember.userId)) { try { modifyMember.userId = decryptUuid( @@ -3120,7 +3519,7 @@ function decryptGroupChange( if (!isValidRole(modifyMember.role)) { throw new Error( - 'decryptGroupChange: modifyMemberRole had invalid role' + `decryptGroupChange: modifyMemberRole had invalid role ${modifyMember.role}` ); } @@ -3128,12 +3527,11 @@ function decryptGroupChange( }) ); - // modifyMemberProfileKeys?: - // Array; + // modifyMemberProfileKeys?: Array< + // GroupChangeClass.Actions.ModifyMemberProfileKeyAction + // >; actions.modifyMemberProfileKeys = compact( - (actions.modifyMemberProfileKeys || []).map(_modifyMemberProfileKey => { - const modifyMemberProfileKey = _modifyMemberProfileKey; - + (actions.modifyMemberProfileKeys || []).map(modifyMemberProfileKey => { if (hasData(modifyMemberProfileKey.presentation)) { const { profileKey, uuid } = decryptProfileKeyCredentialPresentation( clientZkGroupCipher, @@ -3175,13 +3573,13 @@ function decryptGroupChange( }) ); - // addPendingMembers?: Array; + // addPendingMembers?: Array< + // GroupChangeClass.Actions.AddMemberPendingProfileKeyAction + // >; actions.addPendingMembers = compact( - (actions.addPendingMembers || []).map(_addPendingMember => { - const addPendingMember = _addPendingMember; - + (actions.addPendingMembers || []).map(addPendingMember => { if (addPendingMember.added) { - const decrypted = decryptPendingMember( + const decrypted = decryptMemberPendingProfileKey( clientZkGroupCipher, addPendingMember.added, logId @@ -3199,11 +3597,11 @@ function decryptGroupChange( }) ); - // deletePendingMembers?: Array; + // deletePendingMembers?: Array< + // GroupChangeClass.Actions.DeleteMemberPendingProfileKeyAction + // >; actions.deletePendingMembers = compact( - (actions.deletePendingMembers || []).map(_deletePendingMember => { - const deletePendingMember = _deletePendingMember; - + (actions.deletePendingMembers || []).map(deletePendingMember => { if (hasData(deletePendingMember.deletedUserId)) { try { deletePendingMember.deletedUserId = decryptUuid( @@ -3241,11 +3639,11 @@ function decryptGroupChange( }) ); - // promotePendingMembers?: Array; + // promotePendingMembers?: Array< + // GroupChangeClass.Actions.PromoteMemberPendingProfileKeyAction + // >; actions.promotePendingMembers = compact( - (actions.promotePendingMembers || []).map(_promotePendingMember => { - const promotePendingMember = _promotePendingMember; - + (actions.promotePendingMembers || []).map(promotePendingMember => { if (hasData(promotePendingMember.presentation)) { const { profileKey, uuid } = decryptProfileKeyCredentialPresentation( clientZkGroupCipher, @@ -3338,7 +3736,7 @@ function decryptGroupChange( !isValidAccess(actions.modifyAttributesAccess.attributesAccess) ) { throw new Error( - 'decryptGroupChange: modifyAttributesAccess.attributesAccess was not a valid role' + `decryptGroupChange: modifyAttributesAccess.attributesAccess was not valid: ${actions.modifyAttributesAccess.attributesAccess}` ); } @@ -3348,20 +3746,153 @@ function decryptGroupChange( !isValidAccess(actions.modifyMemberAccess.membersAccess) ) { throw new Error( - 'decryptGroupChange: modifyMemberAccess.membersAccess was not a valid role' + `decryptGroupChange: modifyMemberAccess.membersAccess was not valid: ${actions.modifyMemberAccess.membersAccess}` ); } + // modifyAddFromInviteLinkAccess?: + // GroupChangeClass.Actions.ModifyAddFromInviteLinkAccessControlAction; + if ( + actions.modifyAddFromInviteLinkAccess && + !isValidLinkAccess( + actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess + ) + ) { + throw new Error( + `decryptGroupChange: modifyAddFromInviteLinkAccess.addFromInviteLinkAccess was not valid: ${actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess}` + ); + } + + // addMemberPendingAdminApprovals?: Array< + // GroupChangeClass.Actions.AddMemberPendingAdminApprovalAction + // >; + actions.addMemberPendingAdminApprovals = compact( + (actions.addMemberPendingAdminApprovals || []).map( + addPendingAdminApproval => { + if (addPendingAdminApproval.added) { + const decrypted = decryptMemberPendingAdminApproval( + clientZkGroupCipher, + addPendingAdminApproval.added, + logId + ); + if (!decrypted) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt addPendingAdminApproval.added. Dropping member.` + ); + return null; + } + + addPendingAdminApproval.added = decrypted; + return addPendingAdminApproval; + } + throw new Error( + 'decryptGroupChange: addPendingAdminApproval was missing added field!' + ); + } + ) + ); + + // deleteMemberPendingAdminApprovals?: Array< + // GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction + // >; + actions.deleteMemberPendingAdminApprovals = compact( + (actions.deleteMemberPendingAdminApprovals || []).map( + deletePendingApproval => { + if (hasData(deletePendingApproval.deletedUserId)) { + try { + deletePendingApproval.deletedUserId = decryptUuid( + clientZkGroupCipher, + deletePendingApproval.deletedUserId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt deletePendingApproval.deletedUserId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + } else { + throw new Error( + 'decryptGroupChange: deletePendingApproval.deletedUserId was missing' + ); + } + + window.normalizeUuids( + deletePendingApproval, + ['deletedUserId'], + 'groups.decryptGroupChange' + ); + + if (!window.isValidGuid(deletePendingApproval.deletedUserId)) { + window.log.warn( + `decryptGroupChange/${logId}: Dropping deletePendingApproval due to invalid deletedUserId` + ); + + return null; + } + + return deletePendingApproval; + } + ) + ); + + // promoteMemberPendingAdminApprovals?: Array< + // GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction + // >; + actions.promoteMemberPendingAdminApprovals = compact( + (actions.promoteMemberPendingAdminApprovals || []).map( + promoteAdminApproval => { + if (hasData(promoteAdminApproval.userId)) { + try { + promoteAdminApproval.userId = decryptUuid( + clientZkGroupCipher, + promoteAdminApproval.userId.toArrayBuffer() + ); + } catch (error) { + window.log.warn( + `decryptGroupChange/${logId}: Unable to decrypt promoteAdminApproval.userId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + } else { + throw new Error( + 'decryptGroupChange: promoteAdminApproval.userId was missing' + ); + } + + if (!isValidRole(promoteAdminApproval.role)) { + throw new Error( + `decryptGroupChange: promoteAdminApproval had invalid role ${promoteAdminApproval.role}` + ); + } + + return promoteAdminApproval; + } + ) + ); + + // modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction; + if ( + actions.modifyInviteLinkPassword && + hasData(actions.modifyInviteLinkPassword.inviteLinkPassword) + ) { + actions.modifyInviteLinkPassword.inviteLinkPassword = actions.modifyInviteLinkPassword.inviteLinkPassword.toString( + 'base64' + ); + } else { + actions.modifyInviteLinkPassword = undefined; + } + return actions; } function decryptGroupState( - _groupState: GroupClass, + groupState: GroupClass, groupSecretParams: string, logId: string ): GroupClass { const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams); - const groupState = _groupState; // title if (hasData(groupState.title)) { @@ -3404,20 +3935,19 @@ function decryptGroupState( } // accessControl - if ( - !groupState.accessControl || - !isValidAccess(groupState.accessControl.attributes) - ) { + if (!isValidAccess(groupState.accessControl?.attributes)) { throw new Error( - 'decryptGroupState: Access control for attributes is missing or invalid' + `decryptGroupState: Access control for attributes is invalid: ${groupState.accessControl?.attributes}` ); } - if ( - !groupState.accessControl || - !isValidAccess(groupState.accessControl.members) - ) { + if (!isValidAccess(groupState.accessControl?.members)) { throw new Error( - 'decryptGroupState: Access control for members is missing or invalid' + `decryptGroupState: Access control for members is invalid: ${groupState.accessControl?.members}` + ); + } + if (!isValidLinkAccess(groupState.accessControl?.addFromInviteLink)) { + throw new Error( + `decryptGroupState: Access control for invite link is invalid: ${groupState.accessControl?.addFromInviteLink}` ); } @@ -3437,25 +3967,43 @@ function decryptGroupState( ); } - // pending members - if (groupState.pendingMembers) { - groupState.pendingMembers = compact( - groupState.pendingMembers.map((member: PendingMemberClass) => - decryptPendingMember(clientZkGroupCipher, member, logId) + // membersPendingProfileKey + if (groupState.membersPendingProfileKey) { + groupState.membersPendingProfileKey = compact( + groupState.membersPendingProfileKey.map( + (member: MemberPendingProfileKeyClass) => + decryptMemberPendingProfileKey(clientZkGroupCipher, member, logId) ) ); } + // membersPendingAdminApproval + if (groupState.membersPendingAdminApproval) { + groupState.membersPendingAdminApproval = compact( + groupState.membersPendingAdminApproval.map( + (member: MemberPendingAdminApprovalClass) => + decryptMemberPendingAdminApproval(clientZkGroupCipher, member, logId) + ) + ); + } + + // inviteLinkPassword + if (hasData(groupState.inviteLinkPassword)) { + groupState.inviteLinkPassword = groupState.inviteLinkPassword.toString( + 'base64' + ); + } else { + groupState.inviteLinkPassword = undefined; + } + return groupState; } function decryptMember( clientZkGroupCipher: ClientZkGroupCipher, - _member: MemberClass, + member: MemberClass, logId: string ) { - const member = _member; - // userId if (hasData(member.userId)) { try { @@ -3501,19 +4049,17 @@ function decryptMember( // role if (!isValidRole(member.role)) { - throw new Error('decryptMember: Member had invalid role'); + throw new Error(`decryptMember: Member had invalid role ${member.role}`); } return member; } -function decryptPendingMember( +function decryptMemberPendingProfileKey( clientZkGroupCipher: ClientZkGroupCipher, - _member: PendingMemberClass, + member: MemberPendingProfileKeyClass, logId: string ) { - const member = _member; - // addedByUserId if (hasData(member.addedByUserId)) { try { @@ -3523,7 +4069,7 @@ function decryptPendingMember( ); } catch (error) { window.log.warn( - `decryptPendingMember/${logId}: Unable to decrypt pending member addedByUserId. Dropping member.`, + `decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member addedByUserId. Dropping member.`, error && error.stack ? error.stack : error ); return null; @@ -3532,32 +4078,27 @@ function decryptPendingMember( window.normalizeUuids( member, ['addedByUserId'], - 'groups.decryptPendingMember' + 'groups.decryptMemberPendingProfileKey' ); if (!window.isValidGuid(member.addedByUserId)) { window.log.warn( - `decryptPendingMember/${logId}: Dropping pending member due to invalid addedByUserId` + `decryptMemberPendingProfileKey/${logId}: Dropping pending member due to invalid addedByUserId` ); return null; } } else { - throw new Error('decryptPendingMember: Member had missing addedByUserId'); + throw new Error( + 'decryptMemberPendingProfileKey: Member had missing addedByUserId' + ); } // timestamp - if (member.timestamp) { - member.timestamp = member.timestamp.toNumber(); - - const now = Date.now(); - if (!member.timestamp || member.timestamp > now) { - member.timestamp = now; - } - } + member.timestamp = normalizeTimestamp(member.timestamp); if (!member.member) { window.log.warn( - `decryptPendingMember/${logId}: Dropping pending member due to missing member details` + `decryptMemberPendingProfileKey/${logId}: Dropping pending member due to missing member details` ); return null; @@ -3574,7 +4115,7 @@ function decryptPendingMember( ); } catch (error) { window.log.warn( - `decryptPendingMember/${logId}: Unable to decrypt pending member userId. Dropping member.`, + `decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member userId. Dropping member.`, error && error.stack ? error.stack : error ); return null; @@ -3583,18 +4124,20 @@ function decryptPendingMember( window.normalizeUuids( member.member, ['userId'], - 'groups.decryptPendingMember' + 'groups.decryptMemberPendingProfileKey' ); if (!window.isValidGuid(member.member.userId)) { window.log.warn( - `decryptPendingMember/${logId}: Dropping pending member due to invalid member.userId` + `decryptMemberPendingProfileKey/${logId}: Dropping pending member due to invalid member.userId` ); return null; } } else { - throw new Error('decryptPendingMember: Member had missing member.userId'); + throw new Error( + 'decryptMemberPendingProfileKey: Member had missing member.userId' + ); } // profileKey @@ -3603,11 +4146,11 @@ function decryptPendingMember( member.member.profileKey = decryptProfileKey( clientZkGroupCipher, profileKey.toArrayBuffer(), - userId + member.member.userId ); } catch (error) { window.log.warn( - `decryptPendingMember/${logId}: Unable to decrypt pending member profileKey. Dropping profileKey.`, + `decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member profileKey. Dropping profileKey.`, error && error.stack ? error.stack : error ); member.member.profileKey = null; @@ -3615,7 +4158,7 @@ function decryptPendingMember( if (!isValidProfileKey(member.member.profileKey)) { window.log.warn( - `decryptPendingMember/${logId}: Dropping profileKey, since it was invalid` + `decryptMemberPendingProfileKey/${logId}: Dropping profileKey, since it was invalid` ); member.member.profileKey = null; @@ -3624,12 +4167,83 @@ function decryptPendingMember( // role if (!isValidRole(role)) { - throw new Error('decryptPendingMember: Member had invalid role'); + throw new Error( + `decryptMemberPendingProfileKey: Member had invalid role ${role}` + ); } return member; } +function decryptMemberPendingAdminApproval( + clientZkGroupCipher: ClientZkGroupCipher, + member: MemberPendingAdminApprovalClass, + logId: string +) { + // timestamp + member.timestamp = normalizeTimestamp(member.timestamp); + + const { userId, profileKey } = member; + + // userId + if (hasData(userId)) { + try { + member.userId = decryptUuid(clientZkGroupCipher, userId.toArrayBuffer()); + } catch (error) { + window.log.warn( + `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt pending member userId. Dropping member.`, + error && error.stack ? error.stack : error + ); + return null; + } + + window.normalizeUuids( + member, + ['userId'], + 'groups.decryptMemberPendingAdminApproval' + ); + + if (!window.isValidGuid(member.userId)) { + window.log.warn( + `decryptMemberPendingAdminApproval/${logId}: Invalid userId. Dropping member.` + ); + + return null; + } + } else { + throw new Error('decryptMemberPendingAdminApproval: Missing userId'); + } + + // profileKey + if (hasData(profileKey)) { + try { + member.profileKey = decryptProfileKey( + clientZkGroupCipher, + profileKey.toArrayBuffer(), + member.userId + ); + } catch (error) { + window.log.warn( + `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt profileKey. Dropping profileKey.`, + error && error.stack ? error.stack : error + ); + member.profileKey = null; + } + + if (!isValidProfileKey(member.profileKey)) { + window.log.warn( + `decryptMemberPendingAdminApproval/${logId}: Dropping profileKey, since it was invalid` + ); + + member.profileKey = null; + } + } + + return member; +} + +/* eslint-enable no-param-reassign */ + export function getMembershipList( conversationId: string ): Array<{ uuid: string; uuidCiphertext: ArrayBuffer }> { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index cdb151aa0..83cdd7870 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -230,6 +230,7 @@ export type ConversationAttributesType = { accessControl?: { attributes: AccessRequiredEnum; members: AccessRequiredEnum; + addFromInviteLink: AccessRequiredEnum; }; avatar?: { url: string; @@ -239,6 +240,8 @@ export type ConversationAttributesType = { expireTimer?: number; membersV2?: Array; pendingMembersV2?: Array; + pendingAdminApprovalV2?: Array; + groupInviteLinkPassword?: string; previousGroupV1Id?: string; previousGroupV1Members?: Array; }; @@ -247,6 +250,12 @@ export type GroupV2MemberType = { conversationId: string; role: MemberRoleEnum; joinedAtVersion: number; + + // Note that these are temporary flags, generated by applyGroupChange, but eliminated + // by applyGroupState. They are used to make our diff-generation more intelligent but + // not after that. + joinedFromLink?: boolean; + approvedByAdmin?: boolean; }; export type GroupV2PendingMemberType = { addedByUserId?: string; @@ -254,6 +263,10 @@ export type GroupV2PendingMemberType = { timestamp: number; role: MemberRoleEnum; }; +export type GroupV2PendingAdminApprovalType = { + conversationId: string; + timestamp: number; +}; export type VerificationOptions = { key?: null | ArrayBuffer; diff --git a/ts/state/createStore.ts b/ts/state/createStore.ts index eb35cded0..297950e63 100644 --- a/ts/state/createStore.ts +++ b/ts/state/createStore.ts @@ -37,6 +37,12 @@ const directConsole = { const logger = createLogger({ logger: directConsole, + predicate: (_getState, action) => { + if (action.type === 'network/CHECK_NETWORK_STATUS') { + return false; + } + return true; + }, }); const middlewareList = [ diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index 5e69caa6b..eaf245742 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -169,13 +169,15 @@ type DeviceNameProtobufTypes = { type GroupsProtobufTypes = { AvatarUploadAttributes: typeof AvatarUploadAttributesClass; Member: typeof MemberClass; - PendingMember: typeof PendingMemberClass; + MemberPendingProfileKey: typeof MemberPendingProfileKeyClass; + MemberPendingAdminApproval: typeof MemberPendingAdminApprovalClass; AccessControl: typeof AccessControlClass; Group: typeof GroupClass; GroupChange: typeof GroupChangeClass; GroupChanges: typeof GroupChangesClass; GroupAttributeBlob: typeof GroupAttributeBlobClass; GroupExternalCredential: typeof GroupExternalCredentialClass; + GroupInviteLink: typeof GroupInviteLinkClass; }; type SignalServiceProtobufTypes = { @@ -227,7 +229,7 @@ type ProtobufCollectionType = { // with a type that the app can use. Being more rigorous with these // types would require code changes, out of scope for now. export type ProtoBinaryType = any; -type ProtoBigNumberType = any; +export type ProtoBigNumberType = any; // Groups.proto @@ -272,17 +274,29 @@ export declare namespace MemberClass { } } -export declare class PendingMemberClass { +export declare class MemberPendingProfileKeyClass { static decode: ( data: ArrayBuffer | ByteBufferClass, encoding?: string - ) => PendingMemberClass; + ) => MemberPendingProfileKeyClass; member?: MemberClass; addedByUserId?: ProtoBinaryType; timestamp?: ProtoBigNumberType; } +export declare class MemberPendingAdminApprovalClass { + static decode: ( + data: ArrayBuffer | ByteBufferClass, + encoding?: string + ) => MemberPendingProfileKeyClass; + + userId?: ProtoBinaryType; + profileKey?: ProtoBinaryType; + presentation?: ProtoBinaryType; + timestamp?: ProtoBigNumberType; +} + export declare class AccessControlClass { static decode: ( data: ArrayBuffer | ByteBufferClass, @@ -291,6 +305,7 @@ export declare class AccessControlClass { attributes?: AccessRequiredEnum; members?: AccessRequiredEnum; + addFromInviteLink?: AccessRequiredEnum; } export type AccessRequiredEnum = number; @@ -298,10 +313,11 @@ export type AccessRequiredEnum = number; // Note: we need to use namespaces to express nested classes in Typescript export declare namespace AccessControlClass { class AccessRequired { - static ANY: number; static UNKNOWN: number; + static ANY: number; static MEMBER: number; static ADMINISTRATOR: number; + static UNSATISFIABLE: number; } } @@ -319,7 +335,9 @@ export declare class GroupClass { accessControl?: AccessControlClass; version?: number; members?: Array; - pendingMembers?: Array; + membersPendingProfileKey?: Array; + membersPendingAdminApproval?: Array; + inviteLinkPassword?: ProtoBinaryType; } export declare class GroupChangeClass { @@ -351,18 +369,31 @@ export declare namespace GroupChangeClass { modifyMemberProfileKeys?: Array< GroupChangeClass.Actions.ModifyMemberProfileKeyAction >; - addPendingMembers?: Array; + addPendingMembers?: Array< + GroupChangeClass.Actions.AddMemberPendingProfileKeyAction + >; deletePendingMembers?: Array< - GroupChangeClass.Actions.DeletePendingMemberAction + GroupChangeClass.Actions.DeleteMemberPendingProfileKeyAction >; promotePendingMembers?: Array< - GroupChangeClass.Actions.PromotePendingMemberAction + GroupChangeClass.Actions.PromoteMemberPendingProfileKeyAction >; modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction; modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction; modifyDisappearingMessagesTimer?: GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction; modifyAttributesAccess?: GroupChangeClass.Actions.ModifyAttributesAccessControlAction; modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction; + modifyAddFromInviteLinkAccess?: GroupChangeClass.Actions.ModifyAddFromInviteLinkAccessControlAction; + addMemberPendingAdminApprovals?: Array< + GroupChangeClass.Actions.AddMemberPendingAdminApprovalAction + >; + deleteMemberPendingAdminApprovals?: Array< + GroupChangeClass.Actions.DeleteMemberPendingAdminApprovalAction + >; + promoteMemberPendingAdminApprovals?: Array< + GroupChangeClass.Actions.PromoteMemberPendingAdminApprovalAction + >; + modifyInviteLinkPassword?: GroupChangeClass.Actions.ModifyInviteLinkPasswordAction; } } @@ -370,6 +401,7 @@ export declare namespace GroupChangeClass { export declare namespace GroupChangeClass.Actions { class AddMemberAction { added?: MemberClass; + joinFromInviteLink?: boolean; } class DeleteMemberAction { @@ -389,15 +421,15 @@ export declare namespace GroupChangeClass.Actions { uuid: string; } - class AddPendingMemberAction { - added?: PendingMemberClass; + class AddMemberPendingProfileKeyAction { + added?: MemberPendingProfileKeyClass; } - class DeletePendingMemberAction { + class DeleteMemberPendingProfileKeyAction { deletedUserId?: ProtoBinaryType; } - class PromotePendingMemberAction { + class PromoteMemberPendingProfileKeyAction { presentation?: ProtoBinaryType; // The result of decryption @@ -405,6 +437,19 @@ export declare namespace GroupChangeClass.Actions { uuid: string; } + class AddMemberPendingAdminApprovalAction { + added?: MemberPendingAdminApprovalClass; + } + + class DeleteMemberPendingAdminApprovalAction { + deletedUserId?: ProtoBinaryType; + } + + class PromoteMemberPendingAdminApprovalAction { + userId?: ProtoBinaryType; + role?: MemberRoleEnum; + } + class ModifyTitleAction { title?: ProtoBinaryType; } @@ -424,6 +469,14 @@ export declare namespace GroupChangeClass.Actions { class ModifyMembersAccessControlAction { membersAccess?: AccessRequiredEnum; } + + class ModifyAddFromInviteLinkAccessControlAction { + addFromInviteLinkAccess?: AccessRequiredEnum; + } + + class ModifyInviteLinkPasswordAction { + inviteLinkPassword?: ProtoBinaryType; + } } export declare class GroupChangesClass { @@ -452,6 +505,21 @@ export declare class GroupExternalCredentialClass { token?: string; } +export declare class GroupInviteLinkClass { + v1Contents?: GroupInviteLinkClass.GroupInviteLinkContentsV1; + + // Note: this isn't part of the proto, but our protobuf library tells us which + // field has been set with this prop. + contents?: 'v1Contents'; +} + +export declare namespace GroupInviteLinkClass { + class GroupInviteLinkContentsV1 { + groupMasterKey?: ProtoBinaryType; + inviteLinkPassword?: ProtoBinaryType; + } +} + export declare class GroupAttributeBlobClass { static decode: ( data: ArrayBuffer | ByteBufferClass, diff --git a/ts/window.d.ts b/ts/window.d.ts index 6ce4cf0d0..c6c6c7584 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -480,6 +480,9 @@ declare global { // Flags isGroupCallingEnabled: () => boolean; + GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean; + GV2_ENABLE_CHANGE_PROCESSING: boolean; + GV2_ENABLE_STATE_PROCESSING: boolean; } interface Error {