refactor: migrate extra info feature to the new architecture

Bug: twpowertools:176
Change-Id: I379216066b973fe76f000ab9581053c1f0da569e
diff --git a/src/features/Features.ts b/src/features/Features.ts
index 6eab170..41e6b5d 100644
--- a/src/features/Features.ts
+++ b/src/features/Features.ts
@@ -2,12 +2,14 @@
 import AutoRefreshFeature from './autoRefresh/autoRefresh.feature';
 import InfiniteScrollFeature from './infiniteScroll/infiniteScroll.feature';
 import ScriptFilterListProvider from '../common/architecture/scripts/ScriptFilterListProvider';
+import ExtraInfoFeature from './extraInfo/extraInfo.feature';
 
 export type ConcreteFeatureClass = { new (): Feature };
 
 export default class Features extends ScriptFilterListProvider {
   private features: ConcreteFeatureClass[] = [
     AutoRefreshFeature,
+    ExtraInfoFeature,
     InfiniteScrollFeature,
   ];
   private initializedFeatures: Feature[];
diff --git a/src/features/extraInfo/core/consts.js b/src/features/extraInfo/core/consts.js
new file mode 100644
index 0000000..236cf2e
--- /dev/null
+++ b/src/features/extraInfo/core/consts.js
@@ -0,0 +1,198 @@
+export const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
+export const kViewThreadResponse = 'TWPT_ViewThreadResponse';
+export const kViewForumRequest = 'TWPT_ViewForumRequest';
+export const kViewForumResponse = 'TWPT_ViewForumResponse';
+
+// Used to match each category with the corresponding string.
+export const kAbuseCategories = [
+  ['1', 'account'],
+  ['2', 'displayname'],
+  ['3', 'avatar'],
+];
+export const kAbuseViolationCategories = {
+  0: 'NO_VIOLATION',
+  1: 'COMMUNITY_POLICY_VIOLATION',
+  2: 'LEGAL_VIOLATION',
+  3: 'CSAI_VIOLATION',
+  4: 'OTHER_VIOLATION',
+};
+export const kAbuseViolationCategoriesI18n = {
+  0: 'noviolation',
+  1: 'communitypolicy',
+  2: 'legal',
+  3: 'csai',
+  4: 'other',
+};
+
+// The following array will appear in the interface as is (without being
+// translated).
+export const kAbuseViolationTypes = {
+  0: 'UNSPECIFIED',
+  23: 'ACCOUNT_DISABLED',
+  55: 'ACCOUNT_HAS_SERVICES_DISABLED',
+  35: 'ACCOUNT_HIJACKED',
+  96: 'ACCOUNT_LEAKED_CREDENTIALS',
+  92: 'ACCOUNT_NOT_SUPPORTED',
+  81: 'ARTISTIC_NUDITY',
+  66: 'BAD_BEHAVIOR_PATTERN',
+  78: 'BAD_ENGAGEMENT_BEHAVIOR_PATTERN',
+  79: 'BORDERLINE_HARASSMENT',
+  80: 'BORDERLINE_HATE_SPEECH',
+  38: 'BOTNET',
+  32: 'BRANDING_VIOLATION',
+  100: 'CAPITALIZING_TRAGIC_EVENTS',
+  105: 'CLOAKING',
+  49: 'COIN_MINING',
+  7: 'COMMERCIAL_CONTENT',
+  97: 'COPPA_REGULATED',
+  57: 'COPYRIGHT_CIRCUMVENTION',
+  8: 'COPYRIGHTED_CONTENT',
+  58: 'COURT_ORDER',
+  51: 'CSAI',
+  94: 'CSAI_INSPECT',
+  52: 'CSAI_CARTOON_HUMOR',
+  53: 'CSAI_SOLICITATION',
+  108: 'CSAI_NON_APPARENT',
+  67: 'DANGEROUS',
+  37: 'DATA_SCRAPING',
+  86: 'DECEPTIVE_OAUTH_IMPLEMENTATION',
+  46: 'DEFAMATORY_CONTENT',
+  36: 'DELINQUENT_BILLING',
+  30: 'DISRUPTION_ATTEMPT',
+  112: 'DOMESTIC_INTERFERENCE',
+  22: 'DOS',
+  9: 'DUPLICATE_CONTENT',
+  68: 'DUPLICATE_LOCAL_PAGE',
+  121: 'NON_QUALIFYING_ORGANIZATION',
+  115: 'EGREGIOUS_INTERACTION_WITH_MINOR',
+  83: 'ENGAGEMENT_COLLUSION',
+  41: 'EXPLOIT_ATTACKS',
+  65: 'FAKE_USER',
+  2: 'FRAUD',
+  21: 'FREE_TRIAL_VIOLATION',
+  43: 'GIBBERISH',
+  101: 'FOREIGN_INTERFERENCE',
+  59: 'GOVERNMENT_ORDER',
+  10: 'GRAPHICAL_VIOLENCE',
+  11: 'HARASSMENT',
+  12: 'HATE_SPEECH',
+  90: 'IDENTICAL_PRODUCT_NAME',
+  60: 'ILLEGAL_DRUGS',
+  13: 'IMPERSONATION',
+  69: 'IMPERSONATION_WITH_PII',
+  116: 'INAPPROPRIATE_INTERACTION_WITH_MINOR',
+  45: 'INAPPROPRIATE_CONTENT_SPEECH',
+  106: 'INTENTIONAL_THWARTING',
+  27: 'INTRUSION_ATTEMPT',
+  87: 'INVALID_API_USAGE',
+  14: 'INVALID_CONTENT',
+  20: 'INVALID_GCE_USAGE',
+  120: 'INVALID_STORAGE_USAGE',
+  15: 'INVALID_IMAGE_QUALITY',
+  88: 'INVALID_API_PRIVACY_POLICY_DISCLOSURE',
+  54: 'INVALID_USAGE_OF_IP_PROXYING',
+  99: 'KEYWORD_STUFFING',
+  61: 'LEGAL_COUNTERFEIT',
+  62: 'LEGAL_EXPORT',
+  63: 'LEGAL_PRIVACY',
+  33: 'LEGAL_REVIEW',
+  91: 'LEGAL_PROTECTED',
+  70: 'LOW_QUALITY_CONTENT',
+  93: 'LOW_REPUTATION_PHONE_NUMBER',
+  6: 'MALICIOUS_SOFTWARE',
+  40: 'MALWARE',
+  113: 'MISLEADING',
+  114: 'MISREP_OF_ID',
+  89: 'MEMBER_OF_ABUSIVE_GCE_NETWORK',
+  84: 'NON_CONSENSUAL_EXPLICIT_IMAGERY',
+  1: 'NONE',
+  102: 'OFF_TOPIC',
+  31: 'OPEN_PROXY',
+  28: 'PAYMENT_FRAUD',
+  16: 'PEDOPHILIA',
+  71: 'PERSONAL_INFORMATION_CONTENT',
+  25: 'PHISHING',
+  34: 'POLICY_REVIEW',
+  17: 'PORNOGRAPHY',
+  29: 'QUOTA_CIRCUMVENTION',
+  72: 'QUOTA_EXCEEDED',
+  73: 'REGULATED',
+  24: 'REPEATED_POLICY_VIOLATION',
+  104: 'RESOURCE_COMPROMISED',
+  107: 'REWARD_PROGRAMS_ABUSE',
+  74: 'ROGUE_PHARMA',
+  82: 'ESCORT',
+  75: 'SPAMMY_LOCAL_VERTICAL',
+  39: 'SEND_EMAIL_SPAM',
+  117: 'SEXTORTION',
+  118: 'SEX_TRAFFICKING',
+  44: 'SEXUALLY_EXPLICIT_CONTENT',
+  3: 'SHARDING',
+  95: 'SOCIAL_ENGINEERING',
+  109: 'SUSPICIOUS',
+  19: 'TRADEMARK_CONTENT',
+  50: 'TRAFFIC_PUMPING',
+  76: 'UNSAFE_RACY',
+  103: 'UNUSUAL_ACTIVITY_ALERT',
+  64: 'UNWANTED_CONTENT',
+  26: 'UNWANTED_SOFTWARE',
+  77: 'VIOLENT_EXTREMISM',
+  119: 'UNAUTH_IMAGES_OF_MINORS',
+  85: 'UNAUTHORIZED_SERVICE_RESELLING',
+  98: 'CSAI_EXTERNAL',
+  5: 'SPAM',
+  4: 'UNSAFE',
+  47: 'CHILD_PORNOGRAPHY_INCITATION',
+  18: 'TERRORISM_SUPPORT',
+  56: 'CSAI_WORST_OF_WORST',
+};
+
+// These values will be translated
+export const kItemMetadataStateI18n = {
+  1: 'published',
+  2: 'draft',
+  3: 'automated_abuse_take_down_hide2',
+  4: 'automated_abuse_take_down_delete2',
+  13: 'automated_abuse_reinstate2',
+  // TODO: Add the following line and its corresponding translation once we know
+  // what the state means: `21: 'automated_abuse_manual_review',`
+  10: 'automated_off_topic_hide2',
+  14: 'automated_flagged_pending_manual_review2',
+  5: 'user_flagged_pending_manual_review',
+  6: 'owner_deleted',
+  7: 'manual_take_down_hide2',
+  17: 'manual_profile_take_down_suspend2',
+  8: 'manual_take_down_delete2',
+  18: 'reinstate_profile_takedown2',
+  9: 'reinstate_abuse_takedown2',
+  11: 'clear_off_topic2',
+  12: 'confirm_off_topic2',
+  15: 'googler_off_topic_hide2',
+  16: 'expert_flagged_pending_manual_review',
+  19: 'awaiting_classification',
+  20: 'generated_answer_adopted',
+};
+export const kItemMetadataState = {
+  0: 'UNDEFINED',
+  1: 'PUBLISHED',
+  2: 'DRAFT',
+  3: 'AUTOMATED_ABUSE_TAKE_DOWN_HIDE',
+  4: 'AUTOMATED_ABUSE_TAKE_DOWN_DELETE',
+  13: 'AUTOMATED_ABUSE_REINSTATE',
+  21: 'AUTOMATED_ABUSE_MANUAL_REVIEW',
+  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',
+  19: 'AWAITING_CLASSIFICATION',
+  20: 'GENERATED_ANSWER_ADOPTED',
+};
diff --git a/src/features/extraInfo/core/index.js b/src/features/extraInfo/core/index.js
new file mode 100644
index 0000000..c7be5cd
--- /dev/null
+++ b/src/features/extraInfo/core/index.js
@@ -0,0 +1,68 @@
+import OptionsWatcher from '../../../common/optionsWatcher.js';
+
+import ProfileInfoHandler from './infoHandlers/profile.js';
+import ThreadInfoHandler from './infoHandlers/thread.js';
+import ThreadListInfoHandler from './infoHandlers/threadList.js';
+import ExpandedThreadListExtraInfoInjection from './injections/expandedThreadList.js';
+import ProfileAbuseExtraInfoInjection from './injections/profileAbuse.js';
+import ProfilePerForumStatsExtraInfoInjection from './injections/profilePerForumStats.js';
+import ThreadCommentExtraInfoInjection from './injections/threadComment.js';
+import ThreadListExtraInfoInjection from './injections/threadList.js';
+import ThreadQuestionExtraInfoInjection from './injections/threadQuestion.js';
+import ThreadReplyExtraInfoInjection from './injections/threadReply.js';
+
+export default class ExtraInfo {
+  constructor() {
+    const optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
+
+    const profileInfoHandler = new ProfileInfoHandler();
+    const threadInfoHandler = new ThreadInfoHandler();
+    const threadListInfoHandler = new ThreadListInfoHandler();
+
+    this.profileAbuse =
+        new ProfileAbuseExtraInfoInjection(profileInfoHandler, optionsWatcher);
+    this.profilePerForumStats = new ProfilePerForumStatsExtraInfoInjection(
+        profileInfoHandler, optionsWatcher);
+    this.threadQuestion =
+        new ThreadQuestionExtraInfoInjection(threadInfoHandler, optionsWatcher);
+    this.threadReply =
+        new ThreadReplyExtraInfoInjection(threadInfoHandler, optionsWatcher);
+    this.threadComment =
+        new ThreadCommentExtraInfoInjection(threadInfoHandler, optionsWatcher);
+    this.expandedThreadList = new ExpandedThreadListExtraInfoInjection(
+        threadListInfoHandler, optionsWatcher);
+    this.threadList =
+        new ThreadListExtraInfoInjection(threadListInfoHandler, optionsWatcher);
+  }
+
+  injectAbuseChipsAtProfileIfEnabled(card) {
+    this.profileAbuse.injectIfEnabled({card});
+  }
+
+  injectAtThreadListIfEnabled(li) {
+    const injectionDetails = this.threadList.getInjectionDetails(li);
+    this.threadList.injectIfEnabled(injectionDetails);
+  }
+
+  injectAtExpandedThreadListIfEnabled(toolbelt) {
+    const injectionDetails =
+        this.expandedThreadList.getInjectionDetails(toolbelt);
+    this.expandedThreadList.injectIfEnabled(injectionDetails);
+  }
+
+  injectPerForumStatsIfEnabled(chart) {
+    this.profilePerForumStats.injectIfEnabled({chart});
+  }
+
+  injectAtQuestionIfEnabled(stateChips) {
+    this.threadQuestion.injectIfEnabled({stateChips, isMessageNode: false});
+  }
+
+  injectAtReplyIfEnabled(messageNode) {
+    this.threadReply.injectIfEnabled({messageNode, isMessageNode: true});
+  }
+
+  injectAtCommentIfEnabled(messageNode) {
+    this.threadComment.injectIfEnabled({messageNode, isMessageNode: true});
+  }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/base.js b/src/features/extraInfo/core/infoHandlers/base.js
new file mode 100644
index 0000000..732ccee
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/base.js
@@ -0,0 +1,17 @@
+import {shouldImplement} from '../../../../common/commonUtils.js';
+
+export default class BaseInfoHandler {
+  constructor() {
+    if (this.constructor == BaseInfoHandler) {
+      throw new Error('The base class cannot be instantiated.');
+    }
+  }
+
+  /**
+   * Should return a promise which resolves to the current info in a best-effort
+   * manner (if it can't retrieve the current info it is allowed to fail).
+   */
+  async getCurrentInfo(_injectionDetails) {
+    shouldImplement('getCurrentInfo');
+  }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/basedOnResponseEvent.js b/src/features/extraInfo/core/infoHandlers/basedOnResponseEvent.js
new file mode 100644
index 0000000..30bb36d
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/basedOnResponseEvent.js
@@ -0,0 +1,86 @@
+import {waitFor} from 'poll-until-promise';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+
+import BaseInfoHandler from './base.js';
+
+export default class ResponseEventBasedInfoHandler extends BaseInfoHandler {
+  constructor() {
+    super();
+
+    if (this.constructor == ResponseEventBasedInfoHandler) {
+      throw new Error('The base class cannot be instantiated.');
+    }
+
+    this.setUpDefaultInfoValue();
+    this.setUpEventHandler();
+  }
+
+  /**
+   * Should return the name of the XHR interceptor event for the API response
+   * which has the information being handled.
+   */
+  getEvent() {
+    shouldImplement('getEvent');
+  }
+
+  /**
+   * This function should return a promise which resolves to a boolean
+   * specifying whether this.info is the information related to the view that
+   * the user is currently on.
+   */
+  async isInfoCurrent(_injectionDetails) {
+    shouldImplement('isInfoCurrent');
+  }
+
+  /**
+   * Should return the options for the waitFor function which is called when
+   * checking whether the information is current or not.
+   */
+  getWaitForCurrentInfoOptions() {
+    shouldImplement('getWaitForCurrentInfoOptions');
+  }
+
+  setUpDefaultInfoValue() {
+    this.info = {
+      body: {},
+      id: -1,
+      timestamp: 0,
+    };
+  }
+
+  setUpEventHandler() {
+    window.addEventListener(this.getEvent(), e => {
+      if (e.detail.id < this.info.id) return;
+
+      this.updateInfoWithNewValue(e);
+    });
+  }
+
+  /**
+   * Updates the info value with the information obtained from an event.
+   * Can be overriden to implement more advanced logic.
+   *
+   * @param {Event} e
+   */
+  updateInfoWithNewValue(e) {
+    this.info = {
+      body: e.detail.body,
+      id: e.detail.id,
+      timestamp: Date.now(),
+    };
+  }
+
+  async getCurrentInfo(injectionDetails) {
+    const options = this.getWaitForCurrentInfoOptions();
+    return waitFor(
+        () => this.attemptToGetCurrentInfo(injectionDetails), options);
+  }
+
+  async attemptToGetCurrentInfo(injectionDetails) {
+    const isInfoCurrent = await this.isInfoCurrent(injectionDetails);
+    if (!isInfoCurrent) throw new Error('Didn\'t receive current information');
+
+    return this.info;
+  }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/profile.js b/src/features/extraInfo/core/infoHandlers/profile.js
new file mode 100644
index 0000000..28e8309
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/profile.js
@@ -0,0 +1,20 @@
+import {kViewUnifiedUserResponseEvent} from '../consts.js';
+
+import ResponseEventBasedInfoHandler from './basedOnResponseEvent.js';
+
+export default class ProfileInfoHandler extends ResponseEventBasedInfoHandler {
+  getEvent() {
+    return kViewUnifiedUserResponseEvent;
+  }
+
+  async isInfoCurrent() {
+    return Date.now() - this.info.timestamp < 15 * 1000;
+  }
+
+  getWaitForCurrentInfoOptions() {
+    return {
+      interval: 500,
+      timeout: 15 * 1000,
+    };
+  }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/thread.js b/src/features/extraInfo/core/infoHandlers/thread.js
new file mode 100644
index 0000000..6c8fb73
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/thread.js
@@ -0,0 +1,107 @@
+import {waitFor} from 'poll-until-promise';
+
+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 = 3 * 1000;
+const kCurrentInfoExpiresInMs = kTimeoutInMs * 1.5;
+
+export default class ThreadInfoHandler extends ResponseEventBasedInfoHandler {
+  getEvent() {
+    return kViewThreadResponse;
+  }
+
+  getWaitForCurrentInfoOptions() {
+    return {
+      interval: kIntervalInMs,
+      timeout: kTimeoutInMs,
+    };
+  }
+
+  setUpDefaultInfoValue() {
+    this.info = {
+      thread: new ThreadModel(),
+      messages: [],
+      id: -1,
+      timestamp: 0,
+    };
+  }
+
+  updateInfoWithNewValue(e) {
+    const newThread = new ThreadModel(e.detail.body?.[1]);
+    if (newThread.getId() != this.info.thread.getId()) {
+      this.info.messages = [];
+    }
+
+    const newMessages = newThread.getAllMessagesList();
+    this.updateRecordedMessages(newMessages);
+
+    this.info.thread = newThread;
+    this.info.id = e.detail.id;
+    this.info.timestamp = Date.now();
+  }
+
+  updateRecordedMessages(newMessages) {
+    const nonUpdatedMessages = this.info.messages.filter(message => {
+      return !newMessages.some(newMessage => {
+        return message.getId() == newMessage.getId();
+      });
+    });
+    this.info.messages = nonUpdatedMessages.concat(newMessages);
+  }
+
+  async getCurrentInfo(injectionDetails) {
+    return this
+        .getCurrentThreads(injectionDetails, /* checkRecentTimestamp = */ true)
+        .catch(() => {
+          console.debug(
+              `extraInfo: couldn't get updated thread info. Trying to ` +
+              `get the information even if it is old.`);
+          return this.getCurrentThreads(
+              injectionDetails, /* checkRecentTimestamp = */ false);
+        });
+  }
+
+  async getCurrentThreads(injectionDetails, checkRecentTimestamp) {
+    injectionDetails.checkRecentTimestamp = checkRecentTimestamp;
+    const options = this.getWaitForCurrentInfoOptions();
+    return waitFor(
+        () => this.attemptToGetCurrentInfo(injectionDetails), options);
+  }
+
+  async isInfoCurrent(injectionDetails) {
+    const checkRecentTimestamp = injectionDetails.checkRecentTimestamp;
+    const isMessageNode = injectionDetails.isMessageNode;
+    const messageNode = injectionDetails.messageNode;
+
+    return (!checkRecentTimestamp || this.isThreadCurrent()) &&
+        (!isMessageNode || this.currentThreadContainsMessage(messageNode));
+  }
+
+  isThreadCurrent() {
+    const currentPage = this.parseThreadUrl();
+    return Date.now() - this.info.timestamp < kCurrentInfoExpiresInMs &&
+        this.info.thread.getId() == currentPage.thread &&
+        this.info.thread.getForumId() == currentPage.forum;
+  }
+
+  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.getMessageFromList(
+        messageId, this.info.messages);
+    return message !== undefined;
+  }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/threadList.js b/src/features/extraInfo/core/infoHandlers/threadList.js
new file mode 100644
index 0000000..47d7e65
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/threadList.js
@@ -0,0 +1,100 @@
+import {waitFor} from 'poll-until-promise';
+
+import {kViewForumRequest, kViewForumResponse} from '../consts.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseInfoHandler from './base.js';
+
+const kCheckIntervalInMs = 450;
+const kTimeoutInMs = 2 * 1000;
+
+export default class ThreadListInfoHandler extends BaseInfoHandler {
+  constructor() {
+    super();
+
+    this.setUpDefaultValues();
+    this.setUpEventHandlers();
+  }
+
+  setUpDefaultValues() {
+    this.threads = [];
+    this.isFirstBatch = null;
+    this.requestId = -1;
+    this.timestamp = 0;
+  }
+
+  setUpEventHandlers() {
+    window.addEventListener(kViewForumRequest, e => this.onThreadRequest(e));
+    window.addEventListener(kViewForumResponse, e => this.onThreadResponse(e));
+  }
+
+  onThreadRequest(e) {
+    // Ignore ViewForum requests made by the chat feature and the "Mark as
+    // duplicate" dialog.
+    //
+    // All those requests have |maxNum| set to 10 and 20 respectively, while
+    // the requests that we want to handle are the ones to initially load the
+    // thread list (which currently requests 100 threads) and the ones to load
+    // more threads (which request 50 threads).
+    const maxNum = e.detail.body?.['2']?.['1']?.['2'];
+    if (maxNum == 10 || maxNum == 20) return;
+
+    this.requestId = e.detail.id;
+    this.isFirstBatch =
+        !e.detail.body?.['2']?.['1']?.['3']?.['2'];  // Pagination token
+  }
+
+  onThreadResponse(e) {
+    if (e.detail.id != this.requestId) return;
+
+    const threads = e.detail.body?.['1']?.['2'] ?? [];
+    if (this.isFirstBatch)
+      this.threads = threads;
+    else
+      this.threads = this.threads.concat(threads);
+
+    this.timestamp = Date.now();
+  }
+
+  async getCurrentInfo(injectionDetails) {
+    const currentThreadInfo = injectionDetails.threadInfo;
+    const checkRecentTimestamp = !injectionDetails.isExpanded;
+
+    return this.getCurrentThreads(currentThreadInfo, checkRecentTimestamp)
+        .catch(err => {
+          if (checkRecentTimestamp) {
+            return this.getCurrentThreads(
+                currentThreadInfo, /* checkRecentTimestamp = */ false);
+          } else {
+            throw err;
+          }
+        });
+  }
+
+  async getCurrentThreads(currentThreadInfo, checkRecentTimestamp) {
+    const options = {
+      interval: kCheckIntervalInMs,
+      timeout: kTimeoutInMs,
+    };
+    return waitFor(
+        () => this.attemptToGetCurrentThreads(
+            currentThreadInfo, checkRecentTimestamp),
+        options);
+  }
+
+  async attemptToGetCurrentThreads(currentThreadInfo, checkRecentTimestamp) {
+    if (!this.isThreadListCurrent(currentThreadInfo, checkRecentTimestamp))
+      throw new Error('Didn\'t receive current information');
+
+    return this.threads;
+  }
+
+  isThreadListCurrent(currentThreadInfo, checkRecentTimestamp) {
+    if (checkRecentTimestamp && Date.now() - this.timestamp > kTimeoutInMs)
+      return false;
+
+    const thread = ThreadExtraInfoService.getThreadFromThreadList(
+        this.threads, currentThreadInfo);
+    return thread !== undefined;
+  }
+}
diff --git a/src/features/extraInfo/core/injections/base.js b/src/features/extraInfo/core/injections/base.js
new file mode 100644
index 0000000..66c024e
--- /dev/null
+++ b/src/features/extraInfo/core/injections/base.js
@@ -0,0 +1,100 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+import {createExtBadge} from '../../../../contentScripts/communityConsole/utils/common.js';
+
+export default class BaseExtraInfoInjection {
+  constructor(infoHandler, optionsWatcher) {
+    if (this.constructor == BaseExtraInfoInjection) {
+      throw new Error('The base class cannot be instantiated.');
+    }
+
+    this.infoHandler = infoHandler;
+    this.optionsWatcher = optionsWatcher;
+  }
+
+  /**
+   * Method which actually injects the extra information. It should be
+   * implemented by the extending class.
+   */
+  inject() {
+    shouldImplement('inject');
+  }
+
+  /**
+   * Overridable method which is called when an error ocurred while retrieving
+   * the info needed to inject the extra information. This is useful to show an
+   * error component in the screen.
+   */
+  injectOnInfoRetrievalError() {}
+
+  async isEnabled() {
+    return await this.optionsWatcher.isEnabled('extrainfo');
+  }
+
+  /**
+   * This is the method which should be called when injecting extra information.
+   */
+  async injectIfEnabled(injectionDetails) {
+    const isEnabled = await this.isEnabled();
+    if (!isEnabled) return;
+
+    return this.infoHandler.getCurrentInfo(injectionDetails)
+        .catch(err => {
+          this.injectOnInfoRetrievalError();
+          throw err;
+        })
+        .then(info => this.inject(info, injectionDetails))
+        .catch(err => {
+          console.error(
+              `${this.constructor.name}: error while injecting extra info: `,
+              err);
+        });
+  }
+
+  /**
+   * Add chips which contain |chipContentList| to |node|. If |withContainer| is
+   * set to true, a container will contain all the chips.
+   */
+  addExtraInfoChips(chipContentList, node, withContainer = false) {
+    if (chipContentList.length == 0) return;
+
+    let container;
+    if (withContainer) {
+      container = document.createElement('div');
+      container.classList.add('TWPT-extrainfo-container');
+    } else {
+      container = node;
+    }
+
+    let tooltips = [];
+
+    for (const content of chipContentList) {
+      const tooltip = this.addChipToContainer(content, container);
+      tooltips.push(tooltip);
+    }
+
+    if (withContainer) node.append(container);
+
+    for (const tooltip of tooltips) new MDCTooltip(tooltip);
+  }
+
+  /**
+   * Adds a chip to the container and returns a tooltip element to be
+   * instantiated.
+   */
+  addChipToContainer(chipContent, container) {
+    let chip = document.createElement('div');
+    chip.classList.add('TWPT-extrainfo-chip');
+
+    const [badge, badgeTooltip] = createExtBadge();
+
+    let span = document.createElement('span');
+    span.append(chipContent);
+
+    chip.append(badge, span);
+    container.append(chip);
+
+    return badgeTooltip;
+  }
+}
diff --git a/src/features/extraInfo/core/injections/baseThreadMessage.js b/src/features/extraInfo/core/injections/baseThreadMessage.js
new file mode 100644
index 0000000..d0cd162
--- /dev/null
+++ b/src/features/extraInfo/core/injections/baseThreadMessage.js
@@ -0,0 +1,54 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+import ThreadModel from '../../../../models/Thread.js';
+import MessageExtraInfoService from '../services/message.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class BaseThreadMessageExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  /**
+   * The class of the interactions root element.
+   */
+  getInteractionsRootClass() {
+    shouldImplement('getInteractionsRootClass');
+  }
+
+  /**
+   * The class of the interactions root element which signifies that it is
+   * non-empty.
+   */
+  getInteractionsRootNonEmptyClass() {
+    shouldImplement('getInteractionsRootNonEmptyClass');
+  }
+
+  inject(threadInfo, injectionDetails) {
+    const messageNode = injectionDetails.messageNode;
+    const message = this.#getMessage(threadInfo.messages, messageNode);
+    const [chips, tooltips] = MessageExtraInfoService.getMessageChips(message);
+    this.#injectChips(chips, messageNode);
+    for (const tooltip of tooltips) new MDCTooltip(tooltip);
+  }
+
+  #getMessage(messagesList, messageNode) {
+    const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
+    return MessageExtraInfoService.getMessageFromList(messageId, messagesList);
+  }
+
+  #injectChips(chips, messageNode) {
+    const interactionsElement =
+        messageNode.querySelector('.' + this.getInteractionsRootClass());
+    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(this.getInteractionsRootNonEmptyClass());
+  }
+}
diff --git a/src/features/extraInfo/core/injections/expandedThreadList.js b/src/features/extraInfo/core/injections/expandedThreadList.js
new file mode 100644
index 0000000..f5a6874
--- /dev/null
+++ b/src/features/extraInfo/core/injections/expandedThreadList.js
@@ -0,0 +1,39 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {parseUrl} from '../../../../common/commonUtils.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ExpandedThreadListExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  getInjectionDetails(toolbelt) {
+    const headerContent =
+        toolbelt?.parentNode?.parentNode?.parentNode?.querySelector?.(
+            '.main-header .header a.header-content');
+    if (headerContent === null) {
+      throw new Error(
+          `extraInfo: Header is not present in the thread item's DOM.`);
+    }
+
+    const threadInfo = parseUrl(headerContent.href);
+    if (threadInfo === false)
+      throw new Error(`extraInfo: Thread's link cannot be parsed.`);
+
+    return {
+      toolbelt,
+      threadInfo,
+      isExpanded: true,
+    };
+  }
+
+  inject(threads, injectionDetails) {
+    const thread = ThreadExtraInfoService.getThreadFromThreadList(
+        threads, injectionDetails.threadInfo);
+    const [chipContentList, tooltips] =
+        ThreadExtraInfoService.getThreadChips(thread);
+    this.addExtraInfoChips(
+        chipContentList, injectionDetails.toolbelt, /* withContainer = */ true);
+    for (const tooltip of tooltips) new MDCTooltip(tooltip);
+  }
+}
diff --git a/src/features/extraInfo/core/injections/profileAbuse.js b/src/features/extraInfo/core/injections/profileAbuse.js
new file mode 100644
index 0000000..2ba911c
--- /dev/null
+++ b/src/features/extraInfo/core/injections/profileAbuse.js
@@ -0,0 +1,78 @@
+import {kAbuseCategories, kAbuseViolationCategories, kAbuseViolationCategoriesI18n, kAbuseViolationTypes} from '../consts.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ProfileAbuseExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  constructor(infoHandler, optionsWatcher) {
+    super(infoHandler, optionsWatcher);
+    this.unifiedUserView = undefined;
+  }
+
+  inject(profileInfo, injectionDetails) {
+    this.unifiedUserView = profileInfo.body?.['1'];
+    const chips = this.getChips();
+    this.addExtraInfoChips(
+        chips, injectionDetails.card, /* withContainer = */ true);
+  }
+
+  getChips() {
+    const chips = [
+      this.getGeneralAbuseViolationCategoryChip(),
+      ...this.getProfileAbuseChips(),
+      this.getAppealCountChip(),
+    ];
+
+    return chips.filter(chip => chip !== null);
+  }
+
+  getGeneralAbuseViolationCategoryChip() {
+    const abuseViolationCategory = this.unifiedUserView?.['6'];
+    if (!abuseViolationCategory) return null;
+    return this.getAbuseViolationCategoryChipContent(abuseViolationCategory);
+  }
+
+  getProfileAbuseChips() {
+    return kAbuseCategories
+        .map(category => {
+          return this.getProfileAbuseCategoryChip(category);
+        })
+        .filter(chip => chip !== null);
+  }
+
+  getAppealCountChip() {
+    const profileAbuse = this.unifiedUserView?.['1']?.['8'];
+    const appealCount = profileAbuse?.['4'];
+    if (appealCount === undefined) return null;
+
+    return chrome.i18n.getMessage(
+        'inject_extrainfo_profile_appealsnum', [appealCount]);
+  }
+
+  getAbuseViolationCategoryChipContent(abuseViolationCategory) {
+    const content = document.createElement('span');
+
+    const categoryI18nKey = 'inject_extrainfo_profile_abusecategory_' +
+        kAbuseViolationCategoriesI18n[abuseViolationCategory];
+    const categoryLocalized =
+        chrome.i18n.getMessage(categoryI18nKey) ?? abuseViolationCategory;
+    content.textContent = chrome.i18n.getMessage(
+        'inject_extrainfo_profile_abusecategory', [categoryLocalized]);
+
+    content.title = kAbuseViolationCategories[abuseViolationCategory] ??
+        abuseViolationCategory;
+
+    return content;
+  }
+
+  getProfileAbuseCategoryChip(abuseCategory) {
+    const [protoIndex, category] = abuseCategory;
+    const profileAbuse = this.unifiedUserView?.['1']?.['8'];
+    const violation = profileAbuse?.[protoIndex]?.['1']?.['1'];
+    if (!violation) return null;
+
+    return chrome.i18n.getMessage(
+        'inject_extrainfo_profile_abuse_' + category,
+        [kAbuseViolationTypes[violation]]);
+  }
+}
diff --git a/src/features/extraInfo/core/injections/profilePerForumStats.js b/src/features/extraInfo/core/injections/profilePerForumStats.js
new file mode 100644
index 0000000..83de9db
--- /dev/null
+++ b/src/features/extraInfo/core/injections/profilePerForumStats.js
@@ -0,0 +1,22 @@
+import {getDisplayLanguage} from '../../../../contentScripts/communityConsole/utils/common.js';
+import PerForumStatsSection from '../../../../contentScripts/communityConsole/utils/PerForumStatsSection.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ProfilePerForumStatsExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  constructor(infoHandler, optionsWatcher) {
+    super(infoHandler, optionsWatcher);
+    this.displayLanguage = getDisplayLanguage();
+  }
+
+  async isEnabled() {
+    return await this.optionsWatcher.isEnabled('perforumstats');
+  }
+
+  inject(profileInfo, injectionDetails) {
+    new PerForumStatsSection(
+        injectionDetails.chart?.parentNode, profileInfo.body,
+        this.displayLanguage, /* isCommunityConsole = */ true);
+  }
+}
diff --git a/src/features/extraInfo/core/injections/threadComment.js b/src/features/extraInfo/core/injections/threadComment.js
new file mode 100644
index 0000000..ed14575
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadComment.js
@@ -0,0 +1,11 @@
+import BaseThreadMessageExtraInfoInjection from './baseThreadMessage.js';
+
+export default class ThreadCommentExtraInfoInjection extends BaseThreadMessageExtraInfoInjection {
+  getInteractionsRootClass() {
+    return 'scTailwindThreadMessageMessageinteractionsroot';
+  }
+
+  getInteractionsRootNonEmptyClass() {
+    return 'scTailwindThreadMessageMessageinteractionsinteractions';
+  }
+}
diff --git a/src/features/extraInfo/core/injections/threadList.js b/src/features/extraInfo/core/injections/threadList.js
new file mode 100644
index 0000000..e35fbe0
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadList.js
@@ -0,0 +1,77 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {parseUrl} from '../../../../common/commonUtils.js';
+import {createExtBadge} from '../../../../contentScripts/communityConsole/utils/common.js';
+import {kItemMetadataState, kItemMetadataStateI18n} from '../consts.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ThreadListExtraInfoInjection extends
+    BaseExtraInfoInjection {
+  getInjectionDetails(li) {
+    const headerContent = li.querySelector(
+        'ec-thread-summary .main-header .header a.header-content');
+    if (headerContent === null) {
+      throw new Error(
+          `extraInfo: Header is not present in the thread item's DOM.`);
+    }
+
+    const threadInfo = parseUrl(headerContent.href);
+    if (threadInfo === false)
+      throw new Error(`extraInfo: Thread's link cannot be parsed.`);
+
+    return {
+      li,
+      threadInfo,
+      isExpanded: false,
+    };
+  }
+
+  inject(threads, injectionDetails) {
+    const thread = ThreadExtraInfoService.getThreadFromThreadList(
+        threads, injectionDetails.threadInfo);
+
+    const state = thread?.['2']?.['12']?.['1'];
+    if (!state || [1, 13, 18, 9].includes(state)) return;
+
+    const [label, badgeTooltip] = this.createLabelElement(state);
+    const authorLine = this.getAuthorLine(injectionDetails.li);
+    authorLine.prepend(label);
+
+    new MDCTooltip(badgeTooltip);
+  }
+
+  createLabelElement(state) {
+    const label = document.createElement('div');
+    label.classList.add('TWPT-label');
+
+    const [badge, badgeTooltip] = createExtBadge();
+
+    let span = document.createElement('span');
+    let stateLocalized;
+    if (kItemMetadataStateI18n[state]) {
+      const stateI18nKey =
+          'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
+      stateLocalized = chrome.i18n.getMessage(stateI18nKey) ?? state;
+    } else {
+      stateLocalized = kItemMetadataState[state] ?? state;
+    }
+    span.textContent = stateLocalized;
+    span.title = kItemMetadataState[state] ?? state;
+
+    label.append(badge, span);
+
+    return [label, badgeTooltip];
+  }
+
+  getAuthorLine(li) {
+    const authorLine = li.querySelector(
+        'ec-thread-summary .header-content .top-row .author-line');
+    if (!authorLine) {
+      throw new Error(
+          `extraInfo: Author line is not present in the thread item's DOM.`);
+    }
+    return authorLine;
+  }
+}
diff --git a/src/features/extraInfo/core/injections/threadQuestion.js b/src/features/extraInfo/core/injections/threadQuestion.js
new file mode 100644
index 0000000..d797ee1
--- /dev/null
+++ b/src/features/extraInfo/core/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.thread.data);
+    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/features/extraInfo/core/injections/threadReply.js b/src/features/extraInfo/core/injections/threadReply.js
new file mode 100644
index 0000000..ed14575
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadReply.js
@@ -0,0 +1,11 @@
+import BaseThreadMessageExtraInfoInjection from './baseThreadMessage.js';
+
+export default class ThreadCommentExtraInfoInjection extends BaseThreadMessageExtraInfoInjection {
+  getInteractionsRootClass() {
+    return 'scTailwindThreadMessageMessageinteractionsroot';
+  }
+
+  getInteractionsRootNonEmptyClass() {
+    return 'scTailwindThreadMessageMessageinteractionsinteractions';
+  }
+}
diff --git a/src/features/extraInfo/core/services/message.js b/src/features/extraInfo/core/services/message.js
new file mode 100644
index 0000000..268fea5
--- /dev/null
+++ b/src/features/extraInfo/core/services/message.js
@@ -0,0 +1,47 @@
+import StatesExtraInfoService from './states.js';
+
+export default class MessageExtraInfoService {
+  static getMessageIdFromNode(messageNode) {
+    const isMainReply =
+        messageNode.tagName == 'SC-TAILWIND-THREAD-MESSAGE-MESSAGE-CARD';
+    const cardContentClass = isMainReply ?
+        '.scTailwindThreadMessageMessagecardcontent' :
+        '.scTailwindThreadMessageCommentcardnested-reply';
+    const id = messageNode.querySelector(cardContentClass)
+                   ?.getAttribute?.('data-stats-id');
+    if (id === undefined)
+      throw new Error(`Couldn't retrieve message id from node.`);
+    return id;
+  }
+
+  static getMessageFromList(messageId, messagesList) {
+    for (const message of messagesList) {
+      if (message.getId() == messageId) return message;
+    }
+    throw new Error(`Couldn't find message ${messageId} in the message list.`);
+  }
+
+  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/features/extraInfo/core/services/states.js b/src/features/extraInfo/core/services/states.js
new file mode 100644
index 0000000..17dc34e
--- /dev/null
+++ b/src/features/extraInfo/core/services/states.js
@@ -0,0 +1,111 @@
+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;
+
+    let stateLocalized;
+    if (kItemMetadataStateI18n[state]) {
+      const stateI18nKey =
+          'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
+      stateLocalized = chrome.i18n.getMessage(stateI18nKey) ?? state;
+    } else {
+      stateLocalized = kItemMetadataState[state] ?? 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/features/extraInfo/core/services/thread.js b/src/features/extraInfo/core/services/thread.js
new file mode 100644
index 0000000..c36dfa5
--- /dev/null
+++ b/src/features/extraInfo/core/services/thread.js
@@ -0,0 +1,58 @@
+import StatesExtraInfoService from './states.js';
+
+export default class ThreadExtraInfoService {
+  /**
+   * Get a |chipContentList| array with the chips related to the thread, and a
+   * |tooltips| array with the corresponding tooltips which should be
+   * initialized after the chips are added to the DOM.
+   */
+  static getThreadChips(thread) {
+    let chips = [];
+    let tooltips = [];
+
+    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));
+
+    const itemMetadata = thread?.['2']?.['12'];
+    chips.push(...StatesExtraInfoService.getMetadataChips(itemMetadata));
+
+    const liveReviewStatus = thread?.['2']?.['38'];
+    const [liveReviewInfo, liveReviewTooltip] =
+        StatesExtraInfoService.getLiveReviewStatusChip(liveReviewStatus);
+    if (liveReviewInfo) chips.push(liveReviewInfo);
+    if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
+
+    return [chips, tooltips];
+  }
+
+  static getTrendingChips(thread) {
+    const chips = [];
+
+    const isTrending = thread?.['2']?.['25'];
+    const isTrendingAutoMarked = thread?.['39'];
+    if (isTrendingAutoMarked)
+      chips.push(document.createTextNode(
+          chrome.i18n.getMessage('inject_extrainfo_thread_autotrending')));
+    else if (isTrending)
+      chips.push(document.createTextNode(
+          chrome.i18n.getMessage('inject_extrainfo_thread_trending')));
+
+    return chips;
+  }
+
+  static getThreadFromThreadList(threadList, currentThreadInfo) {
+    return threadList?.find?.(thread => {
+      const threadInfo = thread?.['2']?.['1'];
+      const threadId = threadInfo?.['1'];
+      const forumId = threadInfo?.['3'];
+      return threadId == currentThreadInfo.thread &&
+          forumId == currentThreadInfo.forum;
+    });
+  }
+}
diff --git a/src/features/extraInfo/extraInfo.feature.ts b/src/features/extraInfo/extraInfo.feature.ts
new file mode 100644
index 0000000..f7f9f66
--- /dev/null
+++ b/src/features/extraInfo/extraInfo.feature.ts
@@ -0,0 +1,18 @@
+import Feature from '../../common/architecture/features/Feature';
+import { ConcreteScript } from '../../common/architecture/scripts/Script';
+import CCExtraInfoDependencySetUpScript from './scripts/ccExtraInfoDependencySetUp.script';
+import CCExtraInfoInjectScript from './scripts/ccExtraInfoInject.script';
+import CCExtraInfoMainScript from './scripts/ccExtraInfoMain.script';
+import CCExtraInfoStylesScript from './scripts/ccExtraInfoStyles.script';
+
+export default class ExtraInfoFeature extends Feature {
+  public readonly scripts: ConcreteScript[] = [
+    CCExtraInfoDependencySetUpScript,
+    CCExtraInfoInjectScript,
+    CCExtraInfoMainScript,
+    CCExtraInfoStylesScript,
+  ];
+
+  readonly codename = 'extraInfo';
+  readonly relatedOptions: string[] = [];
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler.ts
new file mode 100644
index 0000000..7cc895a
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler.ts
@@ -0,0 +1,11 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+export default class CCExtraInfoProfileAbuseChipsHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector = 'ec-unified-user .scTailwindUser_profileUsercardmain';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectAbuseChipsAtProfileIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler.ts
new file mode 100644
index 0000000..d08db21
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler.ts
@@ -0,0 +1,12 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+export default class CCExtraInfoProfilePerForumStatsHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector =
+    'ec-unified-user .scTailwindUser_profileUserprofilesection sc-tailwind-shared-activity-chart';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectPerForumStatsIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler.ts
new file mode 100644
index 0000000..802dc51
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadCommentHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector = 'sc-tailwind-thread-message-message-list sc-tailwind-thread-message-comment-card';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectAtCommentIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler.ts
new file mode 100644
index 0000000..0d16772
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadQuestionHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector = 'sc-tailwind-thread-question-question-card sc-tailwind-thread-question-state-chips';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectAtQuestionIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler.ts
new file mode 100644
index 0000000..543ddb0
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadReplyHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector = 'sc-tailwind-thread-message-message-list sc-tailwind-thread-message-message-card';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectAtReplyIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler.ts
new file mode 100644
index 0000000..5d26037
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadListHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector = 'li:has(ec-thread-summary)';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectAtThreadListIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler.ts
new file mode 100644
index 0000000..b6ef4dc
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info in the toolbelt of an expanded thread list item.
+ */
+export default class CCExtraInfoThreadListToolbeltHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+  cssSelector = 'ec-thread-summary .main .toolbelt';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.extraInfo.injectAtExpandedThreadListIfEnabled(node);
+  }
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoDependencySetUp.script.ts b/src/features/extraInfo/scripts/ccExtraInfoDependencySetUp.script.ts
new file mode 100644
index 0000000..cd595c5
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoDependencySetUp.script.ts
@@ -0,0 +1,15 @@
+import { Dependency, ExtraInfoDependency } from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import SetUpDependenciesScript from '../../../common/architecture/scripts/setUpDependencies/SetUpDependenciesScript';
+
+export default class CCExtraInfoDependencySetUpScript extends SetUpDependenciesScript {
+  public priority = 101;
+  public page = ScriptPage.CommunityConsole;
+  public environment = ScriptEnvironment.ContentScript;
+  public runPhase = ScriptRunPhase.Start;
+  public dependencies: Dependency[] = [ExtraInfoDependency];
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoInject.script.ts b/src/features/extraInfo/scripts/ccExtraInfoInject.script.ts
new file mode 100644
index 0000000..2bd6364
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoInject.script.ts
@@ -0,0 +1,14 @@
+import Script, { ScriptEnvironment, ScriptPage, ScriptRunPhase } from "../../../common/architecture/scripts/Script";
+import { injectScript } from "../../../common/contentScriptsUtils";
+
+export default class CCExtraInfoInjectScript extends Script {
+  priority = 11;
+
+  page = ScriptPage.CommunityConsole;
+  environment = ScriptEnvironment.ContentScript;
+  runPhase = ScriptRunPhase.Start;
+
+  execute() {
+    injectScript(chrome.runtime.getURL('extraInfoInject.bundle.js'));
+  }
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoMain.script.ts b/src/features/extraInfo/scripts/ccExtraInfoMain.script.ts
new file mode 100644
index 0000000..80fbfbf
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoMain.script.ts
@@ -0,0 +1,43 @@
+import DependenciesProviderSingleton, {
+  ExtraInfoDependency,
+} from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import NodeWatcherScript from '../../../common/architecture/scripts/nodeWatcher/NodeWatcherScript';
+import ExtraInfo from '../core';
+import CCExtraInfoProfileAbuseChipsHandler from '../nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler';
+import CCExtraInfoProfilePerForumStatsHandler from '../nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler';
+import CCExtraInfoThreadCommentHandler from '../nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler';
+import CCExtraInfoThreadListHandler from '../nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler';
+import CCExtraInfoThreadListToolbeltHandler from '../nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler';
+import CCExtraInfoThreadQuestionHandler from '../nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler';
+import CCExtraInfoThreadReplyHandler from '../nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler';
+
+export interface CCExtraInfoMainOptions {
+  extraInfo: ExtraInfo;
+}
+
+export default class CCExtraInfoMainScript extends NodeWatcherScript<CCExtraInfoMainOptions> {
+  page = ScriptPage.CommunityConsole;
+  environment = ScriptEnvironment.ContentScript;
+  runPhase = ScriptRunPhase.Main;
+  handlers = new Map([
+    ['ccExtraInfoProfile', CCExtraInfoProfileAbuseChipsHandler],
+    ['ccExtraInfoProfilePerForumStats', CCExtraInfoProfilePerForumStatsHandler],
+    ['ccExtraInfoThreadComment', CCExtraInfoThreadCommentHandler],
+    ['ccExtraInfoThreadList', CCExtraInfoThreadListHandler],
+    ['ccExtraInfoThreadListToolbelt', CCExtraInfoThreadListToolbeltHandler],
+    ['ccExtraInfoThreadQuestion', CCExtraInfoThreadQuestionHandler],
+    ['ccExtraInfoThreadReply', CCExtraInfoThreadReplyHandler],
+  ]);
+
+  protected optionsFactory(): CCExtraInfoMainOptions {
+    const dependenciesProvider = DependenciesProviderSingleton.getInstance();
+    return {
+      extraInfo: dependenciesProvider.getDependency(ExtraInfoDependency),
+    };
+  }
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoStyles.script.ts b/src/features/extraInfo/scripts/ccExtraInfoStyles.script.ts
new file mode 100644
index 0000000..4d806f6
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoStyles.script.ts
@@ -0,0 +1,17 @@
+import Script, {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import { injectStylesheet } from '../../../common/contentScriptsUtils';
+
+export default class CCExtraInfoStylesScript extends Script {
+  page = ScriptPage.CommunityConsole;
+  environment = ScriptEnvironment.ContentScript;
+  runPhase = ScriptRunPhase.Main;
+
+  execute() {
+    injectStylesheet(chrome.runtime.getURL('css/extrainfo.css'));
+    injectStylesheet(chrome.runtime.getURL('css/extrainfo_perforumstats.css'));
+  }
+}