Add initial extra info feature

Only the extra profile in profiles is shown, since it is the easiest to
implement.

Bug: twpowertools:93
Change-Id: Ife4f31ee056f74bd478702347d75044b024abf1e
diff --git a/src/common/optionsPrototype.json5 b/src/common/optionsPrototype.json5
index ca38042..0d337b2 100644
--- a/src/common/optionsPrototype.json5
+++ b/src/common/optionsPrototype.json5
@@ -126,6 +126,11 @@
     context: 'experiments',
     killSwitchType: 'experiment',
   },
+  'extrainfo': {
+    defaultValue: false,
+    context: 'experiments',
+    killSwitchType: 'experiment',
+  },
 
   // Internal options:
   'ccdarktheme_switch_enabled': {
diff --git a/src/common/xhrInterceptors.json5 b/src/common/xhrInterceptors.json5
index 63bec85..2e9a443 100644
--- a/src/common/xhrInterceptors.json5
+++ b/src/common/xhrInterceptors.json5
@@ -15,5 +15,10 @@
       urlRegex: "api/CreateMessage",
       intercepts: "request",
     },
+    {
+      eventName: "ViewUnifiedUserResponse",
+      urlRegex: "api/ViewUnifiedUser",
+      intercepts: "response",
+    },
   ],
 }
diff --git a/src/contentScripts/communityConsole/extraInfo.js b/src/contentScripts/communityConsole/extraInfo.js
new file mode 100644
index 0000000..c572523
--- /dev/null
+++ b/src/contentScripts/communityConsole/extraInfo.js
@@ -0,0 +1,257 @@
+import {MDCTooltip} from '@material/tooltip';
+import {waitFor} from 'poll-until-promise';
+
+import {isOptionEnabled} from '../../common/optionsUtils.js';
+
+import {createExtBadge} from './utils/common.js';
+
+const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
+
+const kAbuseCategories = [
+  ['1', 'Account'],
+  ['2', 'Display name'],
+  ['3', 'Avatar'],
+];
+const kAbuseViolationCategories = {
+  0: 'NO_VIOLATION',
+  1: 'COMMUNITY_POLICY_VIOLATION',
+  2: 'LEGAL_VIOLATION',
+  3: 'CSAI_VIOLATION',
+  4: 'OTHER_VIOLATION',
+};
+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',
+};
+
+export default class ExtraInfo {
+  constructor() {
+    this.lastProfile = {
+      body: {},
+      id: -1,
+      timestamp: 0,
+    };
+    this.setUpHandlers();
+  }
+
+  setUpHandlers() {
+    window.addEventListener(kViewUnifiedUserResponseEvent, e => {
+      if (e.detail.id < this.lastProfile.id) return;
+
+      this.lastProfile = {
+        body: e.detail.body,
+        id: e.detail.id,
+        timestamp: Date.now(),
+      };
+    });
+  }
+
+  // Add a pretty component which contains |info| to |node|.
+  addExtraInfoElement(info, node) {
+    // Don't create
+    if (info.length == 0) return;
+
+    let container = document.createElement('div');
+    container.classList.add('TWPT-extrainfo-container');
+
+    let badgeCell = document.createElement('div');
+    badgeCell.classList.add('TWPT-extrainfo-badge-cell');
+
+    let badge, badgeTooltip;
+    [badge, badgeTooltip] = createExtBadge();
+    badgeCell.append(badge);
+
+    let infoCell = document.createElement('div');
+    infoCell.classList.add('TWPT-extrainfo-info-cell');
+
+    for (const i of info) {
+      let iRow = document.createElement('div');
+      iRow.append(i);
+      infoCell.append(iRow);
+    }
+
+    container.append(badgeCell, infoCell);
+    node.append(container);
+    new MDCTooltip(badgeTooltip);
+  }
+
+  fieldInfo(field, value) {
+    let span = document.createElement('span');
+    span.append(document.createTextNode(field + ': '));
+
+    let valueEl = document.createElement('span');
+    valueEl.style.fontFamily = 'monospace';
+    valueEl.textContent = value;
+
+    span.append(valueEl);
+    return span;
+  }
+
+  // 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');
+        },
+        {
+          interval: 500,
+          timeout: 15 * 1000,
+        })
+        .then(profile => {
+          let info = [];
+          const abuseViolationCategory = profile.body?.['1']?.['6'];
+          if (abuseViolationCategory) {
+            info.push(this.fieldInfo(
+                'Abuse category',
+                kAbuseViolationCategories[abuseViolationCategory] ??
+                    abuseViolationCategory));
+          }
+
+          const profileAbuse = profile.body?.['1']?.['1']?.['8'];
+
+          for (const [index, category] of kAbuseCategories) {
+            const violation = profileAbuse?.[index]?.['1']?.['1'];
+            if (violation) {
+              info.push(this.fieldInfo(
+                  category + ' policy violation',
+                  kAbuseViolationTypes[violation]));
+            }
+          }
+
+          const appealCount = profileAbuse?.['4'];
+          if (appealCount !== undefined)
+            info.push(this.fieldInfo('Number of appeals', appealCount));
+
+          this.addExtraInfoElement(info, card);
+        })
+        .catch(err => {
+          console.error(
+              'extraInfo: error while injecting profile extra info: ', err);
+        });
+  }
+
+  injectAtProfileIfEnabled(card) {
+    isOptionEnabled('extrainfo').then(isEnabled => {
+      if (isEnabled) return this.injectAtProfile(card);
+    });
+  }
+}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
index 870f1c4..a534f38 100644
--- a/src/contentScripts/communityConsole/main.js
+++ b/src/contentScripts/communityConsole/main.js
@@ -22,6 +22,9 @@
   // Load more bar (for the "load more"/"load all" buttons)
   '.load-more-bar',
 
