Flatten threads: don't show reply button when appropriate
This CL also refactors the response interceptors to use the new
ThreadModel.
Fixed: twpowertools:160
Change-Id: I859e0fa1b8f5f4057bd66af3d167e4b21c6d12ed
diff --git a/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
index e8585d0..557a533 100644
--- a/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
+++ b/src/contentScripts/communityConsole/flattenThreads/flattenThreads.js
@@ -3,7 +3,7 @@
export const kReplyPayloadSelector =
'.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) .scTailwindThreadPostcontentroot html-blob';
export const kReplyActionButtonsSelector =
- '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) sc-tailwind-thread-message-message-actions';
+ '.scTailwindThreadMessageMessagecardcontent:not(.scTailwindThreadMessageMessagecardpromoted) sc-tailwind-thread-message-message-actions';
export const kAdditionalInfoSelector = '.ck-indent-9996300035194';
export const kMatchingSelectors = [
kReplyPayloadSelector,
@@ -66,7 +66,7 @@
node.closest('.scTailwindThreadMessageMessagecardcontent')
.querySelector('.scTailwindThreadMessageMessagecardbody html-blob');
const extraInfo = this.getExtraInfo(root);
- if (!extraInfo) return;
+ if (!extraInfo || !extraInfo.canComment) return;
this.injectReplyBtn(node, extraInfo);
}
diff --git a/src/models/Gap.js b/src/models/Gap.js
index 59e4882..8c380b2 100644
--- a/src/models/Gap.js
+++ b/src/models/Gap.js
@@ -1,6 +1,9 @@
+import ThreadModel from './Thread.js';
+
export default class GapModel {
- constructor(data) {
+ constructor(data, thread) {
this.data = data ?? {};
+ this.thread = thread ?? new ThreadModel();
}
getCount() {
diff --git a/src/models/Message.js b/src/models/Message.js
index 7aae392..05dc795 100644
--- a/src/models/Message.js
+++ b/src/models/Message.js
@@ -1,9 +1,11 @@
+import ItemMetadataState from './enums/ItemMetadataState.js';
import GapModel from './Gap.js';
import ThreadModel from './Thread.js';
export default class MessageModel {
- constructor(data) {
+ constructor(data, thread) {
this.data = data ?? {};
+ this.thread = thread ?? new ThreadModel();
this.commentsAndGaps = null;
}
@@ -21,10 +23,17 @@
return this.data[12] ?? [];
}
+ #getMessageOrGapModels() {
+ const rawMogs = this.getRawCommentsAndGaps();
+ return rawMogs.filter(mog => mog !== undefined).map(mog => {
+ if (mog[1]) return new MessageModel(mog[1], this.thread);
+ if (mog[2]) return new GapModel(mog[2], this.thread);
+ });
+ }
+
getCommentsAndGaps() {
if (this.commentsAndGaps === null)
- this.commentsAndGaps =
- MessageModel.mapToMessageOrGapModels(this.getRawCommentsAndGaps());
+ this.commentsAndGaps = this.#getMessageOrGapModels();
return this.commentsAndGaps;
}
@@ -59,6 +68,24 @@
delete this.data[1][37];
}
+ isDeleted() {
+ return this.data[5]?.[3] ?? null;
+ }
+
+ getState() {
+ return this.data[5]?.[1] ?? null;
+ }
+
+ isTakenDown() {
+ return [
+ ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_DELETE,
+ ItemMetadataState.MANUAL_PROFILE_TAKE_DOWN_SUSPEND,
+ ItemMetadataState.AUTOMATED_ABUSE_TAKE_DOWN_HIDE,
+ ItemMetadataState.MANUAL_TAKE_DOWN_DELETE,
+ ItemMetadataState.MANUAL_TAKE_DOWN_HIDE,
+ ].includes(this.getState());
+ }
+
isComment() {
return !!this.getParentMessageId;
}
@@ -67,16 +94,31 @@
return {1: this.data};
}
- static mapToMessageOrGapModels(rawArray) {
- return rawArray.filter(mog => mog !== undefined).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());
}
+
+ /**
+ * The following method is based on logic written by Googlers in the TW
+ * frontend and thus is not included as part of the MIT license.
+ *
+ * Source:
+ * module$exports$google3$customer_support$content$ui$client$tailwind$models$message_model$message_model.MessageModel.prototype.canComment
+ */
+ canComment(currentUser) {
+ if (this.isDeleted()) return false;
+ if (this.isTakenDown()) return false;
+ if (currentUser.isAccountDisabled()) return false;
+ if (this.thread.isLocked() &&
+ !currentUser.isAtLeastCommunityManager(this.thread.getForumId())) {
+ return false;
+ }
+ if (this.thread.isSoftLocked() && !currentUser.isAtLeastSilverRole() &&
+ !this.thread.isAuthoredByUser()) {
+ return false;
+ }
+ return true;
+ }
}
diff --git a/src/models/StartupData.js b/src/models/StartupData.js
new file mode 100644
index 0000000..047c154
--- /dev/null
+++ b/src/models/StartupData.js
@@ -0,0 +1,41 @@
+import UserModel from './User.js';
+
+export default class StartupDataModel {
+ constructor(data) {
+ this.data = data ?? {};
+ }
+
+ static buildFromCCDOM() {
+ const startupData =
+ document.querySelector('html')?.getAttribute?.('data-startup');
+ if (!startupData) {
+ console.warn('Haven\'t found CC startup data.');
+ return null;
+ }
+
+ let startup;
+ try {
+ startup = JSON.parse(startupData);
+ } catch (error) {
+ console.warn('Haven\'t been able to parse CC startup data.');
+ }
+
+ return new StartupDataModel(startup);
+ }
+
+ getRawUser() {
+ return this.data[1]?.[1] ?? null;
+ }
+
+ getAuthUser() {
+ return this.data[2]?.[1] ?? '0';
+ }
+
+ getRawForumsInfo() {
+ return this.data[1]?.[2] ?? null;
+ }
+
+ getCurrentUserModel() {
+ return new UserModel(this.getRawUser(), this);
+ }
+}
diff --git a/src/models/Thread.js b/src/models/Thread.js
index e02c0a5..e87f541 100644
--- a/src/models/Thread.js
+++ b/src/models/Thread.js
@@ -2,6 +2,60 @@
import MessageModel from './Message.js';
export default class ThreadModel {
+ constructor(data) {
+ this.data = data ?? {};
+ }
+
+ getId() {
+ return this.data[2]?.[1]?.[1] ?? null;
+ }
+
+ getForumId() {
+ return this.data[2]?.[1]?.[3] ?? null;
+ }
+
+ getRawCommentsAndGaps() {
+ return this.data[40] ?? [];
+ }
+
+ setRawCommentsAndGaps(cogs) {
+ this.data[40] = cogs;
+ }
+
+ getMessageOrGapModels() {
+ const rawMogs = this.getRawCommentsAndGaps();
+ return rawMogs.filter(mog => mog !== undefined).map(mog => {
+ if (mog[1]) return new MessageModel(mog[1], this);
+ if (mog[2]) return new GapModel(mog[2], this);
+ });
+ }
+
+ setLastMessage(message) {
+ if (!this.data[17]) this.data[17] = [];
+ this.data[17][3] = message;
+ }
+
+ setNumMessages(num) {
+ this.data[8] = num;
+ }
+
+ isLocked() {
+ // TODO: When a forum is read-only, this should also return true.
+ return this.data[2]?.[5] == true;
+ }
+
+ isSoftLocked() {
+ return this.data[2]?.[51] == true;
+ }
+
+ isAuthoredByUser() {
+ return this.data[9] == true;
+ }
+
+ toRawThread() {
+ return this.data;
+ }
+
/**
* 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.
diff --git a/src/models/User.js b/src/models/User.js
new file mode 100644
index 0000000..127634a
--- /dev/null
+++ b/src/models/User.js
@@ -0,0 +1,58 @@
+import {kUserRoleEnum, kUserRoleRank} from './enums/UserRole.js';
+import StartupData from './StartupData.js';
+
+export default class UserModel {
+ constructor(data, startupData) {
+ this.data = data ?? {};
+ this.startupData = startupData ?? new StartupData();
+ }
+
+ getRawAccountAbuse() {
+ return this.data[8]?.[1] ?? null;
+ }
+
+ hasAccountAbuse() {
+ return this.getRawAccountAbuse() !== null;
+ }
+
+ isAccountDisabled() {
+ return this.hasAccountAbuse();
+ }
+
+ getRole(forumId) {
+ const forumsInfo = this.startupData.getRawForumsInfo() ?? [];
+ for (const f of forumsInfo) {
+ const itForumId = f[1] ?? f[2]?.[1]?.[1];
+ if (itForumId == forumId) {
+ return f[3]?.[1]?.[3] ?? kUserRoleEnum.ROLE_USER;
+ }
+ }
+ return kUserRoleEnum.ROLE_USER;
+ }
+
+ #isRoleAtLeast(a, b) {
+ const aRank = kUserRoleRank[a] ?? 0;
+ const bRank = kUserRoleRank[b] ?? 0;
+ return aRank >= bRank;
+ }
+
+ getHighestRole() {
+ const forumsInfo = this.startupData.getRawForumsInfo() ?? [];
+ const roles = forumsInfo.map(f => {
+ return f[3]?.[1]?.[3] ?? kUserRoleEnum.ROLE_USER;
+ });
+ return roles.reduce((prev, current) => {
+ return this.#isRoleAtLeast(current, prev) ? current : prev;
+ });
+ }
+
+ isAtLeastCommunityManager(forumId = null) {
+ const role = forumId ? this.getRole(forumId) : this.getHighestRole();
+ return this.#isRoleAtLeast(role, kUserRoleEnum.ROLE_COMMUNITY_MANAGER);
+ }
+
+ isAtLeastSilverRole(forumId = null) {
+ const role = forumId ? this.getRole(forumId) : this.getHighestRole();
+ return this.#isRoleAtLeast(role, kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_2);
+ }
+}
diff --git a/src/models/enums/ItemMetadataState.js b/src/models/enums/ItemMetadataState.js
new file mode 100644
index 0000000..3331c08
--- /dev/null
+++ b/src/models/enums/ItemMetadataState.js
@@ -0,0 +1,24 @@
+const kItemMetadataStateEnum = {
+ UNDEFINED: 0,
+ PUBLISHED: 1,
+ DRAFT: 2,
+ AUTOMATED_ABUSE_TAKE_DOWN_HIDE: 3,
+ AUTOMATED_ABUSE_TAKE_DOWN_DELETE: 4,
+ AUTOMATED_ABUSE_REINSTATE: 13,
+ AUTOMATED_OFF_TOPIC_HIDE: 10,
+ AUTOMATED_FLAGGED_PENDING_MANUAL_REVIEW: 14,
+ USER_FLAGGED_PENDING_MANUAL_REVIEW: 5,
+ OWNER_DELETED: 6,
+ MANUAL_TAKE_DOWN_HIDE: 7,
+ MANUAL_PROFILE_TAKE_DOWN_SUSPEND: 17,
+ MANUAL_TAKE_DOWN_DELETE: 8,
+ REINSTATE_PROFILE_TAKEDOWN: 18,
+ REINSTATE_ABUSE_TAKEDOWN: 9,
+ CLEAR_OFF_TOPIC: 11,
+ CONFIRM_OFF_TOPIC: 12,
+ GOOGLER_OFF_TOPIC_HIDE: 15,
+ EXPERT_FLAGGED_PENDING_MANUAL_REVIEW: 16,
+ AWAITING_CLASSIFICATION: 19,
+};
+
+export default kItemMetadataStateEnum;
diff --git a/src/models/enums/UserRole.js b/src/models/enums/UserRole.js
new file mode 100644
index 0000000..287f194
--- /dev/null
+++ b/src/models/enums/UserRole.js
@@ -0,0 +1,25 @@
+export const kUserRoleEnum = {
+ ROLE_USER: 0,
+ ROLE_PRODUCT_EXPERT_LEVEL_1: 1,
+ ROLE_PRODUCT_EXPERT_LEVEL_2: 2,
+ ROLE_PRODUCT_EXPERT_LEVEL_3: 3,
+ ROLE_PRODUCT_EXPERT_LEVEL_4: 4,
+ ROLE_PRODUCT_EXPERT_LEVEL_5: 5,
+ ROLE_COMMUNITY_MANAGER: 10,
+ ROLE_COMMUNITY_SPECIALIST: 20,
+ ROLE_GOOGLE_EMPLOYEE: 100,
+ ROLE_ALUMNUS: 30,
+};
+export default kUserRoleEnum;
+
+export let kUserRoleRank = {};
+kUserRoleRank[kUserRoleEnum.ROLE_USER] = 0;
+kUserRoleRank[kUserRoleEnum.ROLE_ALUMNUS] = 1;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_1] = 2;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_2] = 3;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_3] = 4;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_4] = 5;
+kUserRoleRank[kUserRoleEnum.ROLE_PRODUCT_EXPERT_LEVEL_5] = 6;
+kUserRoleRank[kUserRoleEnum.ROLE_COMMUNITY_SPECIALIST] = 7;
+kUserRoleRank[kUserRoleEnum.ROLE_COMMUNITY_MANAGER] = 8;
+kUserRoleRank[kUserRoleEnum.ROLE_GOOGLE_EMPLOYEE] = 9;
diff --git a/src/xhrInterceptor/responseModifiers/flattenThread.js b/src/xhrInterceptor/responseModifiers/flattenThread.js
index b515191..2eac64d 100644
--- a/src/xhrInterceptor/responseModifiers/flattenThread.js
+++ b/src/xhrInterceptor/responseModifiers/flattenThread.js
@@ -1,6 +1,10 @@
import {kAdditionalInfoClass} from '../../contentScripts/communityConsole/flattenThreads/flattenThreads.js';
import GapModel from '../../models/Gap.js';
import MessageModel from '../../models/Message.js';
+import StartupDataModel from '../../models/StartupData.js';
+import ThreadModel from '../../models/Thread.js';
+
+const currentUser = StartupDataModel.buildFromCCDOM().getCurrentUserModel();
const flattenThread = {
urlRegex: /api\/ViewThread/i,
@@ -13,9 +17,10 @@
async interceptor(_request, response) {
if (!response[1]?.[40]) return response;
+ const thread = new ThreadModel(response[1]);
+
// Do the actual flattening
- const originalMogs =
- MessageModel.mapToMessageOrGapModels(response[1][40] ?? []);
+ const originalMogs = thread.getMessageOrGapModels();
let extraMogs = [];
originalMogs.forEach(mog => {
if (mog instanceof GapModel) return;
@@ -56,25 +61,28 @@
return diff > 0 ? 1 : diff < 0 ? -1 : 0;
});
- response[1][40] = mogs.map(mog => mog.toRawMessageOrGap());
+ thread.setRawCommentsAndGaps(mogs.map(mog => mog.toRawMessageOrGap()));
// Set last_message to the last message after sorting
- if (response[1]?.[17]?.[3])
- response[1][17][3] = response[1][40].slice(-1)?.[1];
+ thread.setLastMessage(thread.getRawCommentsAndGaps().slice(-1)?.[1]);
// Set num_messages to the updated value, since we've flattened the replies.
- response[1][8] = response[1][40].length;
+ thread.setNumMessages(thread.getRawCommentsAndGaps().length);
+
+ response[1] = thread.toRawThread();
return response;
},
getAdditionalInformation(message, mogs, prevReplyId, prevReplyParentId) {
const id = message.getId();
const parentId = message.getParentMessageId();
const authorName = message.getAuthor()?.[1]?.[1];
+ const canComment = message.canComment(currentUser);
if (!parentId) {
return {
isComment: false,
id,
authorName,
+ canComment,
};
}
@@ -96,8 +104,9 @@
payload: prevMessage?.getPayload(),
author: prevMessage?.getAuthor(),
},
+ canComment,
};
- }
+ },
};
export default flattenThread;
diff --git a/src/xhrInterceptor/responseModifiers/loadMoreThread.js b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
index f259fe4..2ddf37d 100644
--- a/src/xhrInterceptor/responseModifiers/loadMoreThread.js
+++ b/src/xhrInterceptor/responseModifiers/loadMoreThread.js
@@ -17,17 +17,20 @@
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) {
+ const thread = new ThreadModel(response[1]);
+
+ if (!thread.getForumId() || !thread.getId()) {
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);
+ const mogs = thread.getMessageOrGapModels();
+ thread.setRawCommentsAndGaps(
+ await this.loadGaps(thread.getForumId(), thread.getId(), mogs, 0));
+
+ response[1] = thread.toRawThread();
return response;
},
loadGaps(forumId, threadId, mogs, it) {
@@ -94,7 +97,8 @@
},
/* authenticated = */ true, authuser)
.then(res => {
- return MessageModel.mapToMessageOrGapModels(res[1]?.[40] ?? []);
+ const thread = new ThreadModel(res[1]);
+ return thread.getMessageOrGapModels();
});
}
};