refactor: refactor thread, message, and gap models to Typescript

Bug: twpowertools:230
Change-Id: I6729546ec4e84f4ef2e801a3c479b63c351008f9
diff --git a/src/common/protojs.types.ts b/src/common/protojs.types.ts
new file mode 100644
index 0000000..0ac6164
--- /dev/null
+++ b/src/common/protojs.types.ts
@@ -0,0 +1,23 @@
+export type ProtobufNumber = string | number;
+
+/**
+ * Protobuf message encoded in the regular format returned by default by the
+ * Tailwind API.
+ *
+ * @example
+ * ```ts
+ * // Numbers are coerced to strings.
+ * {
+ *   1: {
+ *     2: 'foo',
+ *     3: {
+ *       42: 'bar',
+ *       43: false,
+ *       44: null,
+ *     },
+ *   },
+ *   2: [true, false, true],
+ * },
+ * ```
+ */
+export type ProtobufObject = any;
diff --git a/src/features/extraInfo/core/infoHandlers/thread.js b/src/features/extraInfo/core/infoHandlers/thread.js
index 6c8fb73..d783fc0 100644
--- a/src/features/extraInfo/core/infoHandlers/thread.js
+++ b/src/features/extraInfo/core/infoHandlers/thread.js
@@ -1,7 +1,7 @@
 import {waitFor} from 'poll-until-promise';
 
 import {parseUrl} from '../../../../common/commonUtils.js';
-import ThreadModel from '../../../../models/Thread.js';
+import ThreadModel from '../../../../models/Thread';
 import {kViewThreadResponse} from '../consts.js';
 import MessageExtraInfoService from '../services/message.js';
 
diff --git a/src/features/extraInfo/core/injections/baseThreadMessage.js b/src/features/extraInfo/core/injections/baseThreadMessage.js
index d0cd162..568f2db 100644
--- a/src/features/extraInfo/core/injections/baseThreadMessage.js
+++ b/src/features/extraInfo/core/injections/baseThreadMessage.js
@@ -1,7 +1,6 @@
 import {MDCTooltip} from '@material/tooltip';
 
 import {shouldImplement} from '../../../../common/commonUtils.js';
-import ThreadModel from '../../../../models/Thread.js';
 import MessageExtraInfoService from '../services/message.js';
 
 import BaseExtraInfoInjection from './base.js';
diff --git a/src/features/extraInfo/core/injections/threadQuestion.js b/src/features/extraInfo/core/injections/threadQuestion.js
index d797ee1..0aa397f 100644
--- a/src/features/extraInfo/core/injections/threadQuestion.js
+++ b/src/features/extraInfo/core/injections/threadQuestion.js
@@ -1,6 +1,5 @@
 import {MDCTooltip} from '@material/tooltip';
 
-import ThreadModel from '../../../../models/Thread.js';
 import ThreadExtraInfoService from '../services/thread.js';
 
 import BaseExtraInfoInjection from './base.js';
