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',