fix(extra-info): handle successive message loads and promoted messages

When loading a thread, some messages might be collapsed and are loaded
in subsequent requests. This CL handles this case by holding an array of
messages which grows each time the user expands a gap of messages.

Furthermore, this CL also considers promoted messages for the message
list (before this, the extra info chips were added to promoted messages
only if they were loaded outside of the promoted messages structure as
well).

Bug: twpowertools:93
Change-Id: I8b6f4e8f4a97c7f5e4cdde52b6b773b9631fbe57
diff --git a/src/contentScripts/communityConsole/extraInfo/infoHandlers/basedOnResponseEvent.js b/src/contentScripts/communityConsole/extraInfo/infoHandlers/basedOnResponseEvent.js
index 881356c..30bb36d 100644
--- a/src/contentScripts/communityConsole/extraInfo/infoHandlers/basedOnResponseEvent.js
+++ b/src/contentScripts/communityConsole/extraInfo/infoHandlers/basedOnResponseEvent.js
@@ -53,14 +53,24 @@
     window.addEventListener(this.getEvent(), e => {
       if (e.detail.id < this.info.id) return;
 
-      this.info = {
-        body: e.detail.body,
-        id: e.detail.id,
-        timestamp: Date.now(),
-      };
+      this.updateInfoWithNewValue(e);
     });
   }
 
