refactor: migrate extra info feature to the new architecture
Bug: twpowertools:176
Change-Id: I379216066b973fe76f000ab9581053c1f0da569e
diff --git a/src/features/extraInfo/core/consts.js b/src/features/extraInfo/core/consts.js
new file mode 100644
index 0000000..236cf2e
--- /dev/null
+++ b/src/features/extraInfo/core/consts.js
@@ -0,0 +1,198 @@
+export const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
+export const kViewThreadResponse = 'TWPT_ViewThreadResponse';
+export const kViewForumRequest = 'TWPT_ViewForumRequest';
+export const kViewForumResponse = 'TWPT_ViewForumResponse';
+
+// Used to match each category with the corresponding string.
+export const kAbuseCategories = [
+ ['1', 'account'],
+ ['2', 'displayname'],
+ ['3', 'avatar'],
+];
+export const kAbuseViolationCategories = {
+ 0: 'NO_VIOLATION',
+ 1: 'COMMUNITY_POLICY_VIOLATION',
+ 2: 'LEGAL_VIOLATION',
+ 3: 'CSAI_VIOLATION',
+ 4: 'OTHER_VIOLATION',
+};
+export const kAbuseViolationCategoriesI18n = {
+ 0: 'noviolation',
+ 1: 'communitypolicy',
+ 2: 'legal',
+ 3: 'csai',
+ 4: 'other',
+};
+
+// The following array will appear in the interface as is (without being
+// translated).
+export const kAbuseViolationTypes = {
+ 0: 'UNSPECIFIED',
+ 23: 'ACCOUNT_DISABLED',
+ 55: 'ACCOUNT_HAS_SERVICES_DISABLED',
+ 35: 'ACCOUNT_HIJACKED',
+ 96: 'ACCOUNT_LEAKED_CREDENTIALS',
+ 92: 'ACCOUNT_NOT_SUPPORTED',
+ 81: 'ARTISTIC_NUDITY',
+ 66: 'BAD_BEHAVIOR_PATTERN',
+ 78: 'BAD_ENGAGEMENT_BEHAVIOR_PATTERN',
+ 79: 'BORDERLINE_HARASSMENT',
+ 80: 'BORDERLINE_HATE_SPEECH',
+ 38: 'BOTNET',
+ 32: 'BRANDING_VIOLATION',
+ 100: 'CAPITALIZING_TRAGIC_EVENTS',
+ 105: 'CLOAKING',
+ 49: 'COIN_MINING',
+ 7: 'COMMERCIAL_CONTENT',
+ 97: 'COPPA_REGULATED',
+ 57: 'COPYRIGHT_CIRCUMVENTION',
+ 8: 'COPYRIGHTED_CONTENT',
+ 58: 'COURT_ORDER',
+ 51: 'CSAI',
+ 94: 'CSAI_INSPECT',
+ 52: 'CSAI_CARTOON_HUMOR',
+ 53: 'CSAI_SOLICITATION',
+ 108: 'CSAI_NON_APPARENT',
+ 67: 'DANGEROUS',
+ 37: 'DATA_SCRAPING',
+ 86: 'DECEPTIVE_OAUTH_IMPLEMENTATION',
+ 46: 'DEFAMATORY_CONTENT',
+ 36: 'DELINQUENT_BILLING',
+ 30: 'DISRUPTION_ATTEMPT',
+ 112: 'DOMESTIC_INTERFERENCE',
+ 22: 'DOS',
+ 9: 'DUPLICATE_CONTENT',
+ 68: 'DUPLICATE_LOCAL_PAGE',
+ 121: 'NON_QUALIFYING_ORGANIZATION',
+ 115: 'EGREGIOUS_INTERACTION_WITH_MINOR',
+ 83: 'ENGAGEMENT_COLLUSION',
+ 41: 'EXPLOIT_ATTACKS',
+ 65: 'FAKE_USER',
+ 2: 'FRAUD',
+ 21: 'FREE_TRIAL_VIOLATION',
+ 43: 'GIBBERISH',
+ 101: 'FOREIGN_INTERFERENCE',
+ 59: 'GOVERNMENT_ORDER',
+ 10: 'GRAPHICAL_VIOLENCE',
+ 11: 'HARASSMENT',
+ 12: 'HATE_SPEECH',
+ 90: 'IDENTICAL_PRODUCT_NAME',
+ 60: 'ILLEGAL_DRUGS',
+ 13: 'IMPERSONATION',
+ 69: 'IMPERSONATION_WITH_PII',
+ 116: 'INAPPROPRIATE_INTERACTION_WITH_MINOR',
+ 45: 'INAPPROPRIATE_CONTENT_SPEECH',
+ 106: 'INTENTIONAL_THWARTING',
+ 27: 'INTRUSION_ATTEMPT',
+ 87: 'INVALID_API_USAGE',
+ 14: 'INVALID_CONTENT',
+ 20: 'INVALID_GCE_USAGE',
+ 120: 'INVALID_STORAGE_USAGE',
+ 15: 'INVALID_IMAGE_QUALITY',
+ 88: 'INVALID_API_PRIVACY_POLICY_DISCLOSURE',
+ 54: 'INVALID_USAGE_OF_IP_PROXYING',
+ 99: 'KEYWORD_STUFFING',
+ 61: 'LEGAL_COUNTERFEIT',
+ 62: 'LEGAL_EXPORT',
+ 63: 'LEGAL_PRIVACY',
+ 33: 'LEGAL_REVIEW',
+ 91: 'LEGAL_PROTECTED',
+ 70: 'LOW_QUALITY_CONTENT',
+ 93: 'LOW_REPUTATION_PHONE_NUMBER',
+ 6: 'MALICIOUS_SOFTWARE',
+ 40: 'MALWARE',
+ 113: 'MISLEADING',
+ 114: 'MISREP_OF_ID',
+ 89: 'MEMBER_OF_ABUSIVE_GCE_NETWORK',
+ 84: 'NON_CONSENSUAL_EXPLICIT_IMAGERY',
+ 1: 'NONE',
+ 102: 'OFF_TOPIC',
+ 31: 'OPEN_PROXY',
+ 28: 'PAYMENT_FRAUD',
+ 16: 'PEDOPHILIA',
+ 71: 'PERSONAL_INFORMATION_CONTENT',
+ 25: 'PHISHING',
+ 34: 'POLICY_REVIEW',
+ 17: 'PORNOGRAPHY',
+ 29: 'QUOTA_CIRCUMVENTION',
+ 72: 'QUOTA_EXCEEDED',
+ 73: 'REGULATED',
+ 24: 'REPEATED_POLICY_VIOLATION',
+ 104: 'RESOURCE_COMPROMISED',
+ 107: 'REWARD_PROGRAMS_ABUSE',
+ 74: 'ROGUE_PHARMA',
+ 82: 'ESCORT',
+ 75: 'SPAMMY_LOCAL_VERTICAL',
+ 39: 'SEND_EMAIL_SPAM',
+ 117: 'SEXTORTION',
+ 118: 'SEX_TRAFFICKING',
+ 44: 'SEXUALLY_EXPLICIT_CONTENT',
+ 3: 'SHARDING',
+ 95: 'SOCIAL_ENGINEERING',
+ 109: 'SUSPICIOUS',
+ 19: 'TRADEMARK_CONTENT',
+ 50: 'TRAFFIC_PUMPING',
+ 76: 'UNSAFE_RACY',
+ 103: 'UNUSUAL_ACTIVITY_ALERT',
+ 64: 'UNWANTED_CONTENT',
+ 26: 'UNWANTED_SOFTWARE',
+ 77: 'VIOLENT_EXTREMISM',
+ 119: 'UNAUTH_IMAGES_OF_MINORS',
+ 85: 'UNAUTHORIZED_SERVICE_RESELLING',
+ 98: 'CSAI_EXTERNAL',
+ 5: 'SPAM',
+ 4: 'UNSAFE',
+ 47: 'CHILD_PORNOGRAPHY_INCITATION',
+ 18: 'TERRORISM_SUPPORT',
+ 56: 'CSAI_WORST_OF_WORST',
+};
+
+// These values will be translated
+export const kItemMetadataStateI18n = {
+ 1: 'published',
+ 2: 'draft',
+ 3: 'automated_abuse_take_down_hide2',
+ 4: 'automated_abuse_take_down_delete2',
+ 13: 'automated_abuse_reinstate2',
+ // TODO: Add the following line and its corresponding translation once we know
+ // what the state means: `21: 'automated_abuse_manual_review',`
+ 10: 'automated_off_topic_hide2',
+ 14: 'automated_flagged_pending_manual_review2',
+ 5: 'user_flagged_pending_manual_review',
+ 6: 'owner_deleted',
+ 7: 'manual_take_down_hide2',
+ 17: 'manual_profile_take_down_suspend2',
+ 8: 'manual_take_down_delete2',
+ 18: 'reinstate_profile_takedown2',
+ 9: 'reinstate_abuse_takedown2',
+ 11: 'clear_off_topic2',
+ 12: 'confirm_off_topic2',
+ 15: 'googler_off_topic_hide2',
+ 16: 'expert_flagged_pending_manual_review',
+ 19: 'awaiting_classification',
+ 20: 'generated_answer_adopted',
+};
+export const kItemMetadataState = {
+ 0: 'UNDEFINED',
+ 1: 'PUBLISHED',
+ 2: 'DRAFT',
+ 3: 'AUTOMATED_ABUSE_TAKE_DOWN_HIDE',
+ 4: 'AUTOMATED_ABUSE_TAKE_DOWN_DELETE',
+ 13: 'AUTOMATED_ABUSE_REINSTATE',
+ 21: 'AUTOMATED_ABUSE_MANUAL_REVIEW',
+ 10: 'AUTOMATED_OFF_TOPIC_HIDE',
+ 14: 'AUTOMATED_FLAGGED_PENDING_MANUAL_REVIEW',
+ 5: 'USER_FLAGGED_PENDING_MANUAL_REVIEW',
+ 6: 'OWNER_DELETED',
+ 7: 'MANUAL_TAKE_DOWN_HIDE',
+ 17: 'MANUAL_PROFILE_TAKE_DOWN_SUSPEND',
+ 8: 'MANUAL_TAKE_DOWN_DELETE',
+ 18: 'REINSTATE_PROFILE_TAKEDOWN',
+ 9: 'REINSTATE_ABUSE_TAKEDOWN',
+ 11: 'CLEAR_OFF_TOPIC',
+ 12: 'CONFIRM_OFF_TOPIC',
+ 15: 'GOOGLER_OFF_TOPIC_HIDE',
+ 16: 'EXPERT_FLAGGED_PENDING_MANUAL_REVIEW',
+ 19: 'AWAITING_CLASSIFICATION',
+ 20: 'GENERATED_ANSWER_ADOPTED',
+};
diff --git a/src/features/extraInfo/core/index.js b/src/features/extraInfo/core/index.js
new file mode 100644
index 0000000..c7be5cd
--- /dev/null
+++ b/src/features/extraInfo/core/index.js
@@ -0,0 +1,68 @@
+import OptionsWatcher from '../../../common/optionsWatcher.js';
+
+import ProfileInfoHandler from './infoHandlers/profile.js';
+import ThreadInfoHandler from './infoHandlers/thread.js';
+import ThreadListInfoHandler from './infoHandlers/threadList.js';
+import ExpandedThreadListExtraInfoInjection from './injections/expandedThreadList.js';
+import ProfileAbuseExtraInfoInjection from './injections/profileAbuse.js';
+import ProfilePerForumStatsExtraInfoInjection from './injections/profilePerForumStats.js';
+import ThreadCommentExtraInfoInjection from './injections/threadComment.js';
+import ThreadListExtraInfoInjection from './injections/threadList.js';
+import ThreadQuestionExtraInfoInjection from './injections/threadQuestion.js';
+import ThreadReplyExtraInfoInjection from './injections/threadReply.js';
+
+export default class ExtraInfo {
+ constructor() {
+ const optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
+
+ const profileInfoHandler = new ProfileInfoHandler();
+ const threadInfoHandler = new ThreadInfoHandler();
+ const threadListInfoHandler = new ThreadListInfoHandler();
+
+ this.profileAbuse =
+ new ProfileAbuseExtraInfoInjection(profileInfoHandler, optionsWatcher);
+ this.profilePerForumStats = new ProfilePerForumStatsExtraInfoInjection(
+ profileInfoHandler, optionsWatcher);
+ this.threadQuestion =
+ new ThreadQuestionExtraInfoInjection(threadInfoHandler, optionsWatcher);
+ this.threadReply =
+ new ThreadReplyExtraInfoInjection(threadInfoHandler, optionsWatcher);
+ this.threadComment =
+ new ThreadCommentExtraInfoInjection(threadInfoHandler, optionsWatcher);
+ this.expandedThreadList = new ExpandedThreadListExtraInfoInjection(
+ threadListInfoHandler, optionsWatcher);
+ this.threadList =
+ new ThreadListExtraInfoInjection(threadListInfoHandler, optionsWatcher);
+ }
+
+ injectAbuseChipsAtProfileIfEnabled(card) {
+ this.profileAbuse.injectIfEnabled({card});
+ }
+
+ injectAtThreadListIfEnabled(li) {
+ const injectionDetails = this.threadList.getInjectionDetails(li);
+ this.threadList.injectIfEnabled(injectionDetails);
+ }
+
+ injectAtExpandedThreadListIfEnabled(toolbelt) {
+ const injectionDetails =
+ this.expandedThreadList.getInjectionDetails(toolbelt);
+ this.expandedThreadList.injectIfEnabled(injectionDetails);
+ }
+
+ injectPerForumStatsIfEnabled(chart) {
+ this.profilePerForumStats.injectIfEnabled({chart});
+ }
+
+ injectAtQuestionIfEnabled(stateChips) {
+ this.threadQuestion.injectIfEnabled({stateChips, isMessageNode: false});
+ }
+
+ injectAtReplyIfEnabled(messageNode) {
+ this.threadReply.injectIfEnabled({messageNode, isMessageNode: true});
+ }
+
+ injectAtCommentIfEnabled(messageNode) {
+ this.threadComment.injectIfEnabled({messageNode, isMessageNode: true});
+ }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/base.js b/src/features/extraInfo/core/infoHandlers/base.js
new file mode 100644
index 0000000..732ccee
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/base.js
@@ -0,0 +1,17 @@
+import {shouldImplement} from '../../../../common/commonUtils.js';
+
+export default class BaseInfoHandler {
+ constructor() {
+ if (this.constructor == BaseInfoHandler) {
+ throw new Error('The base class cannot be instantiated.');
+ }
+ }
+
+ /**
+ * Should return a promise which resolves to the current info in a best-effort
+ * manner (if it can't retrieve the current info it is allowed to fail).
+ */
+ async getCurrentInfo(_injectionDetails) {
+ shouldImplement('getCurrentInfo');
+ }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/basedOnResponseEvent.js b/src/features/extraInfo/core/infoHandlers/basedOnResponseEvent.js
new file mode 100644
index 0000000..30bb36d
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/basedOnResponseEvent.js
@@ -0,0 +1,86 @@
+import {waitFor} from 'poll-until-promise';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+
+import BaseInfoHandler from './base.js';
+
+export default class ResponseEventBasedInfoHandler extends BaseInfoHandler {
+ constructor() {
+ super();
+
+ if (this.constructor == ResponseEventBasedInfoHandler) {
+ throw new Error('The base class cannot be instantiated.');
+ }
+
+ this.setUpDefaultInfoValue();
+ this.setUpEventHandler();
+ }
+
+ /**
+ * Should return the name of the XHR interceptor event for the API response
+ * which has the information being handled.
+ */
+ getEvent() {
+ shouldImplement('getEvent');
+ }
+
+ /**
+ * This function should return a promise which resolves to a boolean
+ * specifying whether this.info is the information related to the view that
+ * the user is currently on.
+ */
+ async isInfoCurrent(_injectionDetails) {
+ shouldImplement('isInfoCurrent');
+ }
+
+ /**
+ * Should return the options for the waitFor function which is called when
+ * checking whether the information is current or not.
+ */
+ getWaitForCurrentInfoOptions() {
+ shouldImplement('getWaitForCurrentInfoOptions');
+ }
+
+ setUpDefaultInfoValue() {
+ this.info = {
+ body: {},
+ id: -1,
+ timestamp: 0,
+ };
+ }
+
+ setUpEventHandler() {
+ window.addEventListener(this.getEvent(), e => {
+ if (e.detail.id < this.info.id) return;
+
+ this.updateInfoWithNewValue(e);
+ });
+ }
+
+ /**
+ * Updates the info value with the information obtained from an event.
+ * Can be overriden to implement more advanced logic.
+ *
+ * @param {Event} e
+ */
+ updateInfoWithNewValue(e) {
+ this.info = {
+ body: e.detail.body,
+ id: e.detail.id,
+ timestamp: Date.now(),
+ };
+ }
+
+ async getCurrentInfo(injectionDetails) {
+ const options = this.getWaitForCurrentInfoOptions();
+ return waitFor(
+ () => this.attemptToGetCurrentInfo(injectionDetails), options);
+ }
+
+ async attemptToGetCurrentInfo(injectionDetails) {
+ const isInfoCurrent = await this.isInfoCurrent(injectionDetails);
+ if (!isInfoCurrent) throw new Error('Didn\'t receive current information');
+
+ return this.info;
+ }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/profile.js b/src/features/extraInfo/core/infoHandlers/profile.js
new file mode 100644
index 0000000..28e8309
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/profile.js
@@ -0,0 +1,20 @@
+import {kViewUnifiedUserResponseEvent} from '../consts.js';
+
+import ResponseEventBasedInfoHandler from './basedOnResponseEvent.js';
+
+export default class ProfileInfoHandler extends ResponseEventBasedInfoHandler {
+ getEvent() {
+ return kViewUnifiedUserResponseEvent;
+ }
+
+ async isInfoCurrent() {
+ return Date.now() - this.info.timestamp < 15 * 1000;
+ }
+
+ getWaitForCurrentInfoOptions() {
+ return {
+ interval: 500,
+ timeout: 15 * 1000,
+ };
+ }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/thread.js b/src/features/extraInfo/core/infoHandlers/thread.js
new file mode 100644
index 0000000..6c8fb73
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/thread.js
@@ -0,0 +1,107 @@
+import {waitFor} from 'poll-until-promise';
+
+import {parseUrl} from '../../../../common/commonUtils.js';
+import ThreadModel from '../../../../models/Thread.js';
+import {kViewThreadResponse} from '../consts.js';
+import MessageExtraInfoService from '../services/message.js';
+
+import ResponseEventBasedInfoHandler from './basedOnResponseEvent.js';
+
+const kIntervalInMs = 500;
+const kTimeoutInMs = 3 * 1000;
+const kCurrentInfoExpiresInMs = kTimeoutInMs * 1.5;
+
+export default class ThreadInfoHandler extends ResponseEventBasedInfoHandler {
+ getEvent() {
+ return kViewThreadResponse;
+ }
+
+ getWaitForCurrentInfoOptions() {
+ return {
+ interval: kIntervalInMs,
+ timeout: kTimeoutInMs,
+ };
+ }
+
+ setUpDefaultInfoValue() {
+ this.info = {
+ thread: new ThreadModel(),
+ messages: [],
+ id: -1,
+ timestamp: 0,
+ };
+ }
+
+ updateInfoWithNewValue(e) {
+ const newThread = new ThreadModel(e.detail.body?.[1]);
+ if (newThread.getId() != this.info.thread.getId()) {
+ this.info.messages = [];
+ }
+
+ const newMessages = newThread.getAllMessagesList();
+ this.updateRecordedMessages(newMessages);
+
+ this.info.thread = newThread;
+ this.info.id = e.detail.id;
+ this.info.timestamp = Date.now();
+ }
+
+ updateRecordedMessages(newMessages) {
+ const nonUpdatedMessages = this.info.messages.filter(message => {
+ return !newMessages.some(newMessage => {
+ return message.getId() == newMessage.getId();
+ });
+ });
+ this.info.messages = nonUpdatedMessages.concat(newMessages);
+ }
+
+ async getCurrentInfo(injectionDetails) {
+ return this
+ .getCurrentThreads(injectionDetails, /* checkRecentTimestamp = */ true)
+ .catch(() => {
+ console.debug(
+ `extraInfo: couldn't get updated thread info. Trying to ` +
+ `get the information even if it is old.`);
+ return this.getCurrentThreads(
+ injectionDetails, /* checkRecentTimestamp = */ false);
+ });
+ }
+
+ async getCurrentThreads(injectionDetails, checkRecentTimestamp) {
+ injectionDetails.checkRecentTimestamp = checkRecentTimestamp;
+ const options = this.getWaitForCurrentInfoOptions();
+ return waitFor(
+ () => this.attemptToGetCurrentInfo(injectionDetails), options);
+ }
+
+ async isInfoCurrent(injectionDetails) {
+ const checkRecentTimestamp = injectionDetails.checkRecentTimestamp;
+ const isMessageNode = injectionDetails.isMessageNode;
+ const messageNode = injectionDetails.messageNode;
+
+ return (!checkRecentTimestamp || this.isThreadCurrent()) &&
+ (!isMessageNode || this.currentThreadContainsMessage(messageNode));
+ }
+
+ isThreadCurrent() {
+ const currentPage = this.parseThreadUrl();
+ return Date.now() - this.info.timestamp < kCurrentInfoExpiresInMs &&
+ this.info.thread.getId() == currentPage.thread &&
+ this.info.thread.getForumId() == currentPage.forum;
+ }
+
+ parseThreadUrl() {
+ const currentPage = parseUrl(location.href);
+ if (currentPage === false)
+ throw new Error(`couldn't parse current URL: ${location.href}`);
+
+ return currentPage;
+ }
+
+ currentThreadContainsMessage(messageNode) {
+ const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
+ const message = MessageExtraInfoService.getMessageFromList(
+ messageId, this.info.messages);
+ return message !== undefined;
+ }
+}
diff --git a/src/features/extraInfo/core/infoHandlers/threadList.js b/src/features/extraInfo/core/infoHandlers/threadList.js
new file mode 100644
index 0000000..47d7e65
--- /dev/null
+++ b/src/features/extraInfo/core/infoHandlers/threadList.js
@@ -0,0 +1,100 @@
+import {waitFor} from 'poll-until-promise';
+
+import {kViewForumRequest, kViewForumResponse} from '../consts.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseInfoHandler from './base.js';
+
+const kCheckIntervalInMs = 450;
+const kTimeoutInMs = 2 * 1000;
+
+export default class ThreadListInfoHandler extends BaseInfoHandler {
+ constructor() {
+ super();
+
+ this.setUpDefaultValues();
+ this.setUpEventHandlers();
+ }
+
+ setUpDefaultValues() {
+ this.threads = [];
+ this.isFirstBatch = null;
+ this.requestId = -1;
+ this.timestamp = 0;
+ }
+
+ setUpEventHandlers() {
+ window.addEventListener(kViewForumRequest, e => this.onThreadRequest(e));
+ window.addEventListener(kViewForumResponse, e => this.onThreadResponse(e));
+ }
+
+ onThreadRequest(e) {
+ // Ignore ViewForum requests made by the chat feature and the "Mark as
+ // duplicate" dialog.
+ //
+ // All those requests have |maxNum| set to 10 and 20 respectively, while
+ // the requests that we want to handle are the ones to initially load the
+ // thread list (which currently requests 100 threads) and the ones to load
+ // more threads (which request 50 threads).
+ const maxNum = e.detail.body?.['2']?.['1']?.['2'];
+ if (maxNum == 10 || maxNum == 20) return;
+
+ this.requestId = e.detail.id;
+ this.isFirstBatch =
+ !e.detail.body?.['2']?.['1']?.['3']?.['2']; // Pagination token
+ }
+
+ onThreadResponse(e) {
+ if (e.detail.id != this.requestId) return;
+
+ const threads = e.detail.body?.['1']?.['2'] ?? [];
+ if (this.isFirstBatch)
+ this.threads = threads;
+ else
+ this.threads = this.threads.concat(threads);
+
+ this.timestamp = Date.now();
+ }
+
+ async getCurrentInfo(injectionDetails) {
+ const currentThreadInfo = injectionDetails.threadInfo;
+ const checkRecentTimestamp = !injectionDetails.isExpanded;
+
+ return this.getCurrentThreads(currentThreadInfo, checkRecentTimestamp)
+ .catch(err => {
+ if (checkRecentTimestamp) {
+ return this.getCurrentThreads(
+ currentThreadInfo, /* checkRecentTimestamp = */ false);
+ } else {
+ throw err;
+ }
+ });
+ }
+
+ async getCurrentThreads(currentThreadInfo, checkRecentTimestamp) {
+ const options = {
+ interval: kCheckIntervalInMs,
+ timeout: kTimeoutInMs,
+ };
+ return waitFor(
+ () => this.attemptToGetCurrentThreads(
+ currentThreadInfo, checkRecentTimestamp),
+ options);
+ }
+
+ async attemptToGetCurrentThreads(currentThreadInfo, checkRecentTimestamp) {
+ if (!this.isThreadListCurrent(currentThreadInfo, checkRecentTimestamp))
+ throw new Error('Didn\'t receive current information');
+
+ return this.threads;
+ }
+
+ isThreadListCurrent(currentThreadInfo, checkRecentTimestamp) {
+ if (checkRecentTimestamp && Date.now() - this.timestamp > kTimeoutInMs)
+ return false;
+
+ const thread = ThreadExtraInfoService.getThreadFromThreadList(
+ this.threads, currentThreadInfo);
+ return thread !== undefined;
+ }
+}
diff --git a/src/features/extraInfo/core/injections/base.js b/src/features/extraInfo/core/injections/base.js
new file mode 100644
index 0000000..66c024e
--- /dev/null
+++ b/src/features/extraInfo/core/injections/base.js
@@ -0,0 +1,100 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+import {createExtBadge} from '../../../../contentScripts/communityConsole/utils/common.js';
+
+export default class BaseExtraInfoInjection {
+ constructor(infoHandler, optionsWatcher) {
+ if (this.constructor == BaseExtraInfoInjection) {
+ throw new Error('The base class cannot be instantiated.');
+ }
+
+ this.infoHandler = infoHandler;
+ this.optionsWatcher = optionsWatcher;
+ }
+
+ /**
+ * Method which actually injects the extra information. It should be
+ * implemented by the extending class.
+ */
+ inject() {
+ shouldImplement('inject');
+ }
+
+ /**
+ * Overridable method which is called when an error ocurred while retrieving
+ * the info needed to inject the extra information. This is useful to show an
+ * error component in the screen.
+ */
+ injectOnInfoRetrievalError() {}
+
+ async isEnabled() {
+ return await this.optionsWatcher.isEnabled('extrainfo');
+ }
+
+ /**
+ * This is the method which should be called when injecting extra information.
+ */
+ async injectIfEnabled(injectionDetails) {
+ const isEnabled = await this.isEnabled();
+ if (!isEnabled) return;
+
+ return this.infoHandler.getCurrentInfo(injectionDetails)
+ .catch(err => {
+ this.injectOnInfoRetrievalError();
+ throw err;
+ })
+ .then(info => this.inject(info, injectionDetails))
+ .catch(err => {
+ console.error(
+ `${this.constructor.name}: error while injecting extra info: `,
+ err);
+ });
+ }
+
+ /**
+ * Add chips which contain |chipContentList| to |node|. If |withContainer| is
+ * set to true, a container will contain all the chips.
+ */
+ addExtraInfoChips(chipContentList, node, withContainer = false) {
+ if (chipContentList.length == 0) return;
+
+ let container;
+ if (withContainer) {
+ container = document.createElement('div');
+ container.classList.add('TWPT-extrainfo-container');
+ } else {
+ container = node;
+ }
+
+ let tooltips = [];
+
+ for (const content of chipContentList) {
+ const tooltip = this.addChipToContainer(content, container);
+ tooltips.push(tooltip);
+ }
+
+ if (withContainer) node.append(container);
+
+ for (const tooltip of tooltips) new MDCTooltip(tooltip);
+ }
+
+ /**
+ * Adds a chip to the container and returns a tooltip element to be
+ * instantiated.
+ */
+ addChipToContainer(chipContent, container) {
+ let chip = document.createElement('div');
+ chip.classList.add('TWPT-extrainfo-chip');
+
+ const [badge, badgeTooltip] = createExtBadge();
+
+ let span = document.createElement('span');
+ span.append(chipContent);
+
+ chip.append(badge, span);
+ container.append(chip);
+
+ return badgeTooltip;
+ }
+}
diff --git a/src/features/extraInfo/core/injections/baseThreadMessage.js b/src/features/extraInfo/core/injections/baseThreadMessage.js
new file mode 100644
index 0000000..d0cd162
--- /dev/null
+++ b/src/features/extraInfo/core/injections/baseThreadMessage.js
@@ -0,0 +1,54 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {shouldImplement} from '../../../../common/commonUtils.js';
+import ThreadModel from '../../../../models/Thread.js';
+import MessageExtraInfoService from '../services/message.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class BaseThreadMessageExtraInfoInjection extends
+ BaseExtraInfoInjection {
+ /**
+ * The class of the interactions root element.
+ */
+ getInteractionsRootClass() {
+ shouldImplement('getInteractionsRootClass');
+ }
+
+ /**
+ * The class of the interactions root element which signifies that it is
+ * non-empty.
+ */
+ getInteractionsRootNonEmptyClass() {
+ shouldImplement('getInteractionsRootNonEmptyClass');
+ }
+
+ inject(threadInfo, injectionDetails) {
+ const messageNode = injectionDetails.messageNode;
+ const message = this.#getMessage(threadInfo.messages, messageNode);
+ const [chips, tooltips] = MessageExtraInfoService.getMessageChips(message);
+ this.#injectChips(chips, messageNode);
+ for (const tooltip of tooltips) new MDCTooltip(tooltip);
+ }
+
+ #getMessage(messagesList, messageNode) {
+ const messageId = MessageExtraInfoService.getMessageIdFromNode(messageNode);
+ return MessageExtraInfoService.getMessageFromList(messageId, messagesList);
+ }
+
+ #injectChips(chips, messageNode) {
+ const interactionsElement =
+ messageNode.querySelector('.' + this.getInteractionsRootClass());
+ if (interactionsElement === null)
+ throw new Error(`Couldn't find interactions element.`);
+
+ this.#indicateInteractionsElementIsNonEmpty(interactionsElement);
+
+ this.addExtraInfoChips(
+ chips, interactionsElement, /* withContainer = */ true);
+ }
+
+ #indicateInteractionsElementIsNonEmpty(interactionsElement) {
+ interactionsElement.classList.add(this.getInteractionsRootNonEmptyClass());
+ }
+}
diff --git a/src/features/extraInfo/core/injections/expandedThreadList.js b/src/features/extraInfo/core/injections/expandedThreadList.js
new file mode 100644
index 0000000..f5a6874
--- /dev/null
+++ b/src/features/extraInfo/core/injections/expandedThreadList.js
@@ -0,0 +1,39 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {parseUrl} from '../../../../common/commonUtils.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ExpandedThreadListExtraInfoInjection extends
+ BaseExtraInfoInjection {
+ getInjectionDetails(toolbelt) {
+ const headerContent =
+ toolbelt?.parentNode?.parentNode?.parentNode?.querySelector?.(
+ '.main-header .header a.header-content');
+ if (headerContent === null) {
+ throw new Error(
+ `extraInfo: Header is not present in the thread item's DOM.`);
+ }
+
+ const threadInfo = parseUrl(headerContent.href);
+ if (threadInfo === false)
+ throw new Error(`extraInfo: Thread's link cannot be parsed.`);
+
+ return {
+ toolbelt,
+ threadInfo,
+ isExpanded: true,
+ };
+ }
+
+ inject(threads, injectionDetails) {
+ const thread = ThreadExtraInfoService.getThreadFromThreadList(
+ threads, injectionDetails.threadInfo);
+ const [chipContentList, tooltips] =
+ ThreadExtraInfoService.getThreadChips(thread);
+ this.addExtraInfoChips(
+ chipContentList, injectionDetails.toolbelt, /* withContainer = */ true);
+ for (const tooltip of tooltips) new MDCTooltip(tooltip);
+ }
+}
diff --git a/src/features/extraInfo/core/injections/profileAbuse.js b/src/features/extraInfo/core/injections/profileAbuse.js
new file mode 100644
index 0000000..2ba911c
--- /dev/null
+++ b/src/features/extraInfo/core/injections/profileAbuse.js
@@ -0,0 +1,78 @@
+import {kAbuseCategories, kAbuseViolationCategories, kAbuseViolationCategoriesI18n, kAbuseViolationTypes} from '../consts.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ProfileAbuseExtraInfoInjection extends
+ BaseExtraInfoInjection {
+ constructor(infoHandler, optionsWatcher) {
+ super(infoHandler, optionsWatcher);
+ this.unifiedUserView = undefined;
+ }
+
+ inject(profileInfo, injectionDetails) {
+ this.unifiedUserView = profileInfo.body?.['1'];
+ const chips = this.getChips();
+ this.addExtraInfoChips(
+ chips, injectionDetails.card, /* withContainer = */ true);
+ }
+
+ getChips() {
+ const chips = [
+ this.getGeneralAbuseViolationCategoryChip(),
+ ...this.getProfileAbuseChips(),
+ this.getAppealCountChip(),
+ ];
+
+ return chips.filter(chip => chip !== null);
+ }
+
+ getGeneralAbuseViolationCategoryChip() {
+ const abuseViolationCategory = this.unifiedUserView?.['6'];
+ if (!abuseViolationCategory) return null;
+ return this.getAbuseViolationCategoryChipContent(abuseViolationCategory);
+ }
+
+ getProfileAbuseChips() {
+ return kAbuseCategories
+ .map(category => {
+ return this.getProfileAbuseCategoryChip(category);
+ })
+ .filter(chip => chip !== null);
+ }
+
+ getAppealCountChip() {
+ const profileAbuse = this.unifiedUserView?.['1']?.['8'];
+ const appealCount = profileAbuse?.['4'];
+ if (appealCount === undefined) return null;
+
+ return chrome.i18n.getMessage(
+ 'inject_extrainfo_profile_appealsnum', [appealCount]);
+ }
+
+ getAbuseViolationCategoryChipContent(abuseViolationCategory) {
+ const content = document.createElement('span');
+
+ const categoryI18nKey = 'inject_extrainfo_profile_abusecategory_' +
+ kAbuseViolationCategoriesI18n[abuseViolationCategory];
+ const categoryLocalized =
+ chrome.i18n.getMessage(categoryI18nKey) ?? abuseViolationCategory;
+ content.textContent = chrome.i18n.getMessage(
+ 'inject_extrainfo_profile_abusecategory', [categoryLocalized]);
+
+ content.title = kAbuseViolationCategories[abuseViolationCategory] ??
+ abuseViolationCategory;
+
+ return content;
+ }
+
+ getProfileAbuseCategoryChip(abuseCategory) {
+ const [protoIndex, category] = abuseCategory;
+ const profileAbuse = this.unifiedUserView?.['1']?.['8'];
+ const violation = profileAbuse?.[protoIndex]?.['1']?.['1'];
+ if (!violation) return null;
+
+ return chrome.i18n.getMessage(
+ 'inject_extrainfo_profile_abuse_' + category,
+ [kAbuseViolationTypes[violation]]);
+ }
+}
diff --git a/src/features/extraInfo/core/injections/profilePerForumStats.js b/src/features/extraInfo/core/injections/profilePerForumStats.js
new file mode 100644
index 0000000..83de9db
--- /dev/null
+++ b/src/features/extraInfo/core/injections/profilePerForumStats.js
@@ -0,0 +1,22 @@
+import {getDisplayLanguage} from '../../../../contentScripts/communityConsole/utils/common.js';
+import PerForumStatsSection from '../../../../contentScripts/communityConsole/utils/PerForumStatsSection.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ProfilePerForumStatsExtraInfoInjection extends
+ BaseExtraInfoInjection {
+ constructor(infoHandler, optionsWatcher) {
+ super(infoHandler, optionsWatcher);
+ this.displayLanguage = getDisplayLanguage();
+ }
+
+ async isEnabled() {
+ return await this.optionsWatcher.isEnabled('perforumstats');
+ }
+
+ inject(profileInfo, injectionDetails) {
+ new PerForumStatsSection(
+ injectionDetails.chart?.parentNode, profileInfo.body,
+ this.displayLanguage, /* isCommunityConsole = */ true);
+ }
+}
diff --git a/src/features/extraInfo/core/injections/threadComment.js b/src/features/extraInfo/core/injections/threadComment.js
new file mode 100644
index 0000000..ed14575
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadComment.js
@@ -0,0 +1,11 @@
+import BaseThreadMessageExtraInfoInjection from './baseThreadMessage.js';
+
+export default class ThreadCommentExtraInfoInjection extends BaseThreadMessageExtraInfoInjection {
+ getInteractionsRootClass() {
+ return 'scTailwindThreadMessageMessageinteractionsroot';
+ }
+
+ getInteractionsRootNonEmptyClass() {
+ return 'scTailwindThreadMessageMessageinteractionsinteractions';
+ }
+}
diff --git a/src/features/extraInfo/core/injections/threadList.js b/src/features/extraInfo/core/injections/threadList.js
new file mode 100644
index 0000000..e35fbe0
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadList.js
@@ -0,0 +1,77 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import {parseUrl} from '../../../../common/commonUtils.js';
+import {createExtBadge} from '../../../../contentScripts/communityConsole/utils/common.js';
+import {kItemMetadataState, kItemMetadataStateI18n} from '../consts.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ThreadListExtraInfoInjection extends
+ BaseExtraInfoInjection {
+ getInjectionDetails(li) {
+ const headerContent = li.querySelector(
+ 'ec-thread-summary .main-header .header a.header-content');
+ if (headerContent === null) {
+ throw new Error(
+ `extraInfo: Header is not present in the thread item's DOM.`);
+ }
+
+ const threadInfo = parseUrl(headerContent.href);
+ if (threadInfo === false)
+ throw new Error(`extraInfo: Thread's link cannot be parsed.`);
+
+ return {
+ li,
+ threadInfo,
+ isExpanded: false,
+ };
+ }
+
+ inject(threads, injectionDetails) {
+ const thread = ThreadExtraInfoService.getThreadFromThreadList(
+ threads, injectionDetails.threadInfo);
+
+ const state = thread?.['2']?.['12']?.['1'];
+ if (!state || [1, 13, 18, 9].includes(state)) return;
+
+ const [label, badgeTooltip] = this.createLabelElement(state);
+ const authorLine = this.getAuthorLine(injectionDetails.li);
+ authorLine.prepend(label);
+
+ new MDCTooltip(badgeTooltip);
+ }
+
+ createLabelElement(state) {
+ const label = document.createElement('div');
+ label.classList.add('TWPT-label');
+
+ const [badge, badgeTooltip] = createExtBadge();
+
+ let span = document.createElement('span');
+ let stateLocalized;
+ if (kItemMetadataStateI18n[state]) {
+ const stateI18nKey =
+ 'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
+ stateLocalized = chrome.i18n.getMessage(stateI18nKey) ?? state;
+ } else {
+ stateLocalized = kItemMetadataState[state] ?? state;
+ }
+ span.textContent = stateLocalized;
+ span.title = kItemMetadataState[state] ?? state;
+
+ label.append(badge, span);
+
+ return [label, badgeTooltip];
+ }
+
+ getAuthorLine(li) {
+ const authorLine = li.querySelector(
+ 'ec-thread-summary .header-content .top-row .author-line');
+ if (!authorLine) {
+ throw new Error(
+ `extraInfo: Author line is not present in the thread item's DOM.`);
+ }
+ return authorLine;
+ }
+}
diff --git a/src/features/extraInfo/core/injections/threadQuestion.js b/src/features/extraInfo/core/injections/threadQuestion.js
new file mode 100644
index 0000000..d797ee1
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadQuestion.js
@@ -0,0 +1,24 @@
+import {MDCTooltip} from '@material/tooltip';
+
+import ThreadModel from '../../../../models/Thread.js';
+import ThreadExtraInfoService from '../services/thread.js';
+
+import BaseExtraInfoInjection from './base.js';
+
+export default class ThreadQuestionExtraInfoInjection extends
+ BaseExtraInfoInjection {
+ inject(threadInfo, injectionDetails) {
+ const [chips, tooltips] =
+ ThreadExtraInfoService.getThreadChips(threadInfo.thread.data);
+ this.#injectChips(chips, injectionDetails.stateChips);
+ for (const tooltip of tooltips) new MDCTooltip(tooltip);
+ }
+
+ #injectChips(chips, stateChipsElement) {
+ const stateChipsContainer = stateChipsElement.querySelector(
+ '.scTailwindThreadQuestionStatechipsroot');
+ const container = stateChipsContainer ?? stateChipsElement;
+ const shouldCreateContainer = stateChipsContainer === null;
+ this.addExtraInfoChips(chips, container, shouldCreateContainer);
+ }
+}
diff --git a/src/features/extraInfo/core/injections/threadReply.js b/src/features/extraInfo/core/injections/threadReply.js
new file mode 100644
index 0000000..ed14575
--- /dev/null
+++ b/src/features/extraInfo/core/injections/threadReply.js
@@ -0,0 +1,11 @@
+import BaseThreadMessageExtraInfoInjection from './baseThreadMessage.js';
+
+export default class ThreadCommentExtraInfoInjection extends BaseThreadMessageExtraInfoInjection {
+ getInteractionsRootClass() {
+ return 'scTailwindThreadMessageMessageinteractionsroot';
+ }
+
+ getInteractionsRootNonEmptyClass() {
+ return 'scTailwindThreadMessageMessageinteractionsinteractions';
+ }
+}
diff --git a/src/features/extraInfo/core/services/message.js b/src/features/extraInfo/core/services/message.js
new file mode 100644
index 0000000..268fea5
--- /dev/null
+++ b/src/features/extraInfo/core/services/message.js
@@ -0,0 +1,47 @@
+import StatesExtraInfoService from './states.js';
+
+export default class MessageExtraInfoService {
+ static getMessageIdFromNode(messageNode) {
+ const isMainReply =
+ messageNode.tagName == 'SC-TAILWIND-THREAD-MESSAGE-MESSAGE-CARD';
+ const cardContentClass = isMainReply ?
+ '.scTailwindThreadMessageMessagecardcontent' :
+ '.scTailwindThreadMessageCommentcardnested-reply';
+ const id = messageNode.querySelector(cardContentClass)
+ ?.getAttribute?.('data-stats-id');
+ if (id === undefined)
+ throw new Error(`Couldn't retrieve message id from node.`);
+ return id;
+ }
+
+ static getMessageFromList(messageId, messagesList) {
+ for (const message of messagesList) {
+ if (message.getId() == messageId) return message;
+ }
+ throw new Error(`Couldn't find message ${messageId} in the message list.`);
+ }
+
+ static getMessageChips(messageModel) {
+ const chips = [];
+ const tooltips = [];
+
+ const endPendingStateTimestampMicros =
+ messageModel.getEndPendingStateTimestampMicros();
+ const [pendingStateChip, pendingStateTooltip] =
+ StatesExtraInfoService.getPendingStateChip(
+ endPendingStateTimestampMicros);
+ if (pendingStateChip) chips.push(pendingStateChip);
+ if (pendingStateTooltip) tooltips.push(pendingStateTooltip);
+
+ const itemMetadata = messageModel.data?.[1]?.[5];
+ chips.push(...StatesExtraInfoService.getMetadataChips(itemMetadata));
+
+ const liveReviewStatus = messageModel.data?.[1]?.[36];
+ const [liveReviewChip, liveReviewTooltip] =
+ StatesExtraInfoService.getLiveReviewStatusChip(liveReviewStatus);
+ if (liveReviewChip) chips.push(liveReviewChip);
+ if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
+
+ return [chips, tooltips];
+ }
+}
diff --git a/src/features/extraInfo/core/services/states.js b/src/features/extraInfo/core/services/states.js
new file mode 100644
index 0000000..17dc34e
--- /dev/null
+++ b/src/features/extraInfo/core/services/states.js
@@ -0,0 +1,111 @@
+import {createPlainTooltip} from '../../../../common/tooltip.js';
+import {kItemMetadataState, kItemMetadataStateI18n} from '../consts.js';
+
+export default class StatesExtraInfoService {
+ static getPendingStateChip(endPendingStateTimestampMicros) {
+ const endPendingStateTimestamp =
+ Math.floor(endPendingStateTimestampMicros / 1e3);
+ const now = Date.now();
+ if (!endPendingStateTimestampMicros || endPendingStateTimestamp < now)
+ return [null, null];
+
+ const span = document.createElement('span');
+ span.textContent =
+ chrome.i18n.getMessage('inject_extrainfo_message_pendingstate');
+
+ const date = new Date(endPendingStateTimestamp).toLocaleString();
+ const pendingTooltip = createPlainTooltip(
+ span,
+ chrome.i18n.getMessage(
+ 'inject_extrainfo_message_pendingstate_tooltip', [date]),
+ false);
+ return [span, pendingTooltip];
+ }
+
+ static getLiveReviewStatusChip(liveReviewStatus) {
+ const verdict = liveReviewStatus?.['1'];
+ if (!verdict) return [null, null];
+
+ const [label, labelClass] = this.getLiveReviewStatusLabel(verdict);
+ if (!label || !labelClass) return [null, null];
+
+ const reviewedBy = liveReviewStatus?.['2'];
+ const timestamp = liveReviewStatus?.['3'];
+ const date = (new Date(Math.floor(timestamp / 1e3))).toLocaleString();
+
+ let a = document.createElement('a');
+ a.href = 'https://support.google.com/s/community/user/' + reviewedBy;
+ a.classList.add(labelClass);
+ a.textContent = chrome.i18n.getMessage(
+ 'inject_extrainfo_message_livereviewverdict',
+ [chrome.i18n.getMessage(
+ 'inject_extrainfo_message_livereviewverdict_' + label)]);
+ let liveReviewTooltip = createPlainTooltip(a, date, false);
+ return [a, liveReviewTooltip];
+ }
+
+ static getLiveReviewStatusLabel(verdict) {
+ let label, labelClass;
+ switch (verdict) {
+ case 1: // LIVE_REVIEW_RELEVANT
+ label = 'relevant';
+ labelClass = 'TWPT-extrainfo-good';
+ break;
+
+ case 2: // LIVE_REVIEW_OFF_TOPIC
+ label = 'offtopic';
+ labelClass = 'TWPT-extrainfo-bad';
+ break;
+
+ case 3: // LIVE_REVIEW_ABUSE
+ label = 'abuse';
+ labelClass = 'TWPT-extrainfo-bad';
+ break;
+
+ default:
+ return [null, null];
+ }
+ return [label, labelClass];
+ }
+
+ static getMetadataChips(itemMetadata) {
+ return [
+ this.getStateChip(itemMetadata),
+ this.getShadowBlockChip(itemMetadata),
+ ].filter(chip => chip !== null);
+ }
+
+ static getStateChip(itemMetadata) {
+ const state = itemMetadata?.['1'];
+ if (!state || state == 1) return null;
+
+ let stateLocalized;
+ if (kItemMetadataStateI18n[state]) {
+ const stateI18nKey =
+ 'inject_extrainfo_message_state_' + kItemMetadataStateI18n[state];
+ stateLocalized = chrome.i18n.getMessage(stateI18nKey) ?? state;
+ } else {
+ stateLocalized = kItemMetadataState[state] ?? state;
+ }
+
+ const span = document.createElement('span');
+ span.textContent = chrome.i18n.getMessage(
+ 'inject_extrainfo_message_state', [stateLocalized]);
+ span.title = kItemMetadataState[state] ?? state;
+ return span;
+ }
+
+ static getShadowBlockChip(itemMetadata) {
+ const shadowBlockInfo = itemMetadata?.['10'];
+ const blockedTimestampMicros = shadowBlockInfo?.['2'];
+ if (!blockedTimestampMicros) return null;
+
+ const isBlocked = shadowBlockInfo?.['1'];
+ let span = document.createElement('span');
+ span.textContent = chrome.i18n.getMessage(
+ 'inject_extrainfo_message_shadowblock' +
+ (isBlocked ? 'active' : 'notactive'));
+ if (isBlocked) span.classList.add('TWPT-extrainfo-bad');
+ return span;
+ }
+}
diff --git a/src/features/extraInfo/core/services/thread.js b/src/features/extraInfo/core/services/thread.js
new file mode 100644
index 0000000..c36dfa5
--- /dev/null
+++ b/src/features/extraInfo/core/services/thread.js
@@ -0,0 +1,58 @@
+import StatesExtraInfoService from './states.js';
+
+export default class ThreadExtraInfoService {
+ /**
+ * Get a |chipContentList| array with the chips related to the thread, and a
+ * |tooltips| array with the corresponding tooltips which should be
+ * initialized after the chips are added to the DOM.
+ */
+ static getThreadChips(thread) {
+ let chips = [];
+ let tooltips = [];
+
+ const endPendingStateTimestampMicros = thread?.['2']?.['39'];
+ const [pendingStateInfo, pendingTooltip] =
+ StatesExtraInfoService.getPendingStateChip(
+ endPendingStateTimestampMicros);
+ if (pendingStateInfo) chips.push(pendingStateInfo);
+ if (pendingTooltip) tooltips.push(pendingTooltip);
+
+ chips.push(...this.getTrendingChips(thread));
+
+ const itemMetadata = thread?.['2']?.['12'];
+ chips.push(...StatesExtraInfoService.getMetadataChips(itemMetadata));
+
+ const liveReviewStatus = thread?.['2']?.['38'];
+ const [liveReviewInfo, liveReviewTooltip] =
+ StatesExtraInfoService.getLiveReviewStatusChip(liveReviewStatus);
+ if (liveReviewInfo) chips.push(liveReviewInfo);
+ if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
+
+ return [chips, tooltips];
+ }
+
+ static getTrendingChips(thread) {
+ const chips = [];
+
+ const isTrending = thread?.['2']?.['25'];
+ const isTrendingAutoMarked = thread?.['39'];
+ if (isTrendingAutoMarked)
+ chips.push(document.createTextNode(
+ chrome.i18n.getMessage('inject_extrainfo_thread_autotrending')));
+ else if (isTrending)
+ chips.push(document.createTextNode(
+ chrome.i18n.getMessage('inject_extrainfo_thread_trending')));
+
+ return chips;
+ }
+
+ static getThreadFromThreadList(threadList, currentThreadInfo) {
+ return threadList?.find?.(thread => {
+ const threadInfo = thread?.['2']?.['1'];
+ const threadId = threadInfo?.['1'];
+ const forumId = threadInfo?.['3'];
+ return threadId == currentThreadInfo.thread &&
+ forumId == currentThreadInfo.forum;
+ });
+ }
+}
diff --git a/src/features/extraInfo/extraInfo.feature.ts b/src/features/extraInfo/extraInfo.feature.ts
new file mode 100644
index 0000000..f7f9f66
--- /dev/null
+++ b/src/features/extraInfo/extraInfo.feature.ts
@@ -0,0 +1,18 @@
+import Feature from '../../common/architecture/features/Feature';
+import { ConcreteScript } from '../../common/architecture/scripts/Script';
+import CCExtraInfoDependencySetUpScript from './scripts/ccExtraInfoDependencySetUp.script';
+import CCExtraInfoInjectScript from './scripts/ccExtraInfoInject.script';
+import CCExtraInfoMainScript from './scripts/ccExtraInfoMain.script';
+import CCExtraInfoStylesScript from './scripts/ccExtraInfoStyles.script';
+
+export default class ExtraInfoFeature extends Feature {
+ public readonly scripts: ConcreteScript[] = [
+ CCExtraInfoDependencySetUpScript,
+ CCExtraInfoInjectScript,
+ CCExtraInfoMainScript,
+ CCExtraInfoStylesScript,
+ ];
+
+ readonly codename = 'extraInfo';
+ readonly relatedOptions: string[] = [];
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler.ts
new file mode 100644
index 0000000..7cc895a
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler.ts
@@ -0,0 +1,11 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+export default class CCExtraInfoProfileAbuseChipsHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector = 'ec-unified-user .scTailwindUser_profileUsercardmain';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectAbuseChipsAtProfileIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler.ts
new file mode 100644
index 0000000..d08db21
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler.ts
@@ -0,0 +1,12 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+export default class CCExtraInfoProfilePerForumStatsHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector =
+ 'ec-unified-user .scTailwindUser_profileUserprofilesection sc-tailwind-shared-activity-chart';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectPerForumStatsIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler.ts
new file mode 100644
index 0000000..802dc51
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadCommentHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector = 'sc-tailwind-thread-message-message-list sc-tailwind-thread-message-comment-card';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectAtCommentIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler.ts
new file mode 100644
index 0000000..0d16772
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadQuestionHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector = 'sc-tailwind-thread-question-question-card sc-tailwind-thread-question-state-chips';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectAtQuestionIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler.ts
new file mode 100644
index 0000000..543ddb0
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadReplyHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector = 'sc-tailwind-thread-message-message-list sc-tailwind-thread-message-message-card';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectAtReplyIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler.ts
new file mode 100644
index 0000000..5d26037
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info to threads in the thread list.
+ */
+export default class CCExtraInfoThreadListHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector = 'li:has(ec-thread-summary)';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectAtThreadListIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler.ts b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler.ts
new file mode 100644
index 0000000..b6ef4dc
--- /dev/null
+++ b/src/features/extraInfo/nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler.ts
@@ -0,0 +1,14 @@
+import CssSelectorNodeWatcherScriptHandler from '../../../../common/architecture/scripts/nodeWatcher/handlers/CssSelectorNodeWatcherScriptHandler';
+import { NodeMutation } from '../../../../common/nodeWatcher/NodeWatcherHandler';
+import { CCExtraInfoMainOptions } from '../../scripts/ccExtraInfoMain.script';
+
+/**
+ * Inject extra info in the toolbelt of an expanded thread list item.
+ */
+export default class CCExtraInfoThreadListToolbeltHandler extends CssSelectorNodeWatcherScriptHandler<CCExtraInfoMainOptions> {
+ cssSelector = 'ec-thread-summary .main .toolbelt';
+
+ onMutatedNode({ node }: NodeMutation) {
+ this.options.extraInfo.injectAtExpandedThreadListIfEnabled(node);
+ }
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoDependencySetUp.script.ts b/src/features/extraInfo/scripts/ccExtraInfoDependencySetUp.script.ts
new file mode 100644
index 0000000..cd595c5
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoDependencySetUp.script.ts
@@ -0,0 +1,15 @@
+import { Dependency, ExtraInfoDependency } from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+ ScriptEnvironment,
+ ScriptPage,
+ ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import SetUpDependenciesScript from '../../../common/architecture/scripts/setUpDependencies/SetUpDependenciesScript';
+
+export default class CCExtraInfoDependencySetUpScript extends SetUpDependenciesScript {
+ public priority = 101;
+ public page = ScriptPage.CommunityConsole;
+ public environment = ScriptEnvironment.ContentScript;
+ public runPhase = ScriptRunPhase.Start;
+ public dependencies: Dependency[] = [ExtraInfoDependency];
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoInject.script.ts b/src/features/extraInfo/scripts/ccExtraInfoInject.script.ts
new file mode 100644
index 0000000..2bd6364
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoInject.script.ts
@@ -0,0 +1,14 @@
+import Script, { ScriptEnvironment, ScriptPage, ScriptRunPhase } from "../../../common/architecture/scripts/Script";
+import { injectScript } from "../../../common/contentScriptsUtils";
+
+export default class CCExtraInfoInjectScript extends Script {
+ priority = 11;
+
+ page = ScriptPage.CommunityConsole;
+ environment = ScriptEnvironment.ContentScript;
+ runPhase = ScriptRunPhase.Start;
+
+ execute() {
+ injectScript(chrome.runtime.getURL('extraInfoInject.bundle.js'));
+ }
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoMain.script.ts b/src/features/extraInfo/scripts/ccExtraInfoMain.script.ts
new file mode 100644
index 0000000..80fbfbf
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoMain.script.ts
@@ -0,0 +1,43 @@
+import DependenciesProviderSingleton, {
+ ExtraInfoDependency,
+} from '../../../common/architecture/dependenciesProvider/DependenciesProvider';
+import {
+ ScriptEnvironment,
+ ScriptPage,
+ ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import NodeWatcherScript from '../../../common/architecture/scripts/nodeWatcher/NodeWatcherScript';
+import ExtraInfo from '../core';
+import CCExtraInfoProfileAbuseChipsHandler from '../nodeWatcherHandlers/profile/ccExtraInfoProfileAbuseChips.handler';
+import CCExtraInfoProfilePerForumStatsHandler from '../nodeWatcherHandlers/profile/ccExtraInfoProfilePerForumStats.handler';
+import CCExtraInfoThreadCommentHandler from '../nodeWatcherHandlers/thread/ccExtraInfoThreadComment.handler';
+import CCExtraInfoThreadListHandler from '../nodeWatcherHandlers/threadList/ccExtraInfoThreadList.handler';
+import CCExtraInfoThreadListToolbeltHandler from '../nodeWatcherHandlers/threadList/ccExtraInfoThreadListToolbelt.handler';
+import CCExtraInfoThreadQuestionHandler from '../nodeWatcherHandlers/thread/ccExtraInfoThreadQuestion.handler';
+import CCExtraInfoThreadReplyHandler from '../nodeWatcherHandlers/thread/ccExtraInfoThreadReply.handler';
+
+export interface CCExtraInfoMainOptions {
+ extraInfo: ExtraInfo;
+}
+
+export default class CCExtraInfoMainScript extends NodeWatcherScript<CCExtraInfoMainOptions> {
+ page = ScriptPage.CommunityConsole;
+ environment = ScriptEnvironment.ContentScript;
+ runPhase = ScriptRunPhase.Main;
+ handlers = new Map([
+ ['ccExtraInfoProfile', CCExtraInfoProfileAbuseChipsHandler],
+ ['ccExtraInfoProfilePerForumStats', CCExtraInfoProfilePerForumStatsHandler],
+ ['ccExtraInfoThreadComment', CCExtraInfoThreadCommentHandler],
+ ['ccExtraInfoThreadList', CCExtraInfoThreadListHandler],
+ ['ccExtraInfoThreadListToolbelt', CCExtraInfoThreadListToolbeltHandler],
+ ['ccExtraInfoThreadQuestion', CCExtraInfoThreadQuestionHandler],
+ ['ccExtraInfoThreadReply', CCExtraInfoThreadReplyHandler],
+ ]);
+
+ protected optionsFactory(): CCExtraInfoMainOptions {
+ const dependenciesProvider = DependenciesProviderSingleton.getInstance();
+ return {
+ extraInfo: dependenciesProvider.getDependency(ExtraInfoDependency),
+ };
+ }
+}
diff --git a/src/features/extraInfo/scripts/ccExtraInfoStyles.script.ts b/src/features/extraInfo/scripts/ccExtraInfoStyles.script.ts
new file mode 100644
index 0000000..4d806f6
--- /dev/null
+++ b/src/features/extraInfo/scripts/ccExtraInfoStyles.script.ts
@@ -0,0 +1,17 @@
+import Script, {
+ ScriptEnvironment,
+ ScriptPage,
+ ScriptRunPhase,
+} from '../../../common/architecture/scripts/Script';
+import { injectStylesheet } from '../../../common/contentScriptsUtils';
+
+export default class CCExtraInfoStylesScript extends Script {
+ page = ScriptPage.CommunityConsole;
+ environment = ScriptEnvironment.ContentScript;
+ runPhase = ScriptRunPhase.Main;
+
+ execute() {
+ injectStylesheet(chrome.runtime.getURL('css/extrainfo.css'));
+ injectStylesheet(chrome.runtime.getURL('css/extrainfo_perforumstats.css'));
+ }
+}