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

Bug: twpowertools:230
Change-Id: I6729546ec4e84f4ef2e801a3c479b63c351008f9
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;
+  }
+}