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>
diff --git a/templates/manifest.gjson b/templates/manifest.gjson
index 1b2e8d6..72b6eef 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -71,6 +71,7 @@
         "profileIndicatorInject.bundle.js",
         "batchLockInject.bundle.js",
         "xhrInterceptorInject.bundle.js",
+        "extraInfoInject.bundle.js",
 
         "css/profileindicator_inject.css",
         "css/ccdarktheme.css",
diff --git a/webpack.config.js b/webpack.config.js
index 4a02902..7807749 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -44,6 +44,7 @@
     profileIndicatorInject: './src/injections/profileIndicator.js',
     batchLockInject: './src/injections/batchLock.js',
     xhrInterceptorInject: './src/injections/xhrInterceptor.js',
+    extraInfoInject: './src/injections/extraInfo.js',
 
     // Options page
     optionsCommon: './src/options/optionsCommon.js',