From ab8aa0d982059fc4e82099064ffa4d6263cc1278 Mon Sep 17 00:00:00 2001
From: Audric Ackermann <audric@loki.network>
Date: Mon, 1 Feb 2021 16:40:23 +1100
Subject: [PATCH] disable sending of explicit group updates for now - only
 receiving is ON

---
 preload.js                                    |   4 +-
 ts/session/group/index.ts                     | 134 +++++++++++++++++-
 .../data/group/ClosedGroupUpdateMessage.ts    |  51 +++++++
 .../outgoing/content/data/group/index.ts      |   1 +
 ts/window.d.ts                                |   2 +-
 5 files changed, 183 insertions(+), 9 deletions(-)
 create mode 100644 ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts

diff --git a/preload.js b/preload.js
index a8c6772dd..d59110f9d 100644
--- a/preload.js
+++ b/preload.js
@@ -440,12 +440,12 @@ if (process.env.USE_STUBBED_NETWORK) {
 
 window.lokiFeatureFlags = {
   multiDeviceUnpairing: true,
-  privateGroupChats: true,
   useOnionRequests: true,
   useOnionRequestsV2: true,
   useFileOnionRequests: true,
   useFileOnionRequestsV2: true, // more compact encoding of files in response
   onionRequestHops: 3,
+  useExplicitGroupUpdatesSending: false,
 };
 
 // eslint-disable-next-line no-extend-native,func-names
@@ -476,10 +476,10 @@ if (
 if (config.environment.includes('test-integration')) {
   window.lokiFeatureFlags = {
     multiDeviceUnpairing: true,
-    privateGroupChats: true,
     useOnionRequests: false,
     useFileOnionRequests: false,
     useOnionRequestsV2: false,
+    useExplicitGroupUpdatesSending: false,
   };
   /* eslint-disable global-require, import/no-extraneous-dependencies */
   window.sinon = require('sinon');
diff --git a/ts/session/group/index.ts b/ts/session/group/index.ts
index ca39aae81..c576f69ec 100644
--- a/ts/session/group/index.ts
+++ b/ts/session/group/index.ts
@@ -25,6 +25,7 @@ import {
   ClosedGroupAddedMembersMessage,
   ClosedGroupNameChangeMessage,
   ClosedGroupRemovedMembersMessage,
+  ClosedGroupUpdateMessage,
 } from '../messages/outgoing/content/data/group';
 
 export interface GroupInfo {
@@ -77,6 +78,8 @@ export async function syncMediumGroups(groups: Array<ConversationModel>) {
   // await Promise.all(groups.map(syncMediumGroup));
 }
 
+// tslint:disable: max-func-body-length
+// tslint:disable: cyclomatic-complexity
 export async function initiateGroupUpdate(
   groupId: string,
   groupName: string,
@@ -119,6 +122,111 @@ export async function initiateGroupUpdate(
     expireTimer: convo.get('expireTimer'),
   };
 
+  if (!window.lokiFeatureFlags.useExplicitGroupUpdatesSending) {
+    // we still don't send any explicit group updates for now - only the receiving side is enabled
+    const dbMessageAdded = await addUpdateMessage(convo, diff, 'outgoing');
+    window.getMessageController().register(dbMessageAdded.id, dbMessageAdded);
+    // Check preconditions
+    const hexEncryptionKeyPair = await Data.getLatestClosedGroupEncryptionKeyPair(
+      groupId
+    );
+    if (!hexEncryptionKeyPair) {
+      throw new Error("Couldn't get key pair for closed group");
+    }
+
+    const encryptionKeyPair = ECKeyPair.fromHexKeyPair(hexEncryptionKeyPair);
+    const removedMembers = diff.leavingMembers || [];
+    const newMembers = diff.joiningMembers || []; // joining members
+    const wasAnyUserRemoved = removedMembers.length > 0;
+    const ourPrimary = await UserUtils.getOurNumber();
+    const isUserLeaving = removedMembers.includes(ourPrimary.key);
+    const isCurrentUserAdmin = convo
+      .get('groupAdmins')
+      ?.includes(ourPrimary.key);
+    const expireTimerToShare = groupDetails.expireTimer || 0;
+
+    const admins = convo.get('groupAdmins') || [];
+    if (removedMembers.includes(admins[0]) && newMembers.length !== 0) {
+      throw new Error(
+        "Can't remove admin from closed group without removing everyone."
+      ); // Error.invalidClosedGroupUpdate
+    }
+
+    if (isUserLeaving && newMembers.length !== 0) {
+      if (removedMembers.length !== 1 || newMembers.length !== 0) {
+        throw new Error(
+          "Can't remove self and add or remove others simultaneously."
+        );
+      }
+    }
+
+    // Send the update to the group
+    const mainClosedGroupUpdate = new ClosedGroupUpdateMessage({
+      timestamp: Date.now(),
+      groupId,
+      name: groupName,
+      members,
+      identifier: dbMessageAdded.id || uuid(),
+      expireTimer: expireTimerToShare,
+    });
+
+    if (isUserLeaving) {
+      window.log.info(
+        `We are leaving the group ${groupId}. Sending our leaving message.`
+      );
+      // sent the message to the group and once done, remove everything related to this group
+      window.SwarmPolling.removePubkey(groupId);
+      await getMessageQueue().sendToGroup(mainClosedGroupUpdate, async () => {
+        window.log.info(
+          `Leaving message sent ${groupId}. Removing everything related to this group.`
+        );
+        await Data.removeAllClosedGroupEncryptionKeyPairs(groupId);
+      });
+    } else {
+      // Send the group update, and only once sent, generate and distribute a new encryption key pair if needed
+      await getMessageQueue().sendToGroup(mainClosedGroupUpdate, async () => {
+        if (wasAnyUserRemoved && isCurrentUserAdmin) {
+          // we send the new encryption key only to members already here before the update
+          const membersNotNew = members.filter(m => !newMembers.includes(m));
+          window.log.info(
+            `Sending group update: A user was removed from ${groupId} and we are the admin. Generating and sending a new EncryptionKeyPair`
+          );
+
+          await generateAndSendNewEncryptionKeyPair(groupId, membersNotNew);
+        }
+      });
+
+      if (newMembers.length) {
+        // Send closed group update messages to any new members individually
+        const newClosedGroupUpdate = new ClosedGroupNewMessage({
+          timestamp: Date.now(),
+          name: groupName,
+          groupId,
+          admins,
+          members,
+          keypair: encryptionKeyPair,
+          identifier: dbMessageAdded.id || uuid(),
+          expireTimer: expireTimerToShare,
+        });
+
+        const promises = newMembers.map(async m => {
+          await ConversationController.getInstance().getOrCreateAndWait(
+            m,
+            'private'
+          );
+          const memberPubKey = PubKey.cast(m);
+          await getMessageQueue().sendToPubKey(
+            memberPubKey,
+            newClosedGroupUpdate
+          );
+        });
+        await Promise.all(promises);
+      }
+    }
+
+    return;
+  }
+
   if (diff.newName?.length) {
     const nameOnlyDiff: GroupDiff = { newName: diff.newName };
     const dbMessageName = await addUpdateMessage(
@@ -344,12 +452,26 @@ export async function leaveClosedGroup(groupId: string) {
   window.getMessageController().register(dbMessage.id, dbMessage);
   const existingExpireTimer = convo.get('expireTimer') || 0;
   // Send the update to the group
-  const ourLeavingMessage = new ClosedGroupMemberLeftMessage({
-    timestamp: Date.now(),
-    groupId,
-    identifier: dbMessage.id,
-    expireTimer: existingExpireTimer,
-  });
+  let ourLeavingMessage;
+
+  if (window.lokiFeatureFlags.useExplicitGroupUpdatesSending) {
+    ourLeavingMessage = new ClosedGroupMemberLeftMessage({
+      timestamp: Date.now(),
+      groupId,
+      identifier: dbMessage.id,
+      expireTimer: existingExpireTimer,
+    });
+  } else {
+    const ourPubkey = await UserUtils.getOurNumber();
+    ourLeavingMessage = new ClosedGroupUpdateMessage({
+      timestamp: Date.now(),
+      groupId,
+      identifier: dbMessage.id,
+      expireTimer: existingExpireTimer,
+      name: convo.get('name'),
+      members: convo.get('members').filter(m => m !== ourPubkey.key),
+    });
+  }
 
   window.log.info(
     `We are leaving the group ${groupId}. Sending our leaving message.`
diff --git a/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts b/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts
new file mode 100644
index 000000000..f5a0b2c73
--- /dev/null
+++ b/ts/session/messages/outgoing/content/data/group/ClosedGroupUpdateMessage.ts
@@ -0,0 +1,51 @@
+import { SignalService } from '../../../../../../protobuf';
+import {
+  ClosedGroupMessage,
+  ClosedGroupMessageParams,
+} from './ClosedGroupMessage';
+import { fromHexToArray } from '../../../../../utils/String';
+
+export interface ClosedGroupUpdateMessageParams
+  extends ClosedGroupMessageParams {
+  name: string;
+  members: Array<string>;
+  expireTimer: number;
+}
+
+export class ClosedGroupUpdateMessage extends ClosedGroupMessage {
+  private readonly name: string;
+  private readonly members: Array<string>;
+
+  constructor(params: ClosedGroupUpdateMessageParams) {
+    super({
+      timestamp: params.timestamp,
+      identifier: params.identifier,
+      groupId: params.groupId,
+      expireTimer: params.expireTimer,
+    });
+    this.name = params.name;
+    this.members = params.members;
+
+    // members can be empty. It means noone is in the group anymore and it happens when an admin leaves the group
+    if (!params.members) {
+      throw new Error('Members must be set');
+    }
+    if (!params.name || params.name.length === 0) {
+      throw new Error('Name must cannot be empty');
+    }
+  }
+
+  public dataProto(): SignalService.DataMessage {
+    const dataMessage = new SignalService.DataMessage();
+
+    dataMessage.closedGroupControlMessage = new SignalService.DataMessage.ClosedGroupControlMessage();
+    dataMessage.closedGroupControlMessage.type =
+      SignalService.DataMessage.ClosedGroupControlMessage.Type.UPDATE;
+    dataMessage.closedGroupControlMessage.name = this.name;
+    dataMessage.closedGroupControlMessage.members = this.members.map(
+      fromHexToArray
+    );
+
+    return dataMessage;
+  }
+}
diff --git a/ts/session/messages/outgoing/content/data/group/index.ts b/ts/session/messages/outgoing/content/data/group/index.ts
index e9f861ff7..c16a052c1 100644
--- a/ts/session/messages/outgoing/content/data/group/index.ts
+++ b/ts/session/messages/outgoing/content/data/group/index.ts
@@ -4,3 +4,4 @@ export * from './ClosedGroupNewMessage';
 export * from './ClosedGroupAddedMembersMessage';
 export * from './ClosedGroupNameChangeMessage';
 export * from './ClosedGroupRemovedMembersMessage';
+export * from './ClosedGroupUpdateMessage';
diff --git a/ts/window.d.ts b/ts/window.d.ts
index 10972cabc..75c3cc702 100644
--- a/ts/window.d.ts
+++ b/ts/window.d.ts
@@ -59,11 +59,11 @@ declare global {
     log: any;
     lokiFeatureFlags: {
       multiDeviceUnpairing: boolean;
-      privateGroupChats: boolean;
       useOnionRequests: boolean;
       useOnionRequestsV2: boolean;
       useFileOnionRequests: boolean;
       useFileOnionRequestsV2: boolean;
+      useExplicitGroupUpdatesSending: boolean;
       onionRequestHops: number;
     };
     lokiFileServerAPI: LokiFileServerInstance;