Flatten threads: don't show reply button when appropriate

This CL also refactors the response interceptors to use the new
ThreadModel.

Fixed: twpowertools:160
Change-Id: I859e0fa1b8f5f4057bd66af3d167e4b21c6d12ed
diff --git a/src/models/Gap.js b/src/models/Gap.js
index 59e4882..8c380b2 100644
--- a/src/models/Gap.js
+++ b/src/models/Gap.js
@@ -1,6 +1,9 @@
+import ThreadModel from './Thread.js';
+
 export default class GapModel {
-  constructor(data) {
+  constructor(data, thread) {
     this.data = data ?? {};
+    this.thread = thread ?? new ThreadModel();
   }
 
   getCount() {
diff --git a/src/models/Message.js b/src/models/Message.js
index 7aae392..05dc795 100644
--- a/src/models/Message.js
+++ b/src/models/Message.js
@@ -1,9 +1,11 @@
+import ItemMetadataState from './enums/ItemMetadataState.js';
 import GapModel from './Gap.js';
 import ThreadModel from './Thread.js';
 
 export default class MessageModel {
-  constructor(data) {
+  constructor(data, thread) {
     this.data = data ?? {};
+    this.thread = thread ?? new ThreadModel();
     this.commentsAndGaps = null;
   }
 
@@ -21,10 +23,17 @@
     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 =
-          MessageModel.mapToMessageOrGapModels(this.getRawCommentsAndGaps());
+      this.commentsAndGaps = this.#getMessageOrGapModels();
     return this.commentsAndGaps;
   }
 
@@ -59,6 +68,24 @@
     delete this.data[1][37];
   }
 
+  isDeleted() {
+    return this.data[5]?.[3] ?? null;
+  }
+
+  getState() {
+    return this.data[5]?.[1] ?? 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;
   }
@@ -67,16 +94,31 @@
     return {1: this.data};
   }
 
-  static mapToMessageOrGapModels(rawArray) {
-    return rawArray.filter(mog => mog !== undefined).map(mog => {
-      if (mog[1]) return new MessageModel(mog[1]);
-      if (mog[2]) return new GapModel(mog[2]);
-    });
-  }
-
   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/StartupData.js b/src/models/StartupData.js
new file mode 100644
index 0000000..047c154
--- /dev/null
+++ b/src/models/StartupData.js
@@ -0,0 +1,41 @@
+import UserModel from './User.js';
+
+export default class StartupDataModel {
+  constructor(data) {
+    this.data = data ?? {};
+  }
+
+  static buildFromCCDOM() {
+    const startupData =
+        document.querySelector('html')?.getAttribute?.('data-startup');
+    if (!startupData) {
+      console.warn('Haven\'t found CC startup data.');
+      return null;
+    }
+
+    let startup;
+    try {
+      startup = JSON.parse(startupData);
+    } catch (error) {
+      console.warn('Haven\'t been able to parse CC startup data.');
+    }
+
+    return new StartupDataModel(startup);
+  }
+
+  getRawUser() {
+    return this.data[1]?.[1] ?? null;
+  }
+
+  getAuthUser() {
+    return this.data[2]?.[1] ?? '0';
+  }
+
+  getRawForumsInfo() {
+    return this.data[1]?.[2] ?? null;
+  }
+
+  getCurrentUserModel() {
+    return new UserModel(this.getRawUser(), this);
+  }
+}
diff --git a/src/models/Thread.js b/src/models/Thread.js
index e02c0a5..e87f541 100644
--- a/src/models/Thread.js
+++ b/src/models/Thread.js
@@ -2,6 +2,60 @@
 import MessageModel from './Message.js';
 
 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;
+  }
+
   /**
    * 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.
diff --git a/src/models/User.js b/src/models/User.js
new file mode 100644
index 0000000..127634a
--- /dev/null
+++ b/src/models/User.js
@@ -0,0 +1,58 @@
+import {kUserRoleEnum, kUserRoleRank} from './enums/UserRole.js';
+import StartupData from './StartupData.js';
+
+export default class UserModel {
+  constructor(data, startupData) {
+    this.data = data ?? {};
+    this.startupData = startupData ?? new StartupData();
+  }
+
+  getRawAccountAbuse() {
+    return this.data[8]?.[1] ?? null;
+  }
+
+  hasAccountAbuse() {
+    return this.getRawAccountAbuse() !== null;
+  }
+
+  isAccountDisabled() {
+    return this.hasAccountAbuse();
+  }
+
+  getRole(forumId) {
+    const forumsInfo = this.startupData.getRawForumsInfo() ?? [];
+    for (const f of forumsInfo) {
+      const itForumId = f[1] ?? f[2]?.[1]?.[1];
+      if (itForumId == forumId) {
+        return f[3]?.[1]?.[3] ?? kUserRoleEnum.ROLE_USER;
+      }
+    }
+    return kUserRoleEnum.ROLE_USER;
+  }
+
+  #isRoleAtLeast(a, b) {
+    const aRank = kUserRoleRank[a] ?? 0;
+    const bRank = kUserRoleRank[b] ?? 0;
+    return aRank >= bRank;
+  }
+
+  getHighestRole() {
+    const forumsInfo = this.startupData.getRawForumsInfo() ?? [];
+    const roles = forumsInfo.map(f => {
+      return f[3]?.[1]?.[3] ?? kUserRoleEnum.ROLE_USER;
+    });
+    return roles.reduce((prev, current) => {
+      return this.#isRoleAtLeast(current, prev) ? current : prev;
+    });
+  }
+
+  isAtLeastCommunityManager(forumId = null) {
+    const role = forumId ? this.getRole(forumId) : this.getHighestRole();
+    return this.#isRoleAtLeast(role, kUserRoleEnum.ROLE_COMMUNITY_MANAGER);
+  }
+
+  isAtLeastSilverRole(forumId = null) {
+    const role = forumId ? this.getRole(forumId) : this.getHighestRole();
+    return this.#isRoleAtLeast(role, kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_2);
+  }
+}
diff --git a/src/models/enums/ItemMetadataState.js b/src/models/enums/ItemMetadataState.js
new file mode 100644
index 0000000..3331c08
--- /dev/null
+++ b/src/models/enums/ItemMetadataState.js
@@ -0,0 +1,24 @@
+const kItemMetadataStateEnum = {
+  UNDEFINED: 0,
+  PUBLISHED: 1,
+  DRAFT: 2,
+  AUTOMATED_ABUSE_TAKE_DOWN_HIDE: 3,
+  AUTOMATED_ABUSE_TAKE_DOWN_DELETE: 4,
+  AUTOMATED_ABUSE_REINSTATE: 13,
+  AUTOMATED_OFF_TOPIC_HIDE: 10,
+  AUTOMATED_FLAGGED_PENDING_MANUAL_REVIEW: 14,
+  USER_FLAGGED_PENDING_MANUAL_REVIEW: 5,
+  OWNER_DELETED: 6,
+  MANUAL_TAKE_DOWN_HIDE: 7,
+  MANUAL_PROFILE_TAKE_DOWN_SUSPEND: 17,
+  MANUAL_TAKE_DOWN_DELETE: 8,
+  REINSTATE_PROFILE_TAKEDOWN: 18,
+  REINSTATE_ABUSE_TAKEDOWN: 9,
+  CLEAR_OFF_TOPIC: 11,
+  CONFIRM_OFF_TOPIC: 12,
+  GOOGLER_OFF_TOPIC_HIDE: 15,
+  EXPERT_FLAGGED_PENDING_MANUAL_REVIEW: 16,
+  AWAITING_CLASSIFICATION: 19,
+};
+
+export default kItemMetadataStateEnum;
diff --git a/src/models/enums/UserRole.js b/src/models/enums/UserRole.js
new file mode 100644
index 0000000..287f194
--- /dev/null
+++ b/src/models/enums/UserRole.js
@@ -0,0 +1,25 @@
+export const kUserRoleEnum = {
+  ROLE_USER: 0,
+  ROLE_PRODUCT_EXPERT_LEVEL_1: 1,
+  ROLE_PRODUCT_EXPERT_LEVEL_2: 2,
+  ROLE_PRODUCT_EXPERT_LEVEL_3: 3,
+  ROLE_PRODUCT_EXPERT_LEVEL_4: 4,
+  ROLE_PRODUCT_EXPERT_LEVEL_5: 5,
+  ROLE_COMMUNITY_MANAGER: 10,
+  ROLE_COMMUNITY_SPECIALIST: 20,
+  ROLE_GOOGLE_EMPLOYEE: 100,
+  ROLE_ALUMNUS: 30,
+};
+export default kUserRoleEnum;
+
+export let kUserRoleRank = {};
+kUserRoleRank[kUserRoleEnum.ROLE_USER] = 0;
+kUserRoleRank[kUserRoleEnum.ROLE_ALUMNUS] = 1;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_1] = 2;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_2] = 3;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_3] = 4;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_4] = 5;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_5] = 6;
+kUserRoleRank[kUserRoleEnum.ROLE_COMMUNITY_SPECIALIST] = 7;
+kUserRoleRank[kUserRoleEnum.ROLE_COMMUNITY_MANAGER] = 8;
+kUserRoleRank[kUserRoleEnum.ROLE_GOOGLE_EMPLOYEE] = 9;