threadListAvatars: skip calls to load private threads

- Refactor threadListAvatars code.
- Skip calls to load the avatars for threads in known private forums
  (the private forum ids are retrieved from the startup data object.
- Change order of CCApi method parameters (the authuser is not needed
  when |authentication| is |false|). Also, now the |authenticated|
  parameter is no longer optional. This is to make it explicit in the
  code and prevent mistakes in the future.

Change-Id: Ie47c85dcf00ffbfe269721e5f565ba5dd5259b3b
diff --git a/src/common/api.js b/src/common/api.js
index 17c0211..20a6c7d 100644
--- a/src/common/api.js
+++ b/src/common/api.js
@@ -2,7 +2,7 @@
 
 // Function to wrap calls to the Community Console API with intelligent error
 // handling.
-function CCApi(method, data, authuser, authenticated = true) {
+function CCApi(method, data, authenticated, authuser = 0) {
   var authuserPart =
       authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser);
 
diff --git a/src/content_scripts/console_inject.js b/src/content_scripts/console_inject.js
index 837c2ae..d501c26 100644
--- a/src/content_scripts/console_inject.js
+++ b/src/content_scripts/console_inject.js
@@ -242,92 +242,165 @@
         clone, (readToggle.nextSibling || readToggle));
 }
 
