extraInfo: show extra info in threads

Bug: twpowertools:93
Change-Id: If68561fdf1a4038bfba58d66d5488c1ce76d63bd
diff --git a/src/common/xhrInterceptors.json5 b/src/common/xhrInterceptors.json5
index 126b6fb..2a0ea6b 100644
--- a/src/common/xhrInterceptors.json5
+++ b/src/common/xhrInterceptors.json5
@@ -25,5 +25,10 @@
       urlRegex: "api/ListCannedResponses",
       intercepts: "response",
     },
+    {
+      eventName: "ViewThreadResponse",
+      urlRegex: "api/ViewThread",
+      intercepts: "response",
+    },
   ],
 }
diff --git a/src/contentScripts/communityConsole/extraInfo.js b/src/contentScripts/communityConsole/extraInfo.js
index 266592a..00428b7 100644
--- a/src/contentScripts/communityConsole/extraInfo.js
+++ b/src/contentScripts/communityConsole/extraInfo.js
@@ -1,6 +1,7 @@
 import {MDCTooltip} from '@material/tooltip';
 import {waitFor} from 'poll-until-promise';
 
+import {parseUrl} from '../../common/commonUtils.js';
 import {isOptionEnabled} from '../../common/optionsUtils.js';
 import {createPlainTooltip} from '../../common/tooltip.js';
 
@@ -8,6 +9,7 @@
 
 const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
 const kListCannedResponsesResponse = 'TWPT_ListCannedResponsesResponse';
+const kViewThreadResponse = 'TWPT_ViewThreadResponse';
 
 const kAbuseCategories = [
   ['1', 'Account'],
@@ -141,6 +143,31 @@
   18: 'TERRORISM_SUPPORT',
   56: 'CSAI_WORST_OF_WORST',
 };
+const kItemMetadataState = {
+  0: 'UNDEFINED',
+  1: 'PUBLISHED',
+  2: 'DRAFT',
+  3: 'AUTOMATED_ABUSE_TAKE_DOWN_HIDE',
+  4: 'AUTOMATED_ABUSE_TAKE_DOWN_DELETE',
+  13: 'AUTOMATED_ABUSE_REINSTATE',
+  10: 'AUTOMATED_OFF_TOPIC_HIDE',
+  14: 'AUTOMATED_FLAGGED_PENDING_MANUAL_REVIEW',
+  5: 'USER_FLAGGED_PENDING_MANUAL_REVIEW',
+  6: 'OWNER_DELETED',
+  7: 'MANUAL_TAKE_DOWN_HIDE',
+  17: 'MANUAL_PROFILE_TAKE_DOWN_SUSPEND',
+  8: 'MANUAL_TAKE_DOWN_DELETE',
+  18: 'REINSTATE_PROFILE_TAKEDOWN',
+  9: 'REINSTATE_ABUSE_TAKEDOWN',
+  11: 'CLEAR_OFF_TOPIC',
+  12: 'CONFIRM_OFF_TOPIC',
+  15: 'GOOGLER_OFF_TOPIC_HIDE',
+  16: 'EXPERT_FLAGGED_PENDING_MANUAL_REVIEW',
+};
+const kShadowBlockReason = {
+  0: 'REASON_UNDEFINED',
+  1: 'ULTRON_LOW_QUALITY',
+};
 
 export default class ExtraInfo {
   constructor() {
@@ -154,6 +181,11 @@
       id: -1,
       duplicateNames: new Set(),
     };
+    this.lastThread = {
+      body: {},
+      id: -1,
+      timestamp: 0,
+    };
     this.setUpHandlers();
   }
 
@@ -183,6 +215,15 @@
         duplicateNames,
       };
     });
+    window.addEventListener(kViewThreadResponse, e => {
+      if (e.detail.id < this.lastThread.id) return;
+
+      this.lastThread = {
+        body: e.detail.body,
+        id: e.detail.id,
+        timestamp: Date.now(),
+      };
+    });
   }
 
   // Whether the feature is enabled
@@ -230,14 +271,17 @@
     return span;
   }
 
-  // Profile functionality
+  /**
+   * Profile functionality
+   */
   injectAtProfile(card) {
     waitFor(
         () => {
           let now = Date.now();
           if (now - this.lastProfile.timestamp < 15 * 1000)
             return Promise.resolve(this.lastProfile);
-          return Promise.reject('Didn\'t receive profile information');
+          return Promise.reject(
+              new Error('Didn\'t receive profile information'));
         },
         {
           interval: 500,
@@ -282,8 +326,9 @@
     });
   }
 
