Add 'batch lock' feature

This change adds a 'batch lock' option which, when enabled, makes the
extension display a lock button in the thread list toolbar in the
Community Console.

When this button is clicked, the user is prompted whether they want to
lock or unlock the selected messages.

After the user makes their choice, the action is performed in all the
selected threads and any error while performing it is shown to the user.

Fixes: #24

Change-Id: I70bdc698a8d4694b2f11561fdb0a0d5c17f4d3b5
diff --git a/src/_locales/ca/messages.json b/src/_locales/ca/messages.json
index 65a5cdd..482d022 100644
--- a/src/_locales/ca/messages.json
+++ b/src/_locales/ca/messages.json
@@ -83,6 +83,10 @@
     "message": "Permet arrossegar adreces d'interès a l'editor de text de la Consola de la Comunitat.",
     "description": "Feature checkbox in the options page"
   },
+  "options_batchlock": {
+    "message": "Afegeix l'opció per bloquejar diversos fils a la llista de fils de la Consola de la Comunitat.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Punt indicador",
     "description": "Heading for the profile indicator feature options"
@@ -154,5 +158,53 @@
   "inject_extension_badge_helper": {
     "message": "Afegit per $1",
     "description": "Tooltip for the extension badge, which appears next to components injected by the extension."
+  },
+  "inject_lockbtn": {
+    "message": "Bloquejar/desbloquejar fils",
+    "description": "Tooltip of the 'lock/unlock threads' icon shown when selecting multiple threads in the Community Console. Also the title for the dialog shown after clicking this icon."
+  },
+  "inject_lockdialog_desc": {
+    "message": "Sisplau, confirma l'acció que volies prendre fent clic al botó corresponent d'aquí avall. Tingues en compte que aquesta acció es prendrà sobre tots els fils que hagis seleccionat.",
+    "description": "Text in the 'lock/unlock threads' dialog, which asks the user for confirmation."
+  },
+  "inject_lockdialog_btn_lock": {
+    "message": "Bloqueja",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_unlock": {
+    "message": "Desbloqueja",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_cancel": {
+    "message": "Cancel·la",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_reload": {
+    "message": "Torna a carregar",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_close": {
+    "message": "Tanca",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_log_entry_beginning": {
+    "message": "Fil $1",
+    "description": "First part of the entry in the log of the 'lock/unlock threads' dialog. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_success_lock": {
+    "message": "Bloquejat correctament.",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_error_lock": {
+    "message": "Hi ha hagut un error bloquejant-lo ($1).",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *not* locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_success_unlock": {
+    "message": "Desbloquejat correctament.",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *un*locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_error_unlock": {
+    "message": "Hi ha hagut un error desbloquejant-lo ($1).",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *not* *un*locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
   }
 }
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 5800cf9..ab740c1 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -87,6 +87,10 @@
     "message": "Allow to drag and drop bookmarks to the Community Console text editor.",
     "description": "Feature checkbox in the options page"
   },
+  "options_batchlock": {
+    "message": "Add the option to lock multiple threads from the Community Console thread list.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Indicator dot",
     "description": "Heading for the profile indicator feature options"
@@ -154,5 +158,53 @@
   "inject_extension_badge_helper": {
     "message": "Added by $1",
     "description": "Tooltip for the extension badge, which appears next to components injected by the extension."
+  },
+  "inject_lockbtn": {
+    "message": "Lock/unlock threads",
+    "description": "Tooltip of the 'lock/unlock threads' icon shown when selecting multiple threads in the Community Console. Also the title for the dialog shown after clicking this icon."
+  },
+  "inject_lockdialog_desc": {
+    "message": "Please, confirm the action you want to take by clicking the appropriate button below. Keep in mind this action will be performed in all the threads you have selected.",
+    "description": "Text in the 'lock/unlock threads' dialog, which asks the user for confirmation."
+  },
+  "inject_lockdialog_btn_lock": {
+    "message": "Lock",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_unlock": {
+    "message": "Unlock",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_cancel": {
+    "message": "Cancel",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_reload": {
+    "message": "Reload",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_close": {
+    "message": "Close",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_log_entry_beginning": {
+    "message": "Thread $1",
+    "description": "First part of the entry in the log of the 'lock/unlock threads' dialog. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_success_lock": {
+    "message": "Locked successfully.",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_error_lock": {
+    "message": "An error occurred while locking ($1).",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *not* locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_success_unlock": {
+    "message": "Unlocked successfully.",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *un*locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_error_unlock": {
+    "message": "An error occurred while unlocking ($1).",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *not* *un*locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
   }
 }
diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json
index cce5f74..a0e97bf 100644
--- a/src/_locales/es/messages.json
+++ b/src/_locales/es/messages.json
@@ -83,6 +83,10 @@
     "message": "Permite arrastrar marcadores al editor de texto de la Consola de la Comunidad.",
     "description": "Feature checkbox in the options page"
   },
