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/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>