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/common/optionsPrototype.json5 b/src/common/optionsPrototype.json5
index b1a74e3..8bcc0da 100644
--- a/src/common/optionsPrototype.json5
+++ b/src/common/optionsPrototype.json5
@@ -156,6 +156,11 @@
     context: 'experiments',
     killSwitchType: 'experiment',
   },
+  'flattenthreads': {
+    defaultVale: false,
+    context: 'experiments',
+    killSwitchType: 'experiment',
+  },
 
   // Internal options:
   'ccdarktheme_switch_enabled': {
@@ -163,6 +168,11 @@
     context: 'internal',
     killSwitchType: 'ignore',
   },
+  'flattenthreads_switch_enabled': {
+    defaultValue: true,
+    context: 'internal',
+    killSwitchType: 'ignore',
+  },
 
   // Deprecated options:
   'escalatethreads': {
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);
+  }
+}
diff --git a/src/injections/litComponentsInject.js b/src/injections/litComponentsInject.js
new file mode 100644
index 0000000..f4d59bd
--- /dev/null
+++ b/src/injections/litComponentsInject.js
@@ -0,0 +1,13 @@
+// This file imports necessary web components used for several features which
+// use LitElement (and thus custom web elements). This is done by injecting this
+// javascript file instead of placing this code directly in the content script
+// because `window.customElements` doesn't exist in content scripts.
+import '../contentScripts/communityConsole/workflows/components/index.js';
+import '../contentScripts/communityConsole/threadToolbar/components/index.js';
+
+import {injectStylesheet} from '../common/contentScriptsUtils.js';
+
+// Also, we import Material Icons since the Community Console uses "Google
+// Material Icons" instead of "Material Icons". This is necessary for the MD3
+// components.
+injectStylesheet('https://fonts.googleapis.com/icon?family=Material+Icons');
diff --git a/src/injections/workflowComponentsInject.js b/src/injections/workflowComponentsInject.js
deleted file mode 100644
index d7e788e..0000000
--- a/src/injections/workflowComponentsInject.js
+++ /dev/null
@@ -1,12 +0,0 @@
-// This file imports necessary web components used for the workflows feature.
-// This is done by injecting this javascript file instead of placing this code
-// directly in the content script because `window.customElements` doesn't exist
-// in content scripts.
-import '../contentScripts/communityConsole/workflows/components/index.js';
-
-import {injectStylesheet} from '../common/contentScriptsUtils.js';
-
-// Also, we import Material Icons since the Community Console uses "Google
-// Material Icons" instead of "Material Icons". This is necessary for the MD3
-// components.
-injectStylesheet('https://fonts.googleapis.com/icon?family=Material+Icons');
diff --git a/src/models/Gap.js b/src/models/Gap.js
new file mode 100644
index 0000000..131dfbc
--- /dev/null
+++ b/src/models/Gap.js
@@ -0,0 +1,55 @@
+export default class GapModel {
+  constructor(data) {
+    this.data = data ?? {};
+  }
+
+  getCount() {
+    const a = this.data[1] ?? null;
+    return a != null ? a : 0;
+  }
+
+  setCount(value) {
+    this.data[1] = Number(value);
+  }
+
+  getStartMicroseconds() {
+    return this.data[2] ?? null;
+  }
+
+  setStartMicroseconds(value) {
+    this.data[2] = '' + value;
+  }
+
+  getStartTimestamp() {
+    const a = this.getStartMicroseconds();
+    if (a == null) a = '0';
+    return BigInt(a);
+  }
+
+  getEndMicroseconds() {
+    return this.data[3] ?? null;
+  }
+
+  setEndMicroseconds(value) {
+    this.data[3] = '' + value;
+  }
+
+  getEndTimestamp() {
+    const a = this.getEndMicroseconds();
+    if (a == null) a = '0';
+    return BigInt(a);
+  }
+
+  getParentId() {
+    const a = this.data[4];
+    return a ? Number(a) : 0;
+  }
+
+  setParentId(value) {
+    this.data[4] = '' + value;
+  }
+
+  toRawMessageOrGap() {
+    return {2: this.data};
+  }
+}
diff --git a/src/models/Message.js b/src/models/Message.js
new file mode 100644
index 0000000..d39a9ab
--- /dev/null
+++ b/src/models/Message.js
@@ -0,0 +1,52 @@
+import GapModel from './Gap.js';
+import ThreadModel from './Thread.js';
+
+export default class MessageModel {
+  constructor(data) {
+    this.data = data ?? {};
+    this.commentsAndGaps = null;
+  }
+
+  getCreatedTimestamp() {
+    return this.data[1]?.[1]?.[2] ?? null;
+  }
+
+  getCreatedMicroseconds() {
+    const a = this.getCreatedTimestamp();
+    if (a === null) a = '0';
+    return BigInt(a);
+  }
+
+  getRawCommentsAndGaps() {
+    return this.data[12] ?? [];
+  }
+
+  getCommentsAndGaps() {
+    if (this.commentsAndGaps === null)
+      this.commentsAndGaps =
+          MessageModel.mapToMessageOrGapModels(this.getRawCommentsAndGaps());
+    return this.commentsAndGaps;
+  }
+
+  clearCommentsAndGaps() {
+    this.commentsAndGaps = [];
+    this.data[12] = [];
+  }
+
+  toRawMessageOrGap() {
+    return {1: this.data};
+  }
+
+  static mapToMessageOrGapModels(rawArray) {
+    return rawArray.map(mog => {
+      if (mog[1]) return new MessageModel(mog[1]);
+      if (mog[2]) return new GapModel(mog[2]);
+    });
+  }
+
+  mergeCommentOrGapViews(a) {
+    this.commentsAndGaps = ThreadModel.mergeMessageOrGaps(
+        a.getCommentsAndGaps(), this.getCommentsAndGaps());
+    this.data[12] = this.commentsAndGaps.map(cog => cog.toRawMessageOrGap());
+  }
+}
diff --git a/src/models/Thread.js b/src/models/Thread.js
new file mode 100644
index 0000000..e02c0a5
--- /dev/null
+++ b/src/models/Thread.js
@@ -0,0 +1,99 @@
+import GapModel from './Gap.js';
+import MessageModel from './Message.js';
+
+export default class ThreadModel {
+  /**
+   * The following code is based on logic written by Googlers in the TW frontend
+   * and thus is not included as part of the MIT license.
+   */
+  static mergeMessageOrGaps(a, b) {
+    if (a.length == 0 || b.length == 0)
+      return a.length > 0 ? a : b.length > 0 ? b : [];
+
+    let e = [];
+    for (let g = 0, k = 0, m = 0, q = a[g], u = b[k];
+         g < a.length && k < b.length;) {
+      if (q instanceof MessageModel && u instanceof MessageModel) {
+        if (q.getCreatedMicroseconds() === u.getCreatedMicroseconds()) {
+          u.mergeCommentOrGapViews(q);
+        }
+
+        e.push(u);
+
+        if (g === a.length - 1 || k === b.length - 1) {
+          for (; ++g < a.length;) e.push(a[g]);
+          for (; ++k < b.length;) e.push(b[k]);
+          break;
+        }
+
+        q = a[++g];
+        u = b[++k];
+      } else {
+        if (u instanceof GapModel) {
+          let z;
+          for (z = q instanceof MessageModel ? q.getCreatedMicroseconds() :
+                                               q.getEndTimestamp();
+               z < u.getEndTimestamp();) {
+            e.push(q);
+            m += q instanceof GapModel ? q.getCount() : 1;
+            if (g === a.length - 1) break;
+            q = a[++g];
+            z = q instanceof MessageModel ? q.getCreatedMicroseconds() :
+                                            q.getEndTimestamp();
+          }
+          if (q instanceof GapModel && u.getCount() - m > 0 &&
+              z >= u.getEndTimestamp()) {
+            const gm = new GapModel();
+            gm.setCount(u.getCount() - m);
+            gm.setStartMicroseconds('' + q.getStartTimestamp());
+            gm.setEndMicroseconds('' + u.getEndTimestamp());
+            gm.setParentId(u.getParentId());
+            e.push(gm);
+            m = u.getCount() - m;
+          } else {
+            m = 0;
+          }
+          if (k === b.length - 1) break;
+          u = b[++k];
+        }
+        if (q instanceof GapModel) {
+          let z;
+          for (z = u instanceof MessageModel ? u.getCreatedMicroseconds() :
+                                               u.getEndTimestamp();
+               z < q.getEndTimestamp();) {
+            e.push(u);
+            m += u instanceof GapModel ? u.getCount() : 1;
+            if (k === b.length - 1) break;
+            u = b[++k];
+            z = u instanceof MessageModel ? u.getCreatedMicroseconds() :
+                                            u.getEndTimestamp();
+          }
+          if (u instanceof GapModel && q.getCount() - m > 0 &&
+              z >= q.getEndTimestamp()) {
+            const gm = new GapModel();
+            gm.setCount(q.getCount() - m);
+            gm.setStartMicroseconds('' + u.getStartTimestamp());
+            gm.setEndMicroseconds('' + q.getEndTimestamp());
+            gm.setParentId(q.getParentId());
+            e.push(gm);
+            m = q.getCount() - m;
+          } else {
+            m = 0;
+          }
+          if (g === a.length - 1) break;
+          q = a[++g];
+        }
+      }
+    }
+    return e;
+  }
+
+  static mergeMessageOrGapsMultiarray(mogsModels) {
+    if (mogsModels.length < 1) return [];
+    let mergeResult = mogsModels[0];
+    for (let i = 1; i < mogsModels.length; ++i) {
+      mergeResult = ThreadModel.mergeMessageOrGaps(mergeResult, mogsModels[i]);
+    }
+    return mergeResult;
+  }
+}
diff --git a/src/static/css/thread_toolbar.css b/src/static/css/thread_toolbar.css
new file mode 100644
index 0000000..cd1ac48
--- /dev/null
+++ b/src/static/css/thread_toolbar.css
@@ -0,0 +1,9 @@
+/* Small adjustement to reduce spacing, since it's excessive when adding the toolbar. */
+body.TWPT-threadtoolbar-shown ec-thread .scTailwindThreadThreadcontentreplies-section {
+  padding-top: 0.5rem;
+}
+
+/* Hide reply button when a thread is flattened, since it might not work correctly */
+body.TWPT-flattenthreads-enabled ec-thread sc-tailwind-thread-message-message-list:last-child .scTailwindThreadMessageMessagecardsub-content {
+  display: none;
+}
diff --git a/src/static/css/ui_spacing/shared.css b/src/static/css/ui_spacing/shared.css
index c5b7cdd..2ae4fd8 100644
--- a/src/static/css/ui_spacing/shared.css
+++ b/src/static/css/ui_spacing/shared.css
@@ -27,7 +27,14 @@
   margin-bottom: 0!important;
 }
 