-// TODO(avm99963): This is a prototype. DON'T FORGET TO ADD ERROR HANDLING.
-function injectAvatars(node) {
-  var header = node.querySelector(
-      'ec-thread-summary .main-header .panel-description a.header');
-  if (header === null) return;
+var avatars = {
+  isFilterSetUp: false,
+  privateForums: [],
 
-  var link = parseUrl(header.href);
-  if (link === false) return;
+  // Gets a list of private forums. If it is already cached, the cached list is
+  // returned; otherwise it is also computed and cached.
+  getPrivateForums() {
+    return new Promise((resolve, reject) => {
+      if (this.isFilterSetUp) return resolve(this.privateForums);
 
-  var APIRequestUrl = 'https://support.google.com/s/community/api/ViewThread' +
-      (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
+      if (!document.documentElement.hasAttribute('data-startup'))
+        return reject('[threadListAvatars] Couldn\'t get startup data.');
 
-  fetch(APIRequestUrl, {
-    'headers': {
-      'content-type': 'text/plain; charset=utf-8',
-    },
-    'body': JSON.stringify({
-      1: link.forum,
-      2: link.thread,
-      3: {
-        1: {2: 15},
-        3: true,
-        5: true,
-        10: true,
-        16: true,
-        18: true,
+      var startupData =
+          JSON.parse(document.documentElement.getAttribute('data-startup'));
+      var forums = startupData?.['1']?.['2'];
+      if (forums === undefined)
+        return reject(
+            '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
+
+      for (var f of forums) {
+        var forumId = f?.['2']?.['1']?.['1'];
+        var forumVisibility = f?.['2']?.['18'];
+        if (forumId === undefined || forumVisibility === undefined) {
+          console.warn(
+              '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
+              f);
+          continue;
+        }
+
+        // forumVisibility's value 1 means "PUBLIC".
+        if (forumVisibility != 1) this.privateForums.push(forumId);
       }
-    }),
-    'method': 'POST',
-    'mode': 'cors',
-    'credentials': '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.');
-        }
-      })
-      .then(res => {
-        if (res.status == 400) {
-          throw new Error(
-              res.body[4] ||
-              ('Response status: 400. Error code: ' + res.body[2]));
-        }
 
-        return res.body;
-      })
-      .then(data => {
-        if (!('1' in data) || !('8' in data['1'])) return false;
+      // Forum 51488989 is marked as public but it is in fact private.
+      this.privateForums.push('51488989');
 
-        var messages = data['1']['8'];
-        if (messages == 0) return;
+      this.isFilterSetUp = true;
+      return resolve(this.privateForums);
+    });
+  },
 
+  // Some threads belong to private forums, and this feature will not be able to
+  // get its avatars since it makes an anonymomus call to get the contents of
+  // the thread.
+  //
+  // This function returns whether avatars should be retrieved depending on if
+  // the thread belongs to a known private forum.
+  shouldRetrieveAvatars(thread) {
+    return this.getPrivateForums().then(privateForums => {
+      return !privateForums.includes(thread.forum);
+    });
+  },
+
+  // Get an array with the first |num| messages from the thread |thread|.
+  getFirstMessages(thread, num = 15) {
+    return CCApi(
+               'ViewThread', {
+                 1: thread.forum,
+                 2: thread.thread,
+                 // options
+                 3: {
+                   // pagination
+                   1: {
+                     2: num,  // maxNum
+                   },
+                   3: true,    // withMessages
+                   5: true,    // withUserProfile
+                   10: false,  // withPromotedMessages
+                   16: false,  // withThreadNotes
+                   18: true,   // sendNewThreadIfMoved
+                 }
+               },
+               // |authentication| is false because otherwise this would mark
+               // the thread as read as a side effect, and that would mark all
+               // threads in the list as read.
+               //
+               // Due to the fact that we have to call this endpoint
+               // anonymously, this means we can't retrieve information about
+               // threads in private forums.
+               /* authentication = */ false)
+        .then(data => {
+          var numMessages = data?.['1']?.['8'];
+          if (numMessages == undefined)
+            throw new Error(
+                'Request to view thread doesn\'t include the number of messages');
+          if (numMessages == 0) return [];
+
+          var messages = data?.['1']['3'];
+          if (messages == undefined)
+            throw new Error(
+                'numMessages was ' + numMessages +
+                ' but the response didn\'t include any message.');
+          return messages;
+        });
+  },
+
+  // Get a list of at most |num| avatars for thread |thread|
+  getVisibleAvatars(thread, num = 3) {
+    return this.shouldRetrieveAvatars(thread).then(shouldRetrieve => {
+      if (!shouldRetrieve) {
+        console.debug('[threadListAvatars] Skipping thread', thread);
+        return [];
+      }
+
+      return this.getFirstMessages(thread).then(messages => {
         var avatarUrls = [];
+        for (var m of messages) {
+          var url = m?.['3']?.['1']?.['2'];
 
-        if (!('3' in data['1'])) return false;
-        for (var m of data['1']['3']) {
-          if (!('3' in m) || !('1' in m['3']) || !('2' in m['3']['1']))
-            continue;
-
-          var url = m['3']['1']['2'];
-
+          if (url === undefined) continue;
           if (!avatarUrls.includes(url)) avatarUrls.push(url);
-
           if (avatarUrls.length == 3) break;
         }
 
-        var avatarsContainer = document.createElement('div');
-        avatarsContainer.classList.add('TWPT-avatars');
-
-        var count = Math.floor(Math.random() * 4);
-
-        for (var i = 0; i < avatarUrls.length; ++i) {
-          var avatar = document.createElement('div');
-          avatar.classList.add('TWPT-avatar');
-          avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
-          avatarsContainer.appendChild(avatar);
-        }
-
-        header.appendChild(avatarsContainer);
+        return avatarUrls;
       });
-}
+    });
+  },
+
+  // Inject avatars for thread summary (thread item) |node| in a thread list.
+  inject(node) {
+    var header = node.querySelector(
+        'ec-thread-summary .main-header .panel-description a.header');
+    if (header === null) {
+      console.error(
+          '[threadListAvatars] Header is not present in the thread item\'s DOM.');
+      return;
+    }
+
+    var thread = parseUrl(header.href);
+    if (thread === false) {
+      console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
+      return;
+    }
+
+    this.getVisibleAvatars(thread)
+        .then(avatarUrls => {
+          var avatarsContainer = document.createElement('div');
+          avatarsContainer.classList.add('TWPT-avatars');
+
+          var count = Math.floor(Math.random() * 4);
+
+          for (var i = 0; i < avatarUrls.length; ++i) {
+            var avatar = document.createElement('div');
+            avatar.classList.add('TWPT-avatar');
+            avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
+            avatarsContainer.appendChild(avatar);
+          }
+
+          header.appendChild(avatarsContainer);
+        })
+        .catch(err => {
+          console.error(
+              '[threadListAvatars] Could not retrieve avatars for thread',
+              thread, err);
+        });
+  },
+};
 
 var autoRefresh = {
   isLookingForUpdates: false,
@@ -670,7 +743,7 @@
                    11: false,  // withExpertResponder
                  },
                },
-               authuser)
+               true, authuser)
         .then(thread => {
           if (thread?.[1]?.[6] === true) {
             console.debug(
@@ -693,7 +766,7 @@
                   3: lastMessageId,
                 }],
               },
-              authuser);
+              true, authuser);
         })
         .then(_ => {
           console.debug(
@@ -817,7 +890,7 @@
     if (options.threadlistavatars && ('tagName' in node) &&
         (node.tagName == 'LI') &&
         node.querySelector('ec-thread-summary') !== null) {
-      injectAvatars(node);
+      avatars.inject(node);
     }
 
     // Set up the autorefresh list feature