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/common/nodeWatcher/NodeWatcher.ts b/src/common/nodeWatcher/NodeWatcher.ts
new file mode 100644
index 0000000..73b9560
--- /dev/null
+++ b/src/common/nodeWatcher/NodeWatcher.ts
@@ -0,0 +1,113 @@
+import {
+  NodeWatcherHandler,
+  NodeMutation,
+  NodeMutationType,
+} from './NodeWatcherHandler';
+
+class NodeWatcher {
+  private handlers: Map<string, NodeWatcherHandler> = new Map();
+  private mutationObserver: MutationObserver;
+
+  constructor() {
+    this.mutationObserver = new MutationObserver(
+      this.mutationCallback.bind(this),
+    );
+    this.start();
+  }
+
+  start(): void {
+    this.mutationObserver.observe(document.body, {
+      childList: true,
+      subtree: true,
+    });
+  }
+
+  pause(): void {
+    this.mutationObserver.disconnect();
+  }
+
+  setHandler(key: string, handler: NodeWatcherHandler): void {
+    this.handlers.set(key, handler);
+    this.performInitialDiscovery(handler);
+  }
+
+  removeHandler(key: string): boolean {
+    return this.handlers.delete(key);
+  }
+
+  private mutationCallback(mutationRecords: MutationRecord[]): void {
+    for (const mutationRecord of mutationRecords) {
+      if (mutationRecord.type !== 'childList') continue;
+      this.handleAddedNodes(mutationRecord);
+      this.handleRemovedNodes(mutationRecord);
+    }
+  }
+
+  private handleAddedNodes(mutationRecord: MutationRecord): void {
+    for (const node of mutationRecord.addedNodes) {
+      this.handleMutatedNode({
+        node,
+        mutationRecord,
+        type: NodeMutationType.NewNode,
+      });
+    }
+  }
+
+  private handleRemovedNodes(mutationRecord: MutationRecord): void {
+    for (const node of mutationRecord.removedNodes) {
+      this.handleMutatedNode({
+        node,
+        mutationRecord,
+        type: NodeMutationType.RemovedNode,
+      });
+    }
+  }
+
+  private performInitialDiscovery(handler: NodeWatcherHandler): void {
+    if (handler.initialDiscoverySelector === undefined) return;
+    const candidateNodes = document.querySelectorAll(
+      handler.initialDiscoverySelector,
+    );
+    for (const candidateNode of candidateNodes) {
+      this.handleMutatedNodeWithHandler(
+        {
+          node: candidateNode,
+          type: NodeMutationType.InitialDiscovery,
+          mutationRecord: null,
+        },
+        handler,
+      );
+    }
+  }
+
+  private handleMutatedNode(nodeMutation: NodeMutation): void {
+    for (const [, handler] of this.handlers) {
+      this.handleMutatedNodeWithHandler(nodeMutation, handler);
+    }
+  }
+
+  private handleMutatedNodeWithHandler(
+    nodeMutation: NodeMutation,
+    handler: NodeWatcherHandler,
+  ): void {
+    if (handler.nodeFilter(nodeMutation)) {
+      handler.onMutatedNode(nodeMutation);
+    }
+  }
+}
+
+export default class NodeWatcherSingleton {
+  private static instance: NodeWatcher;
+
+  /**
+   * @see {@link NodeWatcherSingleton.getInstance}
+   */
+  private constructor() {}
+
+  public static getInstance(): NodeWatcher {
+    if (!NodeWatcherSingleton.instance) {
+      NodeWatcherSingleton.instance = new NodeWatcher();
+    }
+    return NodeWatcherSingleton.instance;
+  }
+}
diff --git a/src/common/nodeWatcher/NodeWatcherHandler.ts b/src/common/nodeWatcher/NodeWatcherHandler.ts
new file mode 100644
index 0000000..b6c3a25
--- /dev/null
+++ b/src/common/nodeWatcher/NodeWatcherHandler.ts
@@ -0,0 +1,52 @@
+export enum NodeMutationType {
+  /**
+   * The node was found during initial discovery.
+   */
+  InitialDiscovery,
+  /**
+   * The node has been added.
+   */
+  NewNode,
+  /**
+   * The node was removed
+   */
+  RemovedNode,
+}
+
+export interface NodeMutation {
+  /**
+   * Node being mutated.
+   */
+  node: Node;
+  /**
+   * Which mutation has occurred to the node.
+   */
+  type: NodeMutationType;
+  /**
+   * MutationRecord from which this node mutation has been extracted. It is null
+   * if the type is {@link NodeMutationType.InitialDiscovery}.
+   */
+  mutationRecord: MutationRecord | null;
+}
+
+export interface NodeWatcherHandler {
+  /**
+   * Only node mutations which pass this filter (it returns true) will be passed
+   * to {@link onMutatedNode}.
+   */
+  nodeFilter: (nodeMutation: NodeMutation) => boolean;
+
+  /**
+   * Optional CSS selector used to discover nodes existing prior to the handler
+   * being established. These matching nodes will be evaluated by
+   * {@link onMutatedNode} if they pass {@link nodeFilter}.
+   *
+   * This is useful when watching an node but it has already been created.
+   */
+  initialDiscoverySelector?: string;
+
+  /**
+   * Function which will be called with each of the filtered node mutations.
+   */
+  onMutatedNode: (nodeMutation: NodeMutation) => void;
+}