refactor: migrate infinite scroll feature to a new architecture

This CL introduces a new architecture for the features source code.

Bug: twpowertools:176
Change-Id: I9abc4df2fb67f9bb0c9114aaffc6916d34f1b7ff
diff --git a/src/features/Features.ts b/src/features/Features.ts
new file mode 100644
index 0000000..9b3eaea
--- /dev/null
+++ b/src/features/Features.ts
@@ -0,0 +1,47 @@
+import Feature from '../common/architecture/features/Feature';
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../common/architecture/scripts/Script';
+import InfiniteScrollFeature from './infiniteScroll/infiniteScroll.feature';
+
+export type ConcreteFeatureClass = { new (): Feature };
+
+export interface Context {
+  page: ScriptPage;
+  environment: ScriptEnvironment;
+  runPhase: ScriptRunPhase;
+}
+
+export default class Features {
+  private features: ConcreteFeatureClass[] = [InfiniteScrollFeature];
+  private initializedFeatures: Feature[];
+
+  runScripts(context: Context) {
+    const scripts = this.getScripts(context).sort((a, b) =>
+      a.priority < b.priority ? -1 : 1,
+    );
+    for (const script of scripts) {
+      script.execute();
+    }
+  }
+
+  getScripts(context: Context) {
+    const features = this.getFeatures();
+    const allScripts = features.map((feature) => feature.getScripts()).flat(1);
+    return allScripts.filter(
+      (script) =>
+        script.page === context.page &&
+        script.environment === context.environment &&
+        script.runPhase === context.runPhase,
+    );
+  }
+
+  private getFeatures() {
+    if (this.initializedFeatures === undefined) {
+      this.initializedFeatures = this.features.map((feature) => new feature());
+    }
+    return this.initializedFeatures;
+  }
+}
diff --git a/src/features/infiniteScroll/core/ccInfiniteScroll.js b/src/features/infiniteScroll/core/ccInfiniteScroll.js
new file mode 100644
index 0000000..93e693c
--- /dev/null
+++ b/src/features/infiniteScroll/core/ccInfiniteScroll.js
@@ -0,0 +1,89 @@
+import {getOptions, isOptionEnabled} from '../../../common/optionsUtils.js';
+
+const kInteropLoadMoreClasses = {
+  // New (interop) UI without nested replies
+  'scTailwindThreadMorebuttonload-all': 'threadall',
+  'scTailwindThreadMorebuttonload-more': 'thread',
+
+  // New (interop) UI with nested replies
+  'scTailwindThreadMessagegapload-all': 'threadall',
+  'scTailwindThreadMessagegapload-more': 'thread',
+};
+const kArtificialScrollingDelay = 3500;
+
+export default class CCInfiniteScroll {
+  constructor() {
+    this.intersectionObserver = null;
+  }
+
+  setUpIntersectionObserver(node, isScrollableContent) {
+    if (this.intersectionObserver === null) {
+      var scrollableContent = isScrollableContent ?
+          node :
+          node.querySelector('.scrollable-content');
+      if (scrollableContent !== null) {
+        let intersectionOptions = {
+          root: scrollableContent,
+          rootMargin: '0px',
+          threshold: 1.0,
+        };
+        this.intersectionObserver = new IntersectionObserver(
+            this.intersectionCallback, intersectionOptions);
+      }
+    }
+  }
+
+  intersectionCallback(entries, observer) {
+    entries.forEach(entry => {
+      if (entry.isIntersecting) {
+        console.debug('[infinitescroll] Clicking button: ', entry.target);
+        entry.target.click();
+      }
+    });
+  }
+
+  isPotentiallyArtificialScroll() {
+    return window.location.href.includes('/message/');
+  }
+
+  observeWithPotentialDelay(node) {
+    if (this.intersectionObserver === null) {
+      console.warn(
+          '[infinitescroll] ' +
+          'The intersectionObserver is not ready yet.');
+      return;
+    }
+
+    if (this.isPotentiallyArtificialScroll()) {
+      window.setTimeout(
+          () => {this.intersectionObserver.observe(node)},
+          kArtificialScrollingDelay);
+    } else {
+      this.intersectionObserver.observe(node);
+    }
+  }
+
+  observeLoadMoreBar(bar) {
+    getOptions(['thread', 'threadall']).then(threadOptions => {
+      if (threadOptions.thread)
+        this.observeWithPotentialDelay(bar.querySelector('.load-more-button'));
+      if (threadOptions.threadall)
+        this.observeWithPotentialDelay(bar.querySelector('.load-all-button'));
+    });
+  }
+
+  observeLoadMoreInteropBtn(btn) {
+    let parentClasses = btn.parentNode?.classList;
+    let feature = null;
+    for (const [c, f] of Object.entries(kInteropLoadMoreClasses)) {
+      if (parentClasses?.contains?.(c)) {
+        feature = f;
+        break;
+      }
+    }
+    if (feature === null) return;
+    isOptionEnabled(feature).then(isEnabled => {
+      if (isEnabled) this.observeWithPotentialDelay(btn);
+    });
+  }
+};
diff --git a/src/features/infiniteScroll/infiniteScroll.feature.ts b/src/features/infiniteScroll/infiniteScroll.feature.ts
new file mode 100644
index 0000000..b10ecfa
--- /dev/null
+++ b/src/features/infiniteScroll/infiniteScroll.feature.ts
@@ -0,0 +1,10 @@
+import Feature from '../../common/architecture/features/Feature';
+import { ConcreteScript } from '../../common/architecture/scripts/Script';
+import CCInfiniteScrollScript from './scripts/ccInfiniteScroll.script';
+
+export default class InfiniteScrollFeature extends Feature {
+  public readonly scripts: ConcreteScript[] = [CCInfiniteScrollScript];
+
+  readonly codename = 'infiniteScroll';
+  readonly relatedOptions = ['list', 'thread', 'threadall'];
+}
diff --git a/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollLoadMoreBar.handler.ts b/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollLoadMoreBar.handler.ts
new file mode 100644
index 0000000..da4f5e6
--- /dev/null
+++ b/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollLoadMoreBar.handler.ts
@@ -0,0 +1,11 @@
+import { NodeMutation } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import CssSelectorNodeWatcherScriptHandler from '../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { InfiniteScrollNodeWatcherOptions } from '../scripts/ccInfiniteScroll.script';
+
+export default class CCInfiniteScrollLoadMoreBarHandler extends CssSelectorNodeWatcherScriptHandler<InfiniteScrollNodeWatcherOptions> {
+  cssSelector = '.load-more-bar';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.ccInfiniteScroll.observeLoadMoreBar(node);
+  }
+}
diff --git a/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollLoadMoreBtn.handler.ts b/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollLoadMoreBtn.handler.ts
new file mode 100644
index 0000000..ee54aff
--- /dev/null
+++ b/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollLoadMoreBtn.handler.ts
@@ -0,0 +1,12 @@
+import { NodeMutation } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import CssSelectorNodeWatcherScriptHandler from '../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { InfiniteScrollNodeWatcherOptions } from '../scripts/ccInfiniteScroll.script';
+
+export default class CCInfiniteScrollLoadMoreBtnHandler extends CssSelectorNodeWatcherScriptHandler<InfiniteScrollNodeWatcherOptions> {
+  cssSelector =
+    '.scTailwindThreadMorebuttonbutton, .scTailwindThreadMessagegapbutton';
+
+  onMutatedNode({ node }: NodeMutation) {
+    this.options.ccInfiniteScroll.observeLoadMoreInteropBtn(node);
+  }
+}
diff --git a/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollSetUp.handler.ts b/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollSetUp.handler.ts
new file mode 100644
index 0000000..60331a0
--- /dev/null
+++ b/src/features/infiniteScroll/nodeWatcherHandlers/ccInfiniteScrollSetUp.handler.ts
@@ -0,0 +1,16 @@
+import { NodeMutation } from '../../../common/nodeWatcher/NodeWatcherHandler';
+import CssSelectorNodeWatcherScriptHandler from '../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { InfiniteScrollNodeWatcherOptions } from '../scripts/ccInfiniteScroll.script';
+
+export default class CCInfiniteScrollSetUpHandler extends CssSelectorNodeWatcherScriptHandler<InfiniteScrollNodeWatcherOptions> {
+  cssSelector = 'ec-app, .scrollable-content';
+
+  onMutatedNode({ node }: NodeMutation) {
+    if (!(node instanceof Element)) return;
+    const isScrollableContent = node.classList.contains('scrollable-content');
+    this.options.ccInfiniteScroll.setUpIntersectionObserver(
+      node,
+      isScrollableContent,
+    );
+  }
+}
diff --git a/src/features/infiniteScroll/scripts/ccInfiniteScroll.script.ts b/src/features/infiniteScroll/scripts/ccInfiniteScroll.script.ts
new file mode 100644
index 0000000..d37dc84
--- /dev/null
+++ b/src/features/infiniteScroll/scripts/ccInfiniteScroll.script.ts
@@ -0,0 +1,31 @@
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import NodeWatcherScript from '../../../common/architecture/scripts/nodeWatcher/NodeWatcherScript';
+import CCInfiniteScroll from '../core/ccInfiniteScroll';
+import CCInfiniteScrollLoadMoreBarHandler from '../nodeWatcherHandlers/ccInfiniteScrollLoadMoreBar.handler';
+import CCInfiniteScrollLoadMoreBtnHandler from '../nodeWatcherHandlers/ccInfiniteScrollLoadMoreBtn.handler';
+import CCInfiniteScrollSetUpHandler from '../nodeWatcherHandlers/ccInfiniteScrollSetUp.handler';
+
+export interface InfiniteScrollNodeWatcherOptions {
+  ccInfiniteScroll: CCInfiniteScroll;
+}
+
+export default class CCInfiniteScrollScript extends NodeWatcherScript<InfiniteScrollNodeWatcherOptions> {
+  page = ScriptPage.CommunityConsole;
+  environment = ScriptEnvironment.ContentScript;
+  runPhase = ScriptRunPhase.Main;
+  handlers = new Map([
+    ['ccInfiniteScrollSetUp', CCInfiniteScrollSetUpHandler],
+    ['ccInfiniteScrollLoadMoreBar', CCInfiniteScrollLoadMoreBarHandler],
+    ['ccInfiniteScrollLoadMoreBtn', CCInfiniteScrollLoadMoreBtnHandler],
+  ]);
+
+  protected optionsFactory(): InfiniteScrollNodeWatcherOptions {
+    return {
+      ccInfiniteScroll: new CCInfiniteScroll(),
+    };
+  }
+}