Inject additional information to message payloads

This additional information will be used to inject an element inside
messages quoting their parent message.

Bug: twpowertools:153
Change-Id: I502f9f9d0377ad5cb9be9c2a96cec416609e790f
diff --git a/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
new file mode 100644
index 0000000..91bcfbb
--- /dev/null
+++ b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
@@ -0,0 +1,55 @@
+export const kAdditionalInfoPrefix = '__TWPT_FLATTENTHREADS_ADDITIONALINFO__';
+export const kAdditionalInfoRegex =
+    /^__TWPT_FLATTENTHREADS_ADDITIONALINFO__(.*)/;
+
+export const kReplyPayloadSelector =
+    '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) .scTailwindThreadPostcontentroot html-blob';
+
+export default class FlattenThreads {
+  construct() {}
+
+  getExtraInfo(node) {
+    let rawExtraInfo = null;
+    const possibleExtraInfoNodes =
+        node.querySelectorAll('span[style*=\'display\'][style*=\'none\']');
+    for (const candidate of possibleExtraInfoNodes) {
+      const content = candidate.textContent;
+      const matches = content.match(kAdditionalInfoRegex);
+      if (matches) {
+        rawExtraInfo = matches?.[1] ?? null;
+        break;
+      }
+    }
+    if (!rawExtraInfo) return null;
+    return JSON.parse(rawExtraInfo);
+  }
+
+  injectId(node, extraInfo) {
+    const root = node.closest('.scTailwindThreadMessageMessagecardcontent');
+    if (!root) return false;
+    root.setAttribute('data-twpt-message-id', extraInfo.id);
+    return true;
+  }
+
+  injectQuote(node, extraInfo) {
+    const content = node.closest('.scTailwindThreadPostcontentroot');
+    // @TODO: Change this by the actual quote component
+    const quote = document.createElement('div');
+    quote.textContent = 'QUOTE(' + extraInfo.parentMessage.id + ')';
+    content.prepend(quote);
+  }
+
+  injectIfApplicable(node) {
+    // If we injected the additional information, it means the flatten threads
+    // feature is enabled and in actual use, so we should inject the quote.
+    const extraInfo = this.getExtraInfo(node);
+    if (!extraInfo) return;
+
+    this.injectId(node, extraInfo);
+    if (extraInfo.isComment) this.injectQuote(node, extraInfo);
+  }
+
+  shouldInject(node) {
+    return node.matches(kReplyPayloadSelector);
+  }
+}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 1edb466..a3b1bb7 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -8,6 +8,7 @@
 // #!if ['chromium', 'chromium_mv3'].includes(browser_target)
 import {applyDragAndDropFixIfEnabled} from './dragAndDropFix.js';
 // #!endif
+import {default as FlattenThreads, kReplyPayloadSelector} from './flattenThreads/flattenThreads.js';
 import InfiniteScroll from './infiniteScroll.js';
 import {kRepliesSectionSelector} from './threadToolbar/constants.js';
 import ThreadToolbar from './threadToolbar/threadToolbar.js';
@@ -15,7 +16,7 @@
 import Workflows from './workflows/workflows.js';
 
 var mutationObserver, options, avatars, infiniteScroll, workflows,
-    threadToolbar;
+    threadToolbar, flattenThreads;
 
 const watchedNodesSelectors = [
   // App container (used to set up the intersection observer and inject the dark
@@ -76,6 +77,9 @@
 
   // Thread page reply section (for the thread page toolbar)
   kRepliesSectionSelector,
+
+  // Reply payload (for the flatten threads UI)
+  kReplyPayloadSelector,
 ];
 
 function handleCandidateNode(node) {
@@ -214,6 +218,11 @@
     if (threadToolbar.shouldInject(node)) {
       threadToolbar.injectIfApplicable(node);
     }
+
+    // Inject parent reply quote
+    if (flattenThreads.shouldInject(node)) {
+      flattenThreads.injectIfApplicable(node);
+    }
   }
 }
 
@@ -251,6 +260,7 @@
   infiniteScroll = new InfiniteScroll();
   workflows = new Workflows();
   threadToolbar = new ThreadToolbar();
+  flattenThreads = new FlattenThreads();
 
   // autoRefresh, extraInfo, threadPageDesignWarning and workflowsImport are
   // initialized in start.js
