Add "Force Mark as Read" feature

This change adds a feature to the extension which will mark all threads
as open when visiting them from the Community Console.

This works by making a request to load the thread again (so the
extension knows the last message ID) and then making a request to mark
the thread manually as read, passing the last message ID so the
Community Console knows that's the last message we've read. This last
request is the one which is made when manually marking a thread as read
from the thread list.

This change also adds the file api.js with a wrapper function to call
the Community Console API. In the future, all API calls should use this
function.

Change-Id: Iff1c077bf136807cdbaa710e2e6b8b130df3a27e
diff --git a/docs/features.es.md b/docs/features.es.md
index c7a9363..0772e2f 100644
--- a/docs/features.es.md
+++ b/docs/features.es.md
@@ -152,6 +152,42 @@
 
 Nótese que esto solo aplica a a la Consola de la Comunidad.
 
+### Marca hilos como vistos automáticamente (opción temporal)
+> **Opción:** _Cada vez que abras un hilo en la Consola de la Comunidad, enviar
+automáticamente una petición para marcarlo como leído. Esta es una opción
+temporal usada como <i>workaround</i> de
+[este bug](https://support.google.com/s/community/forum/51488989/thread/114559215)._
+
+*** promo
+**Nota:** Cuando este problema se arregle por parte de Google, una actualización
+a la extensión quitará esta opción y su funcionalidad.
+***
+
+En estos momentos hay un problema en la Consola de la Comunidad: cuando uno abre
+un hilo para leerlo, este no se marca automáticamente como leído. Esto está en
+proceso de ser arreglado por parte de Google, pero hasta que salga el arreglo,
+esta opción sirve como un _workaround_ (solución temporal).
+
+Cuando esta opción está activada, cada vez que abres un hilo en la Consola de la
+Comunidad ocurre lo siguiente:
+
+1. La extensión enviará una petición autenticada a la API para obtener el
+contenido del hilo siendo visualizado. Esto se usa para obtener el ID del último
+mensaje del hilo.
+
+2. La extensión enviará una petición autenticada a la API para cambiar el estado
+del hilo a leído. Para marcar el hilo como leído, la API necesita que se pase el
+ID del foro, el ID del hilo y el ID del último mensaje que se ha cargado, y este
+último valor se obtiene de la llamada anterior a la API.
+
+*** promo
+**Nota:** Debido a la naturaleza de la API, la primera petición también cargará
+varios detalles innecesarios como los contenidos de los primeros mensajes. Esto
+significa que la petición no es muy eficiente. Por esta razón, recomiendo
+desactivar esta opción una vez nos notifiquen que el problema ha sido arreglado
+por Google, para no poner una carga extra a los servidores de Google.
+***
+
 ## Punto indicador
 > **Opciones:** _Muestra si el autor del hilo ha participado en otros hilos_,
 _Muestra el número de preguntas y respuestas escritas por el autor del hilo
diff --git a/docs/features.md b/docs/features.md
index a2b2b33..35f9c93 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -145,6 +145,40 @@
 
 Note that this only applies to the Community Console.
 
+### Automatically mark threads as read (temporary option)
+> **Option name:** _Each time you open a thread in the Community Console,
+automatically send a request to mark it as read. This is a temporary option used
+to work around
+[this bug](https://support.google.com/s/community/forum/51488989/thread/114559215)._
+
+*** promo
+**Note:** When this issue is fixed by Google, an update to the extension will
+remove this option and its underlying functionality.
+***
+
+Currently there's an issue in the Community Console: when opening threads, they
+are not being marked as read automatically. This is in the process of being
+fixed by Google, but until the fix is out, this option serves as a workaround.
+
+When this option is enabled, each time you open a thread in the Community
+Console, the following will happen:
+
+1. The extension will send an authenticated request to the API to get the
+contents of the thread being viewed. This is used to retrieve the ID of the last
+message.
+2. The extension will send an authenticated request to the API to change the
+read status of the thread. In order to mark a thread as read, the API requires
+the caller to pass the forum ID, thread ID, and the ID of the last message which
+has loaded, and this last value is retrieved from the previous call to the API.
+
+*** promo
+**Note:** Due to the nature of the API, the first request will also load many
+unnecessary details such as the contents of the first messages. This means that
+this call is not very efficient. For this reason, I recommend turning this
+feature off once we are notified that the issue has been fixed by Google, so we
+don't put extra load on Google servers.
+***
+
 ## Indicator dot
 > **Option names:** _Show whether the OP has participated in other threads_,
 _Show the number of questions and replies written by the OP within the last `n`
diff --git a/src/_locales/ca/messages.json b/src/_locales/ca/messages.json
index aa91d18..967853a 100644
--- a/src/_locales/ca/messages.json
+++ b/src/_locales/ca/messages.json
@@ -107,6 +107,10 @@
     "message": "Desactiva a la força l'experiment <code class=\"help\" title=\"Aquest experiment, quan està actiu, introdueix un rediseny de la interfície dels perfils que també unifica tots els perfils en un únic.\">SMEI_UNIFIED_PROFILES</code> a la Consola de la Comunitat.",
     "description": "Link to learn more about the profile indicator feature"
   },
+  "options_forcemarkasread": {
+    "message": "Cada vegada que obris un fil a la Consola de la Comunitat, envia automàticament una petició per marcar-lo com a llegit. Aquesta és una opció temporal usada com a <i>workaround</i> d'<a href=\"https://support.google.com/s/community/forum/51488989/thread/114559215\" target=\"_blank\" rel=\"noreferrer noopener\">aquest bug</a>.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Punt indicador",
     "description": "Heading for the profile indicator feature options"
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 09881d9..16c0e0f 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -111,6 +111,10 @@
     "message": "Force disable the <code class=\"help\" title=\"This experiment, when enabled, introduces a redesign of the profile view which also unifies all forum profiles into a single one.\">SMEI_UNIFIED_PROFILES</code> experiment in the Community Console.",
     "description": "Feature checkbox in the options page"
   },
+  "options_forcemarkasread": {
+    "message": "Each time you open a thread in the Community Console, automatically send a request to mark it as read. This is a temporary option used to work around <a href=\"https://support.google.com/s/community/forum/51488989/thread/114559215\" target=\"_blank\" rel=\"noreferrer noopener\">this bug</a>.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Indicator dot",
     "description": "Heading for the profile indicator feature options"
diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json
index 4bea06d..1f70b4e 100644
--- a/src/_locales/es/messages.json
+++ b/src/_locales/es/messages.json
@@ -111,6 +111,10 @@
     "message": "Desactiva forzosamente el experimento <code class=\"help\" title=\"Este experimento, cuando está activado, introduce un rediseño de la interfaz de los perfiles que también unifica todos los perfiles en cada foro en uno único.\">SMEI_UNIFIED_PROFILES</code> en la Consola de la Comunidad.",
     "description": "Link to learn more about the profile indicator feature"
   },
+  "options_forcemarkasread": {
+    "message": "Cada vez que abras un hilo en la Consola de la Comunidad, enviar automáticamente una petición para marcarlo como leído. Esta es una opción temporal usada como <i>workaround</i> de <a href=\"https://support.google.com/s/community/forum/51488989/thread/114559215\" target=\"_blank\" rel=\"noreferrer noopener\">este bug</a>.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_moreinfo": {
     "message": "+info sobre las 2 opciones anteriores",
     "description": "Link to learn more about the profile indicator feature"
diff --git a/src/common/api.js b/src/common/api.js
new file mode 100644
index 0000000..17c0211
--- /dev/null
+++ b/src/common/api.js
@@ -0,0 +1,40 @@
+const CC_API_BASE_URL = 'https://support.google.com/s/community/api/';
+
+// Function to wrap calls to the Community Console API with intelligent error
+// handling.
+function CCApi(method, data, authuser, authenticated = true) {
+  var authuserPart =
+      authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser);
+
+  return fetch(CC_API_BASE_URL + method + authuserPart, {
+           'headers': {
+             'content-type': 'text/plain; charset=utf-8',
+           },
+           'body': JSON.stringify(data),
+           'method': 'POST',
+           'mode': 'cors',
+           'credentials': (authenticated ? 'include' : 'omit'),
+         })
+      .then(res => {
+        if (res.status == 200 || res.status == 400) {
+          return res.json().then(data => ({
+                                   status: res.status,
+                                   body: data,
+                                 }));
+        } else {
+          throw new Error(
+              'Status code ' + res.status + ' was not expected when calling ' +
+              method + '.');
+        }
+      })
+      .then(res => {
+        if (res.status == 400) {
+          throw new Error(
+              res.body[4] ||
+              ('Response status 400 for method ' + method + '. ' +
+               'Error code: ' + (res.body[2] ?? 'unknown')));
+        }
+
+        return res.body;
+      });
+}
diff --git a/src/common/common.js b/src/common/common.js
index 9212389..4fcae45 100644
--- a/src/common/common.js
+++ b/src/common/common.js
@@ -80,6 +80,10 @@
     defaultValue: false,
     context: 'options',
   },
+  'forcemarkasread': {
+    defaultValue: false,
+    context: 'options',
+  },
 
   // Experiments:
   'threadlistavatars': {
diff --git a/src/content_scripts/console_inject.js b/src/content_scripts/console_inject.js
index 670080f..dc3e270 100644
--- a/src/content_scripts/console_inject.js
+++ b/src/content_scripts/console_inject.js
@@ -635,6 +635,65 @@
   mainCardContent.appendChild(container);
 }
 
+// Send a request to mark the current thread as read
+function markCurrentThreadAsRead() {
+  var threadRegex =
+      /\/s\/community\/?.*\/forum\/([0-9]+)\/?.*\/thread\/([0-9]+)/;
+
+  var url = location.href;
+  var matches = url.match(threadRegex);
+  if (matches !== null && matches.length > 2) {
+    var forumId = matches[1];
+    var threadId = matches[2];
+
+    return CCApi(
+               'ViewThread', {
+                 1: forumId,
+                 2: threadId,
+                 // options
+                 3: {
+                   // pagination
+                   1: {
+                     2: 0,  // maxNum
+                   },
+                   3: false,   // withMessages
+                   5: false,   // withUserProfile
+                   6: true,    // withUserReadState
+                   9: false,   // withRequestorProfile
+                   10: false,  // withPromotedMessages
+                   11: false,  // withExpertResponder
+                 },
+               },
+               authuser)
+        .then(thread => {
+          if (thread?.[1]?.[6] === true) {
+            console.debug(
+                'This thread is already marked as read, but marking it as read anyways.');
+          }
+
+          var lastMessageId = thread?.[1]?.[2]?.[10];
+          if (lastMessageId === undefined)
+            throw new Error(
+                'Couldn\'t find lastMessageId in the ViewThread response.');
+
+          return CCApi(
+              'SetUserReadStateBulk', {
+                1: [{
+                  1: forumId,
+                  2: threadId,
+                  3: lastMessageId,
+                }],
+              },
+              authuser);
+        })
+        .catch(err => {
+          console.error(
+              '[forceMarkAsRead] Error while marking current thread as read: ',
+              err);
+        });
+  }
+}
+
 const watchedNodesSelectors = [
   // App container (used to set up the intersection observer and inject the dark
   // mode button)
@@ -663,6 +722,9 @@
 
   // Unified profile iframe
   'iframe',
+
+  // Thread component
+  'ec-thread',
 ];
 
 function handleCandidateNode(node) {
@@ -751,6 +813,11 @@
         unifiedProfilesFix.checkIframe(node)) {
       unifiedProfilesFix.fixIframe(node);
     }
+
+    // Force mark thread as read
+    if (options.forcemarkasread && node.tagName == 'EC-THREAD') {
+      markCurrentThreadAsRead();
+    }
   }
 }
 
