refactor(response-modifier): rewrite modifiers with Typescript

Bug: twpowertools:230
Change-Id: Ibedccb24445098aae87fdbca94b0868bdcfccd41
diff --git a/src/xhrInterceptor/responseModifiers/createMessageRemoveParentRef.js b/src/xhrInterceptor/responseModifiers/createMessageRemoveParentRef.ts
similarity index 76%
rename from src/xhrInterceptor/responseModifiers/createMessageRemoveParentRef.js
rename to src/xhrInterceptor/responseModifiers/createMessageRemoveParentRef.ts
index a127975..5fa59b5 100644
--- a/src/xhrInterceptor/responseModifiers/createMessageRemoveParentRef.js
+++ b/src/xhrInterceptor/responseModifiers/createMessageRemoveParentRef.ts
@@ -1,4 +1,6 @@
-const createMessageRemoveParentRef = {
+import { Modifier } from "./types";
+
+const createMessageRemoveParentRef: Modifier = {
   urlRegex: /api\/CreateMessage/i,
   featureGated: true,
   features: ['flattenthreads', 'flattenthreads_switch_enabled'],
@@ -6,7 +8,7 @@
     return options['flattenthreads'] &&
         options['flattenthreads_switch_enabled'];
   },
-  async interceptor(_request, response) {
+  async interceptor(response) {
     // Remove parent_message_id value (field 37)
     delete response[37];
     return response;
diff --git a/src/xhrInterceptor/responseModifiers/demo.js b/src/xhrInterceptor/responseModifiers/demo.js
deleted file mode 100644
index 5839207..0000000
--- a/src/xhrInterceptor/responseModifiers/demo.js
+++ /dev/null
@@ -1,18 +0,0 @@
-export default {
-  urlRegex: /api\/ViewForum/i,
-  featureGated: true,
-  features: ['demo1', 'demo2'],
-  isEnabled(features) {
-    return features['demo1'] || features['demo2'];
-  },
-  interceptor(_request, _response) {
-    return Promise.resolve({
-      1: {
-        2: [],
-        4: 0,
-        6: {},
-        7: {},
-      },
-    });
-  },
-};
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
deleted file mode 100644
index 6923178..0000000
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ /dev/null
@@ -1,112 +0,0 @@
-import {kAdditionalInfoClass} from '../../features/flattenThreads/core/flattenThreads.js';
-import GapModel from '../../models/Gap.js';
-import MessageModel from '../../models/Message';
-import StartupDataModel from '../../models/StartupData.js';
-import ThreadModel from '../../models/Thread';
-
-const currentUser = StartupDataModel.buildFromCCDOM().getCurrentUserModel();
-
-const flattenThread = {
-  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 thread = new ThreadModel(response[1]);
-
-    // Do the actual flattening
-    const originalMogs = thread.getMessageOrGapModels();
-    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);
-
-    // Add some message data to the payload so the extension can show the parent
-    // comment/reply in the case of comments.
-    let prevReplyId;
-    let prevReplyParentId;
-    mogs.forEach(m => {
-      const info = this.getAdditionalInformation(
-          m, mogs, prevReplyId, prevReplyParentId);
-      prevReplyId = m.getId();
-      prevReplyParentId = info.parentId;
-
-      const extraInfoEl = document.createElement('code');
-      extraInfoEl.textContent = JSON.stringify(info);
-      extraInfoEl.setAttribute('style', 'display: none');
-      extraInfoEl.classList.add(kAdditionalInfoClass);
-      m.newPayload = m.getPayload() + extraInfoEl.outerHTML;
-    });
-    mogs.forEach(m => m.setPayload(m.newPayload));
-
-    // Clear parent_message_id fields
-    mogs.forEach(m => m.clearParentMessageId());
-
-    // Sort the messages by date
-    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;
-    });
-
-    thread.setRawCommentsAndGaps(mogs.map(mog => mog.toRawMessageOrGap()));
-
-    // Set last_message to the last message after sorting
-    thread.setLastMessage(thread.getRawCommentsAndGaps().slice(-1)?.[1]);
-
-    // Set num_messages to the updated value, since we've flattened the replies.
-    thread.setNumMessages(thread.getRawCommentsAndGaps().length);
-
-    response[1] = thread.toRawThread();
-    return response;
-  },
-  getAdditionalInformation(message, mogs, prevReplyId, prevReplyParentId) {
-    const id = message.getId();
-    const parentId = message.getParentMessageId();
-    const authorName = message.getAuthor()?.[1]?.[1];
-    const canComment = message.canComment(currentUser);
-    if (!parentId) {
-      return {
-        isComment: false,
-        id,
-        authorName,
-        canComment,
-      };
-    }
-
-    let prevId;
-    if (parentId == prevReplyParentId && prevReplyParentId)
-      prevId = prevReplyId;
-    else
-      prevId = parentId;
-
-    const prevMessage = prevId ? mogs.find(m => m.getId() == prevId) : null;
-
-    return {
-      isComment: true,
-      id,
-      authorName,
-      parentId,
-      prevMessage: {
-        id: prevId,
-        payload: prevMessage?.getPayload(),
-        author: prevMessage?.getAuthor(),
-      },
-      canComment,
-    };
-  },
-};
-
-export default flattenThread;
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.ts b/src/xhrInterceptor/responseModifiers/flattenThread.ts
new file mode 100644
index 0000000..2cd1771
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.ts
@@ -0,0 +1,163 @@
+import { ProtobufNumber, ProtobufObject } from '../../common/protojs.types';
+import { kAdditionalInfoClass } from '../../features/flattenThreads/core/flattenThreads.js';
+import GapModel from '../../models/Gap';
+import MessageModel from '../../models/Message';
+import StartupDataModel from '../../models/StartupData';
+import ThreadModel from '../../models/Thread';
+import { Modifier } from './types';
+
+const currentUser = StartupDataModel.buildFromCCDOM().getCurrentUserModel();
+
+const flattenThread: Modifier = {
+  urlRegex: /api\/ViewThread/i,
+  featureGated: true,
+  features: ['flattenthreads', 'flattenthreads_switch_enabled'],
+  isEnabled(options) {
+    return (
+      options['flattenthreads'] && options['flattenthreads_switch_enabled']
+    );
+  },
+  async interceptor(response) {
+    if (!response[1]?.[40]) return response;
+
+    const thread = new ThreadModel(response[1]);
+
+    // Do the actual flattening
+    const originalMogs = thread.getMessageOrGapModels();
+    let extraMogs: Array<MessageModel | GapModel> = [];
+    originalMogs.forEach((mog) => {
+      if (mog instanceof GapModel) return;
+      const cogs = mog.getCommentsAndGaps();
+      extraMogs = extraMogs.concat(cogs);
+      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.
+    const newPayloads: Record<string, string> = {};
+    let prevReplyId: ProtobufNumber | undefined = undefined;
+    let prevReplyParentId: ProtobufNumber | undefined = undefined;
+    mogs.forEach((m) => {
+      if (m instanceof GapModel) return;
+
+      const info = getAdditionalInformation(
+        m,
+        mogs,
+        prevReplyId,
+        prevReplyParentId,
+      );
+      prevReplyId = m.getId();
+      prevReplyParentId = info.isComment ? info.parentId : undefined;
+
+      const extraInfoEl = document.createElement('code');
+      extraInfoEl.textContent = JSON.stringify(info);
+      extraInfoEl.setAttribute('style', 'display: none');
+      extraInfoEl.classList.add(kAdditionalInfoClass);
+      newPayloads[m.getId()] = m.getPayload() + extraInfoEl.outerHTML;
+    });
+    mogs.forEach(
+      (m) => m instanceof MessageModel && m.setPayload(newPayloads[m.getId()]),
+    );
+
+    // Clear parent_message_id fields
+    mogs.forEach((m) => m instanceof MessageModel && m.clearParentMessageId());
+
+    // Sort the messages by date
+    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;
+    });
+
+    thread.setRawCommentsAndGaps(mogs.map((mog) => mog.toRawMessageOrGap()));
+
+    // Set last_message to the last message after sorting
+    thread.setLastMessage(thread.getRawCommentsAndGaps().slice(-1)?.[1]);
+
+    // Set num_messages to the updated value, since we've flattened the replies.
+    thread.setNumMessages(thread.getRawCommentsAndGaps().length);
+
+    response[1] = thread.toRawThread();
+    return response;
+  },
+};
+
+interface BaseAdditionalInformation {
+  id: ProtobufNumber;
+  authorName: string;
+  canComment: boolean;
+}
+
+type CommentAdditionalInformation =
+  | {
+      isComment: false;
+    }
+  | {
+      isComment: true;
+      parentId: ProtobufNumber;
+      prevMessage: {
+        id: ProtobufNumber;
+        payload: string;
+        author: ProtobufObject | null;
+      };
+    };
+
+export type AdditionalInformation = BaseAdditionalInformation &
+  CommentAdditionalInformation;
+
+function getAdditionalInformation(
+  message: MessageModel,
+  mogs: Array<MessageModel | GapModel>,
+  prevReplyId: ProtobufNumber | undefined,
+  prevReplyParentId: ProtobufNumber | undefined,
+): AdditionalInformation {
+  const id = message.getId();
+  const parentId = message.getParentMessageId();
+  const authorName = message.getAuthor()?.[1]?.[1];
+  const canComment = message.canComment(currentUser);
+  if (!parentId) {
+    return {
+      isComment: false,
+      id,
+      authorName,
+      canComment,
+    };
+  }
+
+  let prevId;
+  if (parentId == prevReplyParentId && prevReplyParentId) {
+    prevId = prevReplyId;
+  } else {
+    prevId = parentId;
+  }
+
+  const prevMessage = prevId
+    ? mogs.find(
+        (m): m is MessageModel =>
+          m instanceof MessageModel && m.getId() == prevId,
+      )
+    : null;
+
+  return {
+    isComment: true,
+    id,
+    authorName,
+    parentId,
+    prevMessage: {
+      id: prevId,
+      payload: prevMessage?.getPayload(),
+      author: prevMessage?.getAuthor(),
+    },
+    canComment,
+  };
+}
+
+export default flattenThread;
diff --git a/src/xhrInterceptor/responseModifiers/index.js b/src/xhrInterceptor/responseModifiers/index.js
index 057960f..b822153 100644
--- a/src/xhrInterceptor/responseModifiers/index.js
+++ b/src/xhrInterceptor/responseModifiers/index.js
@@ -1,9 +1,9 @@
 import MWOptionsWatcherClient from '../../common/mainWorldOptionsWatcher/Client.js';
 import {convertJSONToResponse, convertJSONToResponseText, getResponseJSON} from '../utils.js';
 
