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/background.js b/src/background.js
index 8d0f411..15f62b9 100644
--- a/src/background.js
+++ b/src/background.js
@@ -1,4 +1,5 @@
 // IMPORTANT: keep this file in sync with sw.js
+import {cleanUpOptions} from './common/optionsUtils.js'
 
 // When the extension gets updated, set new options to their default value.
 chrome.runtime.onInstalled.addListener(function(details) {
diff --git a/src/common/api.js b/src/common/api.js
index 20a6c7d..065d84c 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, authenticated, authuser = 0) {
+export function CCApi(method, data, authenticated, authuser = 0) {
   var authuserPart =
       authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser);
 
diff --git a/src/common/commonUtils.js b/src/common/commonUtils.js
new file mode 100644
index 0000000..ee31037
--- /dev/null
+++ b/src/common/commonUtils.js
@@ -0,0 +1,17 @@
+export function parseUrl(url) {
+  var forum_a = url.match(/forum\/([0-9]+)/i);
+  var thread_a = url.match(/thread\/([0-9]+)/i);
+
+  if (forum_a === null || thread_a === null) {
+    return false;
+  }
+
+  return {
+    'forum': forum_a[1],
+    'thread': thread_a[1],
+  };
+}
+
+export function isEmpty(obj) {
+  return Object.keys(obj).length === 0;
+}
diff --git a/src/common/communityConsoleUtils.js b/src/common/communityConsoleUtils.js
new file mode 100644
index 0000000..05a283c
--- /dev/null
+++ b/src/common/communityConsoleUtils.js
@@ -0,0 +1,13 @@
+// Escapes username from HTML generated by the Community Console.
+export function escapeUsername(username) {
+  var quoteRegex = /"/g;
+  var commentRegex = /<!---->/g;
+  return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
+}
+
+// Retrieves authuser from the data-startup attribute.
+export function getAuthUser() {
+  var startup =
+      JSON.parse(document.querySelector('html').getAttribute('data-startup'));
+  return startup?.[2]?.[1] || '0';
+}
diff --git a/src/common/content_scripts.js b/src/common/contentScriptsUtils.js
similarity index 60%
rename from src/common/content_scripts.js
rename to src/common/contentScriptsUtils.js
index 1bc9a7d..00bc556 100644
--- a/src/common/content_scripts.js
+++ b/src/common/contentScriptsUtils.js
@@ -1,4 +1,4 @@
-function injectStylesheet(stylesheetName, attributes = {}) {
+export function injectStylesheet(stylesheetName, attributes = {}) {
   var link = document.createElement('link');
   link.setAttribute('rel', 'stylesheet');
   link.setAttribute('href', stylesheetName);
@@ -6,18 +6,12 @@
   document.head.appendChild(link);
 }
 
-function injectStyles(css) {
+export function injectStyles(css) {
   injectStylesheet('data:text/css;charset=UTF-8,' + encodeURIComponent(css));
 }
 
-function injectScript(scriptName) {
+export function injectScript(scriptName) {
   var script = document.createElement('script');
   script.src = scriptName;
   document.head.appendChild(script);
 }
-
-function escapeUsername(username) {
-  var quoteRegex = /"/g;
-  var commentRegex = /<!---->/g;
-  return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
-}
diff --git a/src/common/csEventListener.js b/src/common/csEventListener.js
new file mode 100644
index 0000000..14b4295
--- /dev/null
+++ b/src/common/csEventListener.js
@@ -0,0 +1,43 @@
+// In order to pass i18n strings and settings values to the injected scripts,
+// which don't have access to the chrome.* APIs, we use event listeners.
+
+export function setUpListener() {
+  chrome.storage.sync.get(null, function(options) {
+    window.addEventListener('TWPT_sendRequest', evt => {
+      var request = evt.detail;
+      switch (request.data.action) {
+        case 'geti18nMessage':
+          var data = chrome.i18n.getMessage(
+              request.data.msg,
+              (Array.isArray(request.data.placeholders) ?
+                   request.data.placeholders :
+                   []));
+          break;
+
+        case 'getProfileIndicatorOptions':
+          var data = {
+            'indicatorDot': options.profileindicator,
+            'numPosts': options.profileindicatoralt
+          };
+          break;
+
+        case 'getNumPostMonths':
+          var data = options.profileindicatoralt_months;
+          break;
+
+        default:
+          var data = 'unknownAction';
+          console.warn('Unknown action ' + request.data.action + '.');
+          break;
+      }
+
+      var response = {
+        data,
+        requestId: request.id,
+        prefix: (request.prefix || 'TWPT'),
+      };
+
+      window.postMessage(response, '*');
+    });
+  });
+}
diff --git a/src/common/cs_event_listener.js b/src/common/cs_event_listener.js
deleted file mode 100644
index c7de08f..0000000
--- a/src/common/cs_event_listener.js
+++ /dev/null
@@ -1,40 +0,0 @@
-// In order to pass i18n strings and settings values to the injected scripts,
-// which don't have access to the chrome.* APIs, we use event listeners.
-chrome.storage.sync.get(null, function(options) {
-  window.addEventListener('TWPT_sendRequest', evt => {
-    var request = evt.detail;
-    switch (request.data.action) {
-      case 'geti18nMessage':
-        var data = chrome.i18n.getMessage(
-            request.data.msg,
-            (Array.isArray(request.data.placeholders) ?
-                 request.data.placeholders :
-                 []));
-        break;
-
-      case 'getProfileIndicatorOptions':
-        var data = {
-          'indicatorDot': options.profileindicator,
-          'numPosts': options.profileindicatoralt
-        };
-        break;
-
-      case 'getNumPostMonths':
-        var data = options.profileindicatoralt_months;
-        break;
-
-      default:
-        var data = 'unknownAction';
-        console.warn('Unknown action ' + request.data.action + '.');
-        break;
-    }
-
-    var response = {
-      data,
-      requestId: request.id,
-      prefix: (request.prefix || 'TWPT'),
-    };
-
-    window.postMessage(response, '*');
-  });
-});
diff --git a/src/common/extUtils.js b/src/common/extUtils.js
new file mode 100644
index 0000000..a1b4d48
--- /dev/null
+++ b/src/common/extUtils.js
@@ -0,0 +1,13 @@
+// This method is based on the fact that when building the extension for Firefox
+// the browser_specific_settings.gecko entry is included.
+export function isFirefox() {
+  var manifest = chrome.runtime.getManifest();
+  return manifest.browser_specific_settings !== undefined &&
+      manifest.browser_specific_settings.gecko !== undefined;
+}
+
+// Returns whether the extension is a release version.
+export function isReleaseVersion() {
+  var manifest = chrome.runtime.getManifest();
+  return ('version' in manifest) && manifest.version != '0';
+}
diff --git a/src/common/common.js b/src/common/optionsPrototype.json5
similarity index 61%
rename from src/common/common.js
rename to src/common/optionsPrototype.json5
index 4fcae45..191d0e1 100644
--- a/src/common/common.js
+++ b/src/common/optionsPrototype.json5
@@ -1,4 +1,4 @@
-const optionsPrototype = {
+{
   // Available options:
   'list': {
     defaultValue: true,
@@ -118,53 +118,4 @@
     defaultValue: false,
     context: 'deprecated',
   },
-};
-
-const specialOptions = [
-  'profileindicatoralt_months',
-  'ccdarktheme_mode',
-  'ccdarktheme_switch_enabled',
-  'ccdragndropfix',
-];
-
-function isEmpty(obj) {
-  return Object.keys(obj).length === 0;
-}
-
-// Adds missing options with their default value. If |dryRun| is set to false,
-// they are also saved to the sync storage area.
-function cleanUpOptions(options, dryRun = false) {
-  console.log('[cleanUpOptions] Previous options', JSON.stringify(options));
-
-  if (typeof options !== 'object' || options === null) options = {};
-
-  var ok = true;
-  for (const [opt, optMeta] of Object.entries(optionsPrototype)) {
-    if (!(opt in options)) {
-      ok = false;
-      options[opt] = optMeta['defaultValue'];
-    }
-  }
-
-  console.log('[cleanUpOptions] New options', JSON.stringify(options));
-
-  if (!ok && !dryRun) {
-    chrome.storage.sync.set(options);
-  }
-
-  return options;
-}
-
-// This method is based on the fact that when building the extension for Firefox
-// the browser_specific_settings.gecko entry is included.
-function isFirefox() {
-  var manifest = chrome.runtime.getManifest();
-  return manifest.browser_specific_settings !== undefined &&
-      manifest.browser_specific_settings.gecko !== undefined;
-}
-
-// Returns whether the extension is a release version.
-function isReleaseVersion() {
-  var manifest = chrome.runtime.getManifest();
-  return ('version' in manifest) && manifest.version != '0';
 }
diff --git a/src/common/optionsUtils.js b/src/common/optionsUtils.js
new file mode 100644
index 0000000..0efb6c9
--- /dev/null
+++ b/src/common/optionsUtils.js
@@ -0,0 +1,28 @@
+import optionsPrototype from './optionsPrototype.json5';
+import specialOptions from './specialOptions.json5';
+
+export {optionsPrototype, specialOptions};
+
+// Adds missing options with their default value. If |dryRun| is set to false,
+// they are also saved to the sync storage area.
+export function cleanUpOptions(options, dryRun = false) {
+  console.log('[cleanUpOptions] Previous options', JSON.stringify(options));
+
+  if (typeof options !== 'object' || options === null) options = {};
+
+  var ok = true;
+  for (const [opt, optMeta] of Object.entries(optionsPrototype)) {
+    if (!(opt in options)) {
+      ok = false;
+      options[opt] = optMeta['defaultValue'];
+    }
+  }
+
+  console.log('[cleanUpOptions] New options', JSON.stringify(options));
+
+  if (!ok && !dryRun) {
+    chrome.storage.sync.set(options);
+  }
+
+  return options;
+}
diff --git a/src/common/specialOptions.json5 b/src/common/specialOptions.json5
new file mode 100644
index 0000000..55dde20
--- /dev/null
+++ b/src/common/specialOptions.json5
@@ -0,0 +1,6 @@
+[
+  'profileindicatoralt_months',
+  'ccdarktheme_mode',
+  'ccdarktheme_switch_enabled',
+  'ccdragndropfix',
+]
diff --git a/src/contentScripts/communityConsole/autoRefresh.js b/src/contentScripts/communityConsole/autoRefresh.js
new file mode 100644
index 0000000..c39970f
--- /dev/null
+++ b/src/contentScripts/communityConsole/autoRefresh.js
@@ -0,0 +1,250 @@
+import {createExtBadge} from './utils.js';
+import {getAuthUser} from '../../common/communityConsoleUtils.js';
+
+var authuser = getAuthUser();
+
+export var autoRefresh = {
+  isLookingForUpdates: false,
+  isUpdatePromptShown: false,
+  lastTimestamp: null,
+  filter: null,
+  path: null,
+  snackbar: null,
+  interval: null,
+  firstCallTimeout: null,
+  intervalMs: 3 * 60 * 1000,   // 3 minutes
+  firstCallDelayMs: 3 * 1000,  // 3 seconds
+  getStartupData() {
+    return JSON.parse(
+        document.querySelector('html').getAttribute('data-startup'));
+  },
+  isOrderedByTimestampDescending() {
+    var startup = this.getStartupData();
+    // Returns orderOptions.by == TIMESTAMP && orderOptions.desc == true
+    return (
+        startup?.[1]?.[1]?.[3]?.[14]?.[1] == 1 &&
+        startup?.[1]?.[1]?.[3]?.[14]?.[2] == true);
+  },
+  getCustomFilter(path) {
+    var searchRegex = /^\/s\/community\/search\/([^\/]*)/;
+    var matches = path.match(searchRegex);
+    if (matches !== null && matches.length > 1) {
+      var search = decodeURIComponent(matches[1]);
+      var params = new URLSearchParams(search);
+      return params.get('query') || '';
+    }
+
+    return '';
+  },
+  filterHasOverride(filter, override) {
+    var escapedOverride = override.replace(/([^\w\d\s])/gi, '\\$1');
+    var regex = new RegExp('[^a-zA-Z0-9]?' + escapedOverride + ':');
+    return regex.test(filter);
+  },
+  getFilter(path) {
+    var query = this.getCustomFilter(path);
+
+    // Note: This logic has been copied and adapted from the
+    // _buildQuery$1$threadId function in the Community Console
+    var conditions = '';
+    var startup = this.getStartupData();
+
+    // TODO(avm99963): if the selected forums are changed without reloading the
+    // page, this will get the old selected forums. Fix this.
+    var forums = startup?.[1]?.[1]?.[3]?.[8] ?? [];
+    if (!this.filterHasOverride(query, 'forum') && forums !== null &&
+        forums.length > 0)
+      conditions += ' forum:(' + forums.join(' | ') + ')';
+
+    var langs = startup?.[1]?.[1]?.[3]?.[5] ?? [];
+    if (!this.filterHasOverride(query, 'lang') && langs !== null &&
+        langs.length > 0)
+      conditions += ' lang:(' + langs.map(l => '"' + l + '"').join(' | ') + ')';
+
+    if (query.length !== 0 && conditions.length !== 0)
+      return '(' + query + ')' + conditions;
+    return query + conditions;
+  },
+  getLastTimestamp() {
+    var APIRequestUrl = 'https://support.google.com/s/community/api/ViewForum' +
+        (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
+
+    return fetch(APIRequestUrl, {
+             'headers': {
+               'content-type': 'text/plain; charset=utf-8',
+             },
+             'body': JSON.stringify({
+               1: '0',  // TODO: Change, when only a forum is selected, it
+                        // should be set here
+               2: {
+                 1: {
+                   2: 2,
+                 },
+                 2: {
+                   1: 1,
+                   2: true,
+                 },
+                 12: this.filter,
+               },
+             }),
+             'method': 'POST',
+             'mode': 'cors',
+             'credentials': 'include',
+           })
+        .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(body => {
+          var timestamp = body?.[1]?.[2]?.[0]?.[2]?.[17];
+          if (timestamp === undefined)
+            throw new Error(
+                'Unexpected body of response (' +
+                (body?.[1]?.[2]?.[0] === undefined ?
+                     'no threads were returned' :
+                     'the timestamp value is not present in the first thread') +
+                ').');
+
+          return timestamp;
+        });
+    // TODO(avm99963): Add retry mechanism (sometimes thread lists are empty,
+    // but when loading the next page the thread appears).
+    //
+    // NOTE(avm99963): It seems like loading the first 2 threads instead of only
+    // the first one fixes this (empty lists are now rarely returned).
+  },
+  unregister() {
+    console.debug('autorefresh_list: unregistering');
+
+    if (!this.isLookingForUpdates) return;
+
+    window.clearTimeout(this.firstCallTimeout);
+    window.clearInterval(this.interval);
+    this.isUpdatePromptShown = false;
+    this.isLookingForUpdates = false;
+  },
+  showUpdatePrompt() {
+    this.snackbar.classList.remove('TWPT-hidden');
+    document.title = '[!!!] ' + document.title.replace('[!!!] ', '');
+    this.isUpdatePromptShown = true;
+  },
+  hideUpdatePrompt() {
+    this.snackbar.classList.add('TWPT-hidden');
+    document.title = document.title.replace('[!!!] ', '');
+    this.isUpdatePromptShown = false;
+  },
+  injectUpdatePrompt() {
+    var pane = document.createElement('div');
+    pane.classList.add('TWPT-pane-for-snackbar');
+
+    var snackbar = document.createElement('material-snackbar-panel');
+    snackbar.classList.add('TWPT-snackbar');
+    snackbar.classList.add('TWPT-hidden');
+
+    var ac = document.createElement('div');
+    ac.classList.add('TWPT-animation-container');
+
+    var nb = document.createElement('div');
+    nb.classList.add('TWPT-notification-bar');
+
+    var ft = document.createElement('focus-trap');
+
+    var content = document.createElement('div');
+    content.classList.add('TWPT-focus-content-wrapper');
+
+    var badge = createExtBadge();
+
+    var message = document.createElement('div');
+    message.classList.add('TWPT-message');
+    message.textContent =
+        chrome.i18n.getMessage('inject_autorefresh_list_snackbar_message');
+
+    var action = document.createElement('div');
+    action.classList.add('TWPT-action');
+    action.textContent =
+        chrome.i18n.getMessage('inject_autorefresh_list_snackbar_action');
+
+    action.addEventListener('click', e => {
+      this.hideUpdatePrompt();
+      document.querySelector('.app-title-button').click();
+    });
+
+    content.append(badge, message, action);
+    ft.append(content);
+    nb.append(ft);
+    ac.append(nb);
+    snackbar.append(ac);
+    pane.append(snackbar);
+    document.getElementById('default-acx-overlay-container').append(pane);
+    this.snackbar = snackbar;
+  },
+  checkUpdate() {
+    if (location.pathname != this.path) {
+      this.unregister();
+      return;
+    }
+
+    if (this.isUpdatePromptShown) return;
+
+    console.debug('Checking for update at: ', new Date());
+
+    this.getLastTimestamp()
+        .then(timestamp => {
+          if (timestamp != this.lastTimestamp) this.showUpdatePrompt();
+        })
+        .catch(
+            err => console.error(
+                'Coudln\'t get last timestamp (while updating): ', err));
+  },
+  firstCall() {
+    console.debug(
+        'autorefresh_list: now performing first call to finish setup (filter: [' +
+        this.filter + '])');
+
+    if (location.pathname != this.path) {
+      this.unregister();
+      return;
+    }
+
+    this.getLastTimestamp()
+        .then(timestamp => {
+          this.lastTimestamp = timestamp;
+          var checkUpdateCallback = this.checkUpdate.bind(this);
+          this.interval =
+              window.setInterval(checkUpdateCallback, this.intervalMs);
+        })
+        .catch(
+            err => console.error(
+                'Couldn\'t get last timestamp (while setting up): ', err));
+  },
+  setUp() {
+    if (!this.isOrderedByTimestampDescending()) return;
+
+    this.unregister();
+
+    console.debug('autorefresh_list: starting set up...');
+
+    if (this.snackbar === null) this.injectUpdatePrompt();
+    this.isLookingForUpdates = true;
+    this.path = location.pathname;
+    this.filter = this.getFilter(this.path);
+
+    var firstCall = this.firstCall.bind(this);
+    this.firstCallTimeout = window.setTimeout(firstCall, this.firstCallDelayMs);
+  },
+};
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);
+        });
+  },
+};
diff --git a/src/contentScripts/communityConsole/batchLock.js b/src/contentScripts/communityConsole/batchLock.js
new file mode 100644
index 0000000..5bc0361
--- /dev/null
+++ b/src/contentScripts/communityConsole/batchLock.js
@@ -0,0 +1,133 @@
+import {removeChildNodes, createExtBadge} from './utils.js';
+
+export function nodeIsReadToggleBtn(node) {
+  return ('tagName' in node) && node.tagName == 'MATERIAL-BUTTON' &&
+      node.getAttribute('debugid') !== null &&
+      (node.getAttribute('debugid') == 'mark-read-button' ||
+       node.getAttribute('debugid') == 'mark-unread-button') &&
+      ('parentNode' in node) && node.parentNode !== null &&
+      ('parentNode' in node.parentNode) &&
+      node.parentNode.querySelector('[debugid="batchlock"]') === null &&
+      node.parentNode.parentNode !== null &&
+      ('tagName' in node.parentNode.parentNode) &&
+      node.parentNode.parentNode.tagName == 'EC-BULK-ACTIONS';
+}
+
+export function addBatchLockBtn(readToggle) {
+  var clone = readToggle.cloneNode(true);
+  clone.setAttribute('debugid', 'batchlock');
+  clone.classList.add('TWPT-btn--with-badge');
+  clone.setAttribute('title', chrome.i18n.getMessage('inject_lockbtn'));
+  clone.querySelector('material-icon').setAttribute('icon', 'lock');
+  clone.querySelector('i.material-icon-i').textContent = 'lock';
+
+  var badge = createExtBadge();
+  clone.append(badge);
+
+  clone.addEventListener('click', function() {
+    var modal = document.querySelector('.pane[pane-id="default-1"]');
+
+    var dialog = document.createElement('material-dialog');
+    dialog.setAttribute('role', 'dialog');
+    dialog.setAttribute('aria-modal', 'true');
+    dialog.classList.add('TWPT-dialog');
+
+    var header = document.createElement('header');
+    header.setAttribute('role', 'presentation');
+    header.classList.add('TWPT-dialog-header');
+
+    var title = document.createElement('div');
+    title.classList.add('TWPT-dialog-header--title', 'title');
+    title.textContent = chrome.i18n.getMessage('inject_lockbtn');
+
+    header.append(title);
+
+    var main = document.createElement('main');
+    main.setAttribute('role', 'presentation');
+    main.classList.add('TWPT-dialog-main');
+
+    var p = document.createElement('p');
+    p.textContent = chrome.i18n.getMessage('inject_lockdialog_desc');
+
+    main.append(p);
+
+    dialog.append(header, main);
+
+    var footers = [['lock', 'unlock', 'cancel'], ['reload', 'close']];
+
+    for (var i = 0; i < footers.length; ++i) {
+      var footer = document.createElement('footer');
+      footer.setAttribute('role', 'presentation');
+      footer.classList.add('TWPT-dialog-footer');
+      footer.setAttribute('data-footer-id', i);
+
+      if (i > 0) footer.classList.add('is-hidden');
+
+      footers[i].forEach(action => {
+        var btn = document.createElement('material-button');
+        btn.setAttribute('role', 'button');
+        btn.classList.add('TWPT-dialog-footer-btn');
+        if (i == 1) btn.classList.add('is-disabled');
+
+        switch (action) {
+          case 'lock':
+          case 'unlock':
+            btn.addEventListener('click', _ => {
+              if (btn.classList.contains('is-disabled')) return;
+              var message = {
+                action,
+                prefix: 'TWPT-batchlock',
+              };
+              window.postMessage(message, '*');
+            });
+            break;
+
+          case 'cancel':
+          case 'close':
+            btn.addEventListener('click', _ => {
+              if (btn.classList.contains('is-disabled')) return;
+              modal.classList.remove('visible');
+              modal.style.display = 'none';
+              removeChildNodes(modal);
+            });
+            break;
+
+          case 'reload':
+            btn.addEventListener('click', _ => {
+              if (btn.classList.contains('is-disabled')) return;
+              window.location.reload()
+            });
+            break;
+        }
+
+        var content = document.createElement('div');
+        content.classList.add('content', 'TWPT-dialog-footer-btn--content');
+        content.textContent =
+            chrome.i18n.getMessage('inject_lockdialog_btn_' + action);
+
+        btn.append(content);
+        footer.append(btn);
+      });
+
+      var clear = document.createElement('div');
+      clear.style.clear = 'both';
+
+      footer.append(clear);
+      dialog.append(footer);
+    }
+
+    removeChildNodes(modal);
+    modal.append(dialog);
+    modal.classList.add('visible', 'modal');
+    modal.style.display = 'flex';
+  });
+
+  var duplicateBtn =
+      readToggle.parentNode.querySelector('[debugid="mark-duplicate-button"]');
+  if (duplicateBtn)
+    duplicateBtn.parentNode.insertBefore(
+        clone, (duplicateBtn.nextSibling || duplicateBtn));
+  else
+    readToggle.parentNode.insertBefore(
+        clone, (readToggle.nextSibling || readToggle));
+}
diff --git a/src/contentScripts/communityConsole/darkMode.js b/src/contentScripts/communityConsole/darkMode.js
new file mode 100644
index 0000000..d10a77c
--- /dev/null
+++ b/src/contentScripts/communityConsole/darkMode.js
@@ -0,0 +1,49 @@
+import {createExtBadge} from './utils.js';
+
+export function injectDarkModeButton(rightControl, previousDarkModeOption) {
+  var darkThemeSwitch = document.createElement('material-button');
+  darkThemeSwitch.classList.add('TWPT-dark-theme', 'TWPT-btn--with-badge');
+  darkThemeSwitch.setAttribute('button', '');
+  darkThemeSwitch.setAttribute(
+      'title', chrome.i18n.getMessage('inject_ccdarktheme_helper'));
+
+  darkThemeSwitch.addEventListener('click', e => {
+    chrome.storage.sync.get(null, currentOptions => {
+      currentOptions.ccdarktheme_switch_status = !previousDarkModeOption;
+      chrome.storage.sync.set(currentOptions, _ => {
+        location.reload();
+      });
+    });
+  });
+
+  var switchContent = document.createElement('div');
+  switchContent.classList.add('content');
+
+  var icon = document.createElement('material-icon');
+
+  var i = document.createElement('i');
+  i.classList.add('material-icon-i', 'material-icons-extended');
+  i.textContent = 'brightness_4';
+
+  icon.appendChild(i);
+  switchContent.appendChild(icon);
+  darkThemeSwitch.appendChild(switchContent);
+
+  var badgeContent = createExtBadge();
+
+  darkThemeSwitch.appendChild(badgeContent);
+
+  rightControl.style.width =
+      (parseInt(window.getComputedStyle(rightControl).width) + 58) + 'px';
+  rightControl.insertAdjacentElement('afterbegin', darkThemeSwitch);
+}
+
+export function isDarkThemeOn(options) {
+  if (!options.ccdarktheme) return false;
+
+  if (options.ccdarktheme_mode == 'switch')
+    return options.ccdarktheme_switch_status;
+
+  return window.matchMedia &&
+      window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
diff --git a/src/contentScripts/communityConsole/dragAndDropFix.js b/src/contentScripts/communityConsole/dragAndDropFix.js
new file mode 100644
index 0000000..1f293f6
--- /dev/null
+++ b/src/contentScripts/communityConsole/dragAndDropFix.js
@@ -0,0 +1,9 @@
+export function applyDragAndDropFix(node) {
+  console.debug('Adding link drag&drop fix to ', node);
+  node.addEventListener('drop', e => {
+    if (e.dataTransfer.types.includes('text/uri-list')) {
+      e.stopImmediatePropagation();
+      console.debug('Stopping link drop event propagation.');
+    }
+  }, true);
+}
diff --git a/src/contentScripts/communityConsole/forceMarkAsRead.js b/src/contentScripts/communityConsole/forceMarkAsRead.js
new file mode 100644
index 0000000..c573788
--- /dev/null
+++ b/src/contentScripts/communityConsole/forceMarkAsRead.js
@@ -0,0 +1,81 @@
+import {CCApi} from '../../common/api.js';
+import {getAuthUser} from '../../common/communityConsoleUtils.js';
+
+var authuser = getAuthUser();
+
+// Send a request to mark the current thread as read
+export function markCurrentThreadAsRead() {
+  console.debug(
+      '[forceMarkAsRead] %cTrying to mark a thread as read.',
+      'color: #1a73e8;');
+
+  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];
+
+    console.debug('[forceMarkAsRead] Thread details:', {forumId, threadId});
+
+    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
+                 },
+               },
+               true, authuser)
+        .then(thread => {
+          if (thread?.[1]?.[6] === true) {
+            console.debug(
+                '[forceMarkAsRead] This thread is already marked as read, but marking it as read anyways.');
+          }
+
+          var lastMessageId = thread?.[1]?.[2]?.[10];
+
+          console.debug('[forceMarkAsRead] lastMessageId is:', lastMessageId);
+
+          if (lastMessageId === undefined)
+            throw new Error(
+                'Couldn\'t find lastMessageId in the ViewThread response.');
+
+          return CCApi(
+              'SetUserReadStateBulk', {
+                1: [{
+                  1: forumId,
+                  2: threadId,
+                  3: lastMessageId,
+                }],
+              },
+              true, authuser);
+        })
+        .then(_ => {
+          console.debug(
+              '[forceMarkAsRead] %cSuccessfully set as read!',
+              'color: #1e8e3e;');
+        })
+        .catch(err => {
+          console.error(
+              '[forceMarkAsRead] Error while marking current thread as read: ',
+              err);
+        });
+  } else {
+    console.error(
+        '[forceMarkAsRead] Couldn\'t retrieve forumId and threadId from the current URL.',
+        url);
+  }
+}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
new file mode 100644
index 0000000..dd11851
--- /dev/null
+++ b/src/contentScripts/communityConsole/main.js
@@ -0,0 +1,235 @@
+import {injectScript, injectStyles, injectStylesheet} from '../../common/contentScriptsUtils.js';
+
+import {autoRefresh} from './autoRefresh.js';
+import {avatars} from './avatars.js';
+import {addBatchLockBtn, nodeIsReadToggleBtn} from './batchLock.js';
+import {injectDarkModeButton, isDarkThemeOn} from './darkMode.js';
+import {applyDragAndDropFix} from './dragAndDropFix.js';
+import {markCurrentThreadAsRead} from './forceMarkAsRead.js';
+import {injectPreviousPostsLinks} from './profileHistoryLink.js';
+import {unifiedProfilesFix} from './unifiedProfiles.js';
+
+var mutationObserver, intersectionObserver, intersectionOptions, options;
+
+const watchedNodesSelectors = [
+  // App container (used to set up the intersection observer and inject the dark
+  // mode button)
+  'ec-app',
+
+  // Load more bar (for the "load more"/"load all" buttons)
+  '.load-more-bar',
+
+  // Username span/editor inside ec-user (user profile view)
+  'ec-user .main-card .header > .name > span',
+  'ec-user .main-card .header > .name > ec-display-name-editor',
+
+  // Rich text editor
+  'ec-movable-dialog',
+  'ec-rich-text-editor',
+
+  // Read/unread bulk action in the list of thread, for the batch lock feature
+  'ec-bulk-actions material-button[debugid="mark-read-button"]',
+  'ec-bulk-actions material-button[debugid="mark-unread-button"]',
+
+  // Thread list items (used to inject the avatars)
+  'li',
+
+  // Thread list (used for the autorefresh feature)
+  'ec-thread-list',
+
+  // Unified profile iframe
+  'iframe',
+
+  // Thread component
+  'ec-thread',
+];
+
+function handleCandidateNode(node) {
+  if (typeof node.classList !== 'undefined') {
+    if (('tagName' in node) && node.tagName == 'EC-APP') {
+      // Set up the intersectionObserver
+      if (typeof intersectionObserver === 'undefined') {
+        var scrollableContent = node.querySelector('.scrollable-content');
+        if (scrollableContent !== null) {
+          intersectionOptions = {
+            root: scrollableContent,
+            rootMargin: '0px',
+            threshold: 1.0,
+          };
+
+          intersectionObserver = new IntersectionObserver(
+              intersectionCallback, intersectionOptions);
+        }
+      }
+
+      // Inject the dark mode button
+      if (options.ccdarktheme && options.ccdarktheme_mode == 'switch') {
+        var rightControl = node.querySelector('header .right-control');
+        if (rightControl !== null)
+          injectDarkModeButton(rightControl, options.ccdarktheme_switch_status);
+      }
+    }
+
+    // Start the intersectionObserver for the "load more"/"load all" buttons
+    // inside a thread
+    if ((options.thread || options.threadall) &&
+        node.classList.contains('load-more-bar')) {
+      if (typeof intersectionObserver !== 'undefined') {
+        if (options.thread)
+          intersectionObserver.observe(node.querySelector('.load-more-button'));
+        if (options.threadall)
+          intersectionObserver.observe(node.querySelector('.load-all-button'));
+      } else {
+        console.warn(
+            '[infinitescroll] ' +
+            'The intersectionObserver is not ready yet.');
+      }
+    }
+
+    // Show the "previous posts" links
+    //   Here we're selecting the 'ec-user > div' element (unique child)
+    if (options.history &&
+        (node.matches('ec-user .main-card .header > .name > span') ||
+         node.matches(
+             'ec-user .main-card .header > .name > ec-display-name-editor'))) {
+      injectPreviousPostsLinks(node);
+    }
+
+    // Fix the drag&drop issue with the rich text editor
+    //
+    //   We target both tags because in different contexts different
+    //   elements containing the text editor get added to the DOM structure.
+    //   Sometimes it's a EC-MOVABLE-DIALOG which already contains the
+    //   EC-RICH-TEXT-EDITOR, and sometimes it's the EC-RICH-TEXT-EDITOR
+    //   directly.
+    if (options.ccdragndropfix && ('tagName' in node) &&
+        (node.tagName == 'EC-MOVABLE-DIALOG' ||
+         node.tagName == 'EC-RICH-TEXT-EDITOR')) {
+      applyDragAndDropFix(node);
+    }
+
+    // Inject the batch lock button in the thread list
+    if (options.batchlock && nodeIsReadToggleBtn(node)) {
+      addBatchLockBtn(node);
+    }
+
+    // Inject avatar links to threads in the thread list
+    if (options.threadlistavatars && ('tagName' in node) &&
+        (node.tagName == 'LI') &&
+        node.querySelector('ec-thread-summary') !== null) {
+      avatars.inject(node);
+    }
+
+    // Set up the autorefresh list feature
+    if (options.autorefreshlist && ('tagName' in node) &&
+        node.tagName == 'EC-THREAD-LIST') {
+      autoRefresh.setUp();
+    }
+
+    // Redirect unified profile iframe to dark version if applicable
+    if (node.tagName == 'IFRAME' && isDarkThemeOn(options) &&
+        unifiedProfilesFix.checkIframe(node)) {
+      unifiedProfilesFix.fixIframe(node);
+    }
+
+    // Force mark thread as read
+    if (options.forcemarkasread && node.tagName == 'EC-THREAD') {
+      markCurrentThreadAsRead();
+    }
+  }
+}
+
+function handleRemovedNode(node) {
+  // Remove snackbar when exiting thread list view
+  if (options.autorefreshlist && 'tagName' in node &&
+      node.tagName == 'EC-THREAD-LIST') {
+    autoRefresh.hideUpdatePrompt();
+  }
+}
+
+function mutationCallback(mutationList, observer) {
+  mutationList.forEach((mutation) => {
+    if (mutation.type == 'childList') {
+      mutation.addedNodes.forEach(function(node) {
+        handleCandidateNode(node);
+      });
+
+      mutation.removedNodes.forEach(function(node) {
+        handleRemovedNode(node);
+      });
+    }
+  });
+}
+
+function intersectionCallback(entries, observer) {
+  entries.forEach(entry => {
+    if (entry.isIntersecting) {
+      entry.target.click();
+    }
+  });
+};
+
+var observerOptions = {
+  childList: true,
+  subtree: true,
+};
+
+chrome.storage.sync.get(null, function(items) {
+  options = items;
+
+  // Before starting the mutation Observer, check whether we missed any
+  // mutations by manually checking whether some watched nodes already
+  // exist.
+  var cssSelectors = watchedNodesSelectors.join(',');
+  document.querySelectorAll(cssSelectors)
+      .forEach(node => handleCandidateNode(node));
+
+  mutationObserver = new MutationObserver(mutationCallback);
+  mutationObserver.observe(document.body, observerOptions);
+
+  if (options.fixedtoolbar) {
+    injectStyles(
+        'ec-bulk-actions{position: sticky; top: 0; background: var(--TWPT-primary-background, #fff); z-index: 96;}');
+  }
+
+  if (options.increasecontrast) {
+    injectStyles(
+        '.thread-summary.read:not(.checked){background: var(--TWPT-thread-read-background, #ecedee)!important;}');
+  }
+
+  if (options.stickysidebarheaders) {
+    injectStyles(
+        'material-drawer .main-header{background: var(--TWPT-drawer-background, #fff)!important; position: sticky; top: 0; z-index: 1;}');
+  }
+
+  if (options.enhancedannouncementsdot) {
+    injectStylesheet(
+        chrome.runtime.getURL('css/enhanced_announcements_dot.css'));
+  }
+
+  if (options.repositionexpandthread) {
+    injectStylesheet(
+        chrome.runtime.getURL('css/reposition_expand_thread.css'));
+  }
+
+  if (options.ccforcehidedrawer) {
+    var drawer = document.querySelector('material-drawer');
+    if (drawer !== null && drawer.classList.contains('mat-drawer-expanded')) {
+      document.querySelector('.material-drawer-button').click();
+    }
+  }
+
+  if (options.batchlock) {
+    injectScript(chrome.runtime.getURL('batchLockInject.bundle.js'));
+    injectStylesheet(chrome.runtime.getURL('css/batchlock_inject.css'));
+  }
+
+  if (options.threadlistavatars) {
+    injectStylesheet(
+        chrome.runtime.getURL('css/thread_list_avatars.css'));
+  }
+
+  if (options.autorefreshlist) {
+    injectStylesheet(chrome.runtime.getURL('css/autorefresh_list.css'));
+  }
+});
diff --git a/src/contentScripts/communityConsole/profileHistoryLink.js b/src/contentScripts/communityConsole/profileHistoryLink.js
new file mode 100644
index 0000000..d53f9ee
--- /dev/null
+++ b/src/contentScripts/communityConsole/profileHistoryLink.js
@@ -0,0 +1,59 @@
+import {getNParent, createExtBadge} from './utils.js';
+import {escapeUsername, getAuthUser} from '../../common/communityConsoleUtils.js';
+
+var authuser = getAuthUser();
+
+function addProfileHistoryLink(node, type, query) {
+  var urlpart = encodeURIComponent('query=' + query);
+  var authuserpart =
+      (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
+  var container = document.createElement('div');
+  container.style.margin = '3px 0';
+
+  var link = document.createElement('a');
+  link.setAttribute(
+      'href',
+      'https://support.google.com/s/community/search/' + urlpart +
+          authuserpart);
+  link.innerText = chrome.i18n.getMessage('inject_previousposts_' + type);
+
+  container.appendChild(link);
+  node.appendChild(container);
+}
+
+export function injectPreviousPostsLinks(nameElement) {
+  var mainCardContent = getNParent(nameElement, 3);
+  if (mainCardContent === null) {
+    console.error(
+        '[previousposts] Couldn\'t find |.main-card-content| element.');
+    return;
+  }
+
+  var forumId = location.href.split('/forum/')[1].split('/')[0] || '0';
+
+  var nameTag =
+      (nameElement.tagName == 'EC-DISPLAY-NAME-EDITOR' ?
+           nameElement.querySelector('.top-section > span') ?? nameElement :
+           nameElement);
+  var name = escapeUsername(nameTag.textContent);
+  var query1 = encodeURIComponent(
+      '(creator:"' + name + '" | replier:"' + name + '") forum:' + forumId);
+  var query2 = encodeURIComponent(
+      '(creator:"' + name + '" | replier:"' + name + '") forum:any');
+
+  var container = document.createElement('div');
+  container.classList.add('TWPT-previous-posts');
+
+  var badge = createExtBadge();
+  container.appendChild(badge);
+
+  var linkContainer = document.createElement('div');
+  linkContainer.classList.add('TWPT-previous-posts--links');
+
+  addProfileHistoryLink(linkContainer, 'forum', query1);
+  addProfileHistoryLink(linkContainer, 'all', query2);
+
+  container.appendChild(linkContainer);
+
+  mainCardContent.appendChild(container);
+}
diff --git a/src/content_scripts/console_inject_start.js b/src/contentScripts/communityConsole/start.js
similarity index 83%
rename from src/content_scripts/console_inject_start.js
rename to src/contentScripts/communityConsole/start.js
index 32f3159..318e466 100644
--- a/src/content_scripts/console_inject_start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -1,3 +1,5 @@
+import {injectStylesheet} from '../../common/contentScriptsUtils.js';
+
 const SMEI_SORT_DIRECTION = 8;
 const SMEI_UNIFIED_PROFILES = 9;
 
@@ -30,11 +32,11 @@
     switch (items.ccdarktheme_mode) {
       case 'switch':
         if (items.ccdarktheme_switch_status == true)
-          injectStylesheet(chrome.runtime.getURL('injections/ccdarktheme.css'));
+          injectStylesheet(chrome.runtime.getURL('css/ccdarktheme.css'));
         break;
 
       case 'system':
-        injectStylesheet(chrome.runtime.getURL('injections/ccdarktheme.css'), {
+        injectStylesheet(chrome.runtime.getURL('css/ccdarktheme.css'), {
           'media': '(prefers-color-scheme: dark)',
         });
         break;
diff --git a/src/contentScripts/communityConsole/unifiedProfiles.js b/src/contentScripts/communityConsole/unifiedProfiles.js
new file mode 100644
index 0000000..e37fbbf
--- /dev/null
+++ b/src/contentScripts/communityConsole/unifiedProfiles.js
@@ -0,0 +1,12 @@
+export var unifiedProfilesFix = {
+  checkIframe(iframe) {
+    var srcRegex = /support.*\.google\.com\/profile\//;
+    return srcRegex.test(iframe.src ?? '');
+  },
+  fixIframe(iframe) {
+    console.info('[unifiedProfilesFix] Fixing unified profiles iframe');
+    var url = new URL(iframe.src);
+    url.searchParams.set('dark', 1);
+    iframe.src = url.href;
+  },
+};
diff --git a/src/contentScripts/communityConsole/utils.js b/src/contentScripts/communityConsole/utils.js
new file mode 100644
index 0000000..ca452b3
--- /dev/null
+++ b/src/contentScripts/communityConsole/utils.js
@@ -0,0 +1,27 @@
+export function removeChildNodes(node) {
+  while (node.firstChild) {
+    node.removeChild(node.firstChild);
+  }
+}
+
+export function getNParent(node, n) {
+  if (n <= 0) return node;
+  if (!('parentNode' in node)) return null;
+  return getNParent(node.parentNode, n - 1);
+}
+
+export function createExtBadge() {
+  var badge = document.createElement('div');
+  badge.classList.add('TWPT-badge');
+  badge.setAttribute(
+      'title', chrome.i18n.getMessage('inject_extension_badge_helper', [
+        chrome.i18n.getMessage('appName')
+      ]));
+
+  var badgeI = document.createElement('i');
+  badgeI.classList.add('material-icon-i', 'material-icons-extended');
+  badgeI.textContent = 'repeat';
+
+  badge.append(badgeI);
+  return badge;
+}
diff --git a/src/content_scripts/profile_inject.js b/src/contentScripts/profile.js
similarity index 96%
rename from src/content_scripts/profile_inject.js
rename to src/contentScripts/profile.js
index d28d3ec..38894d6 100644
--- a/src/content_scripts/profile_inject.js
+++ b/src/contentScripts/profile.js
@@ -1,10 +1,12 @@
+import {escapeUsername} from '../common/communityConsoleUtils.js';
+
 var authuser = (new URL(location.href)).searchParams.get('authuser') || '0';
 
 function getSearchUrl(query) {
   var urlpart = encodeURIComponent('query=' + encodeURIComponent(query));
   var authuserpart =
       (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
-  return url = 'https://support.google.com/s/community/search/' + urlpart +
+  return 'https://support.google.com/s/community/search/' + urlpart +
       authuserpart;
 }
 
diff --git a/src/contentScripts/profileIndicator.js b/src/contentScripts/profileIndicator.js
new file mode 100644
index 0000000..5edc932
--- /dev/null
+++ b/src/contentScripts/profileIndicator.js
@@ -0,0 +1,13 @@
+import {injectScript, injectStylesheet} from '../common/contentScriptsUtils.js';
+import {setUpListener} from '../common/csEventListener.js';
+
+setUpListener();
+
+chrome.storage.sync.get(null, function(options) {
+  if (options.profileindicator || options.profileindicatoralt) {
+    injectScript(
+        chrome.runtime.getURL('profileIndicatorInject.bundle.js'));
+    injectStylesheet(
+        chrome.runtime.getURL('css/profileindicator_inject.css'));
+  }
+});
diff --git a/src/content_scripts/forum_inject.js b/src/contentScripts/publicForum.js
similarity index 100%
rename from src/content_scripts/forum_inject.js
rename to src/contentScripts/publicForum.js
diff --git a/src/content_scripts/thread_inject.js b/src/contentScripts/publicThread.js
similarity index 100%
rename from src/content_scripts/thread_inject.js
rename to src/contentScripts/publicThread.js
diff --git a/src/content_scripts/console_inject.js b/src/content_scripts/console_inject.js
deleted file mode 100644
index 7503df5..0000000
--- a/src/content_scripts/console_inject.js
+++ /dev/null
@@ -1,1028 +0,0 @@
-var mutationObserver, intersectionObserver, intersectionOptions, options,
-    authuser;
-
-function removeChildNodes(node) {
-  while (node.firstChild) {
-    node.removeChild(node.firstChild);
-  }
-}
-
-function getNParent(node, n) {
-  if (n <= 0) return node;
-  if (!('parentNode' in node)) return null;
-  return getNParent(node.parentNode, n - 1);
-}
-
-function parseUrl(url) {
-  var forum_a = url.match(/forum\/([0-9]+)/i);
-  var thread_a = url.match(/thread\/([0-9]+)/i);
-
-  if (forum_a === null || thread_a === null) {
-    return false;
-  }
-
-  return {
-    'forum': forum_a[1],
-    'thread': thread_a[1],
-  };
-}
-
-function createExtBadge() {
-  var badge = document.createElement('div');
-  badge.classList.add('TWPT-badge');
-  badge.setAttribute(
-      'title', chrome.i18n.getMessage('inject_extension_badge_helper', [
-        chrome.i18n.getMessage('appName')
-      ]));
-
-  var badgeI = document.createElement('i');
-  badgeI.classList.add('material-icon-i', 'material-icons-extended');
-  badgeI.textContent = 'repeat';
-
-  badge.append(badgeI);
-  return badge;
-}
-
-function addProfileHistoryLink(node, type, query) {
-  var urlpart = encodeURIComponent('query=' + query);
-  var authuserpart =
-      (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
-  var container = document.createElement('div');
-  container.style.margin = '3px 0';
-
-  var link = document.createElement('a');
-  link.setAttribute(
-      'href',
-      'https://support.google.com/s/community/search/' + urlpart +
-          authuserpart);
-  link.innerText = chrome.i18n.getMessage('inject_previousposts_' + type);
-
-  container.appendChild(link);
-  node.appendChild(container);
-}
-
-function applyDragAndDropFix(node) {
-  console.debug('Adding link drag&drop fix to ', node);
-  node.addEventListener('drop', e => {
-    if (e.dataTransfer.types.includes('text/uri-list')) {
-      e.stopImmediatePropagation();
-      console.debug('Stopping link drop event propagation.');
-    }
-  }, true);
-}
-
-function nodeIsReadToggleBtn(node) {
-  return ('tagName' in node) && node.tagName == 'MATERIAL-BUTTON' &&
-      node.getAttribute('debugid') !== null &&
-      (node.getAttribute('debugid') == 'mark-read-button' ||
-       node.getAttribute('debugid') == 'mark-unread-button') &&
-      ('parentNode' in node) && node.parentNode !== null &&
-      ('parentNode' in node.parentNode) &&
-      node.parentNode.querySelector('[debugid="batchlock"]') === null &&
-      node.parentNode.parentNode !== null &&
-      ('tagName' in node.parentNode.parentNode) &&
-      node.parentNode.parentNode.tagName == 'EC-BULK-ACTIONS';
-}
-
-function injectDarkModeButton(rightControl) {
-  var darkThemeSwitch = document.createElement('material-button');
-  darkThemeSwitch.classList.add('TWPT-dark-theme', 'TWPT-btn--with-badge');
-  darkThemeSwitch.setAttribute('button', '');
-  darkThemeSwitch.setAttribute(
-      'title', chrome.i18n.getMessage('inject_ccdarktheme_helper'));
-
-  darkThemeSwitch.addEventListener('click', e => {
-    chrome.storage.sync.get(null, currentOptions => {
-      currentOptions.ccdarktheme_switch_status =
-          !options.ccdarktheme_switch_status;
-      chrome.storage.sync.set(currentOptions, _ => {
-        location.reload();
-      });
-    });
-  });
-
-  var switchContent = document.createElement('div');
-  switchContent.classList.add('content');
-
-  var icon = document.createElement('material-icon');
-
-  var i = document.createElement('i');
-  i.classList.add('material-icon-i', 'material-icons-extended');
-  i.textContent = 'brightness_4';
-
-  icon.appendChild(i);
-  switchContent.appendChild(icon);
-  darkThemeSwitch.appendChild(switchContent);
-
-  var badgeContent = createExtBadge();
-
-  darkThemeSwitch.appendChild(badgeContent);
-
-  rightControl.style.width =
-      (parseInt(window.getComputedStyle(rightControl).width) + 58) + 'px';
-  rightControl.insertAdjacentElement('afterbegin', darkThemeSwitch);
-}
-
-function addBatchLockBtn(readToggle) {
-  var clone = readToggle.cloneNode(true);
-  clone.setAttribute('debugid', 'batchlock');
-  clone.classList.add('TWPT-btn--with-badge');
-  clone.setAttribute('title', chrome.i18n.getMessage('inject_lockbtn'));
-  clone.querySelector('material-icon').setAttribute('icon', 'lock');
-  clone.querySelector('i.material-icon-i').textContent = 'lock';
-
-  var badge = createExtBadge();
-  clone.append(badge);
-
-  clone.addEventListener('click', function() {
-    var modal = document.querySelector('.pane[pane-id="default-1"]');
-
-    var dialog = document.createElement('material-dialog');
-    dialog.setAttribute('role', 'dialog');
-    dialog.setAttribute('aria-modal', 'true');
-    dialog.classList.add('TWPT-dialog');
-
-    var header = document.createElement('header');
-    header.setAttribute('role', 'presentation');
-    header.classList.add('TWPT-dialog-header');
-
-    var title = document.createElement('div');
-    title.classList.add('TWPT-dialog-header--title', 'title');
-    title.textContent = chrome.i18n.getMessage('inject_lockbtn');
-
-    header.append(title);
-
-    var main = document.createElement('main');
-    main.setAttribute('role', 'presentation');
-    main.classList.add('TWPT-dialog-main');
-
-    var p = document.createElement('p');
-    p.textContent = chrome.i18n.getMessage('inject_lockdialog_desc');
-
-    main.append(p);
-
-    dialog.append(header, main);
-
-    var footers = [['lock', 'unlock', 'cancel'], ['reload', 'close']];
-
-    for (var i = 0; i < footers.length; ++i) {
-      var footer = document.createElement('footer');
-      footer.setAttribute('role', 'presentation');
-      footer.classList.add('TWPT-dialog-footer');
-      footer.setAttribute('data-footer-id', i);
-
-      if (i > 0) footer.classList.add('is-hidden');
-
-      footers[i].forEach(action => {
-        var btn = document.createElement('material-button');
-        btn.setAttribute('role', 'button');
-        btn.classList.add('TWPT-dialog-footer-btn');
-        if (i == 1) btn.classList.add('is-disabled');
-
-        switch (action) {
-          case 'lock':
-          case 'unlock':
-            btn.addEventListener('click', _ => {
-              if (btn.classList.contains('is-disabled')) return;
-              var message = {
-                action,
-                prefix: 'TWPT-batchlock',
-              };
-              window.postMessage(message, '*');
-            });
-            break;
-
-          case 'cancel':
-          case 'close':
-            btn.addEventListener('click', _ => {
-              if (btn.classList.contains('is-disabled')) return;
-              modal.classList.remove('visible');
-              modal.style.display = 'none';
-              removeChildNodes(modal);
-            });
-            break;
-
-          case 'reload':
-            btn.addEventListener('click', _ => {
-              if (btn.classList.contains('is-disabled')) return;
-              window.location.reload()
-            });
-            break;
-        }
-
-        var content = document.createElement('div');
-        content.classList.add('content', 'TWPT-dialog-footer-btn--content');
-        content.textContent =
-            chrome.i18n.getMessage('inject_lockdialog_btn_' + action);
-
-        btn.append(content);
-        footer.append(btn);
-      });
-
-      var clear = document.createElement('div');
-      clear.style.clear = 'both';
-
-      footer.append(clear);
-      dialog.append(footer);
-    }
-
-    removeChildNodes(modal);
-    modal.append(dialog);
-    modal.classList.add('visible', 'modal');
-    modal.style.display = 'flex';
-  });
-
-  var duplicateBtn =
-      readToggle.parentNode.querySelector('[debugid="mark-duplicate-button"]');
-  if (duplicateBtn)
-    duplicateBtn.parentNode.insertBefore(
-        clone, (duplicateBtn.nextSibling || duplicateBtn));
-  else
-    readToggle.parentNode.insertBefore(
-        clone, (readToggle.nextSibling || readToggle));
-}
-
-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);
-        });
-  },
-};
-
-var autoRefresh = {
-  isLookingForUpdates: false,
-  isUpdatePromptShown: false,
-  lastTimestamp: null,
-  filter: null,
-  path: null,
-  snackbar: null,
-  interval: null,
-  firstCallTimeout: null,
-  intervalMs: 3 * 60 * 1000,   // 3 minutes
-  firstCallDelayMs: 3 * 1000,  // 3 seconds
-  getStartupData() {
-    return JSON.parse(
-        document.querySelector('html').getAttribute('data-startup'));
-  },
-  isOrderedByTimestampDescending() {
-    var startup = this.getStartupData();
-    // Returns orderOptions.by == TIMESTAMP && orderOptions.desc == true
-    return (
-        startup?.[1]?.[1]?.[3]?.[14]?.[1] == 1 &&
-        startup?.[1]?.[1]?.[3]?.[14]?.[2] == true);
-  },
-  getCustomFilter(path) {
-    var searchRegex = /^\/s\/community\/search\/([^\/]*)/;
-    var matches = path.match(searchRegex);
-    if (matches !== null && matches.length > 1) {
-      var search = decodeURIComponent(matches[1]);
-      var params = new URLSearchParams(search);
-      return params.get('query') || '';
-    }
-
-    return '';
-  },
-  filterHasOverride(filter, override) {
-    var escapedOverride = override.replace(/([^\w\d\s])/gi, '\\$1');
-    var regex = new RegExp('[^a-zA-Z0-9]?' + escapedOverride + ':');
-    return regex.test(filter);
-  },
-  getFilter(path) {
-    var query = this.getCustomFilter(path);
-
-    // Note: This logic has been copied and adapted from the
-    // _buildQuery$1$threadId function in the Community Console
-    var conditions = '';
-    var startup = this.getStartupData();
-
-    // TODO(avm99963): if the selected forums are changed without reloading the
-    // page, this will get the old selected forums. Fix this.
-    var forums = startup?.[1]?.[1]?.[3]?.[8] ?? [];
-    if (!this.filterHasOverride(query, 'forum') && forums !== null &&
-        forums.length > 0)
-      conditions += ' forum:(' + forums.join(' | ') + ')';
-
-    var langs = startup?.[1]?.[1]?.[3]?.[5] ?? [];
-    if (!this.filterHasOverride(query, 'lang') && langs !== null &&
-        langs.length > 0)
-      conditions += ' lang:(' + langs.map(l => '"' + l + '"').join(' | ') + ')';
-
-    if (query.length !== 0 && conditions.length !== 0)
-      return '(' + query + ')' + conditions;
-    return query + conditions;
-  },
-  getLastTimestamp() {
-    var APIRequestUrl = 'https://support.google.com/s/community/api/ViewForum' +
-        (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
-
-    return fetch(APIRequestUrl, {
-             'headers': {
-               'content-type': 'text/plain; charset=utf-8',
-             },
-             'body': JSON.stringify({
-               1: '0',  // TODO: Change, when only a forum is selected, it
-                        // should be set here
-               2: {
-                 1: {
-                   2: 2,
-                 },
-                 2: {
-                   1: 1,
-                   2: true,
-                 },
-                 12: this.filter,
-               },
-             }),
-             'method': 'POST',
-             'mode': 'cors',
-             'credentials': 'include',
-           })
-        .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(body => {
-          var timestamp = body?.[1]?.[2]?.[0]?.[2]?.[17];
-          if (timestamp === undefined)
-            throw new Error(
-                'Unexpected body of response (' +
-                (body?.[1]?.[2]?.[0] === undefined ?
-                     'no threads were returned' :
-                     'the timestamp value is not present in the first thread') +
-                ').');
-
-          return timestamp;
-        });
-    // TODO(avm99963): Add retry mechanism (sometimes thread lists are empty,
-    // but when loading the next page the thread appears).
-    //
-    // NOTE(avm99963): It seems like loading the first 2 threads instead of only
-    // the first one fixes this (empty lists are now rarely returned).
-  },
-  unregister() {
-    console.debug('autorefresh_list: unregistering');
-
-    if (!this.isLookingForUpdates) return;
-
-    window.clearTimeout(this.firstCallTimeout);
-    window.clearInterval(this.interval);
-    this.isUpdatePromptShown = false;
-    this.isLookingForUpdates = false;
-  },
-  showUpdatePrompt() {
-    this.snackbar.classList.remove('TWPT-hidden');
-    document.title = '[!!!] ' + document.title.replace('[!!!] ', '');
-    this.isUpdatePromptShown = true;
-  },
-  hideUpdatePrompt() {
-    this.snackbar.classList.add('TWPT-hidden');
-    document.title = document.title.replace('[!!!] ', '');
-    this.isUpdatePromptShown = false;
-  },
-  injectUpdatePrompt() {
-    var pane = document.createElement('div');
-    pane.classList.add('TWPT-pane-for-snackbar');
-
-    var snackbar = document.createElement('material-snackbar-panel');
-    snackbar.classList.add('TWPT-snackbar');
-    snackbar.classList.add('TWPT-hidden');
-
-    var ac = document.createElement('div');
-    ac.classList.add('TWPT-animation-container');
-
-    var nb = document.createElement('div');
-    nb.classList.add('TWPT-notification-bar');
-
-    var ft = document.createElement('focus-trap');
-
-    var content = document.createElement('div');
-    content.classList.add('TWPT-focus-content-wrapper');
-
-    var badge = createExtBadge();
-
-    var message = document.createElement('div');
-    message.classList.add('TWPT-message');
-    message.textContent =
-        chrome.i18n.getMessage('inject_autorefresh_list_snackbar_message');
-
-    var action = document.createElement('div');
-    action.classList.add('TWPT-action');
-    action.textContent =
-        chrome.i18n.getMessage('inject_autorefresh_list_snackbar_action');
-
-    action.addEventListener('click', e => {
-      this.hideUpdatePrompt();
-      document.querySelector('.app-title-button').click();
-    });
-
-    content.append(badge, message, action);
-    ft.append(content);
-    nb.append(ft);
-    ac.append(nb);
-    snackbar.append(ac);
-    pane.append(snackbar);
-    document.getElementById('default-acx-overlay-container').append(pane);
-    this.snackbar = snackbar;
-  },
-  checkUpdate() {
-    if (location.pathname != this.path) {
-      this.unregister();
-      return;
-    }
-
-    if (this.isUpdatePromptShown) return;
-
-    console.debug('Checking for update at: ', new Date());
-
-    this.getLastTimestamp()
-        .then(timestamp => {
-          if (timestamp != this.lastTimestamp) this.showUpdatePrompt();
-        })
-        .catch(
-            err => console.error(
-                'Coudln\'t get last timestamp (while updating): ', err));
-  },
-  firstCall() {
-    console.debug(
-        'autorefresh_list: now performing first call to finish setup (filter: [' +
-        this.filter + '])');
-
-    if (location.pathname != this.path) {
-      this.unregister();
-      return;
-    }
-
-    this.getLastTimestamp()
-        .then(timestamp => {
-          this.lastTimestamp = timestamp;
-          var checkUpdateCallback = this.checkUpdate.bind(this);
-          this.interval =
-              window.setInterval(checkUpdateCallback, this.intervalMs);
-        })
-        .catch(
-            err => console.error(
-                'Couldn\'t get last timestamp (while setting up): ', err));
-  },
-  setUp() {
-    if (!this.isOrderedByTimestampDescending()) return;
-
-    this.unregister();
-
-    console.debug('autorefresh_list: starting set up...');
-
-    if (this.snackbar === null) this.injectUpdatePrompt();
-    this.isLookingForUpdates = true;
-    this.path = location.pathname;
-    this.filter = this.getFilter(this.path);
-
-    var firstCall = this.firstCall.bind(this);
-    this.firstCallTimeout = window.setTimeout(firstCall, this.firstCallDelayMs);
-  },
-};
-
-function isDarkThemeOn() {
-  if (!options.ccdarktheme) return false;
-
-  if (options.ccdarktheme_mode == 'switch')
-    return options.ccdarktheme_switch_status;
-
-  return window.matchMedia &&
-      window.matchMedia('(prefers-color-scheme: dark)').matches;
-}
-
-var unifiedProfilesFix = {
-  checkIframe(iframe) {
-    var srcRegex = /support.*\.google\.com\/profile\//;
-    return srcRegex.test(iframe.src ?? '');
-  },
-  fixIframe(iframe) {
-    console.info('[unifiedProfilesFix] Fixing unified profiles iframe');
-    var url = new URL(iframe.src);
-    url.searchParams.set('dark', 1);
-    iframe.src = url.href;
-  },
-};
-
-function injectPreviousPostsLinks(nameElement) {
-  var mainCardContent = getNParent(nameElement, 3);
-  if (mainCardContent === null) {
-    console.error(
-        '[previousposts] Couldn\'t find |.main-card-content| element.');
-    return;
-  }
-
-  var forumId = location.href.split('/forum/')[1].split('/')[0] || '0';
-
-  var nameTag =
-      (nameElement.tagName == 'EC-DISPLAY-NAME-EDITOR' ?
-           nameElement.querySelector('.top-section > span') ?? nameElement :
-           nameElement);
-  var name = escapeUsername(nameTag.textContent);
-  var query1 = encodeURIComponent(
-      '(creator:"' + name + '" | replier:"' + name + '") forum:' + forumId);
-  var query2 = encodeURIComponent(
-      '(creator:"' + name + '" | replier:"' + name + '") forum:any');
-
-  var container = document.createElement('div');
-  container.classList.add('TWPT-previous-posts');
-
-  var badge = createExtBadge();
-  container.appendChild(badge);
-
-  var linkContainer = document.createElement('div');
-  linkContainer.classList.add('TWPT-previous-posts--links');
-
-  addProfileHistoryLink(linkContainer, 'forum', query1);
-  addProfileHistoryLink(linkContainer, 'all', query2);
-
-  container.appendChild(linkContainer);
-
-  mainCardContent.appendChild(container);
-}
-
-// Send a request to mark the current thread as read
-function markCurrentThreadAsRead() {
-  console.debug(
-      '[forceMarkAsRead] %cTrying to mark a thread as read.',
-      'color: #1a73e8;');
-
-  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];
-
-    console.debug('[forceMarkAsRead] Thread details:', {forumId, threadId});
-
-    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
-                 },
-               },
-               true, authuser)
-        .then(thread => {
-          if (thread?.[1]?.[6] === true) {
-            console.debug(
-                '[forceMarkAsRead] This thread is already marked as read, but marking it as read anyways.');
-          }
-
-          var lastMessageId = thread?.[1]?.[2]?.[10];
-
-          console.debug('[forceMarkAsRead] lastMessageId is:', lastMessageId);
-
-          if (lastMessageId === undefined)
-            throw new Error(
-                'Couldn\'t find lastMessageId in the ViewThread response.');
-
-          return CCApi(
-              'SetUserReadStateBulk', {
-                1: [{
-                  1: forumId,
-                  2: threadId,
-                  3: lastMessageId,
-                }],
-              },
-              true, authuser);
-        })
-        .then(_ => {
-          console.debug(
-              '[forceMarkAsRead] %cSuccessfully set as read!',
-              'color: #1e8e3e;');
-        })
-        .catch(err => {
-          console.error(
-              '[forceMarkAsRead] Error while marking current thread as read: ',
-              err);
-        });
-  } else {
-    console.error(
-        '[forceMarkAsRead] Couldn\'t retrieve forumId and threadId from the current URL.',
-        url);
-  }
-}
-
-const watchedNodesSelectors = [
-  // App container (used to set up the intersection observer and inject the dark
-  // mode button)
-  'ec-app',
-
-  // Load more bar (for the "load more"/"load all" buttons)
-  '.load-more-bar',
-
-  // Username span/editor inside ec-user (user profile view)
-  'ec-user .main-card .header > .name > span',
-  'ec-user .main-card .header > .name > ec-display-name-editor',
-
-  // Rich text editor
-  'ec-movable-dialog',
-  'ec-rich-text-editor',
-
-  // Read/unread bulk action in the list of thread, for the batch lock feature
-  'ec-bulk-actions material-button[debugid="mark-read-button"]',
-  'ec-bulk-actions material-button[debugid="mark-unread-button"]',
-
-  // Thread list items (used to inject the avatars)
-  'li',
-
-  // Thread list (used for the autorefresh feature)
-  'ec-thread-list',
-
-  // Unified profile iframe
-  'iframe',
-
-  // Thread component
-  'ec-thread',
-];
-
-function handleCandidateNode(node) {
-  if (typeof node.classList !== 'undefined') {
-    if (('tagName' in node) && node.tagName == 'EC-APP') {
-      // Set up the intersectionObserver
-      if (typeof intersectionObserver === 'undefined') {
-        var scrollableContent = node.querySelector('.scrollable-content');
-        if (scrollableContent !== null) {
-          intersectionOptions = {
-            root: scrollableContent,
-            rootMargin: '0px',
-            threshold: 1.0,
-          };
-
-          intersectionObserver = new IntersectionObserver(
-              intersectionCallback, intersectionOptions);
-        }
-      }
-
-      // Inject the dark mode button
-      if (options.ccdarktheme && options.ccdarktheme_mode == 'switch') {
-        var rightControl = node.querySelector('header .right-control');
-        if (rightControl !== null) injectDarkModeButton(rightControl);
-      }
-    }
-
-    // Start the intersectionObserver for the "load more"/"load all" buttons
-    // inside a thread
-    if ((options.thread || options.threadall) &&
-        node.classList.contains('load-more-bar')) {
-      if (typeof intersectionObserver !== 'undefined') {
-        if (options.thread)
-          intersectionObserver.observe(node.querySelector('.load-more-button'));
-        if (options.threadall)
-          intersectionObserver.observe(node.querySelector('.load-all-button'));
-      } else {
-        console.warn(
-            '[infinitescroll] ' +
-            'The intersectionObserver is not ready yet.');
-      }
-    }
-
-    // Show the "previous posts" links
-    //   Here we're selecting the 'ec-user > div' element (unique child)
-    if (options.history &&
-        (node.matches('ec-user .main-card .header > .name > span') ||
-         node.matches(
-             'ec-user .main-card .header > .name > ec-display-name-editor'))) {
-      injectPreviousPostsLinks(node);
-    }
-
-    // Fix the drag&drop issue with the rich text editor
-    //
-    //   We target both tags because in different contexts different
-    //   elements containing the text editor get added to the DOM structure.
-    //   Sometimes it's a EC-MOVABLE-DIALOG which already contains the
-    //   EC-RICH-TEXT-EDITOR, and sometimes it's the EC-RICH-TEXT-EDITOR
-    //   directly.
-    if (options.ccdragndropfix && ('tagName' in node) &&
-        (node.tagName == 'EC-MOVABLE-DIALOG' ||
-         node.tagName == 'EC-RICH-TEXT-EDITOR')) {
-      applyDragAndDropFix(node);
-    }
-
-    // Inject the batch lock button in the thread list
-    if (options.batchlock && nodeIsReadToggleBtn(node)) {
-      addBatchLockBtn(node);
-    }
-
-    // Inject avatar links to threads in the thread list
-    if (options.threadlistavatars && ('tagName' in node) &&
-        (node.tagName == 'LI') &&
-        node.querySelector('ec-thread-summary') !== null) {
-      avatars.inject(node);
-    }
-
-    // Set up the autorefresh list feature
-    if (options.autorefreshlist && ('tagName' in node) &&
-        node.tagName == 'EC-THREAD-LIST') {
-      autoRefresh.setUp();
-    }
-
-    // Redirect unified profile iframe to dark version if applicable
-    if (node.tagName == 'IFRAME' && isDarkThemeOn() &&
-        unifiedProfilesFix.checkIframe(node)) {
-      unifiedProfilesFix.fixIframe(node);
-    }
-
-    // Force mark thread as read
-    if (options.forcemarkasread && node.tagName == 'EC-THREAD') {
-      markCurrentThreadAsRead();
-    }
-  }
-}
-
-function handleRemovedNode(node) {
-  // Remove snackbar when exiting thread list view
-  if (options.autorefreshlist && 'tagName' in node &&
-      node.tagName == 'EC-THREAD-LIST') {
-    autoRefresh.hideUpdatePrompt();
-  }
-}
-
-function mutationCallback(mutationList, observer) {
-  mutationList.forEach((mutation) => {
-    if (mutation.type == 'childList') {
-      mutation.addedNodes.forEach(function(node) {
-        handleCandidateNode(node);
-      });
-
-      mutation.removedNodes.forEach(function(node) {
-        handleRemovedNode(node);
-      });
-    }
-  });
-}
-
-function intersectionCallback(entries, observer) {
-  entries.forEach(entry => {
-    if (entry.isIntersecting) {
-      entry.target.click();
-    }
-  });
-};
-
-var observerOptions = {
-  childList: true,
-  subtree: true,
-};
-
-chrome.storage.sync.get(null, function(items) {
-  options = items;
-
-  var startup =
-      JSON.parse(document.querySelector('html').getAttribute('data-startup'));
-  authuser = startup[2][1] || '0';
-
-  // Before starting the mutation Observer, check whether we missed any
-  // mutations by manually checking whether some watched nodes already
-  // exist.
-  var cssSelectors = watchedNodesSelectors.join(',');
-  document.querySelectorAll(cssSelectors)
-      .forEach(node => handleCandidateNode(node));
-
-  mutationObserver = new MutationObserver(mutationCallback);
-  mutationObserver.observe(document.body, observerOptions);
-
-  if (options.fixedtoolbar) {
-    injectStyles(
-        'ec-bulk-actions{position: sticky; top: 0; background: var(--TWPT-primary-background, #fff); z-index: 96;}');
-  }
-
-  if (options.increasecontrast) {
-    injectStyles(
-        '.thread-summary.read:not(.checked){background: var(--TWPT-thread-read-background, #ecedee)!important;}');
-  }
-
-  if (options.stickysidebarheaders) {
-    injectStyles(
-        'material-drawer .main-header{background: var(--TWPT-drawer-background, #fff)!important; position: sticky; top: 0; z-index: 1;}');
-  }
-
-  if (options.enhancedannouncementsdot) {
-    injectStylesheet(
-        chrome.runtime.getURL('injections/enhanced_announcements_dot.css'));
-  }
-
-  if (options.repositionexpandthread) {
-    injectStylesheet(
-        chrome.runtime.getURL('injections/reposition_expand_thread.css'));
-  }
-
-  if (options.ccforcehidedrawer) {
-    var drawer = document.querySelector('material-drawer');
-    if (drawer !== null && drawer.classList.contains('mat-drawer-expanded')) {
-      document.querySelector('.material-drawer-button').click();
-    }
-  }
-
-  if (options.batchlock) {
-    injectScript(chrome.runtime.getURL('injections/batchlock_inject.js'));
-    injectStylesheet(chrome.runtime.getURL('injections/batchlock_inject.css'));
-  }
-
-  if (options.threadlistavatars) {
-    injectStylesheet(
-        chrome.runtime.getURL('injections/thread_list_avatars.css'));
-  }
-
-  if (options.autorefreshlist) {
-    injectStylesheet(chrome.runtime.getURL('injections/autorefresh_list.css'));
-  }
-});
diff --git a/src/content_scripts/profileindicator_inject.js b/src/content_scripts/profileindicator_inject.js
deleted file mode 100644
index 8cd1cfc..0000000
--- a/src/content_scripts/profileindicator_inject.js
+++ /dev/null
@@ -1,8 +0,0 @@
-chrome.storage.sync.get(null, function(options) {
-  if (options.profileindicator || options.profileindicatoralt) {
-    injectScript(
-        chrome.runtime.getURL('injections/profileindicator_inject.js'));
-    injectStylesheet(
-        chrome.runtime.getURL('injections/profileindicator_inject.css'));
-  }
-});
diff --git a/src/injections/batchlock_inject.js b/src/injections/batchLock.js
similarity index 91%
rename from src/injections/batchlock_inject.js
rename to src/injections/batchLock.js
index 3ffd298..9a91203 100644
--- a/src/injections/batchlock_inject.js
+++ b/src/injections/batchLock.js
@@ -1,16 +1,5 @@
-function parseUrl(url) {
-  var forum_a = url.match(/forum\/([0-9]+)/i);
-  var thread_a = url.match(/thread\/([0-9]+)/i);
-
-  if (forum_a === null || thread_a === null) {
-    return false;
-  }
-
-  return {
-    'forum': forum_a[1],
-    'thread': thread_a[1],
-  };
-}
+import {parseUrl} from '../common/commonUtils.js';
+import {getAuthUser} from '../common/communityConsoleUtils.js';
 
 function recursiveParentElement(el, tag) {
   while (el !== document.documentElement) {
@@ -20,12 +9,6 @@
   return undefined;
 }
 
-function returnAuthUser() {
-  var startup =
-      JSON.parse(document.querySelector('html').getAttribute('data-startup'));
-  return startup[2][1] || '0';
-}
-
 // Source:
 // https://stackoverflow.com/questions/33063774/communication-from-an-injected-script-to-the-content-script-with-a-response
 var contentScriptRequest = (function() {
@@ -122,7 +105,7 @@
   modal.querySelector('main').textContent = '';
   modal.querySelector('main').append(p, log);
 
-  var authuser = returnAuthUser();
+  var authuser = getAuthUser();
   var APIRequestUrl =
       'https://support.google.com/s/community/api/SetThreadAttribute' +
       (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
diff --git a/src/injections/profileindicator_inject.js b/src/injections/profileIndicator.js
similarity index 97%
rename from src/injections/profileindicator_inject.js
rename to src/injections/profileIndicator.js
index b3bbdc6..ee897fe 100644
--- a/src/injections/profileindicator_inject.js
+++ b/src/injections/profileIndicator.js
@@ -1,3 +1,5 @@
+import {escapeUsername} from '../common/communityConsoleUtils.js';
+
 var CCProfileRegex =
     /^(?:https:\/\/support\.google\.com)?\/s\/community(?:\/forum\/[0-9]*)?\/user\/(?:[0-9]+)$/;
 var CCRegex = /^https:\/\/support\.google\.com\/s\/community/;
@@ -39,12 +41,6 @@
   return false;
 }
 
-function escapeUsername(username) {
-  var quoteRegex = /"/g;
-  var commentRegex = /<!---->/g;
-  return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
-}
-
 function APIRequest(action, body) {
   var authuserPart =
       (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
@@ -381,7 +377,7 @@
     getOptionsAndHandleIndicators(node, true);
   }
 
-  mutationObserver = new MutationObserver(mutationCallback);
+  var mutationObserver = new MutationObserver(mutationCallback);
   mutationObserver.observe(document.body, observerOptions);
 } else {
   // We are in TW
diff --git a/src/options/options_common.js b/src/optionsCommon.js
similarity index 94%
rename from src/options/options_common.js
rename to src/optionsCommon.js
index 098a632..afb6bde 100644
--- a/src/options/options_common.js
+++ b/src/optionsCommon.js
@@ -1,3 +1,6 @@
+import {cleanUpOptions, optionsPrototype, specialOptions} from './common/optionsUtils.js';
+import {isFirefox, isReleaseVersion} from './common/extUtils.js';
+
 var savedSuccessfullyTimeout = null;
 
 const exclusiveOptions = [['thread', 'threadall']];
@@ -88,7 +91,10 @@
   chrome.storage.sync.get(null, function(items) {
     items = cleanUpOptions(items, false);
 
-    for ([opt, optMeta] of Object.entries(optionsPrototype)) {
+    for (var entry of Object.entries(optionsPrototype)) {
+      var opt = entry[0];
+      var optMeta = entry[1];
+
       if (!isOptionShown(opt)) continue;
 
       if (specialOptions.includes(opt)) {
diff --git a/src/LICENSE b/src/static/LICENSE
similarity index 100%
rename from src/LICENSE
rename to src/static/LICENSE
diff --git a/src/_locales/ca/messages.json b/src/static/_locales/ca/messages.json
similarity index 100%
rename from src/_locales/ca/messages.json
rename to src/static/_locales/ca/messages.json
diff --git a/src/_locales/en/messages.json b/src/static/_locales/en/messages.json
similarity index 100%
rename from src/_locales/en/messages.json
rename to src/static/_locales/en/messages.json
diff --git a/src/_locales/es/messages.json b/src/static/_locales/es/messages.json
similarity index 100%
rename from src/_locales/es/messages.json
rename to src/static/_locales/es/messages.json
diff --git a/src/_locales/ru/OWNERS b/src/static/_locales/ru/OWNERS
similarity index 100%
rename from src/_locales/ru/OWNERS
rename to src/static/_locales/ru/OWNERS
diff --git a/src/_locales/ru/messages.json b/src/static/_locales/ru/messages.json
similarity index 100%
rename from src/_locales/ru/messages.json
rename to src/static/_locales/ru/messages.json
diff --git a/src/injections/autorefresh_list.css b/src/static/css/autorefresh_list.css
similarity index 100%
rename from src/injections/autorefresh_list.css
rename to src/static/css/autorefresh_list.css
diff --git a/src/injections/batchlock_inject.css b/src/static/css/batchlock_inject.css
similarity index 100%
rename from src/injections/batchlock_inject.css
rename to src/static/css/batchlock_inject.css
diff --git a/src/injections/ccdarktheme.css b/src/static/css/ccdarktheme.css
similarity index 100%
rename from src/injections/ccdarktheme.css
rename to src/static/css/ccdarktheme.css
diff --git a/src/common/console.css b/src/static/css/common/console.css
similarity index 100%
rename from src/common/console.css
rename to src/static/css/common/console.css
diff --git a/src/common/forum.css b/src/static/css/common/forum.css
similarity index 100%
rename from src/common/forum.css
rename to src/static/css/common/forum.css
diff --git a/src/injections/enhanced_announcements_dot.css b/src/static/css/enhanced_announcements_dot.css
similarity index 100%
rename from src/injections/enhanced_announcements_dot.css
rename to src/static/css/enhanced_announcements_dot.css
diff --git a/src/injections/profileindicator_inject.css b/src/static/css/profileindicator_inject.css
similarity index 100%
rename from src/injections/profileindicator_inject.css
rename to src/static/css/profileindicator_inject.css
diff --git a/src/injections/reposition_expand_thread.css b/src/static/css/reposition_expand_thread.css
similarity index 100%
rename from src/injections/reposition_expand_thread.css
rename to src/static/css/reposition_expand_thread.css
diff --git a/src/injections/thread_list_avatars.css b/src/static/css/thread_list_avatars.css
similarity index 100%
rename from src/injections/thread_list_avatars.css
rename to src/static/css/thread_list_avatars.css
diff --git a/src/icons/128.png b/src/static/icons/128.png
similarity index 100%
rename from src/icons/128.png
rename to src/static/icons/128.png
Binary files differ
diff --git a/src/icons/512.png b/src/static/icons/512.png
similarity index 100%
rename from src/icons/512.png
rename to src/static/icons/512.png
Binary files differ
diff --git a/src/options/chrome_style/chrome_style.css b/src/static/options/chrome_style/chrome_style.css
similarity index 100%
rename from src/options/chrome_style/chrome_style.css
rename to src/static/options/chrome_style/chrome_style.css
diff --git a/src/options/experiments.html b/src/static/options/experiments.html
similarity index 90%
rename from src/options/experiments.html
rename to src/static/options/experiments.html
index d132305..81c8a10 100644
--- a/src/options/experiments.html
+++ b/src/static/options/experiments.html
@@ -18,8 +18,7 @@
       </form>
       <div id="save-indicator"></div>
     </main>
-    <script src="../common/common.js"></script>
     <script src="experiments_bit.js"></script>
-    <script src="options_common.js"></script>
+    <script src="../../optionsCommon.bundle.js"></script>
   </body>
 </html>
diff --git a/src/options/experiments_bit.js b/src/static/options/experiments_bit.js
similarity index 100%
rename from src/options/experiments_bit.js
rename to src/static/options/experiments_bit.js
diff --git a/src/options/options.css b/src/static/options/options.css
similarity index 100%
rename from src/options/options.css
rename to src/static/options/options.css
diff --git a/src/options/options.html b/src/static/options/options.html
similarity index 98%
rename from src/options/options.html
rename to src/static/options/options.html
index 8a09d04..09ce1bb 100644
--- a/src/options/options.html
+++ b/src/static/options/options.html
@@ -54,8 +54,7 @@
       </form>
       <div id="save-indicator"></div>
     </main>
-    <script src="../common/common.js"></script>
     <script src="options_bit.js"></script>
-    <script src="options_common.js"></script>
+    <script src="../../optionsCommon.bundle.js"></script>
   </body>
 </html>
diff --git a/src/options/options_bit.js b/src/static/options/options_bit.js
similarity index 100%
rename from src/options/options_bit.js
rename to src/static/options/options_bit.js
diff --git a/src/sw.js b/src/sw.js
index 2f181cb..b8dccf2 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,6 +1,5 @@
 // IMPORTANT: keep this file in sync with background.js
-
-importScripts('common/common.js')
+import {cleanUpOptions} from './common/optionsUtils.js'
 
 // When the extension gets updated, set new options to their default value.
 chrome.runtime.onInstalled.addListener(details => {