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/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.');
+    }
+  }
+});