-import createMessageRemoveParentRef from './createMessageRemoveParentRef.js';
-import flattenThread from './flattenThread.js';
-import loadMoreThread from './loadMoreThread.js';
+import createMessageRemoveParentRef from './createMessageRemoveParentRef';
+import flattenThread from './flattenThread';
+import loadMoreThread from './loadMoreThread';
 
 export const responseModifiers = [
   loadMoreThread,
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
deleted file mode 100644
index 2b2fe63..0000000
--- a/src/xhrInterceptor/responseModifiers/loadMoreThread.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import {CCApi} from '../../common/api.js';
-import {getAuthUser} from '../../common/communityConsoleUtils.js';
-import GapModel from '../../models/Gap.js';
-import MessageModel from '../../models/Message';
-import ThreadModel from '../../models/Thread';
-
-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 thread = new ThreadModel(response[1]);
-
-    if (!thread.getForumId() || !thread.getId()) {
-      console.error(
-          '[loadMoreThread] Couldn\'t find forum id and thread id for:',
-          request.$TWPTRequestURL);
-      return response;
-    }
-
-    const mogs = thread.getMessageOrGapModels();
-    thread.setRawCommentsAndGaps(
-        await this.loadGaps(thread.getForumId(), thread.getId(), mogs, 0));
-
-    response[1] = thread.toRawThread();
-    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.filter(mog => mog !== undefined)));
-    mogs.forEach(mog => {
-      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 => {
-          const thread = new ThreadModel(res[1]);
-          return thread.getMessageOrGapModels();
-        });
-  }
-};
-
-export default loadMoreThread;
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.ts b/src/xhrInterceptor/responseModifiers/loadMoreThread.ts
new file mode 100644
index 0000000..06613d5
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.ts
@@ -0,0 +1,132 @@
+import { CCApi } from '../../common/api.js';
+import { getAuthUser } from '../../common/communityConsoleUtils.js';
+import { ProtobufNumber } from '../../common/protojs.types.js';
+import GapModel from '../../models/Gap.js';
+import MessageModel from '../../models/Message';
+import ThreadModel from '../../models/Thread';
+import { Modifier } from './types.js';
+
+const authuser = getAuthUser();
+
+const loadMoreThread: Modifier = {
+  urlRegex: /api\/ViewThread/i,
+  featureGated: true,
+  features: ['flattenthreads', 'flattenthreads_switch_enabled'],
+  isEnabled(options) {
+    return (
+      options['flattenthreads'] && options['flattenthreads_switch_enabled']
+    );
+  },
+  async interceptor(response, url) {
+    if (!response[1]?.[40]) return response;
+
+    const thread = new ThreadModel(response[1]);
+
+    if (!thread.getForumId() || !thread.getId()) {
+      console.error(
+        "[loadMoreThread] Couldn't find forum id and thread id for:",
+        url,
+      );
+      return response;
+    }
+
+    const mogs = thread.getMessageOrGapModels();
+    thread.setRawCommentsAndGaps(
+      await loadGaps(thread.getForumId(), thread.getId(), mogs, 0),
+    );
+
+    response[1] = thread.toRawThread();
+    return response;
+  },
+};
+
+function loadGaps(
+  forumId: ProtobufNumber,
+  threadId: ProtobufNumber,
+  mogs: Array<MessageModel | GapModel>,
+  it: number,
+): Promise<Array<MessageModel | GapModel>> {
+  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.filter((mog) => mog !== undefined)),
+  );
+  mogs.forEach((mog) => {
+    if (mog instanceof GapModel) {
+      messageOrGapPromises.push(loadGap(forumId, threadId, mog));
+    }
+    if (mog instanceof MessageModel) {
+      mog.getCommentsAndGaps().forEach((cog) => {
+        if (cog instanceof GapModel) {
+          messageOrGapPromises.push(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 loadGaps(forumId, threadId, mogs, it + 1);
+    }
+    return mogs.map((message) => message.toRawMessageOrGap());
+  });
+}
+
+async function loadGap(
+  forumId: ProtobufNumber,
+  threadId: ProtobufNumber,
+  gap: GapModel,
+): Promise<Array<MessageModel | GapModel>> {
+  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) => {
+    const thread = new ThreadModel(res[1]);
+    return thread.getMessageOrGapModels();
+  });
+}
+
+export default loadMoreThread;
diff --git a/src/xhrInterceptor/responseModifiers/types.ts b/src/xhrInterceptor/responseModifiers/types.ts
new file mode 100644
index 0000000..d67b9e3
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/types.ts
@@ -0,0 +1,13 @@
+import {
+  OptionCodename,
+  OptionsValues,
+} from '../../common/options/optionsPrototype';
+import { ProtobufObject } from '../../common/protojs.types';
+
+export interface Modifier {
+  urlRegex: RegExp;
+  featureGated: Boolean;
+  features: OptionCodename[];
+  isEnabled(options: OptionsValues): Boolean;
+  interceptor(response: ProtobufObject, url: string): Promise<ProtobufObject>;
+}