-  // Canned responses (CRs) functionality
-
+  /**
+   * Canned responses (CRs) functionality
+   */
   getCRName(tags, isExpanded) {
     if (!isExpanded)
       return tags.parentNode?.querySelector?.('.text .name')?.textContent;
@@ -298,7 +343,7 @@
   injectAtCR(tags, isExpanded) {
     waitFor(() => {
       if (this.lastCRsList.id != -1) return Promise.resolve(this.lastCRsList);
-      return Promise.reject('Didn\'t receive canned responses list');
+      return Promise.reject(new Error('Didn\'t receive canned responses list'));
     }, {
       interval: 500,
       timeout: 15 * 1000,
@@ -352,4 +397,266 @@
       if (isEnabled) return this.injectAtCR(tags, isExpanded);
     });
   }
+
+  /**
+   * Thread view functionality
+   */
+
+  getPendingStateInfo(endPendingStateTimestampMicros) {
+    const endPendingStateTimestamp =
+        Math.floor(endPendingStateTimestampMicros / 1e3);
+    const now = Date.now();
+    if (endPendingStateTimestampMicros && endPendingStateTimestamp > now) {
+      let span = document.createElement('span');
+      span.textContent = 'Only visible to badged users';
+
+      let date = new Date(endPendingStateTimestamp).toLocaleString();
+      let pendingTooltip =
+          createPlainTooltip(span, 'Visible after ' + date, false);
+      return [span, pendingTooltip];
+    }
+
+    return [null, null];
+  }
+
+  getMetadataInfo(itemMetadata) {
+    let info = [];
+
+    const state = itemMetadata?.['1'];
+    if (state && state != 1)
+      info.push(this.fieldInfo('State', kItemMetadataState[state] ?? state));
+
+    const shadowBlockInfo = itemMetadata?.['10'];
+    const blockedTimestampMicros = shadowBlockInfo?.['2'];
+    if (blockedTimestampMicros) {
+      const isBlocked = shadowBlockInfo?.['1'];
+      let span = document.createElement('span');
+      span.textContent =
+          isBlocked ? 'Shadow block active' : 'Shadow block no longer active';
+      if (isBlocked) span.classList.add('TWPT-extrainfo-bad');
+      info.push(span);
+    }
+
+    return info;
+  }
+
+  getLiveReviewStatusInfo(liveReviewStatus) {
+    const verdict = liveReviewStatus?.['1'];
+    if (!verdict) return [null, null];
+    let label, labelClass;
+    switch (verdict) {
+      case 1:  // LIVE_REVIEW_RELEVANT
+        label = 'Relevant';
+        labelClass = 'TWPT-extrainfo-good';
+        break;
+
+      case 2:  // LIVE_REVIEW_OFF_TOPIC
+        label = 'Off-topic';
+        labelClass = 'TWPT-extrainfo-bad';
+        break;
+
+      case 3:  // LIVE_REVIEW_ABUSE
+        label = 'Abuse';
+        labelClass = 'TWPT-extrainfo-bad';
+        break;
+    }
+    const reviewedBy = liveReviewStatus?.['2'];
+    const timestamp = liveReviewStatus?.['3'];
+    const date = (new Date(Math.floor(timestamp / 1e3))).toLocaleString();
+
+    let a = document.createElement('a');
+    a.href = 'https://support.google.com/s/community/user/' + reviewedBy;
+    a.classList.add(labelClass);
+    a.textContent = 'Live review verdict: ' + label;
+    let liveReviewTooltip = createPlainTooltip(a, date, false);
+    return [a, liveReviewTooltip];
+  }
+
+  injectAtQuestion(question) {
+    let currentPage = parseUrl(location.href);
+    if (currentPage === false) return;
+
+    let content = question.querySelector('ec-question > .content');
+    if (!content) return;
+
+    waitFor(() => {
+      let now = Date.now();
+      let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
+      if (now - this.lastThread.timestamp < 15 * 1000 &&
+          threadInfo?.['1'] == currentPage.thread &&
+          threadInfo?.['3'] == currentPage.forum)
+        return Promise.resolve(this.lastThread);
+      return Promise.reject(new Error('Didn\'t receive thread information'));
+    }, {
+      interval: 500,
+      timeout: 15 * 1000,
+    }).then(thread => {
+      let info = [];
+
+      const endPendingStateTimestampMicros = thread.body['1']?.['2']?.['39'];
+      const [pendingStateInfo, pendingTooltip] =
+          this.getPendingStateInfo(endPendingStateTimestampMicros);
+      if (pendingStateInfo) info.push(pendingStateInfo);
+
+      // NOTE: These attributes don't seem to be included when calling
+      // ViewThread (but are included when calling ViewForum).
+      const isTrending = thread.body['1']?.['2']?.['25'];
+      const isTrendingAutoMarked = thread.body['1']?.['39'];
+      if (isTrendingAutoMarked)
+        info.push(document.createTextNode('Automatically marked as trending'));
+      else if (isTrending)
+        info.push(document.createTextNode('Trending'));
+
+      const itemMetadata = thread.body['1']?.['2']?.['12'];
+      const mdInfo = this.getMetadataInfo(itemMetadata);
+      info.push(...mdInfo);
+
+      const liveReviewStatus = thread.body['1']?.['2']?.['38'];
+      const [liveReviewInfo, liveReviewTooltip] =
+          this.getLiveReviewStatusInfo(liveReviewStatus);
+      if (liveReviewInfo) info.push(liveReviewInfo);
+
+      this.addExtraInfoElement(info, content);
+      if (pendingTooltip) new MDCTooltip(pendingTooltip);
+      if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
+    });
+  }
+
+  injectAtQuestionIfEnabled(question) {
+    this.isEnabled().then(isEnabled => {
+      if (isEnabled) return this.injectAtQuestion(question);
+    });
+  }
+
+  getMessagesByType(thread, type) {
+    if (type === 'reply') return thread?.['1']?.['3'];
+    if (type === 'lastMessage') return thread?.['1']?.['17']?.['3'];
+    if (type === 'suggested') return thread?.['1']?.['17']?.['4'];
+    if (type === 'recommended') return thread?.['1']?.['17']?.['1'];
+  }
+
+  getMessageByTypeAndIndex(thread, type, index) {
+    return this.getMessagesByType(thread, type)?.[index];
+  }
+
+  // Returns true if the last message is included in the messages array (in the
+  // extension context, we say those messages are of the type "reply").
+  lastMessageInReplies(thread) {
+    const lastMessageId = thread?.['1']?.['17']?.['3']?.[0]?.['1']?.['1'];
+    if (!lastMessageId) return true;
+
+    // If the last message is included in the lastMessage array, check if it
+    // also exists in the messages/replies array.
+    const replies = thread?.['1']?.['3'];
+    if (!replies?.length) return false;
+    const lastReplyIndex = replies.length - 1;
+    const lastReplyId = replies[lastReplyIndex]?.['1']?.['1'];
+    return lastMessageId && lastMessageId == lastReplyId;
+  }
+
+  getMessageInfo(thread, message) {
+    const section = message.parentNode;
+
+    let type = 'reply';
+    if (section?.querySelector?.('.heading material-icon[icon="auto_awesome"]'))
+      type = 'suggested';
+    if (section?.querySelector?.('.heading material-icon[icon="check_circle"]'))
+      type = 'recommended';
+
+    let index = -1;
+    let messagesInDom = section.querySelectorAll('ec-message');
+
+    // Number of messages in the DOM.
+    const n = messagesInDom.length;
+
+    if (type !== 'reply') {
+      for (let i = 0; i < n; ++i) {
+        if (message.isEqualNode(messagesInDom[i])) {
+          index = i;
+          break;
+        }
+      }
+    } else {
+      // If the type of the message is a reply, things are slightly more
+      // complex, since replies are paginated and the last message should be
+      // treated separately (it is included diferently in the API response).
+      let lastMessageInReplies = this.lastMessageInReplies(thread);
+      if (message.isEqualNode(messagesInDom[n - 1]) && !lastMessageInReplies) {
+        type = 'lastMessage';
+        index = 0
+      } else {
+        // Number of messages in the current API response.
+        const messagesInResponse = this.getMessagesByType(thread, type);
+        const m = messagesInResponse.length;
+        // If the last message is included in the replies array, we also have to
+        // consider the last message in the DOM.
+        let modifier = lastMessageInReplies ? 1 : 0;
+        for (let k = 0; k < m; ++k) {
+          let i = n - 2 - k + modifier;
+          if (message.isEqualNode(messagesInDom[i])) {
+            index = m - 1 - k;
+            break;
+          }
+        }
+      }
+    }
+
+    return [type, index];
+  }
+
+  injectAtMessage(messageNode) {
+    let currentPage = parseUrl(location.href);
+    if (currentPage === false) return;
+
+    let footer = messageNode.querySelector('.footer-fill');
+    if (!footer) return;
+
+    const [type, index] =
+        this.getMessageInfo(this.lastThread.body, messageNode);
+
+    waitFor(() => {
+      let now = Date.now();
+      let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
+      if (now - this.lastThread.timestamp < 15 * 1000 &&
+          threadInfo?.['1'] == currentPage.thread &&
+          threadInfo?.['3'] == currentPage.forum) {
+        const message =
+            this.getMessageByTypeAndIndex(this.lastThread.body, type, index);
+        if (message) return Promise.resolve(message);
+      }
+
+      return Promise.reject(new Error(
+          'Didn\'t receive thread information (type: ' + type +
+          ', index: ' + index + ')'));
+    }, {
+      interval: 1000,
+      timeout: 15 * 1000,
+    }).then(message => {
+      let info = [];
+
+      const endPendingStateTimestampMicros = message['1']?.['17'];
+      const [pendingStateInfo, pendingTooltip] =
+          this.getPendingStateInfo(endPendingStateTimestampMicros);
+      if (pendingStateInfo) info.push(pendingStateInfo);
+
+      const itemMetadata = message['1']?.['5'];
+      const mdInfo = this.getMetadataInfo(itemMetadata);
+      info.push(...mdInfo);
+
+      const liveReviewStatus = message['1']?.['36'];
+      const [liveReviewInfo, liveReviewTooltip] =
+          this.getLiveReviewStatusInfo(liveReviewStatus);
+      if (liveReviewInfo) info.push(liveReviewInfo);
+
+      this.addExtraInfoElement(info, footer);
+      if (pendingTooltip) new MDCTooltip(pendingTooltip);
+      if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
+    });
+  }
+
+  injectAtMessageIfEnabled(message) {
+    this.isEnabled().then(isEnabled => {
+      if (isEnabled) return this.injectAtMessage(message);
+    });
+  }
 }
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 73ed360..b0d9dbc 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -46,8 +46,14 @@
   'iframe',
 
   // Canned response tags or toolbelt (for the extra info feature)