+  "options_batchlock": {
+    "message": "Añade la opción para bloquear varios hilos en la lista de hilos de la Consola de la Comunidad.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Punto indicador",
     "description": "Heading for the profile indicator feature options"
@@ -154,5 +158,53 @@
   "inject_extension_badge_helper": {
     "message": "Añadido por $1",
     "description": "Tooltip for the extension badge, which appears next to components injected by the extension."
+  },
+  "inject_lockbtn": {
+    "message": "Bloquear/desbloquear hilos",
+    "description": "Tooltip of the 'lock/unlock threads' icon shown when selecting multiple threads in the Community Console. Also the title for the dialog shown after clicking this icon."
+  },
+  "inject_lockdialog_desc": {
+    "message": "Por favor, confirma la acción que quieres tomar haciendo clic en el botón correspondiente aquí debajo. Ten en cuenta que esta acción se tomará sobre todos los hilos que hayas seleccionado.",
+    "description": "Text in the 'lock/unlock threads' dialog, which asks the user for confirmation."
+  },
+  "inject_lockdialog_btn_lock": {
+    "message": "Bloquear",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_unlock": {
+    "message": "Desbloquear",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_cancel": {
+    "message": "Cancelar",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_reload": {
+    "message": "Recargar",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_btn_close": {
+    "message": "Cerrar",
+    "description": "Button in the 'lock/unlock threads' dialog."
+  },
+  "inject_lockdialog_log_entry_beginning": {
+    "message": "Hilo $1",
+    "description": "First part of the entry in the log of the 'lock/unlock threads' dialog. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_success_lock": {
+    "message": "Bloqueado correctamente.",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_error_lock": {
+    "message": "Ha ocurrido un error bloqueándolo ($1).",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *not* locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_success_unlock": {
+    "message": "Desbloqueado correctamente.",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *un*locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
+  },
+  "inject_lockdialog_log_entry_error_unlock": {
+    "message": "Ha ocurrido un error desbloqueándolo ($1).",
+    "description": "Second part of the entry in the log of the 'lock/unlock threads' dialog, when the log entry states that the thread was *not* *un*locked successfully. Log entries are of the form '{first_part}: {second_part}'. For example: 'Thread 164: Locked successfully'."
   }
 }
diff --git a/src/common/common.js b/src/common/common.js
index 6bfdf02..3426fb0 100644
--- a/src/common/common.js
+++ b/src/common/common.js
@@ -19,6 +19,7 @@
   'ccdarktheme_switch_enabled': true,
   'ccforcehidedrawer': false,
   'ccdragndropfix': false,
+  'batchlock': false,
 };
 
 const specialOptions = [
diff --git a/src/common/console.css b/src/common/console.css
index 7e11247..72732f3 100644
--- a/src/common/console.css
+++ b/src/common/console.css
@@ -21,6 +21,27 @@
   font-size: var(--icon-size, 16px);
 }
 
+.TWPT-btn--with-badge {
+  position: relative;
+  padding: 4px;
+  cursor: pointer;
+}
+
+.TWPT-btn--with-badge .content {
+  padding: 8px;
+}
+
+.TWPT-btn--with-badge .TWPT-badge {
+  --icon-size: 13px;
+  position: absolute;
+  bottom: 6px;
+  right: 5px;
+}
+
+.TWPT-dark-theme {
+  padding: 4px 8px!important;
+}
+
 .TWPT-previous-posts {
   display: flex;
   flex-direction: row;
@@ -31,3 +52,99 @@
   --icon-size: 18px;
   margin-right: 8px;
 }
