Add flattenthreads experiment

This experiment allows users to flatten the replies in threads, so they
are shown linearly in a chronological way instead of nested.

When the option is enabled, a switch is added to the thread page which
lets the user switch between flattening replies and not flattening them.

Some UI is still missing (see the design document[1]).

[1]: https://docs.google.com/document/d/1P-HanTHxaOFF_FHh0uSv0GIhG1dxWTJTGoT6VPjjvY0/edit

Bug: twpowertools:153
Change-Id: I43f94442cadc12b752700f0e8d974522be621d3e
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index e5d9af3..1edb466 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -9,10 +9,13 @@
 import {applyDragAndDropFixIfEnabled} from './dragAndDropFix.js';
 // #!endif
 import InfiniteScroll from './infiniteScroll.js';
+import {kRepliesSectionSelector} from './threadToolbar/constants.js';
+import ThreadToolbar from './threadToolbar/threadToolbar.js';
 import {unifiedProfilesFix} from './unifiedProfiles.js';
 import Workflows from './workflows/workflows.js';
 
-var mutationObserver, options, avatars, infiniteScroll, workflows;
+var mutationObserver, options, avatars, infiniteScroll, workflows,
+    threadToolbar;
 
 const watchedNodesSelectors = [
   // App container (used to set up the intersection observer and inject the dark
@@ -70,6 +73,9 @@
 
   // Thread page main content
   'ec-thread > .page > .material-content > div[role="list"]',
+
+  // Thread page reply section (for the thread page toolbar)
+  kRepliesSectionSelector,
 ];
 
 function handleCandidateNode(node) {
@@ -198,10 +204,16 @@
       window.TWPTExtraInfo.injectPerForumStatsIfEnabled(node);
     }
 
+    // Inject old thread page design warning if applicable
     if (node.matches(
             'ec-thread > .page > .material-content > div[role="list"]')) {
       window.TWPTThreadPageDesignWarning.injectWarningIfApplicable(node);
     }
+
+    // Inject thread toolbar
+    if (threadToolbar.shouldInject(node)) {
+      threadToolbar.injectIfApplicable(node);
+    }
   }
 }
 
@@ -238,6 +250,7 @@
   avatars = new AvatarsHandler();
   infiniteScroll = new InfiniteScroll();
   workflows = new Workflows();
+  threadToolbar = new ThreadToolbar();
 
   // autoRefresh, extraInfo, threadPageDesignWarning and workflowsImport are
   // initialized in start.js
@@ -298,6 +311,8 @@
   // Extra info
   injectStylesheet(chrome.runtime.getURL('css/extrainfo.css'));
   injectStylesheet(chrome.runtime.getURL('css/extrainfo_perforumstats.css'));
-  // Workflows
-  injectScript(chrome.runtime.getURL('workflowComponentsInject.bundle.js'));
+  // Workflows, Thread toolbar
+  injectScript(chrome.runtime.getURL('litComponentsInject.bundle.js'));
+  // Thread toolbar
+  injectStylesheet(chrome.runtime.getURL('css/thread_toolbar.css'));
 });
