Add extra info to thread lists

Fixed: twpowertools:103
Change-Id: I957d94d352113b7d81f0f31a014e130b13e0046c
diff --git a/src/contentScripts/communityConsole/extraInfo.js b/src/contentScripts/communityConsole/extraInfo.js
index 8419ea2..acea6fe 100644
--- a/src/contentScripts/communityConsole/extraInfo.js
+++ b/src/contentScripts/communityConsole/extraInfo.js
@@ -11,6 +11,7 @@
 const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
 const kListCannedResponsesResponse = 'TWPT_ListCannedResponsesResponse';
 const kViewThreadResponse = 'TWPT_ViewThreadResponse';
+const kViewForumResponse = 'TWPT_ViewForumResponse';
 
 const kAbuseCategories = [
   ['1', 'Account'],
@@ -187,6 +188,9 @@
       id: -1,
       timestamp: 0,
     };
+    this.lastThreadList = null;
+    this.lastThreadListTimestamp = 0;
+    this.lastThreadListRequestId = -1;
     this.displayLanguage = getDisplayLanguage();
     this.optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
     this.setUpHandlers();
@@ -227,6 +231,21 @@
         timestamp: Date.now(),
       };
     });
+    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(),
+      };
+    });
+    window.addEventListener(kViewForumResponse, e => {
+      if (e.detail.id < this.lastThreadListRequestId) return;
+
+      this.lastThreadList = e.detail.body;
+      this.lastThreadListTimestamp = Date.now();
+    });
   }
 
   // Whether |feature| is enabled
@@ -489,12 +508,53 @@
     return [a, liveReviewTooltip];
   }
 