+
+.TWPT-dialog {
+  display: block!important;
+  width: 600px;
+  max-width: 100%;
+  padding: 16px 0;
+  background: white;
+  box-shadow: 0 24px 38px 3px rgba(0,0,0,.14), 0 9px 46px 8px rgba(0,0,0,.12), 0 11px 15px -7px rgba(0,0,0,.2);
+}
+
+.TWPT-dialog-header {
+  padding: 24px 24px 0;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.TWPT-dialog-header--title {
+  color: #202124;
+  font-family: 'Google Sans', sans-serif;
+  font-size: 22px;
+  font-weight: 400;
+  line-height: 24px;
+  margin-bottom: 4px;
+  text-align: center;
+}
+
+.TWPT-dialog-main {
+  font-size: 13px;
+  font-weight: 400;
+  color: rgba(0, 0, 0, .87);
+  overflow: auto;
+  padding: 0 24px;
+}
+
+.TWPT-dialog-footer {
+  padding: 0 24px;
+}
+
+.TWPT-dialog-footer.is-hidden {
+  display: none;
+}
+
+.TWPT-dialog-footer-btn {
+  display: inline-block;
+  float: right;
+  position: relative;
+  height: 36px;
+  min-width: 64px;
+  margin: 0 4px;
+  cursor: pointer;
+}
+
+.TWPT-dialog-footer-btn:hover::after {
+  content: "";
+  display: block;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  background-color: currentColor;
+  outline: 2px solid transparent;
+  opacity: .12;
+  border-radius: inherit;
+  pointer-events: none;
+}
+
+.TWPT-dialog-footer-btn:not(.is-disabled) {
+  color: #1a73e8!important;
+}
+
+.TWPT-dialog-footer-btn.is-disabled {
+  color: #5f6368!important;
+  cursor: not-allowed;
+}
+
+.TWPT-dialog-footer-btn--content {
+  line-height: 36px;
+  text-align: center;
+}
+
+.TWPT-log {
+  max-height: 300px;
+  padding: 0 8px;
+  margin-bottom: 8px;
+  overflow-y: auto;
+  background-color: #e0e0e0;
+}
+
+.TWPT-log-entry {
+  font-family: 'Roboto Mono', 'Courier New', monospace;
+}
+
+.TWPT-log-entry.TWPT-log-entry--error {
+  color: #ff1744;
+}
diff --git a/src/content_scripts/console_inject.js b/src/content_scripts/console_inject.js
index 4dba614..d39f814 100644
--- a/src/content_scripts/console_inject.js
+++ b/src/content_scripts/console_inject.js
@@ -1,17 +1,25 @@
 var mutationObserver, intersectionObserver, options, authuser;
 
-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;
+function removeChildNodes(node) {
+  while (node.firstChild) {
+    node.removeChild(node.firstChild);
   }
+}
 
-  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) {
@@ -42,6 +50,131 @@
   }, 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 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';
+  });
+  readToggle.parentNode.insertBefore(
+      clone, (readToggle.nextSibling || readToggle));
+}
+
 function mutationCallback(mutationList, observer) {
   mutationList.forEach((mutation) => {
     if (mutation.type == 'childList') {
@@ -76,20 +209,7 @@
               var container = document.createElement('div');
               container.classList.add('TWPT-previous-posts');
 
-              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.appendChild(badgeI);
+              var badge = createExtBadge();
               container.appendChild(badge);
 
               var linkContainer = document.createElement('div');
@@ -114,6 +234,10 @@
                node.tagName == 'EC-RICH-TEXT-EDITOR')) {
             applyDragAndDropFix(node);
           }
+
+          if (options.batchlock && nodeIsReadToggleBtn(node)) {
+            addBatchLockBtn(node);
+          }
         }
       });
     }
