Add flattenthreads experiment

This experiment allows users to flatten the replies in threads, so they
are shown linearly in a chronological way instead of nested.

When the option is enabled, a switch is added to the thread page which
lets the user switch between flattening replies and not flattening them.

Some UI is still missing (see the design document[1]).

[1]: https://docs.google.com/document/d/1P-HanTHxaOFF_FHh0uSv0GIhG1dxWTJTGoT6VPjjvY0/edit

Bug: twpowertools:153
Change-Id: I43f94442cadc12b752700f0e8d974522be621d3e
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
new file mode 100644
index 0000000..65eb42c
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -0,0 +1,40 @@
+import GapModel from '../../models/Gap.js';
+import MessageModel from '../../models/Message.js';
+
+const loadMoreThread = {
+  urlRegex: /api\/ViewThread/i,
+  featureGated: true,
+  features: ['flattenthreads', 'flattenthreads_switch_enabled'],
+  isEnabled(options) {
+    return options['flattenthreads'] &&
+        options['flattenthreads_switch_enabled'];
+  },
+  async interceptor(_request, response) {
+    if (!response[1]?.[40]) return response;
+
+    const originalMogs =
+        MessageModel.mapToMessageOrGapModels(response[1][40] ?? []);
+    let extraMogs = [];
+    originalMogs.forEach(mog => {
+      if (mog instanceof GapModel) return;
+      const cogs = mog.getCommentsAndGaps();
+      extraMogs = extraMogs.concat(cogs);
+      mog.clearCommentsAndGaps();
+    });
+    const mogs = originalMogs.concat(extraMogs);
+    mogs.sort((a, b) => {
+      const c = a instanceof MessageModel ? a.getCreatedMicroseconds() :
+                                            a.getStartTimestamp();
+      const d = b instanceof MessageModel ? b.getCreatedMicroseconds() :
+                                            b.getStartTimestamp();
+      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;
+  },
+};
+
+export default loadMoreThread;
diff --git a/src/xhrInterceptor/responseModifiers/index.js b/src/xhrInterceptor/responseModifiers/index.js
index 6a4573a..2d0b7ce 100644
--- a/src/xhrInterceptor/responseModifiers/index.js
+++ b/src/xhrInterceptor/responseModifiers/index.js
@@ -1,10 +1,12 @@
 import MWOptionsWatcherClient from '../../common/mainWorldOptionsWatcher/Client.js';
 import {convertJSONToResponse, getResponseJSON} from '../utils.js';
 
-import demo from './demo.js';
+import loadMoreThread from './loadMoreThread.js';
+import flattenThread from './flattenThread.js';
 
 export const responseModifiers = [
-  demo,
+  loadMoreThread,
+  flattenThread,
 ];
 
 // Content script target
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
new file mode 100644
index 0000000..f8da127
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
@@ -0,0 +1,100 @@
+import {CCApi} from '../../common/api.js';
+import {getAuthUser} from '../../common/communityConsoleUtils.js';
+import GapModel from '../../models/Gap.js';
+import MessageModel from '../../models/Message.js';
+import ThreadModel from '../../models/Thread.js';
+
+const authuser = getAuthUser();
+
+const loadMoreThread = {
+  urlRegex: /api\/ViewThread/i,
+  featureGated: true,
+  features: ['flattenthreads', 'flattenthreads_switch_enabled'],
+  isEnabled(options) {
+    return options['flattenthreads'] &&
+        options['flattenthreads_switch_enabled'];
+  },
+  async interceptor(request, response) {
+    if (!response[1]?.[40]) return response;
+
+    const forumId = response[1]?.[2]?.[1]?.[3];
+    const threadId = response[1]?.[2]?.[1]?.[1];
+    if (!forumId || !threadId) {
+      console.error(
+          '[loadMoreThread] Couldn\'t find forum id and thread id for:',
+          request.$TWPTRequestUrl);
+      return response;
+    }
+
+    const mogs = MessageModel.mapToMessageOrGapModels(response[1]?.[40] ?? []);
+    response[1][40] = await this.loadGaps(forumId, threadId, mogs, 0);
+    return response;
+  },
+  loadGaps(forumId, threadId, mogs, it) {
+    if (it >= 10) {
+      return Promise.reject(new Error(
+          'loadGaps has been called for more than 10 times, ' +
+          'which means we\'ve entered an infinite loop.'));
+    }
+
+    const messageOrGapPromises = [];
+    messageOrGapPromises.push(Promise.resolve(mogs));
+    for (const mog of mogs) {
+      if (mog instanceof GapModel) {
+        messageOrGapPromises.push(this.loadGap(forumId, threadId, mog));
+      }
+      if (mog instanceof MessageModel) {
+        mog.getCommentsAndGaps().forEach(cog => {
+          if (cog instanceof GapModel) {
+            messageOrGapPromises.push(this.loadGap(forumId, threadId, cog));
+          }
+        });
+      }
+    }
+
+    return Promise.all(messageOrGapPromises).then(res => {
+      // #!if !production
+      console.time('mergeMessages');
+      // #!endif
+      const mogs = ThreadModel.mergeMessageOrGapsMultiarray(res);
+      // #!if !production
+      console.timeEnd('mergeMessages');
+      // #!endif
+      if (mogs.some(mog => {
+            return mog instanceof GapModel ||
+                mog.getCommentsAndGaps().some(cog => cog instanceof GapModel);
+          })) {
+        return this.loadGaps(forumId, threadId, mogs, it + 1);
+      }
+      return mogs.map(message => message.toRawMessageOrGap());
+    });
+  },
+  loadGap(forumId, threadId, gap) {
+    return CCApi(
+               'ViewThread', {
+                 1: forumId,
+                 2: threadId,
+                 3: {
+                   // options
+                   1: {
+                     // pagination
+                     2: gap.getCount(),  // maxNum
+                     7: {
+                       // targetRange
+                       1: gap.getStartMicroseconds(),  // startMicroseconds
+                       2: gap.getEndMicroseconds(),    // endMicroseconds
+                       3: gap.getParentId(),           // parentId
+                     },
+                   },
+                   5: true,   // withUserProfile
+                   10: true,  // withPromotedMessages
+                 },
+               },
+               /* authenticated = */ true, authuser)
+        .then(res => {
+          return MessageModel.mapToMessageOrGapModels(res[1]?.[40] ?? []);
+        });
+  }
+};
+
+export default loadMoreThread;