fix(extra-info): show extra info in the RCE thread page

The extra info feature worked with the old thread pages. This CL brings
support to the new RCE thread pages.

Bug: twpowertools:93
Change-Id: I47e4235afa4f7ec441f5a92edfcc28b1cb5f0419
diff --git a/src/contentScripts/communityConsole/extraInfo/handlers/thread.js b/src/contentScripts/communityConsole/extraInfo/handlers/thread.js
new file mode 100644
index 0000000..ba7b906
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/handlers/thread.js
@@ -0,0 +1,60 @@
+import {parseUrl} from '../../../../common/commonUtils.js';
+import ThreadModel from '../../../../models/Thread.js';
+import {kViewThreadResponse} from '../consts.js';
+import MessageExtraInfoService from '../services/message.js';
+
+import ResponseEventBasedInfoHandler from './basedOnResponseEvent.js';
+
+const kIntervalInMs = 500;
+const kTimeoutInMs = 10 * 1000;
+const kCurrentInfoExpiresInMs = kTimeoutInMs * 1.5;
+
+export default class ThreadInfoHandler extends ResponseEventBasedInfoHandler {
+  constructor() {
+    super();
+
+    this.thread = undefined;
+  }
+
+  getEvent() {
+    return kViewThreadResponse;
+  }
+
+  getWaitForCurrentInfoOptions() {
+    return {
+      interval: kIntervalInMs,
+      timeout: kTimeoutInMs,
+    };
+  }
+
+  async isInfoCurrent(injectionDetails) {
+    this.thread = new ThreadModel(this.info.body?.[1]);
+
+    const currentPage = this.parseThreadUrl();
+    const isCurrentThread =
+        Date.now() - this.info.timestamp < kCurrentInfoExpiresInMs &&
+        this.thread.getId() == currentPage.thread &&
+        this.thread.getForumId() == currentPage.forum;
+
+    const isMessageNode = injectionDetails.isMessageNode;
+    const messageNode = injectionDetails.messageNode;
+
+    return isCurrentThread &&
+        (!isMessageNode || this.currentThreadContainsMessage(messageNode));
+  }
+
+  parseThreadUrl() {
+    const currentPage = parseUrl(location.href);
+    if (currentPage === false)
+      throw new Error(`couldn't parse current URL: ${location.href}`);
+
+    return currentPage;
+  }
+
+  currentThreadContainsMessage(messageNode) {
+    const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
+    const message = MessageExtraInfoService.getMessageFromThreadModel(
+        messageId, this.thread);
+    return message !== undefined;
+  }
+}
diff --git a/src/contentScripts/communityConsole/extraInfo/index.js b/src/contentScripts/communityConsole/extraInfo/index.js
index 21e7546..1d2b024 100644
--- a/src/contentScripts/communityConsole/extraInfo/index.js
+++ b/src/contentScripts/communityConsole/extraInfo/index.js
@@ -1,53 +1,35 @@
-import {MDCTooltip} from '@material/tooltip';
-import {waitFor} from 'poll-until-promise';
-
-import {parseUrl} from '../../../common/commonUtils.js';
 import OptionsWatcher from '../../../common/optionsWatcher.js';
 
-import {kViewThreadResponse} from './consts.js';
 import ProfileInfoHandler from './handlers/profile.js';
+import ThreadInfoHandler from './handlers/thread.js';
 import ThreadListInfoHandler from './handlers/threadList.js';
 import ExpandedThreadListExtraInfoInjection from './injections/expandedThreadList.js';
 import ProfileAbuseExtraInfoInjection from './injections/profileAbuse.js';
 import ProfilePerForumStatsExtraInfoInjection from './injections/profilePerForumStats.js';
 import ThreadListExtraInfoInjection from './injections/threadList.js';