@@ -168,11 +292,8 @@
   }
 
   if (options.ccdarktheme && options.ccdarktheme_mode == 'switch') {
-    injectStylesheet(
-        chrome.runtime.getURL('injections/ccdarktheme_switch.css'));
-
     var darkThemeSwitch = document.createElement('material-button');
-    darkThemeSwitch.classList.add('TWPT-dark-theme');
+    darkThemeSwitch.classList.add('TWPT-dark-theme', 'TWPT-btn--with-badge');
     darkThemeSwitch.setAttribute('button', '');
     darkThemeSwitch.setAttribute(
         'title', chrome.i18n.getMessage('inject_ccdarktheme_helper'));
@@ -200,18 +321,8 @@
     switchContent.appendChild(icon);
     darkThemeSwitch.appendChild(switchContent);
 
-    var badgeContent = document.createElement('div');
-    badgeContent.classList.add('TWPT-badge');
-    badgeContent.setAttribute(
-        'title', chrome.i18n.getMessage('inject_extension_badge_helper', [
-          chrome.i18n.getMessage('appName')
-        ]));
+    var badgeContent = createExtBadge();
 
-    var badgeI = document.createElement('i');
-    badgeI.classList.add('material-icon-i', 'material-icons-extended');
-    badgeI.textContent = 'repeat';
-
-    badgeContent.appendChild(badgeI);
     darkThemeSwitch.appendChild(badgeContent);
 
     var rightControl = document.querySelector('header .right-control');
@@ -226,4 +337,8 @@
       document.querySelector('.material-drawer-button').click();
     }
   }
+
+  if (options.batchlock) {
+    injectScript(chrome.runtime.getURL('injections/batchlock_inject.js'));
+  }
 });
diff --git a/src/injections/batchlock_inject.js b/src/injections/batchlock_inject.js
new file mode 100644
index 0000000..1b5bb48
--- /dev/null
+++ b/src/injections/batchlock_inject.js
@@ -0,0 +1,188 @@
+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 recursiveParentElement(el, tag) {
+  while (el !== document.documentElement) {
+    el = el.parentNode;
+    if (el.tagName == tag) return el;
+  }
+  return undefined;
+}
+
+// Source:
+// https://stackoverflow.com/questions/33063774/communication-from-an-injected-script-to-the-content-script-with-a-response
+var contentScriptRequest = (function() {
+  var requestId = 0;
+  var prefix = 'TWPT-batchlock-generic';
+
+  function sendRequest(data) {
+    var id = requestId++;
+
+    return new Promise(function(resolve, reject) {
+      var listener = function(evt) {
+        if (evt.source === window && evt.data && evt.data.prefix === prefix &&
+            evt.data.requestId == id) {
+          // Deregister self
+          window.removeEventListener('message', listener);
+          resolve(evt.data.data);
+        }
+      };
+
+      window.addEventListener('message', listener);
+
+      var payload = {data, id, prefix};
+
+      window.dispatchEvent(
+          new CustomEvent('TWPT_sendRequest', {detail: payload}));
+    });
+  }
+
+  return {sendRequest: sendRequest};
+})();
+
+function enableEndButtons() {
+  var buttons = document.querySelectorAll(
+      '.pane[pane-id="default-1"] footer[data-footer-id="1"] material-button');
+  buttons.forEach(btn => {
+    btn.classList.remove('is-disabled');
+  });
+}
+
+function addLogEntry(success, action, url, threadId, errDetails = null) {
+  var p1 = contentScriptRequest.sendRequest({
+    action: 'geti18nMessage',
+    msg: 'inject_lockdialog_log_entry_beginning',
+    placeholders: [threadId],
+  });
+
+  var p2 = contentScriptRequest.sendRequest({
+    action: 'geti18nMessage',
+    msg: 'inject_lockdialog_log_entry_' + (success ? 'success' : 'error') +
+        '_' + action,
+    placeholders: [errDetails],
+  });
+
+  Promise.all([p1, p2]).then(strings => {
+    var log = document.getElementById('TWPT-lock-log');
+    var logEntry = document.createElement('p');
+    logEntry.classList.add(
+        'TWPT-log-entry', 'TWPT-log-entry--' + (success ? 'success' : 'error'));
+
+    var a = document.createElement('a');
+    a.href = url;
+    a.target = '_blank';
+    a.textContent = strings[0];
+
+    var end = document.createTextNode(': ' + strings[1]);
+
+    logEntry.append(a, end);
+    log.append(logEntry);
+  });
+}
+
+function lockThreads(action) {
+  var modal = document.querySelector('.pane[pane-id="default-1"]');
+  modal.querySelector('footer[data-footer-id="0"]').classList.add('is-hidden');
+  modal.querySelector('footer[data-footer-id="1"]')
+      .classList.remove('is-hidden');
+
+  var checkboxes = document.querySelectorAll(
+      '.thread-group material-checkbox[aria-checked="true"]');
+
+  var p = document.createElement('p');
+  p.style.textAlign = 'center';
+
+  var progress = document.createElement('progress');
+  progress.max = checkboxes.length;
+  progress.value = 0;
+
+  p.append(progress);
+
+  var log = document.createElement('div');
+  log.id = 'TWPT-lock-log';
+  log.classList.add('TWPT-log');
+
+  modal.querySelector('main').textContent = '';
+  modal.querySelector('main').append(p, log);
+
+  checkboxes.forEach(checkbox => {
+    var url = recursiveParentElement(checkbox, 'A').href;
+    var thread = parseUrl(url);
+    if (thread === false) {
+      console.error('Fatal error: thread URL ' + url + ' could not be parsed.');
+      return;
+    }
+    fetch('https://support.google.com/s/community/api/SetThreadAttribute', {
+      'headers': {
+        'content-type': 'text/plain; charset=utf-8',
+      },
+      'body': JSON.stringify({
+        1: thread.forum,
+        2: thread.thread,
+        3: (action == 'lock' ? 1 : 2),
+      }),
+      '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]));
+          }
+        })
+        .then(_ => {
+          addLogEntry(true, action, url, thread.thread);
+        })
+        .catch(err => {
+          console.error(
+              'An error occurred while locking thread ' + url + ': ' + err);
+          addLogEntry(false, action, url, thread.thread, err);
+        })
+        .then(_ => {
+          progress.value = parseInt(progress.value) + 1;
+          if (progress.value == progress.getAttribute('max'))
+            enableEndButtons();
+        });
+  });
+}
+
+window.addEventListener('message', e => {
+  if (e.source === window && e.data && e.data.prefix === 'TWPT-batchlock' &&
+      e.data.action) {
+    switch (e.data.action) {
+      case 'lock':
+      case 'unlock':
+        console.info('Performing action ' + e.data.action);
+        lockThreads(e.data.action);
+        break;
+
+      default:
+        console.error(
+            'Action \'' + e.data.action +
+            '\' unknown to TWPT-batchlock receiver.');
+    }
+  }
+});
diff --git a/src/injections/ccdarktheme.css b/src/injections/ccdarktheme.css
index 4760876..a903fbb 100644
--- a/src/injections/ccdarktheme.css
+++ b/src/injections/ccdarktheme.css
@@ -607,3 +607,11 @@
   color: var(--TWPT-primary-background)!important;
 }
 