diff --git a/src/models/Gap.js b/src/models/Gap.js
index 8c380b2..f5be276 100644
--- a/src/models/Gap.js
+++ b/src/models/Gap.js
@@ -1,4 +1,4 @@
-import ThreadModel from './Thread.js';
+import ThreadModel from './Thread.ts';
 
 export default class GapModel {
   constructor(data, thread) {
diff --git a/src/models/Message.js b/src/models/Message.js
deleted file mode 100644
index 0e9d6fb..0000000
--- a/src/models/Message.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import ItemMetadataState from './enums/ItemMetadataState.js';
-import GapModel from './Gap.js';
-import ThreadModel from './Thread.js';
-
-export default class MessageModel {
-  constructor(data, thread) {
-    this.data = data ?? {};
-    this.thread = thread ?? new ThreadModel();
-    this.commentsAndGaps = null;
-  }
-
-  getCreatedTimestamp() {
-    return this.data[1]?.[1]?.[2] ?? null;
-  }
-
-  getCreatedMicroseconds() {
-    let a = this.getCreatedTimestamp();
-    if (a === null) a = '0';
-    return BigInt(a);
-  }
-
-  getRawCommentsAndGaps() {
-    return this.data[12] ?? [];
-  }
-
-  #getMessageOrGapModels() {
-    const rawMogs = this.getRawCommentsAndGaps();
-    return rawMogs.filter(mog => mog !== undefined).map(mog => {
-      if (mog[1]) return new MessageModel(mog[1], this.thread);
-      if (mog[2]) return new GapModel(mog[2], this.thread);
-    });
-  }
-
-  getCommentsAndGaps() {
-    if (this.commentsAndGaps === null)
-      this.commentsAndGaps = this.#getMessageOrGapModels();
-    return this.commentsAndGaps;
-  }
-
-  clearCommentsAndGaps() {
-    this.commentsAndGaps = [];
-    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;
-  }
-
-  clearParentMessageId() {
-    if (!this.data[1]) return;
-    delete this.data[1][37];
-  }
-
-  isDeleted() {
-    return this.data[5]?.[3] ?? null;
-  }
-
-  getState() {
-    return this.data[5]?.[1] ?? null;
-  }
-
-  getEndPendingStateTimestampMicros() {
-    return this.data[1]?.[17] ?? null;
-  }
-
-  isTakenDown() {
-    return [
-      ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_DELETE,
-      ItemMetadataState.MANUAL_PROFILE_TAKE_DOWN_SUSPEND,
-      ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_HIDE,
-      ItemMetadataState.MANUAL_TAKE_DOWN_DELETE,
-      ItemMetadataState.MANUAL_TAKE_DOWN_HIDE,
-    ].includes(this.getState());
-  }
-
-  isComment() {
-    return !!this.getParentMessageId;
-  }
-
-  toRawMessageOrGap() {
-    return {1: this.data};
-  }
-
-  mergeCommentOrGapViews(a) {
-    this.commentsAndGaps = ThreadModel.mergeMessageOrGaps(
-        a.getCommentsAndGaps(), this.getCommentsAndGaps());
-    this.data[12] = this.commentsAndGaps.map(cog => cog.toRawMessageOrGap());
-  }
-
-  /**
-   * The following method is based on logic written by Googlers in the TW
-   * frontend and thus is not included as part of the MIT license.
-   *
-   * Source:
-   * module$exports$google3$customer_support$content$ui$client$tailwind$models$message_model$message_model.MessageModel.prototype.canComment
-   */
-  canComment(currentUser) {
-    if (this.isDeleted()) return false;
-    if (this.isTakenDown()) return false;
-    if (currentUser.isAccountDisabled()) return false;
-    if (this.thread.isLocked() &&
-        !currentUser.isAtLeastCommunityManager(this.thread.getForumId())) {
-      return false;
-    }
-    if (this.thread.isSoftLocked() && !currentUser.isAtLeastSilverRole() &&
-        !this.thread.isAuthoredByUser()) {
-      return false;
-    }
-    return true;
-  }
-}
diff --git a/src/models/Message.ts b/src/models/Message.ts
new file mode 100644
index 0000000..d9edfe6
--- /dev/null
+++ b/src/models/Message.ts
@@ -0,0 +1,151 @@
+import { ProtobufNumber, ProtobufObject } from '../common/protojs.types.js';
+import ItemMetadataState from './enums/ItemMetadataState.js';
+import GapModel from './Gap.js';
+import ThreadModel from './Thread';
+import UserModel from './User.js';
+
+// TODO(https://iavm.xyz/b/twpowertools/231): This class is being used for 2
+// messages in different places. Fix this.
+/**
+ * Model for the `ForumMessage` protobuf message.
+ *
+ * WARNING: it has methods which correspond to the `MessageView` message.
+ */
+export default class MessageModel {
+  private data: ProtobufObject;
+  private thread: ThreadModel;
+  private commentsAndGaps: Array<MessageModel | GapModel> | null;
+
+  constructor(data?: ProtobufObject, thread?: ThreadModel) {
+    this.data = data ?? {};
+    this.thread = thread ?? new ThreadModel();
+    this.commentsAndGaps = null;
+  }
+
+  getCreatedTimestamp() {
+    return (this.data[1]?.[1]?.[2] as ProtobufNumber) ?? null;
+  }
+
+  getCreatedMicroseconds() {
+    let a = this.getCreatedTimestamp();
+    if (a === null) a = '0';
+    return BigInt(a);
+  }
+
+  getRawCommentsAndGaps(): ProtobufObject[] {
+    return this.data[12] ?? [];
+  }
+
+  private getMessageOrGapModels(): Array<MessageModel | GapModel> {
+    const rawMogs = this.getRawCommentsAndGaps();
+    return rawMogs
+      .filter((mog) => mog !== undefined)
+      .map((mog) => {
+        if (mog[1]) return new MessageModel(mog[1], this.thread);
+        if (mog[2]) return new GapModel(mog[2], this.thread);
+        throw new Error('Expected message or gap.');
+      });
+  }
+
+  getCommentsAndGaps(): Array<MessageModel | GapModel> {
+    if (this.commentsAndGaps === null)
+      this.commentsAndGaps = this.getMessageOrGapModels();
+    return this.commentsAndGaps;
+  }
+
+  clearCommentsAndGaps() {
+    this.commentsAndGaps = [];
+    this.data[12] = [];
+  }
+
+  getPayload() {
+    return this.data[1]?.[4] as string ?? null;
+  }
+
+  setPayload(value: string | null) {
+    if (!this.data[1]) this.data[1] = [];
+    this.data[1][4] = value;
+  }
+
+  getId() {
+    return this.data[1]?.[1]?.[1] as ProtobufNumber ?? null;
+  }
+
+  getAuthor(): ProtobufObject | null {
+    return this.data[3] ?? null;
+  }
+
+  getParentMessageId() {
+    return this.data[1]?.[37] as ProtobufNumber ?? null;
+  }
+
+  clearParentMessageId() {
+    if (!this.data[1]) return;
+    delete this.data[1][37];
+  }
+
+  isDeleted() {
+    return this.data[5]?.[3] as boolean ?? null;
+  }
+
+  getState() {
+    return this.data[5]?.[1] as number ?? null;
+  }
+
+  getEndPendingStateTimestampMicros() {
+    return this.data[1]?.[17] as ProtobufNumber ?? null;
+  }
+
+  isTakenDown() {
+    return [
+      ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_DELETE,
+      ItemMetadataState.MANUAL_PROFILE_TAKE_DOWN_SUSPEND,
+      ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_HIDE,
+      ItemMetadataState.MANUAL_TAKE_DOWN_DELETE,
+      ItemMetadataState.MANUAL_TAKE_DOWN_HIDE,
+    ].includes(this.getState());
+  }
+
+  isComment() {
+    return !!this.getParentMessageId;
+  }
+
+  toRawMessageOrGap(): ProtobufObject {
+    return { 1: this.data };
+  }
+
+  mergeCommentOrGapViews(a: MessageModel) {
+    this.commentsAndGaps = ThreadModel.mergeMessageOrGaps(
+      a.getCommentsAndGaps(),
+      this.getCommentsAndGaps(),
+    );
+    this.data[12] = this.commentsAndGaps.map((cog) => cog.toRawMessageOrGap());
+  }
+
+  /**
+   * The following method is based on logic written by Googlers in the TW
+   * frontend and thus is not included as part of the MIT license.
+   *
+   * Source:
+   * module$exports$google3$customer_support$content$ui$client$tailwind$models$message_model$message_model.MessageModel.prototype.canComment
+   */
+  canComment(currentUser: UserModel) {
+    if (this.isDeleted()) return false;
+    if (this.isTakenDown()) return false;
+    if (currentUser.isAccountDisabled()) return false;
+    if (
+      this.thread.isLocked() &&
+      !currentUser.isAtLeastCommunityManager(this.thread.getForumId())
+    ) {
+      return false;
+    }
+    if (
+      this.thread.isSoftLocked() &&
+      !currentUser.isAtLeastSilverRole() &&
+      !this.thread.isAuthoredByUser()
+    ) {
+      return false;
+    }
+    return true;
+  }
+}
diff --git a/src/models/Thread.js b/src/models/Thread.js
deleted file mode 100644
index a613fb7..0000000
--- a/src/models/Thread.js
+++ /dev/null
@@ -1,202 +0,0 @@
-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 ?? {};
-  }
-
-  getId() {
-    return this.data[2]?.[1]?.[1] ?? null;
-  }
-
-  getForumId() {
-    return this.data[2]?.[1]?.[3] ?? null;
-  }
-
-  getRawCommentsAndGaps() {
-    return this.data[40] ?? [];
-  }
-
-  setRawCommentsAndGaps(cogs) {
-    this.data[40] = cogs;
-  }
-
-  getMessageOrGapModels() {
-    const rawMogs = this.getRawCommentsAndGaps();
-    return rawMogs.filter(mog => mog !== undefined).map(mog => {
-      if (mog[1]) return new MessageModel(mog[1], this);
-      if (mog[2]) return new GapModel(mog[2], this);
-    });
-  }
-
-  setLastMessage(message) {
-    if (!this.data[17]) this.data[17] = [];
-    this.data[17][3] = message;
-  }
-
-  setNumMessages(num) {
-    this.data[8] = num;
-  }
-
-  isLocked() {
-    // TODO: When a forum is read-only, this should also return true.
-    return this.data[2]?.[5] == true;
-  }
-
-  isSoftLocked() {
-    return this.data[2]?.[51] == true;
-  }
-
-  isAuthoredByUser() {
-    return this.data[9] == true;
-  }
-
-  toRawThread() {
-    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.
-   */
-  static mergeMessageOrGaps(a, b) {
-    if (a.length == 0 || b.length == 0)
-      return a.length > 0 ? a : b.length > 0 ? b : [];
-
-    let e = [];
-    for (let g = 0, k = 0, m = 0, q = a[g], u = b[k];
-         g < a.length && k < b.length;) {
-      if (q instanceof MessageModel && u instanceof MessageModel) {
-        if (q.getCreatedMicroseconds() === u.getCreatedMicroseconds()) {
-          u.mergeCommentOrGapViews(q);
-        }
-
-        e.push(u);
-
-        if (g === a.length - 1 || k === b.length - 1) {
-          for (; ++g < a.length;) e.push(a[g]);
-          for (; ++k < b.length;) e.push(b[k]);
-          break;
-        }
-
-        q = a[++g];
-        u = b[++k];
-      } else {
-        if (u instanceof GapModel) {
-          let z;
-          for (z = q instanceof MessageModel ? q.getCreatedMicroseconds() :
-                                               q.getEndTimestamp();
-               z < u.getEndTimestamp();) {
-            e.push(q);
-            m += q instanceof GapModel ? q.getCount() : 1;
-            if (g === a.length - 1) break;
-            q = a[++g];
-            z = q instanceof MessageModel ? q.getCreatedMicroseconds() :
-                                            q.getEndTimestamp();
-          }
-          if (q instanceof GapModel && u.getCount() - m > 0 &&
-              z >= u.getEndTimestamp()) {
-            const gm = new GapModel();
-            gm.setCount(u.getCount() - m);
-            gm.setStartMicroseconds('' + q.getStartTimestamp());
-            gm.setEndMicroseconds('' + u.getEndTimestamp());
-            gm.setParentId(u.getParentId());
-            e.push(gm);
-            m = u.getCount() - m;
-          } else {
-            m = 0;
-          }
-          if (k === b.length - 1) break;
-          u = b[++k];
-        }
-        if (q instanceof GapModel) {
-          let z;
-          for (z = u instanceof MessageModel ? u.getCreatedMicroseconds() :
-                                               u.getEndTimestamp();
-               z < q.getEndTimestamp();) {
-            e.push(u);
-            m += u instanceof GapModel ? u.getCount() : 1;
-            if (k === b.length - 1) break;
-            u = b[++k];
-            z = u instanceof MessageModel ? u.getCreatedMicroseconds() :
-                                            u.getEndTimestamp();
-          }
-          if (u instanceof GapModel && q.getCount() - m > 0 &&
-              z >= q.getEndTimestamp()) {
-            const gm = new GapModel();
-            gm.setCount(q.getCount() - m);
-            gm.setStartMicroseconds('' + u.getStartTimestamp());
-            gm.setEndMicroseconds('' + q.getEndTimestamp());
-            gm.setParentId(q.getParentId());
-            e.push(gm);
-            m = q.getCount() - m;
-          } else {
-            m = 0;
-          }
-          if (g === a.length - 1) break;
-          q = a[++g];
-        }
-      }
-    }
-    return e;
-  }
-
-  static mergeMessageOrGapsMultiarray(mogsModels) {
-    if (mogsModels.length < 1) return [];
-    let mergeResult = mogsModels[0];
-    for (let i = 1; i < mogsModels.length; ++i) {
-      mergeResult = ThreadModel.mergeMessageOrGaps(mergeResult, mogsModels[i]);
-    }
-    return mergeResult;
-  }
-}
diff --git a/src/models/Thread.ts b/src/models/Thread.ts
new file mode 100644
index 0000000..bc8f8e7
--- /dev/null
+++ b/src/models/Thread.ts
@@ -0,0 +1,241 @@
+import { ProtobufNumber, ProtobufObject } from '../common/protojs.types.js';
+import GapModel from './Gap.js';
+import MessageModel from './Message';
+
+// Keys of the PromotedMessages protobuf message which contain lists of promoted
+// messages.
+const kPromotedMessagesKeys = [1, 2, 3, 4, 5, 6];
+
+/**
+ * Model for the `ThreadView` protobuf message.
+ */
+export default class ThreadModel {
+  private data: ProtobufObject;
+
+  constructor(data?: ProtobufObject) {
+    this.data = data ?? {};
+  }
+
+  getId() {
+    return (this.data[2]?.[1]?.[1] as ProtobufNumber) ?? null;
+  }
+
+  getForumId() {
+    return (this.data[2]?.[1]?.[3] as ProtobufNumber) ?? null;
+  }
+
+  getRawCommentsAndGaps(): ProtobufObject[] {
+    return (this.data[40] as ProtobufObject[]) ?? [];
+  }
+
+  setRawCommentsAndGaps(cogs: ProtobufObject[] | null) {
+    this.data[40] = cogs;
+  }
+
+  getMessageOrGapModels() {
+    const rawMogs = this.getRawCommentsAndGaps();
+    return rawMogs
+      .filter((mog) => mog !== undefined)
+      .map((mog) => {
+        if (mog[1]) return new MessageModel(mog[1], this);
+        if (mog[2]) return new GapModel(mog[2], this);
+        throw new Error('Expected message or gap.');
+      });
+  }
+
+  setLastMessage(message: ProtobufObject | null) {
+    if (!this.data[17]) this.data[17] = [];
+    this.data[17][3] = message;
+  }
+
+  setNumMessages(num: ProtobufNumber | null) {
+    this.data[8] = num;
+  }
+
+  isLocked() {
+    // TODO: When a forum is read-only, this should also return true.
+    return this.data[2]?.[5] == true;
+  }
+
+  isSoftLocked() {
+    return this.data[2]?.[51] == true;
+  }
+
+  isAuthoredByUser() {
+    return this.data[9] == true;
+  }
+
+  toRawThread(): ProtobufObject {
+    return this.data;
+  }
+
+  getPromotedMessagesList(): MessageModel[] {
+    const promotedMessages: MessageModel[] = [];
+    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(): MessageModel[] {
+    const messages: MessageModel[] = [];
+
+    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.
+   */
+  static mergeMessageOrGaps(
+    a: Array<MessageModel | GapModel>,
+    b: Array<MessageModel | GapModel>,
+  ): Array<MessageModel | GapModel> {
+    if (a.length == 0 || b.length == 0)
+      return a.length > 0 ? a : b.length > 0 ? b : [];
+
+    let e: Array<MessageModel | GapModel> = [];
+    for (
+      let g = 0, k = 0, m = 0, q = a[g], u = b[k];
+      g < a.length && k < b.length;
+
+    ) {
+      if (q instanceof MessageModel && u instanceof MessageModel) {
+        if (q.getCreatedMicroseconds() === u.getCreatedMicroseconds()) {
+          u.mergeCommentOrGapViews(q);
+        }
+
+        e.push(u);
+
+        if (g === a.length - 1 || k === b.length - 1) {
+          for (; ++g < a.length; ) e.push(a[g]);
+          for (; ++k < b.length; ) e.push(b[k]);
+          break;
+        }
+
+        q = a[++g];
+        u = b[++k];
+      } else {
+        if (u instanceof GapModel) {
+          let z: bigint;
+          for (
+            z =
+              q instanceof MessageModel
+                ? q.getCreatedMicroseconds()
+                : q.getEndTimestamp();
+            z < u.getEndTimestamp();
+
+          ) {
+            e.push(q);
+            m += q instanceof GapModel ? q.getCount() : 1;
+            if (g === a.length - 1) break;
+            q = a[++g];
+            z =
+              q instanceof MessageModel
+                ? q.getCreatedMicroseconds()
+                : q.getEndTimestamp();
+          }
+          if (
+            q instanceof GapModel &&
+            u.getCount() - m > 0 &&
+            z >= u.getEndTimestamp()
+          ) {
+            const gm = new GapModel();
+            gm.setCount(u.getCount() - m);
+            gm.setStartMicroseconds('' + q.getStartTimestamp());
+            gm.setEndMicroseconds('' + u.getEndTimestamp());
+            gm.setParentId(u.getParentId());
+            e.push(gm);
+            m = u.getCount() - m;
+          } else {
+            m = 0;
+          }
+          if (k === b.length - 1) break;
+          u = b[++k];
+        }
+        if (q instanceof GapModel) {
+          let z: bigint;
+          for (
+            z =
+              u instanceof MessageModel
+                ? u.getCreatedMicroseconds()
+                : u.getEndTimestamp();
+            z < q.getEndTimestamp();
+
+          ) {
+            e.push(u);
+            m += u instanceof GapModel ? u.getCount() : 1;
+            if (k === b.length - 1) break;
+            u = b[++k];
+            z =
+              u instanceof MessageModel
+                ? u.getCreatedMicroseconds()
+                : u.getEndTimestamp();
+          }
+          if (
+            u instanceof GapModel &&
+            q.getCount() - m > 0 &&
+            z >= q.getEndTimestamp()
+          ) {
+            const gm = new GapModel();
+            gm.setCount(q.getCount() - m);
+            gm.setStartMicroseconds('' + u.getStartTimestamp());
+            gm.setEndMicroseconds('' + q.getEndTimestamp());
+            gm.setParentId(q.getParentId());
+            e.push(gm);
+            m = q.getCount() - m;
+          } else {
+            m = 0;
+          }
+          if (g === a.length - 1) break;
+          q = a[++g];
+        }
+      }
+    }
+    return e;
+  }
+
+  static mergeMessageOrGapsMultiarray(
+    mogsModels: Array<Array<MessageModel | GapModel>>,
+  ) {
+    if (mogsModels.length < 1) return [];
+    let mergeResult = mogsModels[0];
+    for (let i = 1; i < mogsModels.length; ++i) {
+      mergeResult = ThreadModel.mergeMessageOrGaps(mergeResult, mogsModels[i]);
+    }
+    return mergeResult;
+  }
+}
diff --git a/src/redirect/index.js b/src/redirect/index.js
index 551d4d6..09c4b87 100644
--- a/src/redirect/index.js
+++ b/src/redirect/index.js
@@ -1,5 +1,5 @@
 import {parseView} from '../common/TWBasicUtils.js';
-import ThreadModel from '../models/Thread.js';
+import ThreadModel from '../models/Thread';
 
 var CCThreadWithoutMessage = /forum\/[0-9]*\/thread\/[0-9]*$/;
 
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
index 452b1fb..6923178 100644
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -1,8 +1,8 @@
 import {kAdditionalInfoClass} from '../../features/flattenThreads/core/flattenThreads.js';
 import GapModel from '../../models/Gap.js';
-import MessageModel from '../../models/Message.js';
+import MessageModel from '../../models/Message';
 import StartupDataModel from '../../models/StartupData.js';
-import ThreadModel from '../../models/Thread.js';
+import ThreadModel from '../../models/Thread';
 
 const currentUser = StartupDataModel.buildFromCCDOM().getCurrentUserModel();
 
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
index 2ddf37d..2b2fe63 100644
--- a/src/xhrInterceptor/responseModifiers/loadMoreThread.js
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
@@ -1,8 +1,8 @@
 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';
+import MessageModel from '../../models/Message';
+import ThreadModel from '../../models/Thread';
 
 const authuser = getAuthUser();