+  /**
+   * Updates the info value with the information obtained from an event.
+   * Can be overriden to implement more advanced logic.
+   *
+   * @param {Event} e
+   */
+  updateInfoWithNewValue(e) {
+    this.info = {
+      body: e.detail.body,
+      id: e.detail.id,
+      timestamp: Date.now(),
+    };
+  }
+
   async getCurrentInfo(injectionDetails) {
     const options = this.getWaitForCurrentInfoOptions();
     return waitFor(
diff --git a/src/contentScripts/communityConsole/extraInfo/infoHandlers/thread.js b/src/contentScripts/communityConsole/extraInfo/infoHandlers/thread.js
index ba7b906..c44d7f8 100644
--- a/src/contentScripts/communityConsole/extraInfo/infoHandlers/thread.js
+++ b/src/contentScripts/communityConsole/extraInfo/infoHandlers/thread.js
@@ -10,12 +10,6 @@
 const kCurrentInfoExpiresInMs = kTimeoutInMs * 1.5;
 
 export default class ThreadInfoHandler extends ResponseEventBasedInfoHandler {
-  constructor() {
-    super();
-
-    this.thread = undefined;
-  }
-
   getEvent() {
     return kViewThreadResponse;
   }
@@ -27,14 +21,44 @@
     };
   }
 
-  async isInfoCurrent(injectionDetails) {
-    this.thread = new ThreadModel(this.info.body?.[1]);
+  setUpDefaultInfoValue() {
+    this.info = {
+      thread: new ThreadModel(),
+      messages: [],
+      id: -1,
+      timestamp: 0,
+    };
+  }
 
+  updateInfoWithNewValue(e) {
+    const newThread = new ThreadModel(e.detail.body?.[1]);
+    if (newThread.getId() != this.info.thread.getId()) {
+      this.info.messages = [];
+    }
+
+    const newMessages = newThread.getAllMessagesList();
+    this.updateRecordedMessages(newMessages);
+
+    this.info.thread = newThread;
+    this.info.id = e.detail.id;
+    this.info.timestamp = Date.now();
+  }
+
+  updateRecordedMessages(newMessages) {
+    const nonUpdatedMessages = this.info.messages.filter(message => {
+      return !newMessages.some(newMessage => {
+        return message.getId() == newMessage.getId();
+      });
+    });
+    this.info.messages = nonUpdatedMessages.concat(newMessages);
+  }
+
+  async isInfoCurrent(injectionDetails) {
     const currentPage = this.parseThreadUrl();
     const isCurrentThread =
         Date.now() - this.info.timestamp < kCurrentInfoExpiresInMs &&
-        this.thread.getId() == currentPage.thread &&
-        this.thread.getForumId() == currentPage.forum;
+        this.info.thread.getId() == currentPage.thread &&
+        this.info.thread.getForumId() == currentPage.forum;
 
     const isMessageNode = injectionDetails.isMessageNode;
     const messageNode = injectionDetails.messageNode;
@@ -53,8 +77,8 @@
 
   currentThreadContainsMessage(messageNode) {
     const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
-    const message = MessageExtraInfoService.getMessageFromThreadModel(
-        messageId, this.thread);
+    const message = MessageExtraInfoService.getMessageFromList(
+        messageId, this.info.messages);
     return message !== undefined;
   }
 }
diff --git a/src/contentScripts/communityConsole/extraInfo/injections/baseThreadMessage.js b/src/contentScripts/communityConsole/extraInfo/injections/baseThreadMessage.js
index d086f9d..d0cd162 100644
--- a/src/contentScripts/communityConsole/extraInfo/injections/baseThreadMessage.js
+++ b/src/contentScripts/communityConsole/extraInfo/injections/baseThreadMessage.js
@@ -25,16 +25,15 @@
 
   inject(threadInfo, injectionDetails) {
     const messageNode = injectionDetails.messageNode;
-    const message = this.#getMessage(threadInfo, messageNode);
+    const message = this.#getMessage(threadInfo.messages, messageNode);
     const [chips, tooltips] = MessageExtraInfoService.getMessageChips(message);
     this.#injectChips(chips, messageNode);
     for (const tooltip of tooltips) new MDCTooltip(tooltip);
   }
 
-  #getMessage(threadInfo, messageNode) {
-    const thread = new ThreadModel(threadInfo.body?.[1]);
+  #getMessage(messagesList, messageNode) {
     const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
-    return MessageExtraInfoService.getMessageFromThreadModel(messageId, thread);
+    return MessageExtraInfoService.getMessageFromList(messageId, messagesList);
   }
 
   #injectChips(chips, messageNode) {
diff --git a/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js b/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js
index 1efe6db..d797ee1 100644
--- a/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js
+++ b/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js
@@ -9,7 +9,7 @@
     BaseExtraInfoInjection {
   inject(threadInfo, injectionDetails) {
     const [chips, tooltips] =
-        ThreadExtraInfoService.getThreadChips(threadInfo.body?.['1']);
+        ThreadExtraInfoService.getThreadChips(threadInfo.thread.data);
     this.#injectChips(chips, injectionDetails.stateChips);
     for (const tooltip of tooltips) new MDCTooltip(tooltip);
   }
diff --git a/src/contentScripts/communityConsole/extraInfo/services/message.js b/src/contentScripts/communityConsole/extraInfo/services/message.js
index 3ad9798..268fea5 100644
--- a/src/contentScripts/communityConsole/extraInfo/services/message.js
+++ b/src/contentScripts/communityConsole/extraInfo/services/message.js
@@ -1,5 +1,3 @@
-import MessageModel from '../../../../models/Message.js';
-
 import StatesExtraInfoService from './states.js';
 
 export default class MessageExtraInfoService {
@@ -16,22 +14,11 @@
     return id;
   }
 
-  static getMessageFromThreadModel(messageId, threadModel) {
-    for (const messageOrGap of threadModel.getMessageOrGapModels()) {
-      if (!(messageOrGap instanceof MessageModel)) continue;
-      if (messageOrGap.getId() == messageId) {
-        return messageOrGap;
-      } else {
-        for (const subMessageOrGap of messageOrGap.getCommentsAndGaps()) {
-          if (!(subMessageOrGap instanceof MessageModel)) continue;
-          if (subMessageOrGap.getId() == messageId) {
-            return subMessageOrGap;
-          }
-        }
-      }
+  static getMessageFromList(messageId, messagesList) {
+    for (const message of messagesList) {
+      if (message.getId() == messageId) return message;
     }
-
-    throw new Error(`Couldn't find message ${messageId} in thread.`);
+    throw new Error(`Couldn't find message ${messageId} in the message list.`);
   }
 
   static getMessageChips(messageModel) {
diff --git a/src/models/Thread.js b/src/models/Thread.js
index e87f541..a613fb7 100644
--- a/src/models/Thread.js
+++ b/src/models/Thread.js
@@ -1,6 +1,10 @@
 import GapModel from './Gap.js';
 import MessageModel from './Message.js';
 
+// Keys of the PromotedMessages protobuf message which contain lists of promoted
+// messages.
+const kPromotedMessagesKeys = [1, 2, 3, 4, 5, 6];
+
 export default class ThreadModel {
   constructor(data) {
     this.data = data ?? {};
@@ -56,6 +60,51 @@
     return this.data;
   }
 
+  getPromotedMessagesList() {
+    const promotedMessages = [];
+    for (const key of kPromotedMessagesKeys) {
+      const messagesList = this.data[17][key] ?? [];
+      for (const rawMessage of messagesList) {
+        const message = new MessageModel(rawMessage);
+        if (message.getId() === null) continue;
+
+        const isMessageAlreadyIncluded = promotedMessages.some(
+            existingMessage => existingMessage.getId() == message.getId());
+        if (isMessageAlreadyIncluded) continue;
+
+        promotedMessages.push(message);
+      }
+    }
+    return promotedMessages;
+  }
+
+  /**
+   * Get a list with all the messages contained in the model.
+   */
+  getAllMessagesList() {
+    const messages = [];
+
+    for (const messageOrGap of this.getMessageOrGapModels()) {
+      if (!(messageOrGap instanceof MessageModel)) continue;
+      messages.push(messageOrGap);
+      for (const subMessageOrGap of messageOrGap.getCommentsAndGaps()) {
+        if (!(subMessageOrGap instanceof MessageModel)) continue;
+        messages.push(subMessageOrGap);
+      }
+    }
+
+    const promotedMessages = this.getPromotedMessagesList();
+    for (const message of promotedMessages) {
+      const isMessageAlreadyIncluded = messages.some(
+          existingMessage => existingMessage.getId() == message.getId());
+      if (isMessageAlreadyIncluded) continue;
+
+      messages.push(message);
+    }
+
+    return messages;
+  }
+
   /**
    * The following code is based on logic written by Googlers in the TW frontend
    * and thus is not included as part of the MIT license.