+/* Custom injected components */
+.TWPT-log {
+  background-color: #424242!important;
+}
+
+.TWPT-log-entry.TWPT-log-entry--error {
+  color: #ff8A80!important;
+}
diff --git a/src/injections/ccdarktheme_switch.css b/src/injections/ccdarktheme_switch.css
deleted file mode 100644
index e42342d..0000000
--- a/src/injections/ccdarktheme_switch.css
+++ /dev/null
@@ -1,16 +0,0 @@
-.TWPT-dark-theme {
-  position: relative;
-  padding: 4px 8px;
-  cursor: pointer;
-}
-
-.TWPT-dark-theme .content {
-  padding: 8px;
-}
-
-.TWPT-dark-theme .TWPT-badge {
-  --icon-size: 13px;
-  position: absolute;
-  bottom: 6px;
-  right: 5px;
-}
diff --git a/src/options.html b/src/options.html
index 29618e5..cac1de5 100644
--- a/src/options.html
+++ b/src/options.html
@@ -21,6 +21,7 @@
       <div class="option"><input type="checkbox" id="ccdarktheme"> <label for="ccdarktheme" data-i18n="ccdarktheme"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
       <div class="option"><input type="checkbox" id="ccforcehidedrawer"> <label for="ccforcehidedrawer" data-i18n="ccforcehidedrawer"></label></div>
       <div id="dragndrop-wrapper" class="option" hidden><input type="checkbox" id="ccdragndropfix"> <label for="ccdragndropfix" data-i18n="ccdragndropfix"></label></div>
+      <div class="option"><input type="checkbox" id="batchlock"> <label for="batchlock" data-i18n="batchlock"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
       <h4 data-i18n="profileindicator_header"></h4>
       <div class="option"><input type="checkbox" id="profileindicator"> <label for="profileindicator" data-i18n="profileindicator"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>
       <div class="option"><input type="checkbox" id="profileindicatoralt"> <label for="profileindicatoralt" data-i18n="profileindicatoralt"></label> <span class="experimental-label" data-i18n="experimental_label"></span></div>