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