-  '.tags',
-  '.toolbelt',
+  'ec-canned-response-row .tags',
+  'ec-canned-response-row .main .toolbelt',
+
+  // Div containing ec-question (for the extra info feature)
+  'ec-thread div[role="list"]',
+
+  // Replies (for the extra info feature)
+  'ec-thread ec-message',
 ];
 
 function handleCandidateNode(node) {
@@ -160,7 +166,17 @@
     }
     if (node.matches('ec-canned-response-row .main .toolbelt')) {
       const tags = node.parentNode?.querySelector?.('.tags');
-      if (tags) window.TWPTExtraInfo.injectAtCRIfEnabled(tags, /* isExpanded = */ true);
+      if (tags)
+        window.TWPTExtraInfo.injectAtCRIfEnabled(tags, /* isExpanded = */ true);
+    }
+
+    // Show additional details in the thread view.
+    if (node.matches('ec-thread div[role="list"]')) {
+      const question = node.querySelector('ec-question');
+      if (question) window.TWPTExtraInfo.injectAtQuestionIfEnabled(question);
+    }
+    if (node.matches('ec-thread ec-message')) {
+      window.TWPTExtraInfo.injectAtMessageIfEnabled(node);
     }
   }
 }
diff --git a/src/static/css/extrainfo.css b/src/static/css/extrainfo.css
index d04d42d..a0c15e2 100644
--- a/src/static/css/extrainfo.css
+++ b/src/static/css/extrainfo.css
@@ -7,7 +7,7 @@
 .TWPT-extrainfo-badge-cell {
   margin: 0 8px;
   --icon-size: 14px;
-  opacity: 0.6;
+  opacity: 0.4;
 }
 
 .TWPT-extrainfo-info-cell {
@@ -33,6 +33,45 @@
   align-self: end;
 }
 
+ec-question .TWPT-extrainfo-container,
+    ec-message .footer .TWPT-extrainfo-container {
+  color: var(--TWPT-secondary-text, #6c6c6c);
+}
+
+ec-question .TWPT-extrainfo-container {
+  font-size: 13px;
+  position: absolute;
+  bottom: 25px;
+  right: 48px;
+}
+
+ec-message .footer .TWPT-extrainfo-container {
+  margin-top: 8px;
+  font-size: 12px;
+}
+
+ec-message .footer .TWPT-extrainfo-badge-cell {
+  margin: 0 6px 0 0;
+  --icon-size: 12px;
+}
+
+ec-message .footer .TWPT-extrainfo-info-cell {
+  padding-left: 6px;
+}
+
+/* Special styles for good/bad labels */
+.TWPT-extrainfo-good {
+  color: var(--TWPT-good-text, green)!important;
+}
+
+.TWPT-extrainfo-warning {
+  color: var(--TWPT-warning-text, orange)!important;
+}
+
+.TWPT-extrainfo-bad {
+  color: var(--TWPT-bad-text, red)!important;
+}
+
 /* Special tags components for canned responses */
 ec-canned-response-row .TWPT-tag {
   background-color: transparent;