Add profile indicator feature

This experimental feature adds an indicator dot next to the OP's
username in order to indicate whether the OP has participated in more
threads in the same forum.

The dot can indicate the following states:
* Blue: the OP has only participated in the current thread.
* Orange: the OP has participated in other threads, but at least the
last 5 ones have been read by the user.
* Red: the OP has participated in other threads, some of which haven't
yet been read by the user.

Change-Id: Ib358d88dacfe0e713247d7757cf7eea1a2b4f8e9
diff --git a/src/_locales/ca/messages.json b/src/_locales/ca/messages.json
index b454bcf..ad6f006 100644
--- a/src/_locales/ca/messages.json
+++ b/src/_locales/ca/messages.json
@@ -63,6 +63,10 @@
     "message": "Incrementa el contrast entre els fils llegits i no llegits a la Consola de la Comunitat.",
     "description": "Feature checkbox in the options page"
   },
+  "options_profileindicator": {
+    "message": "Mostra a la Consola de la Comunitat <span class=\"help\" title=\"Si l'autor ha participat a altres fils, es mostrarà un punt vermell al costat del seu nom d'usuari. Si les publicacions més recents han estat llegides, es mostrarà un punt taronja. Pots posar el teu ratolí a sobre del punt per mostrar què vol dir el seu color.\">si l'autor del fil ha participat a altres fils</span>.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_stickysidebarheaders": {
     "message": "Fes que les capçaleres de la barra lateral de la Consola de la Comunitat es quedin enganxades a dalt (+info a <code>pekb/thread/60784834</code>).",
     "description": "Feature checkbox in the options page"
@@ -82,5 +86,21 @@
   "inject_previousposts": {
     "message": "Historial de publicacions",
     "description": "Link shown in a user profile (in TW and the Community Console) which points to a search showing the user's posts and messages"
+  },
+  "inject_profileindicator_loading": {
+    "message": "Carregant...",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_first_post": {
+    "message": "Aquest és el primer fil d'aquest fòrum creat per aquest usuari.",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_other_posts_read": {
+    "message": "Aquest usuari ha participat en altres fils d'aquest fòrum, però ja has llegit els 5 darrers on ha participat.",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_other_posts_unread": {
+    "message": "Aquest usuari ha participat en altres fils d'aquest fòrum, alguns dels quals no has llegit.",
+    "description": "Tooltip for the profile indicator dot."
   }
 }
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index e958010..f5c5cde 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -67,6 +67,10 @@
     "message": "Increase contrast between read and unread threads in the Community Console.",
     "description": "Feature checkbox in the options page"
   },
+  "options_profileindicator": {
+    "message": "Show in the Community Console <span class=\"help\" title=\"If the OP has participated in other threads, a red dot will be shown next to their username. If the OP's most recent posts have been read, an orange dot will be shown instead. You can hover the dot with your mouse in order to show the meaning of its color.\">whether the OP has participated in other threads</span>.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_save": {
     "message": "Save",
     "description": "Button in the options page to save the settings"
@@ -82,5 +86,21 @@
   "inject_previousposts": {
     "message": "Previous posts",
     "description": "Link shown in a user profile (in TW and the Community Console) which points to a search showing the user's posts and messages"
+  },
+  "inject_profileindicator_loading": {
+    "message": "Loading...",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_first_post": {
+    "message": "This is the first thread created by the OP in this forum.",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_other_posts_read": {
+    "message": "The OP participated in other threads in this forum, but you've read the 5 most recent ones.",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_other_posts_unread": {
+    "message": "The OP participated in other threads in this forum, some of which you haven't read.",
+    "description": "Tooltip for the profile indicator dot."
   }
 }
diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json
index 8627dbb..aacc251 100644
--- a/src/_locales/es/messages.json
+++ b/src/_locales/es/messages.json
@@ -63,6 +63,10 @@
     "message": "Incrementa el contraste entre los hilos leídos y no leídos en la Consola de la Comunidad.",
     "description": "Feature checkbox in the options page"
   },
