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/architecture/features/Feature.ts b/src/common/architecture/features/Feature.ts
new file mode 100644
index 0000000..c14c338
--- /dev/null
+++ b/src/common/architecture/features/Feature.ts
@@ -0,0 +1,29 @@
+import Script, { ConcreteScript } from '../scripts/Script';
+
+export default abstract class Feature {
+  /**
+   * Internal codename used for the feature.
+   *
+   * It will be used for i18n translations, shown in log messages, etc.
+   */
+  abstract readonly codename: string;
+
+  /**
+   * Options which control the behavior of this feature.
+   */
+  abstract readonly relatedOptions: string[];
+
+  /**
+   * Uninitialized scripts which are associated with the feature.
+   */
+  abstract readonly scripts: ConcreteScript[];
+
+  private initializedScripts: Script[];
+
+  public getScripts() {
+    if (this.initializedScripts === undefined) {
+      this.initializedScripts = this.scripts.map((script) => new script());
+    }
+    return this.initializedScripts;
+  }
+}
diff --git a/src/common/architecture/scripts/Script.ts b/src/common/architecture/scripts/Script.ts
new file mode 100644
index 0000000..5cca2c4
--- /dev/null
+++ b/src/common/architecture/scripts/Script.ts
@@ -0,0 +1,66 @@
+export enum ScriptRunPhase {
+  /**
+   * Executed before any Javascript is executed.
+   */
+  Start,
+  /**
+   * Executed after the document is ready.
+   */
+  Main,
+}
+
+export enum ScriptEnvironment {
+  ContentScript,
+  InjectedScript,
+}
+
+export enum ScriptPage {
+  CommunityConsole,
+}
+
+export const ScriptRunPhaseToRunTime: Record<
+  ScriptRunPhase,
+  chrome.userScripts.RunAt
+> = {
+  [ScriptRunPhase.Start]: 'document_start',
+  [ScriptRunPhase.Main]: 'document_idle',
+};
+
+export const ScriptEnvironmentToExecutionWorld: Record<
+  ScriptEnvironment,
+  chrome.scripting.ExecutionWorld
+> = {
+  [ScriptEnvironment.ContentScript]: 'ISOLATED',
+  [ScriptEnvironment.InjectedScript]: 'MAIN',
+};
+
+export type ConcreteScript = { new (): Script };
+
+export default abstract class Script {
+  /**
+   * Priority with which the script is executed. Scripts with a lower value are
+   * executed first.
+   */
+  readonly priority: Number = 2 ** 31;
+
+  /**
+   * Page where the script should be executed.
+   */
+  abstract readonly page: ScriptPage;
+
+  /**
+   * Environment where the script should be executed.
+   */
+  abstract readonly environment: ScriptEnvironment;
+
+  /**
+   * If {@link environment} is {@link ScriptEnvironment.ContentScript}, phase of
+   * the page loading when the script should be executed.
+   */
+  abstract readonly runPhase?: ScriptRunPhase;
+
+  /**
+   * Method which contains the logic of the script.
+   */
+  abstract execute(): void;
+}
diff --git a/src/common/architecture/scripts/nodeWatcher/NodeWatcherScript.ts b/src/common/architecture/scripts/nodeWatcher/NodeWatcherScript.ts
new file mode 100644
index 0000000..2c9b91e
--- /dev/null
+++ b/src/common/architecture/scripts/nodeWatcher/NodeWatcherScript.ts
@@ -0,0 +1,29 @@
+import NodeWatcherSingleton from '../../../nodeWatcher/NodeWatcher';
+import { ConcreteNodeWatcherScriptHandler } from './handlers/NodeWatcherScriptHandler';
+import Script from '../Script';
+
+export default abstract class NodeWatcherScript<Options> extends Script {
+  public abstract handlers: Map<
+    string,
+    ConcreteNodeWatcherScriptHandler<Options>
+  >;
+
+  /**
+   * Resolves to the options when the script is executed.
+   *
+   * This is so we can defer retrieving dependencies until the script is
+   * executed, to prevent loading unnecessary things if they aren't needed
+   * after all.
+   */
+  protected abstract optionsFactory(): Options;
+
+  execute() {
+    const nodeWatcher = NodeWatcherSingleton.getInstance();
+    const options = this.optionsFactory();
+
+    for (const [key, handlerClass] of this.handlers) {
+      const handler = new handlerClass(options);
+      nodeWatcher.setHandler(key, handler);
+    }
+  }
+}
diff --git a/src/common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler.ts b/src/common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler.ts
new file mode 100644
index 0000000..22e34af
--- /dev/null
+++ b/src/common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler.ts
@@ -0,0 +1,27 @@
+import { NodeWatcherScriptHandler } from './NodeWatcherScriptHandler';
+import { NodeMutation, NodeMutationType } from '../../../../nodeWatcher/NodeWatcherHandler';
+
+export default abstract class CssSelectorNodeWatcherScriptHandler<
+  Options,
+> extends NodeWatcherScriptHandler<Options> {
+  readonly mutationTypesProcessed: NodeMutationType[] = [
+    NodeMutationType.InitialDiscovery,
+    NodeMutationType.NewNode,
+  ];
+
+  abstract readonly cssSelector: string;
+
+  nodeFilter(nodeMutation: NodeMutation): boolean {
+    if (
+      !this.mutationTypesProcessed.includes(nodeMutation.type) ||
+      !(nodeMutation.node instanceof Element)
+    ) {
+      return false;
+    }
+    return nodeMutation.node.matches(this.cssSelector);
+  }
+
+  get initialDiscoverySelector() {
+    return this.cssSelector;
+  }
+}
diff --git a/src/common/architecture/scripts/nodeWatcher/handlers/NodeWatcherScriptHandler.ts b/src/common/architecture/scripts/nodeWatcher/handlers/NodeWatcherScriptHandler.ts
new file mode 100644
index 0000000..5b0fd42
--- /dev/null
+++ b/src/common/architecture/scripts/nodeWatcher/handlers/NodeWatcherScriptHandler.ts
@@ -0,0 +1,14 @@
+import { NodeMutation, NodeWatcherHandler } from "../../../../nodeWatcher/NodeWatcherHandler";
+
+export abstract class NodeWatcherScriptHandler<Options>
+  implements NodeWatcherHandler
+{
+  abstract initialDiscoverySelector?: string;
+  constructor(protected options: Options) {}
+  abstract nodeFilter(nodeMutation: NodeMutation): boolean;
+  abstract onMutatedNode(nodeMutation: NodeMutation): void;
+}
+
+export type ConcreteNodeWatcherScriptHandler<Options> = {
+  new (options: Options): NodeWatcherScriptHandler<Options>;
+};
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;
+}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 0200394..ea9cb8c 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -12,12 +12,11 @@
 import {applyDragAndDropFixIfEnabled} from './dragAndDropFix.js';
 // #!endif
 import {default as FlattenThreads, kMatchingSelectors as kFlattenThreadMatchingSelectors} from './flattenThreads/flattenThreads.js';