-import ThreadExtraInfoService from './services/thread.js';
+import ThreadMessageExtraInfoInjection from './injections/threadMessage.js';
+import ThreadQuestionExtraInfoInjection from './injections/threadQuestion.js';
 
 export default class ExtraInfo {
   constructor() {
-    this.optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
+    const optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
 
     const profileInfoHandler = new ProfileInfoHandler();
+    const threadInfoHandler = new ThreadInfoHandler();
     const threadListInfoHandler = new ThreadListInfoHandler();
 
-    this.profileAbuse = new ProfileAbuseExtraInfoInjection(
-        profileInfoHandler, this.optionsWatcher);
+    this.profileAbuse =
+        new ProfileAbuseExtraInfoInjection(profileInfoHandler, optionsWatcher);
     this.profilePerForumStats = new ProfilePerForumStatsExtraInfoInjection(
-        profileInfoHandler, this.optionsWatcher);
+        profileInfoHandler, optionsWatcher);
+    this.threadQuestion =
+        new ThreadQuestionExtraInfoInjection(threadInfoHandler, optionsWatcher);
+    this.threadMessage =
+        new ThreadMessageExtraInfoInjection(threadInfoHandler, optionsWatcher);
     this.expandedThreadList = new ExpandedThreadListExtraInfoInjection(
-        threadListInfoHandler, this.optionsWatcher);
-    this.threadList = new ThreadListExtraInfoInjection(
-        threadListInfoHandler, this.optionsWatcher);
-
-    this.lastThread = {
-      body: {},
-      id: -1,
-      timestamp: 0,
-    };
-
-    this.setUpHandlers();
-  }
-
-  setUpHandlers() {
-    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(),
-      };
-    });
+        threadListInfoHandler, optionsWatcher);
+    this.threadList =
+        new ThreadListExtraInfoInjection(threadListInfoHandler, optionsWatcher);
   }
 
   injectAbuseChipsAtProfileIfEnabled(card) {
@@ -69,124 +51,11 @@
     this.profilePerForumStats.injectIfEnabled({chart});
   }
 