+  "options_profileindicator": {
+    "message": "Muestra en la Consola de la Comunidad <span class=\"help\" title=\"Si el autor ha participado en otros hilos, se mostrará un punto rojo al lado de su nombre de usuario. Si las publicaciones más recientes se han leído, se mostrará un punto naranja. Puedes poner tu ratón encima del punto para mostrar qué quiere decir su color.\">si el autor del hilo ha participado en otros hilos</span>.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_stickysidebarheaders": {
     "message": "Hacer que los encabezados de la barra lateral de la Consola de la Comunidad se queden pegados arriba (+info en <code>pekb/thread/60784834</code>).",
     "description": "Feature checkbox in the options page"
@@ -82,5 +86,21 @@
   "inject_previousposts": {
     "message": "Historial de publicaciones",
     "description": "Link shown in a user profile (in TW and the Community Console) which points to a search showing the user's posts and messages"
+  },
+  "inject_profileindicator_loading": {
+    "message": "Cargando...",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_first_post": {
+    "message": "Este es el primer hilo de este foro creado por este usuario.",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_other_posts_read": {
+    "message": "Este usuario ha participado en otros hilos de este foro, pero ya has leído los 5 últimos donde ha participado.",
+    "description": "Tooltip for the profile indicator dot."
+  },
+  "inject_profileindicator_other_posts_unread": {
+    "message": "Este usuario ha participado en otros hilos de este foro, algunos de los cuales no has leído.",
+    "description": "Tooltip for the profile indicator dot."
   }
 }
diff --git a/src/background.js b/src/background.js
index e97a24e..6728e07 100644
--- a/src/background.js
+++ b/src/background.js
@@ -15,6 +15,7 @@
   'movethreads': false,
   'increasecontrast': false,
   'stickysidebarheaders': false,
+  'profileindicator': false,
 };
 
 function cleanUpOptions() {
diff --git a/src/console_inject.js b/src/console_inject.js
index 646beba..8858baf 100644
--- a/src/console_inject.js
+++ b/src/console_inject.js
@@ -62,48 +62,73 @@
   });
 };
 