diff --git a/src/options/options.html b/src/options/options.html
index c0c1d3e..8a09d04 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -18,7 +18,7 @@
         -->
         <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g><rect fill="none" height="24" width="24"/></g><g><path d="M13,11.33L18,18H6l5-6.67V6h2 M15.96,4H8.04C7.62,4,7.39,4.48,7.65,4.81L9,6.5v4.17L3.2,18.4C2.71,19.06,3.18,20,4,20h16 c0.82,0,1.29-0.94,0.8-1.6L15,10.67V6.5l1.35-1.69C16.61,4.48,16.38,4,15.96,4L15.96,4z"/></g></svg>
       </div>
-      <a href="https://gerrit.avm99963.com/plugins/gitiles/infinitegforums/+/master/docs/features.md" target="_blank" class="features-link">
+      <a href="https://gerrit.avm99963.com/plugins/gitiles/infinitegforums/+/master/docs/features.md" target="_blank" rel="noreferrer noopener" class="features-link">
         <!--
           Material Design Icon - action/help_outline
            - LICENSE: Apache License Version 2.0
@@ -33,18 +33,19 @@
         <div class="option"><input type="checkbox" id="threadall"> <label for="threadall" data-i18n="threadall"></label></div>
         <h4 data-i18n="enhancements"></h4>
         <div class="option"><input type="checkbox" id="fixedtoolbar"> <label for="fixedtoolbar" data-i18n="fixedtoolbar"></label></div>