-  // Whether |feature| is enabled
-  isEnabled(feature) {
-    return this.optionsWatcher.isEnabled(feature);
-  }
-
-  /**
-   * Thread view functionality
-   */
-  injectAtQuestion(stateChips) {
-    let currentPage = parseUrl(location.href);
-    if (currentPage === false) {
-      console.error('extraInfo: couldn\'t parse current URL:', location.href);
-      return;
-    }
-
-    waitFor(
-        () => {
-          let now = Date.now();
-          let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
-          if (now - this.lastThread.timestamp < 30 * 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: 30 * 1000,
-        })
-        .then(thread => {
-          const [info, tooltips] =
-              ThreadExtraInfoService.getThreadChips(thread.body?.['1']);
-          this.addExtraInfoElement(info, stateChips, false);
-          for (const tooltip of tooltips) new MDCTooltip(tooltip);
-        })
-        .catch(err => {
-          console.error(
-              'extraInfo: error while injecting question extra info: ', err);
-        });
-  }
-
   injectAtQuestionIfEnabled(stateChips) {
-    this.isEnabled('extrainfo').then(isEnabled => {
-      if (isEnabled) return this.injectAtQuestion(stateChips);
-    });
+    this.threadQuestion.injectIfEnabled({stateChips, isMessageNode: false});
   }
 
-  injectAtMessage(messageNode) {
-    let currentPage = parseUrl(location.href);
-    if (currentPage === false) {
-      console.error('extraInfo: couldn\'t parse current URL:', location.href);
-      return;
-    }
-
-    let footer = messageNode.querySelector('.footer-fill');
-    if (!footer) {
-      console.error('extraInfo: message doesn\'t have a footer:', messageNode);
-      return;
-    }
-
-    const [type, index] =
-        this.getMessageInfo(this.lastThread.body, messageNode);
-    if (index == -1) {
-      console.error('extraInfo: this.getMessageInfo() returned index -1.');
-      return;
-    }
-
-    waitFor(
-        () => {
-          let now = Date.now();
-          let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
-          if (now - this.lastThread.timestamp < 30 * 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: 30 * 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 = ThreadExtraInfoService.getMetadataInfo(itemMetadata);
-          info.push(...mdInfo);
-
-          const liveReviewStatus = message['1']?.['36'];
-          const [liveReviewInfo, liveReviewTooltip] =
-              this.getLiveReviewStatusChip(liveReviewStatus);
-          if (liveReviewInfo) info.push(liveReviewInfo);
-
-          this.addExtraInfoElement(info, footer, true);
-          if (pendingTooltip) new MDCTooltip(pendingTooltip);
-          if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
-        })
-        .catch(err => {
-          console.error(
-              'extraInfo: error while injecting message extra info: ', err);
-        });
-  }
-
-  injectAtMessageIfEnabled(message) {
-    this.isEnabled('extrainfo').then(isEnabled => {
-      if (isEnabled) return this.injectAtMessage(message);
-    });
+  injectAtMessageIfEnabled(messageNode) {
+    this.threadMessage.injectIfEnabled({messageNode, isMessageNode: true});
   }
 }
diff --git a/src/contentScripts/communityConsole/extraInfo/injections/threadMessage.js b/src/contentScripts/communityConsole/extraInfo/injections/threadMessage.js
new file mode 100644
index 0000000..e203e9e
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/injections/threadMessage.js
@@ -0,0 +1,40 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import ThreadModel from '../../../../models/Thread.js';
+import MessageExtraInfoService from '../services/message.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ThreadMessageExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  inject(threadInfo, injectionDetails) {
+    const messageNode = injectionDetails.messageNode;
+    const message = this.#getMessage(threadInfo, messageNode);
+    const [chips, tooltips] = MessageExtraInfoService.getMessageChips(message);
+    this.#injectChips(chips, messageNode);
+    for (const tooltip of tooltips) new MDCTooltip(tooltip);
+  }
+
+  #getMessage(threadInfo, messageNode) {
+    const thread = new ThreadModel(threadInfo.body?.[1]);
+    const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
+    return MessageExtraInfoService.getMessageFromThreadModel(messageId, thread);
+  }
+
+  #injectChips(chips, messageNode) {
+    const interactionsElement = messageNode.querySelector(
+        '.scTailwindThreadMessageMessageinteractionsroot');
+    if (interactionsElement === null)
+      throw new Error(`Couldn't find interactions element.`);
+
+    this.#indicateInteractionsElementIsNonEmpty(interactionsElement);
+
+    this.addExtraInfoChips(
+        chips, interactionsElement, /* withContainer = */ true);
+  }
+
+  #indicateInteractionsElementIsNonEmpty(interactionsElement) {
+    interactionsElement.classList.add(
+        'scTailwindThreadMessageMessageinteractionsinteractions');
+  }
+}
diff --git a/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js b/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js
new file mode 100644
index 0000000..1efe6db
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/injections/threadQuestion.js
@@ -0,0 +1,24 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import ThreadModel from '../../../../models/Thread.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ThreadQuestionExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  inject(threadInfo, injectionDetails) {
+    const [chips, tooltips] =
+        ThreadExtraInfoService.getThreadChips(threadInfo.body?.['1']);
+    this.#injectChips(chips, injectionDetails.stateChips);
+    for (const tooltip of tooltips) new MDCTooltip(tooltip);
+  }
+
+  #injectChips(chips, stateChipsElement) {
+    const stateChipsContainer = stateChipsElement.querySelector(
+        '.scTailwindThreadQuestionStatechipsroot');
+    const container = stateChipsContainer ?? stateChipsElement;
+    const shouldCreateContainer = stateChipsContainer === null;
+    this.addExtraInfoChips(chips, container, shouldCreateContainer);
+  }
+}
diff --git a/src/contentScripts/communityConsole/extraInfo/services/message.js b/src/contentScripts/communityConsole/extraInfo/services/message.js
new file mode 100644
index 0000000..3b43403
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/services/message.js
@@ -0,0 +1,56 @@
+import MessageModel from '../../../../models/Message.js';
+
+import StatesExtraInfoService from './states.js';
+
+export default class MessageExtraInfoService {
+  static getMessageIdFromNode(messageNode) {
+    const id =
+        messageNode.querySelector('.scTailwindThreadMessageMessagecardcontent')
+            ?.getAttribute?.('data-stats-id');
+    if (id === undefined)
+      throw new Error(`Couldn't retrieve message id from node.`);
+    return id;
+  }
+
+  static getMessageFromThreadModel(messageId, threadModel) {
+    for (const messageOrGap of threadModel.getMessageOrGapModels()) {
+      if (!(messageOrGap instanceof MessageModel)) continue;
+      if (messageOrGap.getId() == messageId) {
+        return messageOrGap;
+      } else {
+        for (const subMessageOrGap of messageOrGap.getCommentsAndGaps()) {
+          if (!(subMessageOrGap instanceof MessageModel)) continue;
+          if (subMessageOrGap.getId() == messageId) {
+            return subMessageOrGap;
+          }
+        }
+      }
+    }
+
+    throw new Error(`Couldn't find message ${messageId} in thread.`);
+  }
+
+  static getMessageChips(messageModel) {
+    const chips = [];
+    const tooltips = [];
+
+    const endPendingStateTimestampMicros =
+        messageModel.getEndPendingStateTimestampMicros();
+    const [pendingStateChip, pendingStateTooltip] =
+        StatesExtraInfoService.getPendingStateChip(
+            endPendingStateTimestampMicros);
+    if (pendingStateChip) chips.push(pendingStateChip);
+    if (pendingStateTooltip) tooltips.push(pendingStateTooltip);
+
+    const itemMetadata = messageModel.data?.[1]?.[5];
+    chips.push(...StatesExtraInfoService.getMetadataChips(itemMetadata));
+
+    const liveReviewStatus = messageModel.data?.[1]?.[36];
+    const [liveReviewChip, liveReviewTooltip] =
+        StatesExtraInfoService.getLiveReviewStatusChip(liveReviewStatus);
+    if (liveReviewChip) chips.push(liveReviewChip);
+    if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
+
+    return [chips, tooltips];
+  }
+}
diff --git a/src/contentScripts/communityConsole/extraInfo/services/states.js b/src/contentScripts/communityConsole/extraInfo/services/states.js
new file mode 100644
index 0000000..6904e3e
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/services/states.js
@@ -0,0 +1,106 @@
+import {createPlainTooltip} from '../../../../common/tooltip.js';
+import {kItemMetadataState, kItemMetadataStateI18n} from '../consts.js';
+
+export default class StatesExtraInfoService {
+  static getPendingStateChip(endPendingStateTimestampMicros) {
+    const endPendingStateTimestamp =
+        Math.floor(endPendingStateTimestampMicros / 1e3);
+    const now = Date.now();
+    if (!endPendingStateTimestampMicros || endPendingStateTimestamp < now)
+      return [null, null];
+
+    const span = document.createElement('span');
+    span.textContent =
+        chrome.i18n.getMessage('inject_extrainfo_message_pendingstate');
+
+    const date = new Date(endPendingStateTimestamp).toLocaleString();
+    const pendingTooltip = createPlainTooltip(
+        span,
+        chrome.i18n.getMessage(
+            'inject_extrainfo_message_pendingstate_tooltip', [date]),
+        false);
+    return [span, pendingTooltip];
+  }
+
+  static getLiveReviewStatusChip(liveReviewStatus) {
+    const verdict = liveReviewStatus?.['1'];
+    if (!verdict) return [null, null];
+
+    const [label, labelClass] = this.getLiveReviewStatusLabel(verdict);
+    if (!label || !labelClass) return [null, null];
+
+    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 = chrome.i18n.getMessage(
+        'inject_extrainfo_message_livereviewverdict',
+        [chrome.i18n.getMessage(
+            'inject_extrainfo_message_livereviewverdict_' + label)]);
+    let liveReviewTooltip = createPlainTooltip(a, date, false);
+    return [a, liveReviewTooltip];
+  }
+
+  static getLiveReviewStatusLabel(verdict) {
+    let label, labelClass;
+    switch (verdict) {
+      case 1:  // LIVE_REVIEW_RELEVANT
+        label = 'relevant';
+        labelClass = 'TWPT-extrainfo-good';
+        break;
+
+      case 2:  // LIVE_REVIEW_OFF_TOPIC
+        label = 'offtopic';
+        labelClass = 'TWPT-extrainfo-bad';
+        break;
+
+      case 3:  // LIVE_REVIEW_ABUSE
+        label = 'abuse';
+        labelClass = 'TWPT-extrainfo-bad';
+        break;
+
+      default:
+        return [null, null];
+    }
+    return [label, labelClass];
+  }
+
+  static getMetadataChips(itemMetadata) {
+    return [
+      this.getStateChip(itemMetadata),
+      this.getShadowBlockChip(itemMetadata),
+    ].filter(chip => chip !== null);
+  }
+
+  static getStateChip(itemMetadata) {
+    const state = itemMetadata?.['1'];
+    if (!state || state == 1) return null;
+
+    const stateI18nKey =
+        'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
+    const stateLocalized = chrome.i18n.getMessage(stateI18nKey) ?? state;
+
+    const span = document.createElement('span');
+    span.textContent = chrome.i18n.getMessage(
+        'inject_extrainfo_message_state', [stateLocalized]);
+    span.title = kItemMetadataState[state] ?? state;
+    return span;
+  }
+
+  static getShadowBlockChip(itemMetadata) {
+    const shadowBlockInfo = itemMetadata?.['10'];
+    const blockedTimestampMicros = shadowBlockInfo?.['2'];
+    if (!blockedTimestampMicros) return null;
+
+    const isBlocked = shadowBlockInfo?.['1'];
+    let span = document.createElement('span');
+    span.textContent = chrome.i18n.getMessage(
+        'inject_extrainfo_message_shadowblock' +
+        (isBlocked ? 'active' : 'notactive'));
+    if (isBlocked) span.classList.add('TWPT-extrainfo-bad');
+    return span;
+  }
+}
diff --git a/src/contentScripts/communityConsole/extraInfo/services/thread.js b/src/contentScripts/communityConsole/extraInfo/services/thread.js
index 357ed3d..c36dfa5 100644
--- a/src/contentScripts/communityConsole/extraInfo/services/thread.js
+++ b/src/contentScripts/communityConsole/extraInfo/services/thread.js
@@ -1,5 +1,4 @@
-import {createPlainTooltip} from '../../../../common/tooltip.js';
-import {kItemMetadataState, kItemMetadataStateI18n} from '../consts.js';
+import StatesExtraInfoService from './states.js';
 
 export default class ThreadExtraInfoService {
   /**
@@ -11,42 +10,27 @@
     let chips = [];
     let tooltips = [];
 
-    const [pendingStateInfo, pendingTooltip] = this.getPendingStateChip(thread);
+    const endPendingStateTimestampMicros = thread?.['2']?.['39'];
+    const [pendingStateInfo, pendingTooltip] =
+        StatesExtraInfoService.getPendingStateChip(
+            endPendingStateTimestampMicros);
     if (pendingStateInfo) chips.push(pendingStateInfo);
     if (pendingTooltip) tooltips.push(pendingTooltip);
 
     chips.push(...this.getTrendingChips(thread));
-    chips.push(...this.getMetadataChips(thread));
 
+    const itemMetadata = thread?.['2']?.['12'];
+    chips.push(...StatesExtraInfoService.getMetadataChips(itemMetadata));
+
+    const liveReviewStatus = thread?.['2']?.['38'];
     const [liveReviewInfo, liveReviewTooltip] =
-        this.getLiveReviewStatusChip(thread);
+        StatesExtraInfoService.getLiveReviewStatusChip(liveReviewStatus);
     if (liveReviewInfo) chips.push(liveReviewInfo);
     if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
 
     return [chips, tooltips];
   }
 
-  static getPendingStateChip(thread) {
-    const endPendingStateTimestampMicros = thread?.['2']?.['39'];
-    const endPendingStateTimestamp =
-        Math.floor(endPendingStateTimestampMicros / 1e3);
-    const now = Date.now();
-    if (!endPendingStateTimestampMicros || endPendingStateTimestamp < now)
-      return [null, null];
-
-    const span = document.createElement('span');
-    span.textContent =
-        chrome.i18n.getMessage('inject_extrainfo_message_pendingstate');
-
-    const date = new Date(endPendingStateTimestamp).toLocaleString();
-    const pendingTooltip = createPlainTooltip(
-        span,
-        chrome.i18n.getMessage(
-            'inject_extrainfo_message_pendingstate_tooltip', [date]),
-        false);
-    return [span, pendingTooltip];
-  }
-
   static getTrendingChips(thread) {
     const chips = [];
 
@@ -62,91 +46,6 @@
     return chips;
   }
 
-  static getMetadataChips(thread) {
-    const itemMetadata = thread?.['2']?.['12'];
-
-    return [
-      this.getStateChip(itemMetadata),
-      this.getShadowBlockChip(itemMetadata),
-    ].filter(chip => chip !== null);
-  }
-
-  static getLiveReviewStatusChip(thread) {
-    const liveReviewStatus = thread?.['2']?.['38'];
-    const verdict = liveReviewStatus?.['1'];
-    if (!verdict) return [null, null];
-
-    const [label, labelClass] = this.getLiveReviewStatusLabel(verdict);
-    if (!label || !labelClass) return [null, null];
-
-    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 = chrome.i18n.getMessage(
-        'inject_extrainfo_message_livereviewverdict',
-        [chrome.i18n.getMessage(
-            'inject_extrainfo_message_livereviewverdict_' + label)]);
-    let liveReviewTooltip = createPlainTooltip(a, date, false);
-    return [a, liveReviewTooltip];
-  }
-
-  static getStateChip(itemMetadata) {
-    const state = itemMetadata?.['1'];
-    if (!state || state == 1) return null;
-
-    const stateI18nKey =
-        'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
-    const stateLocalized = chrome.i18n.getMessage(stateI18nKey) ?? state;
-
-    const span = document.createElement('span');
-    span.textContent = chrome.i18n.getMessage(
-        'inject_extrainfo_message_state', [stateLocalized]);
-    span.title = kItemMetadataState[state] ?? state;
-    return span;
-  }
-
-  static getLiveReviewStatusLabel(verdict) {
-    let label, labelClass;
-    switch (verdict) {
-      case 1:  // LIVE_REVIEW_RELEVANT
-        label = 'relevant';
-        labelClass = 'TWPT-extrainfo-good';
-        break;
-
-      case 2:  // LIVE_REVIEW_OFF_TOPIC
-        label = 'offtopic';
-        labelClass = 'TWPT-extrainfo-bad';
-        break;
-
-      case 3:  // LIVE_REVIEW_ABUSE
-        label = 'abuse';
-        labelClass = 'TWPT-extrainfo-bad';
-        break;
-
-      default:
-        return [null, null];
-    }
-    return [label, labelClass];
-  }
-
-  static getShadowBlockChip(itemMetadata) {
-    const shadowBlockInfo = itemMetadata?.['10'];
-    const blockedTimestampMicros = shadowBlockInfo?.['2'];
-    if (!blockedTimestampMicros) return null;
-
-    const isBlocked = shadowBlockInfo?.['1'];
-    let span = document.createElement('span');
-    span.textContent = chrome.i18n.getMessage(
-        'inject_extrainfo_message_shadowblock' +
-        (isBlocked ? 'active' : 'notactive'));
-    if (isBlocked) span.classList.add('TWPT-extrainfo-bad');
-    return span;
-  }
-
   static getThreadFromThreadList(threadList, currentThreadInfo) {
     return threadList?.find?.(thread => {
       const threadInfo = thread?.['2']?.['1'];
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 3729937..4cd711c 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -64,10 +64,10 @@
   'ec-canned-response-row .main .toolbelt',
 
   // Question state chips container (for the extra info feature)
-  'ec-question .state-chips',
+  'sc-tailwind-thread-question-question-card sc-tailwind-thread-question-state-chips',
 
   // Replies (for the extra info feature)
-  'ec-thread ec-message',
+  'sc-tailwind-thread-message-message-list sc-tailwind-thread-message-message-card',
 
   // User activity chart (for the per-forum stats feature)
   'ec-unified-user .scTailwindUser_profileUserprofilesection ' +
@@ -195,10 +195,12 @@
     }
 
     // Show additional details in the thread view.
-    if (node.matches('ec-question .state-chips')) {
+    if (node.matches(
+            'sc-tailwind-thread-question-question-card sc-tailwind-thread-question-state-chips')) {
       window.TWPTExtraInfo.injectAtQuestionIfEnabled(node);
     }
-    if (node.matches('ec-thread ec-message')) {
+    if (node.matches(
+            'sc-tailwind-thread-message-message-list sc-tailwind-thread-message-message-card')) {
       window.TWPTExtraInfo.injectAtMessageIfEnabled(node);
     }
 
diff --git a/src/models/Message.js b/src/models/Message.js
index 05dc795..0e9d6fb 100644
--- a/src/models/Message.js
+++ b/src/models/Message.js
@@ -76,6 +76,10 @@
     return this.data[5]?.[1] ?? null;
   }
 
+  getEndPendingStateTimestampMicros() {
+    return this.data[1]?.[17] ?? null;
+  }
+
   isTakenDown() {
     return [
       ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_DELETE,