Refactor extension to webpack

This change is the biggest in the history of the project. The entire
project has been refactored so it is built with webpack.

This involves:
- Creating webpack and npm config files.
- Fixing some bugs in the code due to the fact that webpack uses strict
mode.
- Merging some pieces of code which were shared throughout the codebase
(not exhaustive, more work should be done in this direction).
- Splitting the console_inject.js file into separate files (it had 1000+
lines).
- Adapting all the build-related files (Makefile, bash scripts, etc.)
- Changing the docs to explain the new build process.
- Changing the Zuul playbook/roles to adapt to the new build process.

Change-Id: I16476d47825461c3a318b3f1a1eddb06b2df2e89
diff --git a/src/contentScripts/communityConsole/avatars.js b/src/contentScripts/communityConsole/avatars.js
new file mode 100644
index 0000000..253fe8f
--- /dev/null
+++ b/src/contentScripts/communityConsole/avatars.js
@@ -0,0 +1,178 @@
+import {CCApi} from '../../common/api.js';
+import {parseUrl} from '../../common/commonUtils.js';
+
+export var avatars = {
+  isFilterSetUp: false,
+  privateForums: [],
+
+  // 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);
+
+      if (!document.documentElement.hasAttribute('data-startup'))
+        return reject('[threadListAvatars] Couldn\'t get startup data.');
+
+      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);
+      }
+
+      // Forum 51488989 is marked as public but it is in fact private.
+      this.privateForums.push('51488989');
+
+      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 object with the author of the thread and an array of the first |num|
+  // replies 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');
+
+          var messages = numMessages == 0 ? [] : data?.['1']['3'];
+          if (messages === undefined)
+            throw new Error(
+                'numMessages was ' + numMessages +
+                ' but the response didn\'t include any message.');
+
+          var author = data?.['1']?.['4'];
+          if (author === undefined)
+            throw new Error(
+                'Author isn\'t included in the ViewThread response.');
+
+          return {
+            messages,
+            author,
+          };
+        });
+  },
+
+  // 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(result => {
+        var messages = result.messages;
+        var author = result.author;
+
+        var avatarUrls = [];
+
+        var authorUrl = author?.['1']?.['2'];
+        if (authorUrl !== undefined) avatarUrls.push(authorUrl);
+
+        for (var m of messages) {
+          var url = m?.['3']?.['1']?.['2'];
+
+          if (url === undefined) continue;
+          if (!avatarUrls.includes(url)) avatarUrls.push(url);
+          if (avatarUrls.length == 3) break;
+        }
+
+        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);
+        });
+  },
+};