-import InfiniteScroll from './infiniteScroll.js';
 import {kRepliesSectionSelector} from './threadToolbar/constants.js';
 import ThreadToolbar from './threadToolbar/threadToolbar.js';
 import Workflows from './workflows/workflows.js';
 
-var mutationObserver, options, avatars, infiniteScroll, workflows,
+var mutationObserver, options, avatars, workflows,
     threadToolbar, flattenThreads, reportDialogColorThemeFix;
 
 const watchedNodesSelectors = [
@@ -88,8 +87,6 @@
 function handleCandidateNode(node) {
   if (typeof node.classList !== 'undefined') {
     if (('tagName' in node) && node.tagName == 'EC-APP') {
-      infiniteScroll.setUpIntersectionObserver(node, false);
-
       // Inject the dark mode button
       // TODO(avm99963): make this feature dynamic.
       if (options.ccdarktheme && options.ccdarktheme_mode == 'switch') {
@@ -100,21 +97,6 @@
       }
     }
 
-    // To set up infinite scroll
-    if (node.classList.contains('scrollable-content')) {
-      infiniteScroll.setUpIntersectionObserver(node, true);
-    }
-
-    // Start the intersectionObserver for the "load more"/"load all" buttons
-    // inside a thread if the option is currently enabled.
-    if (node.classList.contains('load-more-bar')) {
-      infiniteScroll.observeLoadMoreBar(node);
-    }
-    if (node.classList.contains('scTailwindThreadMorebuttonbutton') ||
-        node.classList.contains('scTailwindThreadMessagegapbutton')) {
-      infiniteScroll.observeLoadMoreInteropBtn(node);
-    }
-
     // Show additional details in the profile view.
     if (node.matches('ec-unified-user .scTailwindUser_profileUsercardmain')) {
       window.TWPTExtraInfo.injectAbuseChipsAtProfileIfEnabled(node);
@@ -284,7 +266,6 @@
 
   // Initialize classes needed by the mutation observer
   avatars = new AvatarsHandler();
-  infiniteScroll = new InfiniteScroll();
   workflows = new Workflows();
   threadToolbar = new ThreadToolbar();
   flattenThreads = new FlattenThreads();
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/contentScripts/communityConsole/infiniteScroll.js b/src/features/infiniteScroll/core/ccInfiniteScroll.js
similarity index 95%
rename from src/contentScripts/communityConsole/infiniteScroll.js
rename to src/features/infiniteScroll/core/ccInfiniteScroll.js
index 447974e..93e693c 100644
--- a/src/contentScripts/communityConsole/infiniteScroll.js
+++ b/src/features/infiniteScroll/core/ccInfiniteScroll.js
@@ -1,4 +1,4 @@
-import {getOptions, isOptionEnabled} from '../../common/optionsUtils.js';
+import {getOptions, isOptionEnabled} from '../../../common/optionsUtils.js';
 
 const kInteropLoadMoreClasses = {
   // New (interop) UI without nested replies
@@ -11,7 +11,7 @@
 };
 const kArtificialScrollingDelay = 3500;
 
-export default class InfiniteScroll {
+export default class CCInfiniteScroll {
   constructor() {
     this.intersectionObserver = null;
   }
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(),
+    };
+  }
+}
diff --git a/src/platforms/communityConsole/entryPoints/main.ts b/src/platforms/communityConsole/entryPoints/main.ts
new file mode 100644
index 0000000..c7d223b
--- /dev/null
+++ b/src/platforms/communityConsole/entryPoints/main.ts
@@ -0,0 +1,16 @@
+import {
+  ScriptEnvironment,
+  ScriptPage,
+  ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import Features from '../../../features/Features';
+
+// Run legacy Javascript entry point
+import '../../../contentScripts/communityConsole/main';
+
+const features = new Features();
+features.runScripts({
+  page: ScriptPage.CommunityConsole,
+  environment: ScriptEnvironment.ContentScript,
+  runPhase: ScriptRunPhase.Main,
+});