-function injectStyles(css) {
+function injectStylesheet(stylesheetName) {
   var link = document.createElement('link');
   link.setAttribute('rel', 'stylesheet');
-  link.setAttribute(
-      'href', 'data:text/css;charset=UTF-8,' + encodeURIComponent(css));
+  link.setAttribute('href', stylesheetName);
   document.head.appendChild(link);
 }
 
+function injectStyles(css) {
+  injectStylesheet('data:text/css;charset=UTF-8,' + encodeURIComponent(css));
+}
+
+function injectScript(scriptName) {
+  var script = document.createElement('script');
+  script.src = scriptName;
+  document.head.appendChild(script);
+}
+
 var observerOptions = {
   childList: true,
   attributes: true,
   subtree: true,
 }
 
-var intersectionOptions =
-    {
-      root: document.querySelector('.scrollable-content'),
-      rootMargin: '0px',
-      threshold: 1.0,
-    }
+var intersectionOptions = {
+  root: document.querySelector('.scrollable-content'),
+  rootMargin: '0px',
+  threshold: 1.0,
+};
 
-    chrome.storage.sync.get(null, function(items) {
-      options = items;
+chrome.storage.sync.get(null, function(items) {
+  options = items;
 
-      mutationObserver = new MutationObserver(mutationCallback);
-      mutationObserver.observe(
-          document.querySelector('.scrollable-content'), observerOptions);
+  mutationObserver = new MutationObserver(mutationCallback);
+  mutationObserver.observe(
+      document.querySelector('.scrollable-content'), observerOptions);
 
-      intersectionObserver =
-          new IntersectionObserver(intersectionCallback, intersectionOptions);
+  intersectionObserver =
+      new IntersectionObserver(intersectionCallback, intersectionOptions);
 
-      if (options.fixedtoolbar) {
-        injectStyles(
-            'ec-bulk-actions{position: sticky; top: 0; background: white; z-index: 96;}');
-      }
+  if (options.fixedtoolbar) {
+    injectStyles(
+        'ec-bulk-actions{position: sticky; top: 0; background: white; z-index: 96;}');
+  }
 
-      if (options.increasecontrast) {
-        injectStyles('.thread-summary.read{background: #ecedee!important;}');
-      }
+  if (options.increasecontrast) {
+    injectStyles('.thread-summary.read{background: #ecedee!important;}');
+  }
 
-      if (options.stickysidebarheaders) {
-        injectStyles(
-            'material-drawer .main-header{background: #fff; position: sticky; top: 0; z-index: 1;}');
-      }
+  if (options.stickysidebarheaders) {
+    injectStyles(
+        'material-drawer .main-header{background: #fff; position: sticky; top: 0; z-index: 1;}');
+  }
+
+  if (options.profileindicator) {
+    injectScript(chrome.runtime.getURL('console_profileindicator_inject.js'));
+    injectStylesheet(
+        chrome.runtime.getURL('console_profileindicator_inject.css'));
+
+    // In order to pass i18n strings to the injected script, which doesn't have
+    // access to the chrome.i18n API.
+    window.addEventListener('geti18nString', evt => {
+      var request = evt.detail;
+      var response = {
+        string: chrome.i18n.getMessage(request.msg),
+        requestId: request.id
+      };
+      window.dispatchEvent(new CustomEvent('sendi18nString', {detail: response}));
     });
+  }
+});
diff --git a/src/console_profileindicator_inject.css b/src/console_profileindicator_inject.css
new file mode 100644
index 0000000..67b0501
--- /dev/null
+++ b/src/console_profileindicator_inject.css
@@ -0,0 +1,47 @@
+ec-user-link.name > * {
+  vertical-align: middle;
+}
+
+.profile-indicator {
+  margin-left: 4px;
+}
+
+.profile-indicator a {
+  text-decoration: none!important;
+}
+
+@keyframes loading-profile-indicator {
+  from {
+    opacity: 0.25;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
+.profile-indicator.profile-indicator--loading a {
+  color: #6f6f6f;
+
+  animation-duration: 0.75s;
+  animation-name: loading-profile-indicator;
+  animation-iteration-count: infinite;
+  animation-direction: alternate;
+}
+
+.profile-indicator.profile-indicator--first-post a {
+  color: #1976D2;
+}
+
+.profile-indicator.profile-indicator--other-posts-read a {
+  color: #FF8F00;
+}
+
+.profile-indicator.profile-indicator--other-posts-unread a {
+  color: #C62828;
+}
+
+.profile-indicator a {
+  opacity: 1;
+  transition: opacity 1s, color 1s;
+}
diff --git a/src/console_profileindicator_inject.js b/src/console_profileindicator_inject.js
new file mode 100644
index 0000000..29ff70d
--- /dev/null
+++ b/src/console_profileindicator_inject.js
@@ -0,0 +1,173 @@
+var profileRegex =
+    /^(?:https:\/\/support\.google\.com)?\/s\/community\/forum\/[0-9]*\/user\/(?:[0-9]+)$/;
+
+const OP_FIRST_POST = 0;
+const OP_OTHER_POSTS_READ = 1;
+const OP_OTHER_POSTS_UNREAD = 2;
+
+const OPClasses = {
+  0: 'profile-indicator--first-post',
+  1: 'profile-indicator--other-posts-read',
+  2: 'profile-indicator--other-posts-unread',
+};
+
+const OPi18n = {
+  0: 'first_post',
+  1: 'other_posts_read',
+  2: 'other_posts_unread',
+};
+
+// Filter used as a workaround to speed up the ViewForum request.
+const FILTER_ALL_LANGUAGES =
+    'lang:(ar | bg | ca | "zh-hk" | "zh-cn" | "zh-tw" | hr | cs | da | nl | en | "en-au" | "en-gb" | et | fil | fi | fr | de | el | iw | hi | hu | id | it | ja | ko | lv | lt | ms | no | pl | "pt-br" | "pt-pt" | ro | ru | sr | sk | sl | es | "es-419" | sv | th | tr | uk | vi)';
+
+function isElementInside(element, outerTag) {
+  while (element !== null && ('tagName' in element)) {
+    if (element.tagName == outerTag) return true;
+    element = element.parentNode;
+  }
+
+  return false;
+}
+
+function escapeUsername(username) {
+  var quoteRegex = /"/g;
+  var commentRegex = /<!---->/g;
+  return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
+}
+
+function getPosts(query, forumId) {
+  return fetch('https://support.google.com/s/community/api/ViewForum', {
+           'credentials': 'include',
+           'headers': {'content-type': 'text/plain; charset=utf-8'},
+           'body': JSON.stringify({
+             '1': forumId,
+             '2': {
+               '1': {
+                 '2': 5,
+               },
+               '2': {
+                 '1': 1,
+                 '2': true,
+               },
+               '12': query,
+             },
+           }),
+           'method': 'POST',
+           'mode': 'cors',
+         })
+      .then(res => res.json());
+}
+
+// Source:
+// https://stackoverflow.com/questions/33063774/communication-from-an-injected-script-to-the-content-script-with-a-response
+var i18nRequest = (function() {
+  var requestId = 0;
+
+  function getMessage(msg) {
+    var id = requestId++;
+
+    return new Promise(function(resolve, reject) {
+      var listener = function(evt) {
+        if (evt.detail.requestId == id) {
+          // Deregister self
+          window.removeEventListener('sendChromeData', listener);
+          resolve(evt.detail.string);
+        }
+      };
+
+      window.addEventListener('sendi18nString', listener);
+
+      var payload = {msg: msg, id: id};
+
+      window.dispatchEvent(new CustomEvent('geti18nString', {detail: payload}));
+    });
+  }
+
+  return {getMessage: getMessage};
+})();
+
+function mutationCallback(mutationList, observer) {
+  mutationList.forEach((mutation) => {
+    if (mutation.type == 'childList') {
+      mutation.addedNodes.forEach(function(node) {
+        if (node.tagName == 'A' && ('href' in node) &&
+            profileRegex.test(node.href) &&
+            isElementInside(node, 'EC-QUESTION') && ('children' in node) &&
+            node.children.length == 0) {
+          var escapedUsername = escapeUsername(node.innerHTML);
+
+          // Create profile indicator dot with a loading state
+          var dotContainer = document.createElement('span');
+          dotContainer.classList.add('profile-indicator');
+          dotContainer.classList.add('profile-indicator--loading');
+          i18nRequest.getMessage('inject_profileindicator_loading')
+              .then(string => dotContainer.setAttribute('title', string));
+
+          var forumUrlSplit = document.location.href.split('/forum/');
+          if (forumUrlSplit.length < 2) throw new Error('Can\'t get forum id.');
+
+          var forumId = forumUrlSplit[1].split('/')[0];
+          var query = '(replier:"' + escapedUsername + '" | creator:"' +
+              escapedUsername + '") ' + FILTER_ALL_LANGUAGES;
+          var encodedQuery = encodeURIComponent(query);
+          var urlpart = encodeURIComponent('query=' + encodedQuery);
+          var dotLink = document.createElement('a');
+          dotLink.href =
+              'https://support.google.com/s/community/search/' + urlpart;
+          dotLink.innerText = '●';
+
+          dotContainer.appendChild(dotLink);
+          node.parentNode.appendChild(dotContainer);
+
+          // Query threads in order to see what state the indicator should be in
+          getPosts(query, forumId)
+              .then(res => {
+                if (!('1' in res) || !('2' in res['1'])) {
+                  throw new Error('Unexpected response.');
+                  return;
+                }
+
+                // Current thread ID
+                var threadUrlSplit = document.location.href.split('/thread/');
+                if (threadUrlSplit.length < 2)
+                  throw new Error('Can\'t get thread id.');
+
+                var currId = threadUrlSplit[1].split('/')[0];
+
+                var OPStatus = OP_FIRST_POST;
+
+                for (const thread of res['1']['2']) {
+                  var id = thread['2']['1']['1'] || undefined;
+                  if (id === undefined || id == currId) continue;
+
+                  var isRead = thread['6'] || false;
+                  if (isRead)
+                    OPStatus = Math.max(OP_OTHER_POSTS_READ, OPStatus);
+                  else
+                    OPStatus = Math.max(OP_OTHER_POSTS_UNREAD, OPStatus);
+                }
+
+                dotContainer.classList.remove('profile-indicator--loading');
+                dotContainer.classList.add(OPClasses[OPStatus]);
+                i18nRequest
+                    .getMessage('inject_profileindicator_' + OPi18n[OPStatus])
+                    .then(string => dotContainer.setAttribute('title', string));
+              })
+              .catch(
+                  err => console.error(
+                      'Unexpected error. Couldn\'t load recent posts.', err));
+        }
+      });
+    }
+  });
+};
+
+var observerOptions = {
+  childList: true,
+  subtree: true,
+}
+
+mutationObserver = new MutationObserver(mutationCallback);
+mutationObserver.observe(
+    document.querySelector('.scrollable-content'), observerOptions);
diff --git a/src/options.html b/src/options.html
index 021db05..7f40cf3 100644
--- a/src/options.html
+++ b/src/options.html
@@ -19,6 +19,7 @@
       <input type="checkbox" id="loaddrafts"> <label for="loaddrafts" data-i18n="loaddrafts"></label> <span class="experimental-label" data-i18n="experimental_label"></span><br>
       <input type="checkbox" id="increasecontrast"> <label for="increasecontrast" data-i18n="increasecontrast"></label><br>
       <input type="checkbox" id="stickysidebarheaders"> <label for="stickysidebarheaders" data-i18n="stickysidebarheaders"></label><br>
+      <input type="checkbox" id="profileindicator"> <label for="profileindicator" data-i18n="profileindicator"></label><br>
     </p>
     <p class="actions"><button id="save" data-i18n="save"></button></p>
     <div id="save-indicator"></div>
diff --git a/src/options.js b/src/options.js
index 637d6fc..af9dede 100644
--- a/src/options.js
+++ b/src/options.js
@@ -15,6 +15,7 @@
   'movethreads': false,
   'increasecontrast': false,
   'stickysidebarheaders': false,
+  'profileindicator': false,
 };
 
 const deprecatedOptions = [