diff --git a/src/contentScripts/communityConsole/threadToolbar/components/index.js b/src/contentScripts/communityConsole/threadToolbar/components/index.js
new file mode 100644
index 0000000..65b2e31
--- /dev/null
+++ b/src/contentScripts/communityConsole/threadToolbar/components/index.js
@@ -0,0 +1,65 @@
+import '@material/web/formfield/formfield.js';
+import '@material/web/switch/switch.js';
+
+import {css, html, LitElement, nothing} from 'lit';
+import {createRef, ref} from 'lit/directives/ref.js';
+
+import {SHARED_MD3_STYLES} from '../../../../common/styles/md3.js';
+import {kEventFlattenThreadsUpdated} from '../constants.js';
+
+export default class TwptThreadToolbarInject extends LitElement {
+  static properties = {
+    options: {type: Object},
+  };
+
+  static styles = [
+    SHARED_MD3_STYLES,
+    css`
+      :host {
+        display: flex;
+        flex-direction: row;
+        padding-top: 1.5rem;
+        padding-left: 0.25rem;
+        padding-right: 0.25rem;
+        padding-bottom: 0.5rem;
+      }
+    `,
+  ];
+
+  nestedViewRef = createRef();
+
+  constructor() {
+    super();
+    this.options = {};
+  }
+
+  renderFlattenRepliesSwitch() {
+    if (!this.options.flattenthreads) return nothing;
+
+    return html`
+      <md-formfield label="Nested view">
+        <md-switch ${ref(this.nestedViewRef)}
+            ?selected=${!this.options?.flattenthreads_switch_enabled}
+            @click=${this._flattenThreadsChanged}>
+      </md-formfield>
+    `;
+  }
+
+  render() {
+    return html`
+      ${this.renderFlattenRepliesSwitch()}
+    `;
+  }
+
+  _flattenThreadsChanged() {
+    const enabled = !this.nestedViewRef.value.selected;
+    const e = new CustomEvent(kEventFlattenThreadsUpdated, {
+      bubbles: true,
+      composed: true,
+      detail: {enabled},
+    });
+    this.dispatchEvent(e);
+  }
+}
+window.customElements.define(
+    'twpt-thread-toolbar-inject', TwptThreadToolbarInject);
diff --git a/src/contentScripts/communityConsole/threadToolbar/constants.js b/src/contentScripts/communityConsole/threadToolbar/constants.js
new file mode 100644
index 0000000..756d880
--- /dev/null
+++ b/src/contentScripts/communityConsole/threadToolbar/constants.js
@@ -0,0 +1,5 @@
+export const kEventFlattenThreadsUpdated =
+    'TWPTThreadToolbarFlattenThreadsUpdated';
+
+export const kRepliesSectionSelector =
+    'ec-thread .scTailwindThreadThreadcontentreplies-section';
diff --git a/src/contentScripts/communityConsole/threadToolbar/threadToolbar.js b/src/contentScripts/communityConsole/threadToolbar/threadToolbar.js
new file mode 100644
index 0000000..6344e3c
--- /dev/null
+++ b/src/contentScripts/communityConsole/threadToolbar/threadToolbar.js
@@ -0,0 +1,57 @@
+import {getOptions} from '../../../common/optionsUtils.js';
+import {softRefreshView} from '../utils/common.js';
+
+import * as consts from './constants.js';
+
+export default class ThreadToolbar {
+  constructor() {
+    this.getOptions().then(options => {
+      this.updateBodyClasses(options);
+    });
+  }
+
+  updateBodyClasses(options) {
+    if (this.shouldSeeToolbar(options))
+      document.body.classList.add('TWPT-threadtoolbar-shown');
+    else
+      document.body.classList.remove('TWPT-threadtoolbar-shown');
+
+    if (options.flattenthreads && options.flattenthreads_switch_enabled)
+      document.body.classList.add('TWPT-flattenthreads-enabled');
+    else
+      document.body.classList.remove('TWPT-flattenthreads-enabled');
+  }
+
+  shouldSeeToolbar(options) {
+    return Object.values(options).some(option => !!option);
+  }
+
+  getOptions() {
+    return getOptions(['flattenthreads', 'flattenthreads_switch_enabled']);
+  }
+
+  inject(node, options) {
+    const toolbar = document.createElement('twpt-thread-toolbar-inject');
+    toolbar.setAttribute('options', JSON.stringify(options));
+    toolbar.addEventListener(consts.kEventFlattenThreadsUpdated, e => {
+      const enabled = e.detail?.enabled;
+      if (typeof enabled != 'boolean') return;
+      chrome.storage.sync.set({flattenthreads_switch_enabled: enabled}, _ => {
+        softRefreshView();
+      });
+    });
+    node.parentElement.insertBefore(toolbar, node);
+  }
+
+  injectIfApplicable(node) {
+    this.getOptions().then(options => {
+      this.updateBodyClasses(options);
+      if (!this.shouldSeeToolbar(options)) return;
+      return this.inject(node, options);
+    });
+  }
+
+  shouldInject(node) {
+    return node.matches(consts.kRepliesSectionSelector);
+  }
+}