extraInfo: show extra info in threads
Bug: twpowertools:93
Change-Id: If68561fdf1a4038bfba58d66d5488c1ce76d63bd
diff --git a/src/common/xhrInterceptors.json5 b/src/common/xhrInterceptors.json5
index 126b6fb..2a0ea6b 100644
--- a/src/common/xhrInterceptors.json5
+++ b/src/common/xhrInterceptors.json5
@@ -25,5 +25,10 @@
urlRegex: "api/ListCannedResponses",
intercepts: "response",
},
+ {
+ eventName: "ViewThreadResponse",
+ urlRegex: "api/ViewThread",
+ intercepts: "response",
+ },
],
}
diff --git a/src/contentScripts/communityConsole/extraInfo.js b/src/contentScripts/communityConsole/extraInfo.js
index 266592a..00428b7 100644
--- a/src/contentScripts/communityConsole/extraInfo.js
+++ b/src/contentScripts/communityConsole/extraInfo.js
@@ -1,6 +1,7 @@
import {MDCTooltip} from '@material/tooltip';
import {waitFor} from 'poll-until-promise';
+import {parseUrl} from '../../common/commonUtils.js';
import {isOptionEnabled} from '../../common/optionsUtils.js';
import {createPlainTooltip} from '../../common/tooltip.js';
@@ -8,6 +9,7 @@
const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
const kListCannedResponsesResponse = 'TWPT_ListCannedResponsesResponse';
+const kViewThreadResponse = 'TWPT_ViewThreadResponse';
const kAbuseCategories = [
['1', 'Account'],
@@ -141,6 +143,31 @@
18: 'TERRORISM_SUPPORT',
56: 'CSAI_WORST_OF_WORST',
};
+const kItemMetadataState = {
+ 0: 'UNDEFINED',
+ 1: 'PUBLISHED',
+ 2: 'DRAFT',
+ 3: 'AUTOMATED_ABUSE_TAKE_DOWN_HIDE',
+ 4: 'AUTOMATED_ABUSE_TAKE_DOWN_DELETE',
+ 13: 'AUTOMATED_ABUSE_REINSTATE',
+ 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',
+};
+const kShadowBlockReason = {
+ 0: 'REASON_UNDEFINED',
+ 1: 'ULTRON_LOW_QUALITY',
+};
export default class ExtraInfo {
constructor() {
@@ -154,6 +181,11 @@
id: -1,
duplicateNames: new Set(),
};
+ this.lastThread = {
+ body: {},
+ id: -1,
+ timestamp: 0,
+ };
this.setUpHandlers();
}
@@ -183,6 +215,15 @@
duplicateNames,
};
});
+ window.addEventListener(kViewThreadResponse, e => {
+ if (e.detail.id < this.lastThread.id) return;
+
+ this.lastThread = {
+ body: e.detail.body,
+ id: e.detail.id,
+ timestamp: Date.now(),
+ };
+ });
}
// Whether the feature is enabled
@@ -230,14 +271,17 @@
return span;
}
- // Profile functionality
+ /**
+ * Profile functionality
+ */
injectAtProfile(card) {
waitFor(
() => {
let now = Date.now();
if (now - this.lastProfile.timestamp < 15 * 1000)
return Promise.resolve(this.lastProfile);
- return Promise.reject('Didn\'t receive profile information');
+ return Promise.reject(
+ new Error('Didn\'t receive profile information'));
},
{
interval: 500,
@@ -282,8 +326,9 @@
});
}
- // Canned responses (CRs) functionality
-
+ /**
+ * Canned responses (CRs) functionality
+ */
getCRName(tags, isExpanded) {
if (!isExpanded)
return tags.parentNode?.querySelector?.('.text .name')?.textContent;
@@ -298,7 +343,7 @@
injectAtCR(tags, isExpanded) {
waitFor(() => {
if (this.lastCRsList.id != -1) return Promise.resolve(this.lastCRsList);
- return Promise.reject('Didn\'t receive canned responses list');
+ return Promise.reject(new Error('Didn\'t receive canned responses list'));
}, {
interval: 500,
timeout: 15 * 1000,
@@ -352,4 +397,266 @@
if (isEnabled) return this.injectAtCR(tags, isExpanded);
});
}
+
+ /**
+ * Thread view functionality
+ */
+
+ getPendingStateInfo(endPendingStateTimestampMicros) {
+ const endPendingStateTimestamp =
+ Math.floor(endPendingStateTimestampMicros / 1e3);
+ const now = Date.now();
+ if (endPendingStateTimestampMicros && endPendingStateTimestamp > now) {
+ let span = document.createElement('span');
+ span.textContent = 'Only visible to badged users';
+
+ let date = new Date(endPendingStateTimestamp).toLocaleString();
+ let pendingTooltip =
+ createPlainTooltip(span, 'Visible after ' + date, false);
+ return [span, pendingTooltip];
+ }
+
+ return [null, null];
+ }
+
+ getMetadataInfo(itemMetadata) {
+ let info = [];
+
+ const state = itemMetadata?.['1'];
+ if (state && state != 1)
+ info.push(this.fieldInfo('State', kItemMetadataState[state] ?? state));
+
+ const shadowBlockInfo = itemMetadata?.['10'];
+ const blockedTimestampMicros = shadowBlockInfo?.['2'];
+ if (blockedTimestampMicros) {
+ const isBlocked = shadowBlockInfo?.['1'];
+ let span = document.createElement('span');
+ span.textContent =
+ isBlocked ? 'Shadow block active' : 'Shadow block no longer active';
+ if (isBlocked) span.classList.add('TWPT-extrainfo-bad');
+ info.push(span);
+ }
+
+ return info;
+ }
+
+ getLiveReviewStatusInfo(liveReviewStatus) {
+ const verdict = liveReviewStatus?.['1'];
+ if (!verdict) return [null, null];
+ let label, labelClass;
+ switch (verdict) {
+ case 1: // LIVE_REVIEW_RELEVANT
+ label = 'Relevant';
+ labelClass = 'TWPT-extrainfo-good';
+ break;
+
+ case 2: // LIVE_REVIEW_OFF_TOPIC
+ label = 'Off-topic';
+ labelClass = 'TWPT-extrainfo-bad';
+ break;
+
+ case 3: // LIVE_REVIEW_ABUSE
+ label = 'Abuse';
+ labelClass = 'TWPT-extrainfo-bad';
+ break;
+ }
+ 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 = 'Live review verdict: ' + label;
+ let liveReviewTooltip = createPlainTooltip(a, date, false);
+ return [a, liveReviewTooltip];
+ }
+
+ injectAtQuestion(question) {
+ let currentPage = parseUrl(location.href);
+ if (currentPage === false) return;
+
+ let content = question.querySelector('ec-question > .content');
+ if (!content) return;
+
+ waitFor(() => {
+ let now = Date.now();
+ let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
+ if (now - this.lastThread.timestamp < 15 * 1000 &&
+ threadInfo?.['1'] == currentPage.thread &&
+ threadInfo?.['3'] == currentPage.forum)
+ return Promise.resolve(this.lastThread);
+ return Promise.reject(new Error('Didn\'t receive thread information'));
+ }, {
+ interval: 500,
+ timeout: 15 * 1000,
+ }).then(thread => {
+ let info = [];
+
+ const endPendingStateTimestampMicros = thread.body['1']?.['2']?.['39'];
+ const [pendingStateInfo, pendingTooltip] =
+ this.getPendingStateInfo(endPendingStateTimestampMicros);
+ if (pendingStateInfo) info.push(pendingStateInfo);
+
+ // NOTE: These attributes don't seem to be included when calling
+ // ViewThread (but are included when calling ViewForum).
+ const isTrending = thread.body['1']?.['2']?.['25'];
+ const isTrendingAutoMarked = thread.body['1']?.['39'];
+ if (isTrendingAutoMarked)
+ info.push(document.createTextNode('Automatically marked as trending'));
+ else if (isTrending)
+ info.push(document.createTextNode('Trending'));
+
+ const itemMetadata = thread.body['1']?.['2']?.['12'];
+ const mdInfo = this.getMetadataInfo(itemMetadata);
+ info.push(...mdInfo);
+
+ const liveReviewStatus = thread.body['1']?.['2']?.['38'];
+ const [liveReviewInfo, liveReviewTooltip] =
+ this.getLiveReviewStatusInfo(liveReviewStatus);
+ if (liveReviewInfo) info.push(liveReviewInfo);
+
+ this.addExtraInfoElement(info, content);
+ if (pendingTooltip) new MDCTooltip(pendingTooltip);
+ if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
+ });
+ }
+
+ injectAtQuestionIfEnabled(question) {
+ this.isEnabled().then(isEnabled => {
+ if (isEnabled) return this.injectAtQuestion(question);
+ });
+ }
+
+ getMessagesByType(thread, type) {
+ if (type === 'reply') return thread?.['1']?.['3'];
+ if (type === 'lastMessage') return thread?.['1']?.['17']?.['3'];
+ if (type === 'suggested') return thread?.['1']?.['17']?.['4'];
+ if (type === 'recommended') return thread?.['1']?.['17']?.['1'];
+ }
+
+ getMessageByTypeAndIndex(thread, type, index) {
+ return this.getMessagesByType(thread, type)?.[index];
+ }
+
+ // Returns true if the last message is included in the messages array (in the
+ // extension context, we say those messages are of the type "reply").
+ lastMessageInReplies(thread) {
+ const lastMessageId = thread?.['1']?.['17']?.['3']?.[0]?.['1']?.['1'];
+ if (!lastMessageId) return true;
+
+ // If the last message is included in the lastMessage array, check if it
+ // also exists in the messages/replies array.
+ const replies = thread?.['1']?.['3'];
+ if (!replies?.length) return false;
+ const lastReplyIndex = replies.length - 1;
+ const lastReplyId = replies[lastReplyIndex]?.['1']?.['1'];
+ return lastMessageId && lastMessageId == lastReplyId;
+ }
+
+ getMessageInfo(thread, message) {
+ const section = message.parentNode;
+
+ let type = 'reply';
+ if (section?.querySelector?.('.heading material-icon[icon="auto_awesome"]'))
+ type = 'suggested';
+ if (section?.querySelector?.('.heading material-icon[icon="check_circle"]'))
+ type = 'recommended';
+
+ let index = -1;
+ let messagesInDom = section.querySelectorAll('ec-message');
+
+ // Number of messages in the DOM.
+ const n = messagesInDom.length;
+
+ if (type !== 'reply') {
+ for (let i = 0; i < n; ++i) {
+ if (message.isEqualNode(messagesInDom[i])) {
+ index = i;
+ break;
+ }
+ }
+ } else {
+ // If the type of the message is a reply, things are slightly more
+ // complex, since replies are paginated and the last message should be
+ // treated separately (it is included diferently in the API response).
+ let lastMessageInReplies = this.lastMessageInReplies(thread);
+ if (message.isEqualNode(messagesInDom[n - 1]) && !lastMessageInReplies) {
+ type = 'lastMessage';
+ index = 0
+ } else {
+ // Number of messages in the current API response.
+ const messagesInResponse = this.getMessagesByType(thread, type);
+ const m = messagesInResponse.length;
+ // If the last message is included in the replies array, we also have to
+ // consider the last message in the DOM.
+ let modifier = lastMessageInReplies ? 1 : 0;
+ for (let k = 0; k < m; ++k) {
+ let i = n - 2 - k + modifier;
+ if (message.isEqualNode(messagesInDom[i])) {
+ index = m - 1 - k;
+ break;
+ }
+ }
+ }
+ }
+
+ return [type, index];
+ }
+
+ injectAtMessage(messageNode) {
+ let currentPage = parseUrl(location.href);
+ if (currentPage === false) return;
+
+ let footer = messageNode.querySelector('.footer-fill');
+ if (!footer) return;
+
+ const [type, index] =
+ this.getMessageInfo(this.lastThread.body, messageNode);
+
+ waitFor(() => {
+ let now = Date.now();
+ let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
+ if (now - this.lastThread.timestamp < 15 * 1000 &&
+ threadInfo?.['1'] == currentPage.thread &&
+ threadInfo?.['3'] == currentPage.forum) {
+ const message =
+ this.getMessageByTypeAndIndex(this.lastThread.body, type, index);
+ if (message) return Promise.resolve(message);
+ }
+
+ return Promise.reject(new Error(
+ 'Didn\'t receive thread information (type: ' + type +
+ ', index: ' + index + ')'));
+ }, {
+ interval: 1000,
+ timeout: 15 * 1000,
+ }).then(message => {
+ let info = [];
+
+ const endPendingStateTimestampMicros = message['1']?.['17'];
+ const [pendingStateInfo, pendingTooltip] =
+ this.getPendingStateInfo(endPendingStateTimestampMicros);
+ if (pendingStateInfo) info.push(pendingStateInfo);
+
+ const itemMetadata = message['1']?.['5'];
+ const mdInfo = this.getMetadataInfo(itemMetadata);
+ info.push(...mdInfo);
+
+ const liveReviewStatus = message['1']?.['36'];
+ const [liveReviewInfo, liveReviewTooltip] =
+ this.getLiveReviewStatusInfo(liveReviewStatus);
+ if (liveReviewInfo) info.push(liveReviewInfo);
+
+ this.addExtraInfoElement(info, footer);
+ if (pendingTooltip) new MDCTooltip(pendingTooltip);
+ if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
+ });
+ }
+
+ injectAtMessageIfEnabled(message) {
+ this.isEnabled().then(isEnabled => {
+ if (isEnabled) return this.injectAtMessage(message);
+ });
+ }
}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 73ed360..b0d9dbc 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -46,8 +46,14 @@
'iframe',
// Canned response tags or toolbelt (for the extra info feature)
- '.tags',
- '.toolbelt',
+ 'ec-canned-response-row .tags',
+ 'ec-canned-response-row .main .toolbelt',
+
+ // Div containing ec-question (for the extra info feature)
+ 'ec-thread div[role="list"]',
+
+ // Replies (for the extra info feature)
+ 'ec-thread ec-message',
];
function handleCandidateNode(node) {
@@ -160,7 +166,17 @@
}
if (node.matches('ec-canned-response-row .main .toolbelt')) {
const tags = node.parentNode?.querySelector?.('.tags');
- if (tags) window.TWPTExtraInfo.injectAtCRIfEnabled(tags, /* isExpanded = */ true);
+ if (tags)
+ window.TWPTExtraInfo.injectAtCRIfEnabled(tags, /* isExpanded = */ true);
+ }
+
+ // Show additional details in the thread view.
+ if (node.matches('ec-thread div[role="list"]')) {
+ const question = node.querySelector('ec-question');
+ if (question) window.TWPTExtraInfo.injectAtQuestionIfEnabled(question);
+ }
+ if (node.matches('ec-thread ec-message')) {
+ window.TWPTExtraInfo.injectAtMessageIfEnabled(node);
}
}
}
diff --git a/src/static/css/extrainfo.css b/src/static/css/extrainfo.css
index d04d42d..a0c15e2 100644
--- a/src/static/css/extrainfo.css
+++ b/src/static/css/extrainfo.css
@@ -7,7 +7,7 @@
.TWPT-extrainfo-badge-cell {
margin: 0 8px;
--icon-size: 14px;
- opacity: 0.6;
+ opacity: 0.4;
}
.TWPT-extrainfo-info-cell {
@@ -33,6 +33,45 @@
align-self: end;
}
+ec-question .TWPT-extrainfo-container,
+ ec-message .footer .TWPT-extrainfo-container {
+ color: var(--TWPT-secondary-text, #6c6c6c);
+}
+
+ec-question .TWPT-extrainfo-container {
+ font-size: 13px;
+ position: absolute;
+ bottom: 25px;
+ right: 48px;
+}
+
+ec-message .footer .TWPT-extrainfo-container {
+ margin-top: 8px;
+ font-size: 12px;
+}
+
+ec-message .footer .TWPT-extrainfo-badge-cell {
+ margin: 0 6px 0 0;
+ --icon-size: 12px;
+}
+
+ec-message .footer .TWPT-extrainfo-info-cell {
+ padding-left: 6px;
+}
+
+/* Special styles for good/bad labels */
+.TWPT-extrainfo-good {
+ color: var(--TWPT-good-text, green)!important;
+}
+
+.TWPT-extrainfo-warning {
+ color: var(--TWPT-warning-text, orange)!important;
+}
+
+.TWPT-extrainfo-bad {
+ color: var(--TWPT-bad-text, red)!important;
+}
+
/* Special tags components for canned responses */
ec-canned-response-row .TWPT-tag {
background-color: transparent;