Add experiment to show per-forum activity in profiles
This is an experiment for now, since there might be performance
improvements and UI changes in the future.
Bug: twpowertools:92
Change-Id: Ief2a423d41a6b7179bb935c7a2a246678a4a4d0a
diff --git a/src/common/optionsPrototype.json5 b/src/common/optionsPrototype.json5
index 0d337b2..4ff1752 100644
--- a/src/common/optionsPrototype.json5
+++ b/src/common/optionsPrototype.json5
@@ -131,6 +131,11 @@
context: 'experiments',
killSwitchType: 'experiment',
},
+ 'perforumstats': {
+ defaultValue: false,
+ context: 'experiments',
+ killSwitchType: 'experiment',
+ },
// Internal options:
'ccdarktheme_switch_enabled': {
diff --git a/src/contentScripts/communityConsole/extraInfo.js b/src/contentScripts/communityConsole/extraInfo.js
index b409b13..35c13b6 100644
--- a/src/contentScripts/communityConsole/extraInfo.js
+++ b/src/contentScripts/communityConsole/extraInfo.js
@@ -5,7 +5,7 @@
import OptionsWatcher from '../../common/optionsWatcher.js';
import {createPlainTooltip} from '../../common/tooltip.js';
-import {createExtBadge} from './utils/common.js';
+import {createExtBadge, getDisplayLanguage} from './utils/common.js';
const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
const kListCannedResponsesResponse = 'TWPT_ListCannedResponsesResponse';
@@ -186,7 +186,8 @@
id: -1,
timestamp: 0,
};
- this.optionsWatcher = new OptionsWatcher(['extrainfo']);
+ this.displayLanguage = getDisplayLanguage();
+ this.optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
this.setUpHandlers();
}
@@ -227,9 +228,9 @@
});
}
- // Whether the feature is enabled
- isEnabled() {
- return this.optionsWatcher.isEnabled('extrainfo');
+ // Whether |feature| is enabled
+ isEnabled(feature) {
+ return this.optionsWatcher.isEnabled(feature);
}
// Add a pretty component which contains |info| to |node|.
@@ -322,7 +323,7 @@
}
injectAtProfileIfEnabled(card) {
- this.isEnabled().then(isEnabled => {
+ this.isEnabled('extrainfo').then(isEnabled => {
if (isEnabled) return this.injectAtProfile(card);
});
}
@@ -394,7 +395,7 @@
// If the tag has already been injected, exit.
if (tags.querySelector('.TWPT-tag')) return;
- this.isEnabled().then(isEnabled => {
+ this.isEnabled('extrainfo').then(isEnabled => {
if (isEnabled) return this.injectAtCR(tags, isExpanded);
});
}
@@ -524,7 +525,7 @@
}
injectAtQuestionIfEnabled(question) {
- this.isEnabled().then(isEnabled => {
+ this.isEnabled('extrainfo').then(isEnabled => {
if (isEnabled) return this.injectAtQuestion(question);
});
}
@@ -656,8 +657,39 @@
}
injectAtMessageIfEnabled(message) {
- this.isEnabled().then(isEnabled => {
+ this.isEnabled('extrainfo').then(isEnabled => {
if (isEnabled) return this.injectAtMessage(message);
});
}
+
+ /**
+ * Per-forum stats in user profiles.
+ */
+
+ injectPerForumStats(chart) {
+ waitFor(() => {
+ let now = Date.now();
+ if (now - this.lastProfile.timestamp < 15 * 1000)
+ return Promise.resolve(this.lastProfile);
+ return Promise.reject(new Error(
+ 'Didn\'t receive profile information (for per-profile stats)'));
+ }, {
+ interval: 500,
+ timeout: 15 * 1000,
+ }).then(profile => {
+ const message = {
+ action: 'injectPerForumStatsSection',
+ prefix: 'TWPT-extrainfo',
+ profile: profile.body,
+ locale: this.displayLanguage,
+ };
+ window.postMessage(message, '*');
+ });
+ }
+
+ injectPerForumStatsIfEnabled(chart) {
+ this.isEnabled('perforumstats').then(isEnabled => {
+ if (isEnabled) this.injectPerForumStats(chart);
+ });
+ }
}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index b0d9dbc..0ffff41 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -54,6 +54,10 @@
// Replies (for the extra info feature)
'ec-thread ec-message',
+
+ // User activity chart (for the per-forum stats feature)
+ 'ec-unified-user .scTailwindUser_profileUserprofilesection ' +
+ 'sc-tailwind-shared-activity-chart',
];
function handleCandidateNode(node) {
@@ -178,6 +182,13 @@
if (node.matches('ec-thread ec-message')) {
window.TWPTExtraInfo.injectAtMessageIfEnabled(node);
}
+
+ // Inject per-forum stats section in the user profile
+ if (node.matches(
+ 'ec-unified-user .scTailwindUser_profileUserprofilesection ' +
+ 'sc-tailwind-shared-activity-chart')) {
+ window.TWPTExtraInfo.injectPerForumStatsIfEnabled(node);
+ }
}
}
diff --git a/src/contentScripts/communityConsole/start.js b/src/contentScripts/communityConsole/start.js
index 8b1c4ef..250df78 100644
--- a/src/contentScripts/communityConsole/start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -39,4 +39,5 @@
}
injectScript(chrome.runtime.getURL('xhrInterceptorInject.bundle.js'));
+ injectScript(chrome.runtime.getURL('extraInfoInject.bundle.js'));
});
diff --git a/src/contentScripts/communityConsole/utils/PerForumStatsSection.js b/src/contentScripts/communityConsole/utils/PerForumStatsSection.js
new file mode 100644
index 0000000..aaa553c
--- /dev/null
+++ b/src/contentScripts/communityConsole/utils/PerForumStatsSection.js
@@ -0,0 +1,172 @@
+// Each entry includes the following information in order:
+// - ID
+// - Name (for the label in the legend)
+// - Codename
+// - Color (for the label in the legend)
+const kDataKeys = [
+ [4, 'Recommended', 'recommended', '#34A853'],
+ [6, 'Replies (not recommended)', 'replies', '#DADCE0'],
+ [5, 'Questions', 'questions', '#77909D'],
+];
+const kRoles = {
+ 1: 'bronze',
+ 2: 'silver',
+ 3: 'gold',
+ 4: 'platinum',
+ 5: 'diamond',
+ 10: 'community_manager',
+ 20: 'community_specialist',
+ 100: 'google_employee',
+ 30: 'alumnus',
+};
+
+export default class PerForumStatsSection {
+ constructor(existingChartSection, profile, locale) {
+ if (typeof window.sc_renderProfileActivityChart !== 'function') {
+ console.error(
+ 'PerForumStatsSection: window.sc_renderProfileActivityChart is not available.');
+ return;
+ }
+ this.locale = locale;
+ this.parseAndSetData(profile);
+ this.buildDOM(existingChartSection);
+ if (this.data.length) this.injectChart(this.data[0]?.id);
+ }
+
+ parseAndSetData(profile) {
+ const forumUserInfos = profile?.[1]?.[7] ?? [];
+ const forumTitles = profile?.[1]?.[8] ?? [];
+
+ const forumUserInfoIDs = forumUserInfos.map(ui => ui[1]);
+ const forumTitleIDs = forumTitles.map(t => t[1]);
+ const intersectionForumIDs =
+ forumUserInfoIDs.filter(id => forumTitleIDs.includes(id));
+
+ this.data = [];
+ for (const id of intersectionForumIDs) {
+ const fui = forumUserInfos.find(ui => ui[1] === id)?.[2];
+ const numMessages = kDataKeys.reduce((prevVal, key) => {
+ if (!fui?.[key[0]]) return prevVal;
+ return prevVal + fui[key[0]].reduce((prevVal, userActivity) => {
+ return prevVal + (userActivity?.[3] ?? 0);
+ }, /* initialValue = */ 0);
+ }, /* initialValue = */ 0);
+ this.data.push({
+ id,
+ forumTitle: forumTitles.find(t => t[1] === id)?.[2],
+ forumUserInfo: fui,
+ numMessages,
+ });
+ }
+ this.data.sort((a, b) => {
+ // First sort by number of messages
+ if (b.numMessages > a.numMessages) return 1;
+ if (b.numMessages < a.numMessages) return -1;
+ // Then sort by name
+ return a.forumTitle.localeCompare(
+ b.forumTitle, 'en', {sensitivity: 'base'});
+ });
+ }
+
+ buildDOM(existingChartSection) {
+ let section = document.createElement('div');
+ section.classList.add('scTailwindUser_profileUserprofilesection');
+
+ let root = document.createElement('div');
+ root.classList.add(
+ 'scTailwindSharedActivitychartroot',
+ 'TWPT-scTailwindSharedActivitychartroot');
+
+ let title = document.createElement('h2');
+ title.classList.add('scTailwindSharedActivitycharttitle');
+ title.textContent = 'Per-forum activity';
+
+ let selector = this.createForumSelector();
+
+ let chartEl = document.createElement('div');
+ chartEl.classList.add('scTailwindSharedActivitychartchart');
+
+ root.append(title, selector, chartEl);
+ section.append(root);
+ existingChartSection.after(section);
+
+ this.chartEl = chartEl;
+ }
+
+ getAplosData(forumId) {
+ let aplosData = [];
+ for (const [key, label, name, color] of kDataKeys) {
+ let rawData = this.data.find(f => f.id === forumId)?.forumUserInfo?.[key];
+ let data;
+ if (!rawData)
+ data = [];
+ else
+ data = rawData.map(m => JSON.stringify(Object.values(m)));
+ aplosData.push({
+ color,
+ data,
+ label,
+ name,
+ });
+ }
+ return aplosData;
+ }
+
+ createForumSelector() {
+ let div = document.createElement('div');
+ div.classList.add('TWPT-select-container');
+
+ let select = document.createElement('select');
+ let noPostsGroup;
+ let noPostsGroupFlag = false;
+ for (const forumData of this.data) {
+ const hasPosted = forumData.numMessages > 0;
+
+ if (!hasPosted && !noPostsGroupFlag) {
+ noPostsGroup = document.createElement('optgroup');
+ noPostsGroup.label = 'Not posted to within the last 12 months';
+ noPostsGroupFlag = true;
+ }
+
+ let additionalLabelsArray = [];
+ if (hasPosted)
+ additionalLabelsArray.push(forumData.numMessages + ' messages');
+ let role = forumData.forumUserInfo?.[1]?.[3] ?? 0;
+ if (role) additionalLabelsArray.push(kRoles[role]);
+ let additionalLabels = '';
+ if (additionalLabelsArray.length > 0)
+ additionalLabels = ' (' + additionalLabelsArray.join(', ') + ')';
+
+ let option = document.createElement('option');
+ option.textContent = forumData.forumTitle + additionalLabels;
+ option.value = forumData.id;
+ if (hasPosted)
+ select.append(option);
+ else
+ noPostsGroup.append(option);
+ }
+ if (noPostsGroupFlag) select.append(noPostsGroup);
+ select.addEventListener('change', e => {
+ let forumId = e.target.value;
+ this.injectChart(forumId);
+ });
+
+ div.append(select);
+ return div;
+ }
+
+ injectChart(forumId) {
+ this.chartEl.replaceChildren();
+
+ let data = this.getAplosData(forumId);
+ let metadata = {
+ activities: [],
+ finalMonth: undefined,
+ locale: this.locale,
+ shouldDisableTransitions: true,
+ };
+ let chartTitle = 'User activity chart';
+ let chart = window.sc_renderProfileActivityChart(
+ this.chartEl, data, metadata, chartTitle);
+ }
+}
diff --git a/src/contentScripts/communityConsole/utils/common.js b/src/contentScripts/communityConsole/utils/common.js
index 68fb736..40bc1fe 100644
--- a/src/contentScripts/communityConsole/utils/common.js
+++ b/src/contentScripts/communityConsole/utils/common.js
@@ -72,3 +72,10 @@
node.parentNode?.querySelector('[debugid="' + debugid + '"]') === null &&
node.parentNode?.parentNode?.tagName == 'EC-BULK-ACTIONS';
}
+
+// Returns the display language set by the user.
+export function getDisplayLanguage() {
+ var startup =
+ JSON.parse(document.querySelector('html').getAttribute('data-startup'));
+ return startup?.[1]?.[1]?.[3]?.[6] ?? 'en';
+}
diff --git a/src/injections/extraInfo.js b/src/injections/extraInfo.js
new file mode 100644
index 0000000..c9c6e2c
--- /dev/null
+++ b/src/injections/extraInfo.js
@@ -0,0 +1,26 @@
+import PerForumStatsSection from '../contentScripts/communityConsole/utils/PerForumStatsSection.js';
+
+window.addEventListener('message', e => {
+ if (e.source === window && e.data?.prefix === 'TWPT-extrainfo') {
+ switch (e.data?.action) {
+ case 'injectPerForumStatsSection':
+ let existingChartSection =
+ document
+ .querySelector(
+ 'sc-tailwind-user_profile-user-profile sc-tailwind-shared-activity-chart')
+ ?.parentNode;
+ if (!existingChartSection) {
+ console.error('extraInfo: couldn\'t find existing chart section.');
+ return;
+ }
+ new PerForumStatsSection(
+ existingChartSection, e.data?.profile, e.data?.locale);
+ break;
+
+ default:
+ console.error(
+ 'Action \'' + e.data?.action +
+ '\' unknown to TWPT-extrainfo receiver.');
+ }
+ }
+});
diff --git a/src/static/_locales/en/messages.json b/src/static/_locales/en/messages.json
index 0e765ec..44271db 100644
--- a/src/static/_locales/en/messages.json
+++ b/src/static/_locales/en/messages.json
@@ -155,6 +155,10 @@
"message": "Show additional information in threads, profiles and the canned responses list.",
"description": "Feature checkbox in the options page"
},
+ "options_perforumstats": {
+ "message": "Show per-forum activity in profiles in the Community Console.",
+ "description": "Feature checkbox in the options page"
+ },
"options_save": {
"message": "Save",
"description": "Button in the options page to save the settings"
diff --git a/src/static/css/extrainfo.css b/src/static/css/extrainfo.css
index a0c15e2..0767ee2 100644
--- a/src/static/css/extrainfo.css
+++ b/src/static/css/extrainfo.css
@@ -106,3 +106,18 @@
ec-canned-response-row .TWPT-content span[aria-describedby] {
cursor: help;
}
+
+/* Per-forum stats section */
+.TWPT-scTailwindSharedActivitychartroot .scTailwindSharedActivitychartchart {
+ margin-top: 18px;
+}
+
+.TWPT-scTailwindSharedActivitychartroot .TWPT-select-container {
+ position: absolute;
+ top: 48px;
+ z-index: 2;
+}
+
+.TWPT-scTailwindSharedActivitychartroot .TWPT-select-container select {
+ max-width: 300px;
+}
diff --git a/src/static/options/experiments.html b/src/static/options/experiments.html
index 9a21fdf..64c81b8 100644
--- a/src/static/options/experiments.html
+++ b/src/static/options/experiments.html
@@ -15,6 +15,7 @@
<div id="optional-permissions-warning" hidden data-i18n="optionalpermissionswarning_header"></div>
<div class="option"><input type="checkbox" id="workflows"> <label for="workflows" data-i18n="workflows"></label> <button id="manage-workflows" data-i18n="workflows_manage"></button></div>
<div class="option"><input type="checkbox" id="extrainfo"> <label for="extrainfo" data-i18n="extrainfo"></label></div>
+ <div class="option"><input type="checkbox" id="perforumstats"> <label for="perforumstats" data-i18n="perforumstats"></label></div>
<div class="actions"><button id="save" data-i18n="save"></button></div>
</form>
<div id="save-indicator"></div>