-        <div class="option"><input type="checkbox" id="redirect"> <label for="redirect" data-i18n="redirect"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
+        <div class="option"><input type="checkbox" id="redirect"> <label for="redirect" data-i18n="redirect"></label></div>
         <div class="option"><input type="checkbox" id="history"> <label for="history" data-i18n="history"></label></div>
         <div class="option"><input type="checkbox" id="loaddrafts"> <label for="loaddrafts" data-i18n="loaddrafts"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
         <div class="option"><input type="checkbox" id="increasecontrast"> <label for="increasecontrast" data-i18n="increasecontrast"></label></div>
         <div class="option"><input type="checkbox" id="stickysidebarheaders"> <label for="stickysidebarheaders" data-i18n="stickysidebarheaders"></label></div>
-        <div class="option"><input type="checkbox" id="ccdarktheme"> <label for="ccdarktheme" data-i18n="ccdarktheme"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
+        <div class="option"><input type="checkbox" id="ccdarktheme"> <label for="ccdarktheme" data-i18n="ccdarktheme"></label></div>
         <div class="option"><input type="checkbox" id="ccforcehidedrawer"> <label for="ccforcehidedrawer" data-i18n="ccforcehidedrawer"></label></div>
         <div id="dragndrop-wrapper" class="option" hidden><input type="checkbox" id="ccdragndropfix"> <label for="ccdragndropfix" data-i18n="ccdragndropfix"></label></div>
         <div class="option"><input type="checkbox" id="batchlock"> <label for="batchlock" data-i18n="batchlock"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
         <div class="option"><input type="checkbox" id="enhancedannouncementsdot"> <label for="enhancedannouncementsdot" data-i18n="enhancedannouncementsdot"></label></div>
         <div class="option"><input type="checkbox" id="repositionexpandthread"> <label for="repositionexpandthread" data-i18n="repositionexpandthread"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
         <div class="option"><input type="checkbox" id="disableunifiedprofiles"> <label for="disableunifiedprofiles" data-i18n="disableunifiedprofiles"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
+        <div class="option"><input type="checkbox" id="forcemarkasread"> <label for="forcemarkasread" data-i18n="forcemarkasread"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
         <h4 data-i18n="profileindicator_header"></h4>
         <div class="option"><input type="checkbox" id="profileindicator"> <label for="profileindicator" data-i18n="profileindicator"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
         <div class="option"><input type="checkbox" id="profileindicatoralt"> <label for="profileindicatoralt" data-i18n="profileindicatoralt"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
diff --git a/templates/manifest.gjson b/templates/manifest.gjson
index 2a7c440..67eeab6 100644
--- a/templates/manifest.gjson
+++ b/templates/manifest.gjson
@@ -18,7 +18,7 @@
   "content_scripts": [
     {
       "matches": ["https://support.google.com/s/community*"],
-      "js": ["common/content_scripts.js", "content_scripts/console_inject.js"]
+      "js": ["common/api.js", "common/content_scripts.js", "content_scripts/console_inject.js"]
     },
     {
       "matches": ["https://support.google.com/s/community*"],