Bug 1348064 - Implement Direct Messages for Matrix. r=clokep a=wsmwk
authorKhushil Mistry <khushil324@gmail.com>
Fri, 26 Jun 2020 13:36:44 +0300
changeset 39493 9f7fb887944f27835f5b36213d9c4fd604b2ad71
parent 39492 2b321be11b3568503c9e93f272a6887a09b781f0
child 39494 43f4af1ddecc084a6b5a7e3b01960e43404ceaea
push id402
push userclokep@gmail.com
push dateMon, 29 Jun 2020 20:48:04 +0000
reviewersclokep, wsmwk
bugs1348064
Bug 1348064 - Implement Direct Messages for Matrix. r=clokep a=wsmwk
chat/protocols/matrix/matrix.jsm
--- a/chat/protocols/matrix/matrix.jsm
+++ b/chat/protocols/matrix/matrix.jsm
@@ -260,23 +260,31 @@ Object.assign(MatrixDirectConversation.p
  *  setAvatarUrl
  *  setDisplayName
  *  setPassword
  *  setPresence
  */
 function MatrixAccount(aProtocol, aImAccount) {
   this._init(aProtocol, aImAccount);
   this.roomList = new Map();
+  this._userToRoom = new Set();
 }
 MatrixAccount.prototype = {
   __proto__: GenericAccountPrototype,
   observe(aSubject, aTopic, aData) {},
   remove() {
     for (let conv of this.roomList.values()) {
-      conv.close();
+      // We want to remove all the conversations. We are not using conv.close
+      // function call because we don't want user to leave all the matrix rooms.
+      // User just want to remove the account so we need to remove the listed
+      // conversations. GenericConversationPrototype.close is used at various
+      // places in the file. It's because of the same reason, we want to remove
+      // the conversation only, don't want user to leave the room. conv.close
+      // function call will make user leave the room and close the conversation.
+      GenericConversationPrototype.close.call(conv);
     }
     delete this.roomList;
     // We want to clear data stored for syncing in indexedDB so when
     // user logins again, one gets the fresh start.
     this._client.clearStores();
   },
   unInit() {},
   connect() {
@@ -305,17 +313,16 @@ MatrixAccount.prototype = {
           }
           this.startClient();
         })
         .catch(error => {
           this.reportDisconnecting(
             Ci.prplIAccount.ERROR_OTHER_ERROR,
             error.message
           );
-          this._client = null;
           this.reportDisconnected();
         });
     });
   },
   /*
    * Hook up the Matrix Client to callbacks to handle various events.
    *
    * The possible events are documented starting at:
@@ -325,86 +332,105 @@ MatrixAccount.prototype = {
     this._client.on("sync", (state, prevState, data) => {
       switch (state) {
         case "PREPARED":
           this.reportConnected();
           break;
         case "STOPPED":
           this._client.logout().then(() => {
             this.reportDisconnected();
-            this._client = null;
           });
           break;
         // TODO: Handle other states (RECONNECTING, ERROR, SYNCING).
       }
     });
     this._client.on("RoomMember.membership", (event, member, oldMembership) => {
-      let conv = this.roomList.get(member.roomId);
-      if (conv) {
-        if (member.membership === "join") {
-          conv.addParticipant(member);
-        } else if (member.membership === "leave") {
-          conv.removeParticipant(member.userId);
+      if (this.roomList.has(member.roomId)) {
+        let conv = this.roomList.get(member.roomId);
+        if (conv.isChat) {
+          if (member.membership === "join") {
+            conv.addParticipant(member);
+          } else if (member.membership === "leave") {
+            conv.removeParticipant(member.userId);
+          }
         }
-        // Other options include "invite".
+        // If we are leaving the room, remove the conversation. If any user gets
+        // added or removed in the direct chat, update the conversation type. We
+        // are treating the direct chat with two people as a direct conversation
+        // only. Matrix supports multiple users in the direct chat. So we will
+        // treat all the rooms which have 2 users including us and classified as
+        // a DM room by SDK a direct conversation and all other rooms as a group
+        // conversations.
+        if (member.membership === "leave" && member.userId == this.userId) {
+          this.roomList.delete(member.roomId);
+          GenericConversationPrototype.close.call(conv);
+        } else if (
+          member.membership === "join" ||
+          member.membership === "leave"
+        ) {
+          this.checkRoomForUpdate(conv);
+        }
       }
     });
+
+    /*
+     * Get the map of direct messaging rooms.
+     */
+    this._client.on("accountData", event => {
+      if (event.getType() == "m.direct") {
+        this._userToRoom = event.getContent();
+      }
+    });
+
     this._client.on(
       "Room.timeline",
       (event, room, toStartOfTimeline, removed, data) => {
-        // TODO: Better handle messages!
         if (toStartOfTimeline) {
           return;
         }
         let conv = this.roomList.get(room.roomId);
-        if (conv) {
-          // If this room was never initialized, do it now.
-          if (!conv._roomId) {
-            conv.initRoom(room);
-          }
-          if (event.getType() === "m.room.message") {
-            conv.writeMessage(event.sender.name, event.getContent().body, {
-              incoming: true,
-            });
-          } else if (event.getType() == "m.room.topic") {
-            conv.setTopic(event.getContent().topic, event.sender.name);
-          } else if (event.getType() == "m.room.power_levels") {
-            conv.notifyObservers(null, "chat-update-topic");
-            conv.writeMessage(
-              event.sender.name,
-              event.getType() + ": " + JSON.stringify(event.getContent()),
-              {
-                system: true,
-              }
-            );
-          } else {
-            // This is an unhandled event type, for now just put it in the room as
-            // the JSON body. This will need to be updated once (most) events are
-            // handled.
-            conv.writeMessage(
-              event.sender.name,
-              event.getType() + ": " + JSON.stringify(event.getContent()),
-              {
-                system: true,
-              }
-            );
-          }
+        if (!conv) {
+          return;
+        }
+        if (event.getType() === "m.room.message") {
+          conv.writeMessage(event.sender.name, event.getContent().body, {
+            incoming: true,
+          });
+        } else if (event.getType() == "m.room.topic") {
+          conv.setTopic(event.getContent().topic, event.sender.name);
+        } else if (conv && event.getType() == "m.room.power_levels") {
+          conv.notifyObservers(null, "chat-update-topic");
+          conv.writeMessage(
+            event.sender.name,
+            event.getType() + ": " + JSON.stringify(event.getContent()),
+            {
+              system: true,
+            }
+          );
+        } else {
+          // This is an unhandled event type, for now just put it in the room as
+          // the JSON body. This will need to be updated once (most) events are
+          // handled.
+          conv.writeMessage(
+            event.sender.name,
+            event.getType() + ": " + JSON.stringify(event.getContent()),
+            {
+              system: true,
+            }
+          );
         }
       }
     );
     // Update the chat participant information.
     this._client.on("RoomMember.name", this.updateRoomMember.bind(this));
     this._client.on("RoomMember.powerLevel", this.updateRoomMember.bind(this));
 
     // TODO Other events to handle:
-    //  Room.accountData
     //  Room.localEchoUpdated
-    //  Room.name
     //  Room.tags
-    //  Room
     //  RoomMember.typing
     //  Session.logged_out
     //  User.avatarUrl
     //  User.currentlyActive
     //  User.displayName
     //  User.presence
 
     this._client.startClient();
@@ -419,53 +445,293 @@ MatrixAccount.prototype = {
         room.summary.info.title &&
         conv._name != room.summary.info.title
       ) {
         conv._name = room.summary.info.title;
         conv.notifyObservers(null, "update-conv-title");
       }
     });
 