+  // Get an |info| array with the info related to the thread, and a |tooltips|
+  // array with the corresponding tooltips which should be initialized after the
+  // info is added to the DOM.
+  //
+  // This is used by the injectAtQuestion() and injectAtThreadList() functions.
+  getThreadInfo(thread) {
+    let info = [];
+    let tooltips = [];
+
+    const endPendingStateTimestampMicros = thread?.['2']?.['39'];
+    const [pendingStateInfo, pendingTooltip] =
+        this.getPendingStateInfo(endPendingStateTimestampMicros);
+    if (pendingStateInfo) info.push(pendingStateInfo);
+    if (pendingTooltip) tooltips.push(pendingTooltip);
+
+    const isTrending = thread?.['2']?.['25'];
+    const isTrendingAutoMarked = thread?.['39'];
+    if (isTrendingAutoMarked)
+      info.push(document.createTextNode('Automatically marked as trending'));
+    else if (isTrending)
+      info.push(document.createTextNode('Trending'));
+
+    const itemMetadata = thread?.['2']?.['12'];
+    const mdInfo = this.getMetadataInfo(itemMetadata);
+    info.push(...mdInfo);
+
+    const liveReviewStatus = thread?.['2']?.['38'];
+    const [liveReviewInfo, liveReviewTooltip] =
+        this.getLiveReviewStatusInfo(liveReviewStatus);
+    if (liveReviewInfo) info.push(liveReviewInfo);
+    if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
+
+    return [info, tooltips];
+  }
+
   injectAtQuestion(question) {
     let currentPage = parseUrl(location.href);
-    if (currentPage === false) return;
+    if (currentPage === false) {
+      console.error('extraInfo: couldn\'t parse current URL:', location.href);
+      return;
+    }
 
     let content = question.querySelector('ec-question > .content');
-    if (!content) return;
+    if (!content) {
+      console.error('extraInfo: question doesn\'t have .content:', messageNode);
+      return;
+    }
 
     waitFor(
         () => {
@@ -512,34 +572,9 @@
           timeout: 30 * 1000,
         })
         .then(thread => {
-          let info = [];
-
-          const endPendingStateTimestampMicros =
-              thread.body['1']?.['2']?.['39'];
-          const [pendingStateInfo, pendingTooltip] =
-              this.getPendingStateInfo(endPendingStateTimestampMicros);
-          if (pendingStateInfo) info.push(pendingStateInfo);
-
-          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);
-
+          const [info, tooltips] = this.getThreadInfo(thread.body?.['1']);
           this.addExtraInfoElement(info, content);
-          if (pendingTooltip) new MDCTooltip(pendingTooltip);
-          if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
+          for (const tooltip of tooltips) new MDCTooltip(tooltip);
         })
         .catch(err => {
           console.error(
@@ -703,6 +738,122 @@
   }
 
   /**
+   * Thread list functionality
+   */
+  injectAtThreadList(li) {
+    waitFor(
+        () => {
+          const header = li.querySelector(
+              'ec-thread-summary .main-header .panel-description a.header');
+          if (header === null) {
+            console.error(
+                'extraInfo: Header is not present in the thread item\'s DOM.');
+            return;
+          }
+
+          const threadInfo = parseUrl(header.href);
+          if (threadInfo === false) {
+            console.error('extraInfo: Thread\'s link cannot be parsed.');
+            return;
+          }
+
+          let authorLine = li.querySelector(
+              'ec-thread-summary .header-content .top-row .author-line');
+          if (!authorLine) {
+            console.error(
+                'extraInfo: Author line is not present in the thread item\'s DOM.');
+            return;
+          }
+
+          let thread = this.lastThreadList?.['1']?.['2']?.find?.(t => {
+            return t?.['2']?.['1']?.['1'] == threadInfo.thread &&
+                t?.['2']?.['1']?.['3'] == threadInfo.forum;
+          });
+          if (thread) return Promise.resolve([thread, authorLine]);
+          return Promise.reject(
+              new Error('Didn\'t receive thread information'));
+        },
+        {
+          interval: 500,
+          timeout: 7 * 1000,
+        })
+        .then(response => {
+          const [thread, authorLine] = response;
+          const state = thread?.['2']?.['12']?.['1'];
+          if (state && ![1, 13, 18, 9].includes(state)) {
+            let label = document.createElement('div');
+            label.classList.add('TWPT-label');
+
+            const [badge, badgeTooltip] = createExtBadge();
+
+            let span = document.createElement('span');
+            span.textContent = kItemMetadataState[state] ?? 'State ' + state;
+
+            label.append(badge, span);
+            authorLine.prepend(label);
+            new MDCTooltip(badgeTooltip);
+          }
+        })
+        .catch(err => {
+          console.error(
+              'extraInfo: error while injecting thread list extra info: ', err);
+        });
+  }
+
+  injectAtThreadListIfEnabled(li) {
+    this.isEnabled('extrainfo').then(isEnabled => {
+      if (isEnabled) this.injectAtThreadList(li);
+    });
+  }
+
+  injectAtExpandedThreadList(toolbelt) {
+    const header =
+        toolbelt?.parentNode?.parentNode?.parentNode?.querySelector?.(
+            '.main-header .panel-description a.header');
+    if (header === null) {
+      console.error(
+          'extraInfo: Header is not present in the thread item\'s DOM.');
+      return;
+    }
+
+    const threadInfo = parseUrl(header.href);
+    if (threadInfo === false) {
+      console.error('extraInfo: Thread\'s link cannot be parsed.');
+      return;
+    }
+
+    waitFor(
+        () => {
+          let thread = this.lastThreadList?.['1']?.['2']?.find?.(t => {
+            return t?.['2']?.['1']?.['1'] == threadInfo.thread &&
+                t?.['2']?.['1']?.['3'] == threadInfo.forum;
+          });
+          if (thread) return Promise.resolve(thread);
+          return Promise.reject(
+              new Error('Didn\'t receive thread information'));
+        },
+        {
+          interval: 500,
+          timeout: 7 * 1000,
+        })
+        .then(thread => {
+          const [info, tooltips] = this.getThreadInfo(thread);
+          this.addExtraInfoElement(info, toolbelt);
+          for (const tooltip of tooltips) new MDCTooltip(tooltip);
+        })
+        .catch(err => {
+          console.error(
+              'extraInfo: error while injecting thread list extra info: ', err);
+        });
+  }
+
+  injectAtExpandedThreadListIfEnabled(toolbelt) {
+    this.isEnabled('extrainfo').then(isEnabled => {
+      if (isEnabled) this.injectAtExpandedThreadList(toolbelt);
+    });
+  }
+
+  /**
    * Per-forum stats in user profiles.
    */
 
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 75de36f..d140484 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -40,9 +40,12 @@
   'ec-bulk-actions material-button[debugid="mark-read-button"]',
   'ec-bulk-actions material-button[debugid="mark-unread-button"]',
 
-  // Thread list items (used to inject the avatars)
+  // Thread list items (used to inject the avatars and extra info)
   'li',
 
+  // Thread list item toolbelt (used for the extra info feature)
+  'ec-thread-summary .main .toolbelt',
+
   // Thread list (used for the autorefresh feature)
   'ec-thread-list',
 
@@ -132,9 +135,17 @@
     // Inject avatar links to threads in the thread list. injectIfEnabled is
     // responsible of determining whether it should run or not depending on its
     // current setting.
+    //
+    // Also, inject extra info in the thread list.
     if (('tagName' in node) && (node.tagName == 'LI') &&
         node.querySelector('ec-thread-summary') !== null) {
       avatars.injectIfEnabled(node);
+      window.TWPTExtraInfo.injectAtThreadListIfEnabled(node);
+    }
+
+    // Inject extra info in the toolbelt of an expanded thread list item.
+    if (node.matches('ec-thread-summary .main .toolbelt')) {
+      window.TWPTExtraInfo.injectAtExpandedThreadListIfEnabled(node);
     }
 
     // Set up the autorefresh list feature. The setUp function is responsible
@@ -212,7 +223,7 @@
   infiniteScroll = new InfiniteScroll();
   workflows = new Workflows();
 
-  // autoRefresh is initialized in start.js
+  // autoRefresh and extraInfo are initialized in start.js
 
   // Before starting the mutation Observer, check whether we missed any
   // mutations by manually checking whether some watched nodes already
diff --git a/src/static/css/extrainfo.css b/src/static/css/extrainfo.css
index 23b0afd..6813195 100644
--- a/src/static/css/extrainfo.css
+++ b/src/static/css/extrainfo.css
@@ -59,6 +59,12 @@
   padding-inline-start: 6px;
 }
 
+ec-thread-summary .main .toolbelt .TWPT-extrainfo-container {
+  margin-inline-start: 115px;
+  margin-bottom: 16px;
+  font-size: 12px;
+}
+
 /* Special styles for good/bad labels */
 .TWPT-extrainfo-good {
   color: var(--TWPT-good-text, green)!important;
@@ -107,6 +113,19 @@
   cursor: help;
 }
 
+/* Special component for the thread list */
+ec-thread-summary .header-content .TWPT-label {
+  display: flex;
+  align-items: center;
+  padding-inline-end: 10px;
+  font-weight: 500;
+}
+
+ec-thread-summary .header-content .TWPT-label .TWPT-badge {
+  --icon-size: 9.5px;
+  margin-right: 2px;
+}
+
 /* Per-forum stats section */
 .TWPT-scTailwindSharedActivitychartroot .scTailwindSharedActivitychartchart {
   margin-top: 26px;
@@ -118,7 +137,7 @@
 }
 
 .TWPT-scTailwindSharedActivitychartroot .scTailwindSharedActivitycharttitle .TWPT-badge {
-  margin-right: 6px;
+  margin-inline-end: 6px;
 }
 
 .TWPT-scTailwindSharedActivitychartroot .TWPT-select-container {
diff --git a/src/static/css/reposition_expand_thread.css b/src/static/css/reposition_expand_thread.css
index 1af6917..ea26942 100644
--- a/src/static/css/reposition_expand_thread.css
+++ b/src/static/css/reposition_expand_thread.css
@@ -17,3 +17,7 @@
 ec-thread-summary .panel .main .content-wrapper > .content > .content {
   padding-inline: 121px 8px;
 }
+
+ec-thread-summary .main .toolbelt .TWPT-extrainfo-container {
+  margin-inline-start: 145px!important;
+}