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/handlers/base.js b/src/contentScripts/communityConsole/extraInfo/handlers/base.js
new file mode 100644
index 0000000..732ccee
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/handlers/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/contentScripts/communityConsole/extraInfo/handlers/basedOnResponseEvent.js b/src/contentScripts/communityConsole/extraInfo/handlers/basedOnResponseEvent.js
new file mode 100644
index 0000000..881356c
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/handlers/basedOnResponseEvent.js
@@ -0,0 +1,76 @@
+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.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/contentScripts/communityConsole/extraInfo/handlers/profile.js b/src/contentScripts/communityConsole/extraInfo/handlers/profile.js
new file mode 100644
index 0000000..28e8309
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/handlers/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/contentScripts/communityConsole/extraInfo/handlers/threadList.js b/src/contentScripts/communityConsole/extraInfo/handlers/threadList.js
new file mode 100644
index 0000000..47d7e65
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo/handlers/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;
+  }
+}