-    // Get the list of joined rooms on the server and create those conversations.
-    this._client.getJoinedRooms().then(response => {
-      for (let roomId of response.joined_rooms) {
-        // If we re-connect and roomList has a conversation with given room ID
-        // that means we have created the associated conversation previously
-        // and we don't need to create it again.
-        if (this.roomList.has(roomId)) {
+    /*
+     * We auto join all the rooms in which we are invited. This will also be
+     * fired for all the rooms we have joined earlier when SDK gets connected.
+     * We will use that part to to make conversations, direct or group.
+     */
+    this._client.on("Room", room => {
+      let me = room.getMember(this.userId);
+      // For now just auto accept the invites by joining the room.
+      if (me && me.membership == "invite") {
+        if (me.events.member.getContent().is_direct) {
+          let roomMembers = room.getJoinedMembers();
+          // If there is just single user in the room, then set the
+          // room as a DM Room by adding it to dmMap in our user's accountData.
+          if (roomMembers.length == 1) {
+            let interlocutorId = roomMembers[0].userId;
+            this.setDirectRoom(interlocutorId, room.roomId);
+            // For the invited rooms, we will not get the summary info from
+            // the room object created after the joining. So we need to use
+            // the name from the room object here.
+            this.getDirectConversation(
+              interlocutorId,
+              room.roomId,
+              room.summary.info.title
+            );
+          } else {
+            this.getGroupConversation(room.roomId, room.summary.info.title);
+          }
+        } else {
+          this.getGroupConversation(room.roomId, room.summary.info.title);
+        }
+      } else if (me && me.membership == "join") {
+        // To avoid the race condition. Whenever we will create the room,
+        // this will also be fired. So we want to avoid making of multiple
+        // conversations with the same room.
+        if (!this.createRoomReturned || this.roomList.has(room.roomId)) {
           return;
         }
-        let conv = new MatrixConversation(this, roomId, this.userId);
-        this.roomList.set(roomId, conv);
-        let room = this._client.getRoom(roomId);
-        if (room && !conv._roomId) {
-          conv.initRoom(room);
+        if (this.isDirectRoom(room.roomId)) {
+          let interlocutorId;
+          for (let roomMember of room.getJoinedMembers()) {
+            if (roomMember.userId != this.userId) {
+              interlocutorId = roomMember.userId;
+              break;
+            }
+          }
+          this.getDirectConversation(interlocutorId);
+        } else {
+          this.getGroupConversation(room.roomId);
         }
       }
     });
   },
 
+  /*
+   * Checks if the room is the direct messaging room or not. We also check
+   * if number of joined users are two including us.
+   *
+   * @param {String} checkRoomId - ID of the room to check if it is direct
+   *                               messaging room or not.
+   * @return {Boolean} - If room is direct direct messaging room or not.
+   */
+  isDirectRoom(checkRoomId) {
+    for (let user of Object.keys(this._userToRoom)) {
+      for (let roomId of this._userToRoom[user]) {
+        if (roomId == checkRoomId) {
+          let room = this._client.getRoom(roomId);
+          if (room && room.getJoinedMembers().length == 2) {
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  },
+
+  /*
+   * Converts the group conversation into the direct conversation.
+   *
+   * @param {Object} groupConv - the group conversation which needs to be
+   *                             converted.
+   */
+  convertToDM(groupConv) {
+    GenericConversationPrototype.close.call(groupConv);
+    let conv = new MatrixDirectConversation(this, groupConv._roomId);
+    this.roomList.set(groupConv._roomId, conv);
+    let directRoom = this._client.getRoom(groupConv._roomId);
+    conv.initRoom(directRoom);
+  },
+
+  /*
+   * Converts the direct conversation into the group conversation.
+   *
+   * @param {Object} directConv - the direct conversation which needs to be
+   *                              converted.
+   */
+  convertToGroup(directConv) {
+    GenericConversationPrototype.close.call(directConv);
+    let conv = new MatrixConversation(this, directConv._roomId, this.userId);
+    this.roomList.set(directConv._roomId, conv);
+    let groupRoom = this._client.getRoom(directConv._roomId);
+    conv.initRoom(groupRoom);
+  },
+
+  /*
+   * Checks if the conversation needs to be changed from the group conversation
+   * to the direct conversation or vice versa.
+   *
+   * @param {Object} conv - the conversation which needs to be checked.
+   */
+  checkRoomForUpdate(conv) {
+    if (conv.room && conv.isChat && this.isDirectRoom(conv._roomId)) {
+      this.convertToDM(conv);
+    } else if (conv.room && !conv.isChat && !this.isDirectRoom(conv._roomId)) {
+      this.convertToGroup(conv);
+    }
+  },
+
+  /*
+   * Returns the group conversation according to the room-id.
+   * 1) If we have a group conversation already, we will return that.
+   * 2) If the room exists on the server, we will join it. It will not do
+   *    anything if we are already joined, it will just create the
+   *    conversation. This is used mainly when a new room gets added.
+   * 3) Create a new room if the conversation does not exist.
+   *
+   * @param {String} roomId - ID of the room.
+   * @param {String} roomName (optional) - Name of the room.
+   *
+   * @return {Object} - The resulted conversation.
+   */
+  getGroupConversation(roomId, roomName) {
+    // If there is a conversation return it.
+    if (this.roomList.has(roomId)) {
+      return this.roomList.get(roomId);
+    }
+
+    if (roomId && this._client.getRoom(roomId)) {
+      let conv = new MatrixConversation(this, roomName || roomId, this.userId);
+      this.roomList.set(roomId, conv);
+      conv.joining = true;
+      this._client
+        .joinRoom(roomId)
+        .then(room => {
+          conv.initRoom(room);
+          conv.joining = false;
+        })
+        .catch(error => {
+          this.ERROR(error);
+          conv.joining = false;
+          conv.close();
+        })
+        .done();
+
+      return conv;
+    }
+
+    if (
+      this.createRoomReturned &&
+      roomId.endsWith(":" + this._client.getDomain())
+    ) {
+      this.createRoomReturned = false;
+      let conv = new MatrixConversation(this, roomId, this.userId);
+      conv.joining = true;
+      let name = roomId.split(":", 1)[0];
+      this._client
+        .createRoom({
+          room_alias_name: name,
+          name,
+          visibility: "private",
+          preset: "private_chat",
+          content: {
+            guest_access: "can_join",
+          },
+          type: "m.room.guest_access",
+          state_key: "",
+        })
+        .then(res => {
+          this.createRoomReturned = true;
+          let newRoomId = res.room_id;
+          let room = this._client.getRoom(newRoomId);
+          conv.initRoom(room);
+          this.roomList.set(newRoomId, conv);
+          conv.joining = false;
+        })
+        .catch(error => {
+          this.createRoomReturned = true;
+          this.ERROR(error);
+          conv.joining = false;
+          conv.close();
+        })
+        .done();
+      return conv;
+    }
+    return null;
+  },
+
+  /*
+   * Flag to avoid the race condition when we create any conversation.
+   */
+  createRoomReturned: true,
+
+  /*
+   * Returns the room ID for user ID if exists for direct messaging.
+   *
+   * @param {String} roomId - ID of the user.
+   *
+   * @return {String} - ID of the room.
+   */
+  getDMRoomIdForUserId(userId) {
+    // Select the mostRecentRoom base on the timestamp of the
+    // most recent event in the room's timeline.
+    let mostRecentRoom = null;
+    let mostRecentTimeStamp = 0;
+
+    // Check in the 'other' user's roomList and add to our list.
+    if (this._userToRoom[userId]) {
+      for (let roomId of this._userToRoom[userId]) {
+        let room = this._client.getRoom(roomId);
+        if (room) {
+          let user = room.getMember(userId);
+          if (user) {
+            let latestEvent = room.timeline[room.timeline.length - 1];
+            // Timeline is null if our user's membership is invite.
+            if (latestEvent) {
+              let eventTimestamp = latestEvent.getTs();
+              if (eventTimestamp > mostRecentTimeStamp) {
+                mostRecentTimeStamp = eventTimestamp;
+                mostRecentRoom = room;
+              }
+            }
+          }
+        }
+      }
+    }
+
+    if (mostRecentRoom) {
+      return mostRecentRoom.roomId;
+    }
+    return null;
+  },
+
+  /*
+   * Sets the room ID for for corresponding user ID for direct messaging
+   * by setting the "m.direct" event of accont data of the SDK client.
+   *
+   * @param {String} roomId - ID of the user.
+   *
+   * @param {String} - ID of the room.
+   */
+  setDirectRoom(userId, roomId) {
+    let dmRoomMap = this._userToRoom;
+    let roomList = dmRoomMap[userId] || [];
+    if (!roomList.includes(roomId)) {
+      roomList.push(roomId);
+      dmRoomMap[userId] = roomList;
+      this._client.setAccountData("m.direct", dmRoomMap);
+    }
+  },
+
   updateRoomMember(event, member) {
-    let conv = this.roomList.get(member.roomId);
-    if (conv) {
-      let participant = conv._participants.get(member.userId);
-      // A participant might not exist (for example, this happens if the user
-      // has only been invited, but has not yet joined).
-      if (participant) {
-        participant._roomMember = member;
-        conv.notifyObservers(participant, "chat-buddy-update");
-        conv.notifyObservers(null, "chat-update-topic");
+    if (this.roomList && this.roomList.has(member.roomId)) {
+      let conv = this.roomList.get(member.roomId);
+      if (conv.isChat) {
+        let participant = conv._participants.get(member.userId);
+        // A participant might not exist (for example, this happens if the user
+        // has only been invited, but has not yet joined).
+        if (participant) {
+          participant._roomMember = member;
+          conv.notifyObservers(participant, "chat-buddy-update");
+          conv.notifyObservers(null, "chat-update-topic");
+        }
       }
     }
   },
 
   disconnect() {
-    if (this._client) {
-      this._client.stopClient();
-    }
+    this._client.stopClient();
     this.reportDisconnected();
   },
 
   get canJoinChat() {
     return true;
   },
   chatRoomFields: {
     // XXX Does it make sense to split up the server into a separate field?
@@ -517,18 +783,108 @@ MatrixAccount.prototype = {
         this.ERROR(error);
         conv.joining = false;
         conv.left = true;
 
         // TODO Perhaps we should call createRoom if the room doesn't exist.
       });
     return conv;
   },
-  createConversation(aName) {
-    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+
+  createConversation(userId) {
+    if (userId == this.userId) {
+      return null;
+    }
+    return this.getDirectConversation(userId);
+  },
+
+  /*
+   * Returns the direct conversation according to the room-id or user-id.
+   * 1) If we have a direct conversation already, we will return that.
+   * 2) If the room exists on the server, we will join it. It will not do
+   *    anything if we are already joined, it will just create the
+   *    conversation. This is used mainly when a new room gets added.
+   * 3) Create a new room if the conversation does not exist.
+   *
+   * @param {String} userId - ID of the user for which we want to get the
+   *                          direct conversation.
+   * @param {String} roomId (optional) - ID of the room.
+   * @param {String} roomName (optional) - Name of the room.
+   *
+   * @return {Object} - The resulted conversation.
+   */
+  getDirectConversation(userId, roomID, roomName) {
+    let DMRoomId = this.getDMRoomIdForUserId(userId);
+    if (DMRoomId && this.roomList.has(DMRoomId)) {
+      return this.roomList.get(DMRoomId);
+    }
+
+    // If user is invited to the room then DMRoomId will be null. In such
+    // cases, we will pass roomID so that user will be joined to the room
+    // and we will create corresponding conversation.
+    if (DMRoomId || roomID) {
+      let conv = new MatrixDirectConversation(
+        this,
+        roomName || DMRoomId || roomID
+      );
+      this.roomList.set(DMRoomId || roomID, conv);
+      conv.joining = true;
+      this._client
+        .joinRoom(DMRoomId || roomID)
+        .then(room => {
+          conv.initRoom(room);
+          conv.joining = false;
+        })
+        .catch(error => {
+          this.ERROR(error);
+          conv.joining = false;
+          conv.close();
+        })
+        .done();
+
+      return conv;
+    }
+
+    if (this.createRoomReturned) {
+      this.createRoomReturned = false;
+      let conv = new MatrixDirectConversation(this, userId);
+      conv.joining = true;
+      this._client
+        .createRoom({
+          is_direct: true,
+          invite: [userId],
+          visibility: "private",
+          preset: "trusted_private_chat",
+          content: {
+            guest_access: "can_join",
+          },
+          type: "m.room.guest_access",
+          state_key: "",
+        })
+        .then(res => {
+          this.createRoomReturned = true;
+          let newRoomId = res.room_id;
+          let room = this._client.getRoom(newRoomId);
+          conv.initRoom(room);
+          this.setDirectRoom(userId, newRoomId);
+          this.roomList.set(newRoomId, conv);
+          conv.joining = false;
+          this.checkRoomForUpdate(conv);
+        })
+        .catch(error => {
+          this.createRoomReturned = true;
+          this.ERROR(error);
+          conv.joining = false;
+          conv.close();
+        })
+        .done();
+
+      return conv;
+    }
+    return null;
   },
 
   requestBuddyInfo(aUserId) {
     let user = this._client.getUser(aUserId);
     if (!user) {
       Services.obs.notifyObservers(
         EmptyEnumerator,
         "user-info-received",