refactor(extra-info): split code into several classes

This commit refactors the extra info feature so the code is more
maintainable, in preparation for a future commit which will make it work
with the RCE thread page.

It doesn't refactor the code related to the thread view since it will be
heavily modified, and the code related to canned responses has been
deleted since the number of uses of a CR is no longer relevant because
it is no longer counted.

Bug: twpowertools:93
Change-Id: I06c045fb9ff0c824c99f63acfa10976b2110e5ed
diff --git a/src/contentScripts/communityConsole/extraInfo/injections/base.js b/src/contentScripts/communityConsole/extraInfo/injections/base.js
new file mode 100644
index 0000000..1498efd
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/injections/base.js
@@ -0,0 +1,97 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+import {createExtBadge} from '../../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');
+  }
+
+  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)
+        .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('material-chip');
+    chip.classList.add('TWPT-extrainfo-chip');
+
+    let chipContentContainer = document.createElement('div');
+    chipContentContainer.classList.add('TWPT-chip-content-container');
+
+    let content = document.createElement('div');
+    content.classList.add('TWPT-content');
+
+    const [badge, badgeTooltip] = createExtBadge();
+
+    let span = document.createElement('span');
+    span.append(chipContent);
+
+    content.append(badge, span);
+    chipContentContainer.append(content);
+    chip.append(chipContentContainer);
+    container.append(chip);
+
+    return badgeTooltip;
+  }
+}
diff --git a/src/contentScripts/communityConsole/extraInfo/injections/expandedThreadList.js b/src/contentScripts/communityConsole/extraInfo/injections/expandedThreadList.js
new file mode 100644
index 0000000..f5a6874
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/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/contentScripts/communityConsole/extraInfo/injections/profileAbuse.js b/src/contentScripts/communityConsole/extraInfo/injections/profileAbuse.js
new file mode 100644
index 0000000..2ba911c
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/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/contentScripts/communityConsole/extraInfo/injections/profilePerForumStats.js b/src/contentScripts/communityConsole/extraInfo/injections/profilePerForumStats.js
new file mode 100644
index 0000000..57278ee
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/injections/profilePerForumStats.js
@@ -0,0 +1,22 @@
+import {getDisplayLanguage} from '../../utils/common.js';
+import PerForumStatsSection from '../../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/contentScripts/communityConsole/extraInfo/injections/threadList.js b/src/contentScripts/communityConsole/extraInfo/injections/threadList.js
new file mode 100644
index 0000000..c4e807c
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/injections/threadList.js
@@ -0,0 +1,71 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {parseUrl} from '../../../../common/commonUtils.js';
+import {createExtBadge} from '../../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');
+    const stateI18nKey =
+        'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
+    span.textContent = chrome.i18n.getMessage(stateI18nKey) ?? state;
+    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;
+  }
+}