diff --git a/src/models/Gap.js b/src/models/Gap.js
index 131dfbc..59e4882 100644
--- a/src/models/Gap.js
+++ b/src/models/Gap.js
@@ -21,7 +21,7 @@
   }
 
   getStartTimestamp() {
-    const a = this.getStartMicroseconds();
+    let a = this.getStartMicroseconds();
     if (a == null) a = '0';
     return BigInt(a);
   }
@@ -35,7 +35,7 @@
   }
 
   getEndTimestamp() {
-    const a = this.getEndMicroseconds();
+    let a = this.getEndMicroseconds();
     if (a == null) a = '0';
     return BigInt(a);
   }
diff --git a/src/models/Message.js b/src/models/Message.js
index d39a9ab..f5e5b37 100644
--- a/src/models/Message.js
+++ b/src/models/Message.js
@@ -12,7 +12,7 @@
   }
 
   getCreatedMicroseconds() {
-    const a = this.getCreatedTimestamp();
+    let a = this.getCreatedTimestamp();
     if (a === null) a = '0';
     return BigInt(a);
   }
@@ -33,6 +33,31 @@
     this.data[12] = [];
   }
 
+  getPayload() {
+    return this.data[1]?.[4] ?? null;
+  }
+
+  setPayload(value) {
+    if (!this.data[1]) this.data[1] = [];
+    this.data[1][4] = value;
+  }
+
+  getId() {
+    return this.data[1]?.[1]?.[1] ?? null;
+  }
+
+  getAuthor() {
+    return this.data[3] ?? null;
+  }
+
+  getParentMessageId() {
+    return this.data[1]?.[37] ?? null;
+  }
+
+  isComment() {
+    return !!this.getParentMessageId;
+  }
+
   toRawMessageOrGap() {
     return {1: this.data};
   }
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
index 65eb42c..cece8b4 100644
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -1,3 +1,4 @@
+import {kAdditionalInfoPrefix} from '../../contentScripts/communityConsole/flattenThreads/flattenThreads.js';
 import GapModel from '../../models/Gap.js';
 import MessageModel from '../../models/Message.js';
 
@@ -12,6 +13,7 @@
   async interceptor(_request, response) {
     if (!response[1]?.[40]) return response;
 
+    // Do the actual flattening
     const originalMogs =
         MessageModel.mapToMessageOrGapModels(response[1][40] ?? []);
     let extraMogs = [];
@@ -22,6 +24,19 @@
       mog.clearCommentsAndGaps();
     });
     const mogs = originalMogs.concat(extraMogs);
+
+    // Add some message data to the payload so the extension can show the parent
+    // comment/reply in the case of comments.
+    mogs.forEach(m => {
+      const info = this.getAdditionalInformation(m, mogs);
+      const span = document.createElement('span');
+      span.textContent = kAdditionalInfoPrefix + JSON.stringify(info);
+      span.setAttribute('style', 'display: none');
+      m.newPayload = m.getPayload() + span.outerHTML;
+    });
+    mogs.forEach(m => m.setPayload(m.newPayload));
+
+    // Sort the messages by date
     mogs.sort((a, b) => {
       const c = a instanceof MessageModel ? a.getCreatedMicroseconds() :
                                             a.getStartTimestamp();
@@ -30,11 +45,35 @@
       const diff = c - d;
       return diff > 0 ? 1 : diff < 0 ? -1 : 0;
     });
+
     response[1][40] = mogs.map(mog => mog.toRawMessageOrGap());
+
     // Set num_messages to the updated value, since we've flattened the replies.
     response[1][8] = response[1][40].length;
     return response;
   },
+  getAdditionalInformation(message, mogs) {
+    const id = message.getId();
+    const parentId = message.getParentMessageId();
+    const parentMessage =
+        parentId ? mogs.find(m => m.getId() === parentId) : null;
+    if (!parentMessage) {
+      return {
+        isComment: false,
+        id,
+      };
+    }
+
+    return {
+      isComment: true,
+      id,
+      parentMessage: {
+        id: parentId,
+        payload: parentMessage.getPayload(),
+        author: parentMessage.getAuthor(),
+      },
+    };
+  }
 };
 
 export default loadMoreThread;