+  // User profile card inside ec-unified-user
+  'ec-unified-user .scTailwindUser_profileUsercardmain',
+
   // Username span/editor inside ec-unified-user (user profile view)
   'ec-unified-user .scTailwindUser_profileUsercarddetails',
 
@@ -89,6 +92,11 @@
       }
     }
 
+    // Show additional details in the profile view.
+    if (node.matches('ec-unified-user .scTailwindUser_profileUsercardmain')) {
+      window.TWPTExtraInfo.injectAtProfileIfEnabled(node);
+    }
+
     // Show the "previous posts" links if the option is currently enabled.
     //   Here we're selecting the 'ec-user > div' element (unique child)
     if (node.matches(
@@ -116,8 +124,7 @@
     // Inject the batch lock and workflow buttons in the thread list if the
     // corresponding options are currently enabled.
     // The order is the inverse because the first one will be shown last.
-    if (batchLock.shouldAddButton(node))
-      batchLock.addButtonIfEnabled(node);
+    if (batchLock.shouldAddButton(node)) batchLock.addButtonIfEnabled(node);
 
     if (workflows.shouldAddThreadListBtn(node))
       workflows.addThreadListBtnIfEnabled(node);
@@ -241,4 +248,6 @@
   injectStylesheet(chrome.runtime.getURL('css/thread_list_avatars.css'));
   // Auto refresh list
   injectStylesheet(chrome.runtime.getURL('css/autorefresh_list.css'));
+  // Extra info
+  injectStylesheet(chrome.runtime.getURL('css/extrainfo.css'));
 });
diff --git a/src/contentScripts/communityConsole/start.js b/src/contentScripts/communityConsole/start.js
index 4e8af62..8b1c4ef 100644
--- a/src/contentScripts/communityConsole/start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -2,6 +2,7 @@
 import {getOptions} from '../../common/optionsUtils.js';
 
 import AutoRefresh from './autoRefresh.js';
+import ExtraInfo from './extraInfo.js';
 
 getOptions(null).then(options => {
   /* IMPORTANT NOTE: Remember to change this when changing the "ifs" below!! */
@@ -17,9 +18,10 @@
         'data-startup', JSON.stringify(startup));
   }
 
-  // Initialized here instead of in main.js so the first |ViewForumResponse|
-  // event is received if it happens when the page loads.
+  // Initialized here instead of in main.js so the first event is received if it
+  // happens when the page loads.
   window.TWPTAutoRefresh = new AutoRefresh();
+  window.TWPTExtraInfo = new ExtraInfo();
 
   if (options.ccdarktheme) {
     switch (options.ccdarktheme_mode) {
diff --git a/src/static/_locales/en/messages.json b/src/static/_locales/en/messages.json
index 1c3e75b..2637808 100644
--- a/src/static/_locales/en/messages.json
+++ b/src/static/_locales/en/messages.json
@@ -151,6 +151,10 @@
     "message": "Manage workflows",
     "description": "Button in the options page which opens the workflow management page."
   },
+  "options_extrainfo": {
+    "message": "Show extra information in threads and profiles.",
+    "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
new file mode 100644
index 0000000..86eed01
--- /dev/null
+++ b/src/static/css/extrainfo.css
@@ -0,0 +1,34 @@
+.TWPT-extrainfo-container {
+  display: inline-flex;
+  color: var(--TWPT-interop-secondary-text, #444746)!important;
+  align-items: center;
+}
+
+.TWPT-extrainfo-badge-cell {
+  margin: 0 8px;
+  --icon-size: 14px;
+  opacity: 0.6;
+}
+
+.TWPT-extrainfo-info-cell {
+  border-left: solid 1px var(--TWPT-interop-subtle-border, #ababab);
+  padding-left: 8px;
+  display: flex;
+  flex-direction: column;
+}
+
+.TWPT-extrainfo-info-cell > div:not(:last-child) {
+  margin-bottom: 2px;
+}
+
+/* Specific styles inside context */
+
+ec-unified-user .scTailwindUser_profileUsercardmain {
+  display: flex;
+  flex-direction: column;
+}
+
+ec-unified-user .TWPT-extrainfo-container {
+  margin-top: 16px;
+  align-self: end;
+}
diff --git a/src/static/options/experiments.html b/src/static/options/experiments.html
index 83a58cb..9a21fdf 100644
--- a/src/static/options/experiments.html
+++ b/src/static/options/experiments.html
@@ -14,6 +14,7 @@
       <form>
         <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="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 800f2ae..1b2e8d6 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -79,7 +79,8 @@
         "css/reposition_expand_thread.css",
         "css/thread_list_avatars.css",
         "css/autorefresh_list.css",
-        "css/image_max_height.css"
+        "css/image_max_height.css",
+        "css/extrainfo.css"
 #if defined(CHROMIUM_MV3)
       ],
       "matches": [