Introduce conversation details screen for New Groups

Co-authored-by: Chris Svenningsen <chris@carbonfive.com>
Co-authored-by: Sidney Keese <me@sidke.com>
This commit is contained in:
Josh Perez 2021-01-29 16:19:24 -05:00 committed by GitHub
parent 1268945840
commit c0510b08a5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 4699 additions and 81 deletions

View file

@ -10,6 +10,11 @@ import {
ConversationAttributesType,
VerificationOptions,
} from '../model-types.d';
import {
GroupV2PendingMembership,
GroupV2RequestingMembership,
} from '../components/conversation/conversation-details/PendingInvites';
import { GroupV2Membership } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage';
import {
@ -295,6 +300,21 @@ export class ConversationModel extends window.Backbone.Model<
}
}
isMemberRequestingToJoin(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
}
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
return false;
}
return pendingAdminApprovalV2.some(
item => item.conversationId === conversationId
);
}
isMemberPending(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
@ -393,7 +413,7 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async removePendingMember(
async approvePendingApprovalRequest(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
@ -401,9 +421,9 @@ export class ConversationModel extends window.Backbone.Model<
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(conversationId)) {
if (!this.isMemberRequestingToJoin(conversationId)) {
window.log.warn(
`removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
`approvePendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.`
);
return undefined;
}
@ -411,20 +431,101 @@ export class ConversationModel extends window.Backbone.Model<
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`removePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
`approvePendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
throw new Error(
`removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
`approvePendingApprovalRequest/${idLog}: Missing uuid for conversation ${conversationId}`
);
}
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({
group: this.attributes,
uuid,
});
}
async denyPendingApprovalRequest(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberRequestingToJoin(conversationId)) {
window.log.warn(
`denyPendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.`
);
return undefined;
}
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
throw new Error(
`denyPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
throw new Error(
`denyPendingApprovalRequest/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
);
}
return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({
group: this.attributes,
uuid,
});
}
async removePendingMember(
conversationIds: Array<string>
): Promise<GroupChangeClass.Actions | undefined> {
const idLog = this.idForLogging();
const uuids = conversationIds
.map(conversationId => {
// This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here
// in conflict/retry cases.
if (!this.isMemberPending(conversationId)) {
window.log.warn(
`removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const pendingMember = window.ConversationController.get(conversationId);
if (!pendingMember) {
window.log.warn(
`removePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
);
return undefined;
}
const uuid = pendingMember.get('uuid');
if (!uuid) {
window.log.warn(
`removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
);
return undefined;
}
return uuid;
})
.filter((uuid): uuid is string => Boolean(uuid));
if (!uuids.length) {
return undefined;
}
return window.Signal.Groups.buildDeletePendingMemberChange({
group: this.attributes,
uuid,
uuids,
});
}
@ -463,6 +564,49 @@ export class ConversationModel extends window.Backbone.Model<
});
}
async toggleAdminChange(
conversationId: string
): Promise<GroupChangeClass.Actions | undefined> {
if (!this.isGroupV2()) {
return undefined;
}
const idLog = this.idForLogging();
if (!this.isMember(conversationId)) {
window.log.warn(
`toggleAdminChange/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
);
return undefined;
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`toggleAdminChange/${idLog}: No conversation found for conversation ${conversationId}`
);
}
const uuid = conversation.get('uuid');
if (!uuid) {
throw new Error(
`toggleAdminChange/${idLog}: Missing uuid for conversation ${conversationId}`
);
}
const MEMBER_ROLES = window.textsecure.protobuf.Member.Role;
const role = this.isAdmin(conversationId)
? MEMBER_ROLES.DEFAULT
: MEMBER_ROLES.ADMINISTRATOR;
return window.Signal.Groups.buildModifyMemberRoleChange({
group: this.attributes,
uuid,
role,
});
}
async modifyGroupV2({
name,
createGroupChange,
@ -1158,7 +1302,7 @@ export class ConversationModel extends window.Backbone.Model<
groupVersion = 2;
}
const members = this.isGroupV2()
const sortedGroupMembers = this.isGroupV2()
? this.getMembers()
.sort((left, right) =>
sortConversationTitles(left, right, this.intlCollator)
@ -1182,6 +1326,7 @@ export class ConversationModel extends window.Backbone.Model<
),
areWeAdmin: this.areWeAdmin(),
canChangeTimer: this.canChangeTimer(),
canEditGroupInfo: this.canEditGroupInfo(),
avatarPath: this.getAvatarPath()!,
color,
draftBodyRanges,
@ -1190,6 +1335,7 @@ export class ConversationModel extends window.Backbone.Model<
firstName: this.get('profileName')!,
groupVersion,
groupId: this.get('groupId'),
groupLink: this.getGroupLink(),
inboxPosition,
isArchived: this.get('isArchived')!,
isBlocked: this.isBlocked(),
@ -1207,11 +1353,17 @@ export class ConversationModel extends window.Backbone.Model<
lastUpdated: this.get('timestamp')!,
left: Boolean(this.get('left')),
markedUnread: this.get('markedUnread')!,
members,
membersCount: this.isPrivate()
? undefined
: (this.get('membersV2')! || this.get('members')! || []).length,
memberships: this.getMemberships(),
pendingMemberships: this.getPendingMemberships(),
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
messageRequestsEnabled,
accessControlAddFromInviteLink: this.get('accessControl')
?.addFromInviteLink,
accessControlAttributes: this.get('accessControl')?.attributes,
accessControlMembers: this.get('accessControl')?.members,
expireTimer: this.get('expireTimer'),
muteExpiresAt: this.get('muteExpiresAt')!,
name: this.get('name')!,
@ -1221,6 +1373,7 @@ export class ConversationModel extends window.Backbone.Model<
secretParams: this.get('secretParams'),
sharedGroupNames: this.get('sharedGroupNames')!,
shouldShowDraft,
sortedGroupMembers,
timestamp,
title: this.getTitle()!,
type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
@ -1480,7 +1633,7 @@ export class ConversationModel extends window.Backbone.Model<
) {
await this.modifyGroupV2({
name: 'delete',
createGroupChange: () => this.removePendingMember(ourConversationId),
createGroupChange: () => this.removePendingMember([ourConversationId]),
});
} else if (
ourConversationId &&
@ -1498,11 +1651,76 @@ export class ConversationModel extends window.Backbone.Model<
}
}
async removeFromGroupV2(conversationId: string): Promise<void> {
if (this.isGroupV2() && this.isMemberPending(conversationId)) {
async toggleAdmin(conversationId: string): Promise<void> {
if (!this.isGroupV2()) {
return;
}
if (!this.isMember(conversationId)) {
window.log.error(
`toggleAdmin: Member ${conversationId} is not a member of the group`
);
return;
}
await this.modifyGroupV2({
name: 'toggleAdmin',
createGroupChange: () => this.toggleAdminChange(conversationId),
});
}
async approvePendingMembershipFromGroupV2(
conversationId: string
): Promise<void> {
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'approvePendingApprovalRequest',
createGroupChange: () =>
this.approvePendingApprovalRequest(conversationId),
});
}
}
async revokePendingMembershipsFromGroupV2(
conversationIds: Array<string>
): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const [conversationId] = conversationIds;
// Only pending memberships can be revoked for multiple members at once
if (conversationIds.length > 1) {
await this.modifyGroupV2({
name: 'removePendingMember',
createGroupChange: () => this.removePendingMember(conversationId),
createGroupChange: () => this.removePendingMember(conversationIds),
});
} else if (this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'denyPendingApprovalRequest',
createGroupChange: () =>
this.denyPendingApprovalRequest(conversationId),
});
} else if (this.isMemberPending(conversationId)) {
await this.modifyGroupV2({
name: 'removePendingMember',
createGroupChange: () => this.removePendingMember([conversationId]),
});
}
}
async removeFromGroupV2(conversationId: string): Promise<void> {
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
await this.modifyGroupV2({
name: 'denyPendingApprovalRequest',
createGroupChange: () =>
this.denyPendingApprovalRequest(conversationId),
});
} else if (this.isGroupV2() && this.isMemberPending(conversationId)) {
await this.modifyGroupV2({
name: 'removePendingMember',
createGroupChange: () => this.removePendingMember([conversationId]),
});
} else if (this.isGroupV2() && this.isMember(conversationId)) {
await this.modifyGroupV2({
@ -2274,6 +2492,114 @@ export class ConversationModel extends window.Backbone.Model<
return this.jobQueue.add(taskWithTimeout);
}
isAdmin(conversationId: string): boolean {
if (!this.isGroupV2()) {
return false;
}
const members = this.get('membersV2') || [];
const member = members.find(x => x.conversationId === conversationId);
if (!member) {
return false;
}
const MEMBER_ROLES = window.textsecure.protobuf.Member.Role;
return member.role === MEMBER_ROLES.ADMINISTRATOR;
}
getMemberships(): Array<GroupV2Membership> {
if (!this.isGroupV2()) {
return [];
}
const members = this.get('membersV2') || [];
return members
.map(member => {
const conversationModel = window.ConversationController.get(
member.conversationId
);
if (!conversationModel || conversationModel.isUnregistered()) {
return null;
}
return {
isAdmin:
member.role ===
window.textsecure.protobuf.Member.Role.ADMINISTRATOR,
metadata: member,
member: conversationModel.format(),
};
})
.filter(
(membership): membership is GroupV2Membership => membership !== null
);
}
getGroupLink(): string | undefined {
if (!this.isGroupV2()) {
return undefined;
}
if (!this.get('groupInviteLinkPassword')) {
return undefined;
}
return window.Signal.Groups.buildGroupLink(this);
}
getPendingMemberships(): Array<GroupV2PendingMembership> {
if (!this.isGroupV2()) {
return [];
}
const members = this.get('pendingMembersV2') || [];
return members
.map(member => {
const conversationModel = window.ConversationController.get(
member.conversationId
);
if (!conversationModel || conversationModel.isUnregistered()) {
return null;
}
return {
metadata: member,
member: conversationModel.format(),
};
})
.filter(
(membership): membership is GroupV2PendingMembership =>
membership !== null
);
}
getPendingApprovalMemberships(): Array<GroupV2RequestingMembership> {
if (!this.isGroupV2()) {
return [];
}
const members = this.get('pendingAdminApprovalV2') || [];
return members
.map(member => {
const conversationModel = window.ConversationController.get(
member.conversationId
);
if (!conversationModel || conversationModel.isUnregistered()) {
return null;
}
return {
metadata: member,
member: conversationModel.format(),
};
})
.filter(
(membership): membership is GroupV2RequestingMembership =>
membership !== null
);
}
getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<ConversationModel> {
@ -3199,6 +3525,166 @@ export class ConversationModel extends window.Backbone.Model<
window.Whisper.events.trigger('updateUnreadCount');
}
async refreshGroupLink(): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const groupInviteLinkPassword = arrayBufferToBase64(
window.Signal.Groups.generateGroupInviteLinkPassword()
);
window.log.info('refreshGroupLink for conversation', this.idForLogging());
await this.modifyGroupV2({
name: 'updateInviteLinkPassword',
createGroupChange: async () =>
window.Signal.Groups.buildInviteLinkPasswordChange(
this.attributes,
groupInviteLinkPassword
),
});
this.set({ groupInviteLinkPassword });
}
async toggleGroupLink(value: boolean): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const shouldCreateNewGroupLink =
value && !this.get('groupInviteLinkPassword');
const groupInviteLinkPassword =
this.get('groupInviteLinkPassword') ||
arrayBufferToBase64(
window.Signal.Groups.generateGroupInviteLinkPassword()
);
window.log.info(
'toggleGroupLink for conversation',
this.idForLogging(),
value
);
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const addFromInviteLink = value
? ACCESS_ENUM.ANY
: ACCESS_ENUM.UNSATISFIABLE;
if (shouldCreateNewGroupLink) {
await this.modifyGroupV2({
name: 'updateNewGroupLink',
createGroupChange: async () =>
window.Signal.Groups.buildNewGroupLinkChange(
this.attributes,
groupInviteLinkPassword,
addFromInviteLink
),
});
} else {
await this.modifyGroupV2({
name: 'updateAccessControlAddFromInviteLink',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlAddFromInviteLinkChange(
this.attributes,
addFromInviteLink
),
});
}
this.set({
accessControl: {
addFromInviteLink,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
if (shouldCreateNewGroupLink) {
this.set({ groupInviteLinkPassword });
}
}
async updateAccessControlAddFromInviteLink(value: boolean): Promise<void> {
if (!this.isGroupV2()) {
return;
}
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
const addFromInviteLink = value
? ACCESS_ENUM.ADMINISTRATOR
: ACCESS_ENUM.ANY;
await this.modifyGroupV2({
name: 'updateAccessControlAddFromInviteLink',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlAddFromInviteLinkChange(
this.attributes,
addFromInviteLink
),
});
this.set({
accessControl: {
addFromInviteLink,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
}
async updateAccessControlAttributes(value: number): Promise<void> {
if (!this.isGroupV2()) {
return;
}
await this.modifyGroupV2({
name: 'updateAccessControlAttributes',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlAttributesChange(
this.attributes,
value
),
});
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
this.set({
accessControl: {
addFromInviteLink:
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
attributes: value,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
}
async updateAccessControlMembers(value: number): Promise<void> {
if (!this.isGroupV2()) {
return;
}
await this.modifyGroupV2({
name: 'updateAccessControlMembers',
createGroupChange: async () =>
window.Signal.Groups.buildAccessControlMembersChange(
this.attributes,
value
),
});
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
this.set({
accessControl: {
addFromInviteLink:
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: value,
},
});
}
async updateExpirationTimer(
providedExpireTimer: number | undefined,
providedSource: unknown,
@ -4187,6 +4673,18 @@ export class ConversationModel extends window.Backbone.Model<
return this.areWeAdmin();
}
canEditGroupInfo(): boolean {
if (!this.isGroupV2()) {
return false;
}
return (
this.areWeAdmin() ||
this.get('accessControl')?.attributes ===
window.textsecure.protobuf.AccessControl.AccessRequired.MEMBER
);
}
areWeAdmin(): boolean {
if (!this.isGroupV2()) {
return false;