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/contentScripts/communityConsole/flattenThreads/flattenThreads.js b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
index e8585d0..557a533 100644
--- a/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
+++ b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
@@ -3,7 +3,7 @@
 export const kReplyPayloadSelector =
     '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) .scTailwindThreadPostcontentroot html-blob';
 export const kReplyActionButtonsSelector =
-  '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) sc-tailwind-thread-message-message-actions';
+    '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) sc-tailwind-thread-message-message-actions';
 export const kAdditionalInfoSelector = '.ck-indent-9996300035194';
 export const kMatchingSelectors = [
   kReplyPayloadSelector,
@@ -66,7 +66,7 @@
         node.closest('.scTailwindThreadMessageMessagecardcontent')
             .querySelector('.scTailwindThreadMessageMessagecardbody html-blob');
     const extraInfo = this.getExtraInfo(root);
-    if (!extraInfo) return;
+    if (!extraInfo || !extraInfo.canComment) return;
 
     this.injectReplyBtn(node, extraInfo);
   }
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;
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
index b515191..2eac64d 100644
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -1,6 +1,10 @@
 import {kAdditionalInfoClass} from '../../contentScripts/communityConsole/flattenThreads/flattenThreads.js';
 import GapModel from '../../models/Gap.js';
 import MessageModel from '../../models/Message.js';
+import StartupDataModel from '../../models/StartupData.js';
+import ThreadModel from '../../models/Thread.js';
+
+const currentUser = StartupDataModel.buildFromCCDOM().getCurrentUserModel();
 
 const flattenThread = {
   urlRegex: /api\/ViewThread/i,
@@ -13,9 +17,10 @@
   async interceptor(_request, response) {
     if (!response[1]?.[40]) return response;
 
+    const thread = new ThreadModel(response[1]);
+
     // Do the actual flattening
-    const originalMogs =
-        MessageModel.mapToMessageOrGapModels(response[1][40] ?? []);
+    const originalMogs = thread.getMessageOrGapModels();
     let extraMogs = [];
     originalMogs.forEach(mog => {
       if (mog instanceof GapModel) return;
@@ -56,25 +61,28 @@
       return diff > 0 ? 1 : diff < 0 ? -1 : 0;
     });
 
-    response[1][40] = mogs.map(mog => mog.toRawMessageOrGap());
+    thread.setRawCommentsAndGaps(mogs.map(mog => mog.toRawMessageOrGap()));
 
     // Set last_message to the last message after sorting
-    if (response[1]?.[17]?.[3])
-      response[1][17][3] = response[1][40].slice(-1)?.[1];
+    thread.setLastMessage(thread.getRawCommentsAndGaps().slice(-1)?.[1]);
 
     // Set num_messages to the updated value, since we've flattened the replies.
-    response[1][8] = response[1][40].length;
+    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,
       };
     }
 
@@ -96,8 +104,9 @@
         payload: prevMessage?.getPayload(),
         author: prevMessage?.getAuthor(),
       },
+      canComment,
     };
-  }
+  },
 };
 
 export default flattenThread;
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
index f259fe4..2ddf37d 100644
--- a/src/xhrInterceptor/responseModifiers/loadMoreThread.js
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
@@ -17,17 +17,20 @@
   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) {
+    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 = MessageModel.mapToMessageOrGapModels(response[1]?.[40] ?? []);
-    response[1][40] = await this.loadGaps(forumId, threadId, mogs, 0);
+    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) {
@@ -94,7 +97,8 @@
                },
                /* authenticated = */ true, authuser)
         .then(res => {
-          return MessageModel.mapToMessageOrGapModels(res[1]?.[40] ?? []);
+          const thread = new ThreadModel(res[1]);
+          return thread.getMessageOrGapModels();
         });
   }
 };