-.scTailwindThreadMessageMessagelistmessage-card {
+/**
+ * When flattening threads, we hide the reply button (in fact the entire blue
+ * footer), so we will reduce the padding moderately.
+ */
+body.TWPT-flattenthreads-enabled .scTailwindThreadMessageMessagelistmessage-card {
+  padding-bottom: 1rem!important;
+}
+body:not(.TWPT-flattenthreads-enabled) .scTailwindThreadMessageMessagelistmessage-card {
   padding-bottom: 0.75rem!important;
 }
 
diff --git a/src/static/options/experiments.html b/src/static/options/experiments.html
index 615d5ec..18fa34d 100644
--- a/src/static/options/experiments.html
+++ b/src/static/options/experiments.html
@@ -16,6 +16,7 @@
         <div class="option"><input type="checkbox" id="workflows"> <label for="workflows" data-i18n="workflows"></label> <button id="manage-workflows" data-i18n="workflows_manage"></button></div>
         <div class="option"><input type="checkbox" id="extrainfo"> <label for="extrainfo" data-i18n="extrainfo"></label></div>
         <div class="option"><input type="checkbox" id="nestedreplies"> <label for="nestedreplies" data-i18n="nestedreplies"></label></div>
+        <div class="option"><input type="checkbox" id="flattenthreads"> <label for="flattenthreads">Flatten threads (feature name TBD)</label></div>
         <div class="actions"><button id="save" data-i18n="save"></button></div>
       </form>
       <div id="save-indicator"></div>
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
new file mode 100644
index 0000000..65eb42c
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -0,0 +1,40 @@
+import GapModel from '../../models/Gap.js';
+import MessageModel from '../../models/Message.js';
+
+const loadMoreThread = {
+  urlRegex: /api\/ViewThread/i,
+  featureGated: true,
+  features: ['flattenthreads', 'flattenthreads_switch_enabled'],
+  isEnabled(options) {
+    return options['flattenthreads'] &&
+        options['flattenthreads_switch_enabled'];
+  },
+  async interceptor(_request, response) {
+    if (!response[1]?.[40]) return response;
+
+    const originalMogs =
+        MessageModel.mapToMessageOrGapModels(response[1][40] ?? []);
+    let extraMogs = [];
+    originalMogs.forEach(mog => {
+      if (mog instanceof GapModel) return;
+      const cogs = mog.getCommentsAndGaps();
+      extraMogs = extraMogs.concat(cogs);
+      mog.clearCommentsAndGaps();
+    });
+    const mogs = originalMogs.concat(extraMogs);
+    mogs.sort((a, b) => {
+      const c = a instanceof MessageModel ? a.getCreatedMicroseconds() :
+                                            a.getStartTimestamp();
+      const d = b instanceof MessageModel ? b.getCreatedMicroseconds() :
+                                            b.getStartTimestamp();
+      const diff = c - d;
+      return diff > 0 ? 1 : diff < 0 ? -1 : 0;
+    });
+    response[1][40] = mogs.map(mog => mog.toRawMessageOrGap());
+    // Set num_messages to the updated value, since we've flattened the replies.
+    response[1][8] = response[1][40].length;
+    return response;
+  },
+};
+
+export default loadMoreThread;
diff --git a/src/xhrInterceptor/responseModifiers/index.js b/src/xhrInterceptor/responseModifiers/index.js
index 6a4573a..2d0b7ce 100644
--- a/src/xhrInterceptor/responseModifiers/index.js
+++ b/src/xhrInterceptor/responseModifiers/index.js
@@ -1,10 +1,12 @@
 import MWOptionsWatcherClient from '../../common/mainWorldOptionsWatcher/Client.js';
 import {convertJSONToResponse, getResponseJSON} from '../utils.js';
 
-import demo from './demo.js';
+import loadMoreThread from './loadMoreThread.js';
+import flattenThread from './flattenThread.js';
 
 export const responseModifiers = [
-  demo,
+  loadMoreThread,
+  flattenThread,
 ];
 
 // Content script target
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
new file mode 100644
index 0000000..f8da127
--- /dev/null
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
@@ -0,0 +1,100 @@
+import {CCApi} from '../../common/api.js';
+import {getAuthUser} from '../../common/communityConsoleUtils.js';
+import GapModel from '../../models/Gap.js';
+import MessageModel from '../../models/Message.js';
+import ThreadModel from '../../models/Thread.js';
+
+const authuser = getAuthUser();
+
+const loadMoreThread = {
+  urlRegex: /api\/ViewThread/i,
+  featureGated: true,
+  features: ['flattenthreads', 'flattenthreads_switch_enabled'],
+  isEnabled(options) {
+    return options['flattenthreads'] &&
+        options['flattenthreads_switch_enabled'];
+  },
+  async interceptor(request, response) {
+    if (!response[1]?.[40]) return response;
+
+    const forumId = response[1]?.[2]?.[1]?.[3];
+    const threadId = response[1]?.[2]?.[1]?.[1];
+    if (!forumId || !threadId) {
+      console.error(
+          '[loadMoreThread] Couldn\'t find forum id and thread id for:',
+          request.$TWPTRequestUrl);
+      return response;
+    }
+
+    const mogs = MessageModel.mapToMessageOrGapModels(response[1]?.[40] ?? []);
+    response[1][40] = await this.loadGaps(forumId, threadId, mogs, 0);
+    return response;
+  },
+  loadGaps(forumId, threadId, mogs, it) {
+    if (it >= 10) {
+      return Promise.reject(new Error(
+          'loadGaps has been called for more than 10 times, ' +
+          'which means we\'ve entered an infinite loop.'));
+    }
+
+    const messageOrGapPromises = [];
+    messageOrGapPromises.push(Promise.resolve(mogs));
+    for (const mog of mogs) {
+      if (mog instanceof GapModel) {
+        messageOrGapPromises.push(this.loadGap(forumId, threadId, mog));
+      }
+      if (mog instanceof MessageModel) {
+        mog.getCommentsAndGaps().forEach(cog => {
+          if (cog instanceof GapModel) {
+            messageOrGapPromises.push(this.loadGap(forumId, threadId, cog));
+          }
+        });
+      }
+    }
+
+    return Promise.all(messageOrGapPromises).then(res => {
+      // #!if !production
+      console.time('mergeMessages');
+      // #!endif
+      const mogs = ThreadModel.mergeMessageOrGapsMultiarray(res);
+      // #!if !production
+      console.timeEnd('mergeMessages');
+      // #!endif
+      if (mogs.some(mog => {
+            return mog instanceof GapModel ||
+                mog.getCommentsAndGaps().some(cog => cog instanceof GapModel);
+          })) {
+        return this.loadGaps(forumId, threadId, mogs, it + 1);
+      }
+      return mogs.map(message => message.toRawMessageOrGap());
+    });
+  },
+  loadGap(forumId, threadId, gap) {
+    return CCApi(
+               'ViewThread', {
+                 1: forumId,
+                 2: threadId,
+                 3: {
+                   // options
+                   1: {
+                     // pagination
+                     2: gap.getCount(),  // maxNum
+                     7: {
+                       // targetRange
+                       1: gap.getStartMicroseconds(),  // startMicroseconds
+                       2: gap.getEndMicroseconds(),    // endMicroseconds
+                       3: gap.getParentId(),           // parentId
+                     },
+                   },
+                   5: true,   // withUserProfile
+                   10: true,  // withPromotedMessages
+                 },
+               },
+               /* authenticated = */ true, authuser)
+        .then(res => {
+          return MessageModel.mapToMessageOrGapModels(res[1]?.[40] ?? []);
+        });
+  }
+};
+
+export default loadMoreThread;
diff --git a/templates/manifest.gjson b/templates/manifest.gjson
index 41d39e2..c8d2ffb 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -84,7 +84,7 @@
         "batchLockInject.bundle.js",
         "xhrInterceptorInject.bundle.js",
         "extraInfoInject.bundle.js",
-        "workflowComponentsInject.bundle.js",
+        "litComponentsInject.bundle.js",
 
         "css/profileindicator_inject.css",
         "css/ccdarktheme.css",
@@ -101,6 +101,7 @@
         "css/ui_spacing/console.css",
         "css/ui_spacing/twbasic.css",
         "css/thread_page_design_warning.css",
+        "css/thread_toolbar.css",
 
         "communityConsoleMain.bundle.js.map",
         "communityConsoleStart.bundle.js.map",
diff --git a/webpack.config.js b/webpack.config.js
index 4f4a849..c515434 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -53,7 +53,7 @@
     batchLockInject: './src/injections/batchLock.js',
     xhrInterceptorInject: './src/injections/xhrProxy.js',
     extraInfoInject: './src/injections/extraInfo.js',
-    workflowComponentsInject: './src/injections/workflowComponentsInject.js',
+    litComponentsInject: './src/injections/litComponentsInject.js',
 
     // Options page
     optionsCommon: './src/options/optionsCommon.js',