refactor: migrate autorefresh feature to the new architecture

Bug: twpowertools:176
Change-Id: If000d8617e9a4d66bdc7f91c1037867a4cb9fbf2
diff --git a/src/features/Features.ts b/src/features/Features.ts
index 9b3eaea..98a4b96 100644
--- a/src/features/Features.ts
+++ b/src/features/Features.ts
@@ -4,6 +4,7 @@
   ScriptPage,
   ScriptRunPhase,
 } from '../common/architecture/scripts/Script';
+import AutoRefreshFeature from './autoRefresh/autoRefresh.feature';
 import InfiniteScrollFeature from './infiniteScroll/infiniteScroll.feature';
 
 export type ConcreteFeatureClass = { new (): Feature };
@@ -15,7 +16,10 @@
 }
 
 export default class Features {
-  private features: ConcreteFeatureClass[] = [InfiniteScrollFeature];
+  private features: ConcreteFeatureClass[] = [
+    AutoRefreshFeature,
+    InfiniteScrollFeature,
+  ];
   private initializedFeatures: Feature[];
 
   runScripts(context: Context) {
diff --git a/src/features/autoRefresh/autoRefresh.feature.ts b/src/features/autoRefresh/autoRefresh.feature.ts
new file mode 100644
index 0000000..285690e
--- /dev/null
+++ b/src/features/autoRefresh/autoRefresh.feature.ts
@@ -0,0 +1,16 @@
+import Feature from '../../common/architecture/features/Feature';
+import { ConcreteScript } from '../../common/architecture/scripts/Script';
+import AutoRefreshNodeWatcherScript from './scripts/nodeWatcher.script';
+import AutoRefreshSetUpScript from './scripts/setUp.script';
+import AutoRefreshStylesScript from './scripts/styles.script';
+
+export default class AutoRefreshFeature extends Feature {
+  public readonly scripts: ConcreteScript[] = [
+    AutoRefreshNodeWatcherScript,
+    AutoRefreshSetUpScript,
+    AutoRefreshStylesScript,
+  ];
+
+  readonly codename = 'autoRefresh';
+  readonly relatedOptions = ['autorefresh'];
+}
diff --git a/src/features/autoRefresh/core/autoRefresh.js b/src/features/autoRefresh/core/autoRefresh.js
new file mode 100644
index 0000000..c2d64d2
--- /dev/null
+++ b/src/features/autoRefresh/core/autoRefresh.js
@@ -0,0 +1,299 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {CCApi} from '../../../common/api.js';
+import {getAuthUser} from '../../../common/communityConsoleUtils.js';
+import {isOptionEnabled} from '../../../common/optionsUtils.js';
+import {createPlainTooltip} from '../../../common/tooltip.js';
+
+import {createExtBadge, softRefreshView} from '../../../contentScripts/communityConsole/utils/common.js';
+
+var authuser = getAuthUser();
+
+const threadListRequestEvent = 'TWPT_ViewForumRequest';
+const threadListLoadEvent = 'TWPT_ViewForumResponse';
+const intervalMs = 3 * 60 * 1000;  // 3 minutes
+
+export default class AutoRefresh {
+  constructor() {
+    this.isLookingForUpdates = false;
+    this.isUpdatePromptShown = false;
+    this.lastTimestamp = null;
+    this.forumId = null;
+    this.filter = null;
+    this.path = null;
+    this.requestId = null;
+    this.requestOrderOptions = null;
+    this.snackbar = null;
+    this.statusIndicator = null;
+    this.interval = null;
+
+    this.setUpHandlers();
+  }
+
+  isOrderedByTimestampDescending() {
+    // This means we didn't intercept the request.
+    if (!this.requestOrderOptions) return false;
+
+    // Returns orderOptions.by == TIMESTAMP && orderOptions.desc == true
+    return (
+        this.requestOrderOptions?.[1] == 1 &&
+        this.requestOrderOptions?.[2] == true);
+  }
+
+  getLastTimestamp() {
+    return CCApi(
+               'ViewForum', {
+                 1: this.forumId,
+                 // options
+                 2: {
+                   // pagination
+                   1: {
+                     2: 2,  // maxNum
+                   },
+                   // order
+                   2: {
+                     1: 1,     // by
+                     2: true,  // desc
+                   },
+                   12: this.filter,  // forumViewFilters
+                 },
+               },
+               /* authenticated = */ true, authuser)
+        .then(body => {
+          var timestamp = body?.[1]?.[2]?.[0]?.[2]?.[17];
+          if (timestamp === undefined)
+            throw new Error(
+                'Unexpected body of response (' +
+                (body?.[1]?.[2]?.[0] === undefined ?
+                     'no threads were returned' :
+                     'the timestamp value is not present in the first thread') +
+                ').');
+
+          return timestamp;
+        });
+    // TODO(avm99963): Add retry mechanism (sometimes thread lists are empty,
+    // but when loading the next page the thread appears).
+    //
+    // NOTE(avm99963): It seems like loading the first 2 threads instead of only
+    // the first one fixes this (empty lists are now rarely returned).
+  }
+
+  unregister() {
+    console.debug('autorefresh_list: unregistering');
+
+    if (!this.isLookingForUpdates) return;
+
+    window.clearInterval(this.interval);
+    this.isUpdatePromptShown = false;
+    this.isLookingForUpdates = false;
+  }
+
+  showUpdatePrompt() {
+    this.snackbar.classList.remove('TWPT-hidden');
+    document.title = '[!!!] ' + document.title.replace('[!!!] ', '');
+    this.isUpdatePromptShown = true;
+  }
+
+  // This function can be called even if the update prompt is not shown.
+  hideUpdatePrompt() {
+    if (this.snackbar) this.snackbar.classList.add('TWPT-hidden');
+    document.title = document.title.replace('[!!!] ', '');
+    this.isUpdatePromptShown = false;
+  }
+
+  injectUpdatePrompt() {
+    var pane = document.createElement('div');
+    pane.classList.add('TWPT-pane-for-snackbar');
+
+    var snackbar = document.createElement('material-snackbar-panel');
+    snackbar.classList.add('TWPT-snackbar');
+    snackbar.classList.add('TWPT-hidden');
+
+    var ac = document.createElement('div');
+    ac.classList.add('TWPT-animation-container');
+
+    var nb = document.createElement('div');
+    nb.classList.add('TWPT-notification-bar');
+
+    var ft = document.createElement('focus-trap');
+
+    var content = document.createElement('div');
+    content.classList.add('TWPT-focus-content-wrapper');
+
+    let badge, badgeTooltip;
+    [badge, badgeTooltip] = createExtBadge();
+
+    var message = document.createElement('div');
+    message.classList.add('TWPT-message');
+    message.textContent =
+        chrome.i18n.getMessage('inject_autorefresh_list_snackbar_message');
+
+    var action = document.createElement('div');
+    action.classList.add('TWPT-action');
+    action.textContent =
+        chrome.i18n.getMessage('inject_autorefresh_list_snackbar_action');
+
+    action.addEventListener('click', e => {
+      this.hideUpdatePrompt();
+      softRefreshView();
+    });
+
+    content.append(badge, message, action);
+    ft.append(content);
+    nb.append(ft);
+    ac.append(nb);
+    snackbar.append(ac);
+    pane.append(snackbar);
+    document.getElementById('default-acx-overlay-container').append(pane);
+    new MDCTooltip(badgeTooltip);
+    this.snackbar = snackbar;
+  }
+
+  // Create an indicator element.
+  createStatusIndicator(isSetUp) {
+    var container = document.createElement('div');
+    container.classList.add('TWPT-autorefresh-status-indicator-container');
+
+    var indicator = document.createElement('div');
+    indicator.classList.add(
+        'TWPT-autorefresh-status-indicator',
+        isSetUp ? 'TWPT-autorefresh-status-indicator--active' :
+                  'TWPT-autorefresh-status-indicator--disabled');
+    indicator.textContent =
+        isSetUp ? 'notifications_active' : 'notifications_off';
+    let label = chrome.i18n.getMessage(
+        isSetUp ? 'inject_autorefresh_list_status_indicator_label_active' :
+                  'inject_autorefresh_list_status_indicator_label_disabled');
+    let statusTooltip = createPlainTooltip(indicator, label, false);
+
+    let badge, badgeTooltip;
+    [badge, badgeTooltip] = createExtBadge();
+
+    container.append(indicator, badge);
+    return [container, badgeTooltip, statusTooltip];
+  }
+
+  injectStatusIndicator(isSetUp) {
+    let badgeTooltip, statusTooltip;
+    [this.statusIndicator, badgeTooltip, statusTooltip] = this.createStatusIndicator(isSetUp);
+
+    var sortOptionsDiv = document.querySelector('ec-thread-list .sort-options');
+    if (sortOptionsDiv) {
+      sortOptionsDiv.prepend(this.statusIndicator);
+      new MDCTooltip(badgeTooltip);
+      new MDCTooltip(statusTooltip);
+      return;
+    }
+
+    console.error('threadListAvatars: Couldn\'t inject status indicator.');
+  }
+
+  checkUpdate() {
+    if (location.pathname != this.path) {
+      this.unregister();
+      return;
+    }
+
+    if (!this.lastTimestamp) {
+      console.error('autorefresh_list: this.lastTimestamp is not set.');
+      this.unregister();
+      return;
+    }
+
+    if (this.isUpdatePromptShown) return;
+
+    console.debug('Checking for update at: ', new Date());
+
+    this.getLastTimestamp()
+        .then(timestamp => {
+          if (timestamp != this.lastTimestamp) this.showUpdatePrompt();
+        })
+        .catch(
+            err => console.error(
+                'Coudln\'t get last timestamp (while updating): ', err));
+  }
+
+  setUpHandlers() {
+    window.addEventListener(
+        threadListRequestEvent, e => this.handleListRequest(e));
+    window.addEventListener(threadListLoadEvent, e => this.handleListLoad(e));
+  }
+
+  // This will set the forum ID and filter which is going to be used to check
+  // for new updates in the thread list.
+  handleListRequest(e) {
+    // If the request was made before the last known one, return.
+    if (this.requestId !== null && e.detail.id < this.requestId) return;
+
+    // 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
+    // request that we want to handle is the initial request to load the thread
+    // list which currently requests 100 threads.
+    var maxNum = e.detail.body?.['2']?.['1']?.['2'];
+    if (maxNum == 10 || maxNum == 20) return;
+
+    // Ignore requests to load more threads in the current thread list. All
+    // those requests include a PaginationToken, and also have |maxNum| set
+    // to 50.
+    var token = e.detail.body?.['2']?.['1']?.['3'];
+    if (token) return;
+
+    this.requestId = e.detail.id;
+    this.requestOrderOptions = e.detail.body?.['2']?.['2'];
+    this.forumId = e.detail.body?.['1'] ?? '0';
+    this.filter = e.detail.body?.['2']?.['12'] ?? '';
+
+    console.debug(
+        'autorefresh_list: handled valid ViewForum request (forumId: ' +
+        this.forumId + ', filter: [' + this.filter + '])');
+  }
+
+  // This will set the timestamp of the first thread in the list, so we can
+  // decide in the future whether there is an update or not.
+  handleListLoad(e) {
+    // We ignore past requests and only consider the most recent one.
+    if (this.requestId !== e.detail.id) return;
+
+    console.debug(
+        'autorefresh_list: handling corresponding ViewForum response');
+
+    this.lastTimestamp = e.detail.body?.['1']?.['2']?.[0]?.['2']?.['17'];
+    if (this.lastTimestamp === undefined)
+      console.error(
+          'autorefresh_list: Unexpected body of response (' +
+          (e.detail.body?.['1']?.['2']?.[0] === undefined ?
+               'no threads were returned' :
+               'the timestamp value is not present in the first thread') +
+          ').');
+  }
+
+  // This is called when a thread list node is detected in the page. This
+  // initializes the interval to check for updates, and several other things.
+  setUp() {
+    isOptionEnabled('autorefreshlist').then(isEnabled => {
+      if (!isEnabled) return;
+
+      if (!this.isOrderedByTimestampDescending()) {
+        this.injectStatusIndicator(false);
+        console.debug(
+            'autorefresh_list: refused to start up because the order is not by timestamp descending.');
+        return;
+      }
+
+      this.unregister();
+
+      console.debug('autorefresh_list: starting set up...');
+
+      if (this.snackbar === null) this.injectUpdatePrompt();
+      this.injectStatusIndicator(true);
+
+      this.isLookingForUpdates = true;
+      this.path = location.pathname;
+
+      var checkUpdateCallback = this.checkUpdate.bind(this);
+      this.interval = window.setInterval(checkUpdateCallback, intervalMs);
+    });
+  }
+};
diff --git a/src/features/autoRefresh/nodeWatcherHandlers/threadListHide.handler.ts b/src/features/autoRefresh/nodeWatcherHandlers/threadListHide.handler.ts
new file mode 100644
index 0000000..4b1082d
--- /dev/null
+++ b/src/features/autoRefresh/nodeWatcherHandlers/threadListHide.handler.ts
@@ -0,0 +1,18 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation, NodeMutationType } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import { AutoRefreshNodeWatcherDependencies } from '../scripts/nodeWatcher.script';
+
+/**
+ * Removes the snackbar when exiting thread list view.
+ */
+export default class AutoRefreshThreadListHideHandler extends CssSelectorNodeWatcherScriptHandler<AutoRefreshNodeWatcherDependencies> {
+  cssSelector = 'ec-thread-list';
+
+  readonly mutationTypesProcessed: NodeMutationType[] = [
+    NodeMutationType.RemovedNode,
+  ];
+
+  onMutatedNode(_: NodeMutation) {
+    this.options.autoRefresh.hideUpdatePrompt();
+  }
+}
diff --git a/src/features/autoRefresh/nodeWatcherHandlers/threadListSetUp.handler.ts b/src/features/autoRefresh/nodeWatcherHandlers/threadListSetUp.handler.ts
new file mode 100644
index 0000000..554be00
--- /dev/null
+++ b/src/features/autoRefresh/nodeWatcherHandlers/threadListSetUp.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import { AutoRefreshNodeWatcherDependencies } from '../scripts/nodeWatcher.script';
+
+/**
+ * Sets up the autorefresh list feature.
+ */
+export default class AutoRefreshThreadListSetUpHandler extends CssSelectorNodeWatcherScriptHandler<AutoRefreshNodeWatcherDependencies> {
+  cssSelector = 'ec-thread-list';
+
+  onMutatedNode(_: NodeMutation) {
+    this.options.autoRefresh.setUp();
+  }
+}
diff --git a/src/features/autoRefresh/scripts/nodeWatcher.script.ts b/src/features/autoRefresh/scripts/nodeWatcher.script.ts
new file mode 100644
index 0000000..5308a35
--- /dev/null
+++ b/src/features/autoRefresh/scripts/nodeWatcher.script.ts
@@ -0,0 +1,33 @@
+import DependenciesProviderSingleton, {
+  AutoRefreshDependency,
+} from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import NodeWatcherScript from '../../../common/architecture/scripts/nodeWatcher/NodeWatcherScript';
+import AutoRefresh from '../core/autoRefresh';
+import AutoRefreshThreadListHideHandler from '../nodeWatcherHandlers/threadListHide.handler';
+import AutoRefreshThreadListSetUpHandler from '../nodeWatcherHandlers/threadListSetUp.handler';
+
+export interface AutoRefreshNodeWatcherDependencies {
+  autoRefresh: AutoRefresh;
+}
+
+export default class AutoRefreshNodeWatcherScript extends NodeWatcherScript<AutoRefreshNodeWatcherDependencies> {
+  public page = ScriptPage.CommunityConsole;
+  public environment = ScriptEnvironment.ContentScript;
+  public runPhase = ScriptRunPhase.Main;
+  public handlers = new Map([
+    ['autoRefreshThreadListSetUp', AutoRefreshThreadListSetUpHandler],
+    ['autoRefreshThreadListHide', AutoRefreshThreadListHideHandler],
+  ]);
+
+  protected optionsFactory(): AutoRefreshNodeWatcherDependencies {
+    const dependenciesProvider = DependenciesProviderSingleton.getInstance();
+    return {
+      autoRefresh: dependenciesProvider.getDependency(AutoRefreshDependency),
+    };
+  }
+}
diff --git a/src/features/autoRefresh/scripts/setUp.script.ts b/src/features/autoRefresh/scripts/setUp.script.ts
new file mode 100644
index 0000000..cb5cce6
--- /dev/null
+++ b/src/features/autoRefresh/scripts/setUp.script.ts
@@ -0,0 +1,18 @@
+import {
+  AutoRefreshDependency,
+  Dependency,
+} 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 AutoRefreshSetUpScript extends SetUpDependenciesScript {
+  public priority = 100;
+  public page = ScriptPage.CommunityConsole;
+  public environment = ScriptEnvironment.ContentScript;
+  public runPhase = ScriptRunPhase.Start;
+  public dependencies: Dependency[] = [AutoRefreshDependency];
+}
diff --git a/src/features/autoRefresh/scripts/styles.script.ts b/src/features/autoRefresh/scripts/styles.script.ts
new file mode 100644
index 0000000..6e78169
--- /dev/null
+++ b/src/features/autoRefresh/scripts/styles.script.ts
@@ -0,0 +1,16 @@
+import Script, {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import { injectStylesheet } from '../../../common/contentScriptsUtils';
+
+export default class AutoRefreshStylesScript extends Script {
+  page = ScriptPage.CommunityConsole;
+  environment = ScriptEnvironment.ContentScript;
+  runPhase = ScriptRunPhase.Main;
+
+  execute() {
+    injectStylesheet(chrome.runtime.getURL('css/autorefresh_list.css'));
+  }
+}