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/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;