Add autorefresh_list experiment

This experiment/feature checks at regular intervals whether there are
updates to thread lists, and notifies the user via a snackbar when
an update exists.

Bug: #42
Change-Id: I98e4aa03a7080c6bff781ce7c850477433090957
diff --git a/src/_locales/ca/messages.json b/src/_locales/ca/messages.json
index f059aa8..5b01409 100644
--- a/src/_locales/ca/messages.json
+++ b/src/_locales/ca/messages.json
@@ -99,6 +99,10 @@
     "message": "Mostra fotos de perfil a les llistes de fils de la Consola de la Comunitat.",
     "description": "Feature checkbox in the options page"
   },
+  "options_autorefreshlist": {
+    "message": "Actualitza les llistes de fils de la Consola de la Comunitat automàticament.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Punt indicador",
     "description": "Heading for the profile indicator feature options"
@@ -226,5 +230,13 @@
   "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'."
+  },
+  "inject_autorefresh_list_snackbar_message": {
+    "message": "Hi ha nous fils.",
+    "description": "Message shown in a snackbar when new threads are found in a thread list. Another button asks the user to refresh the list."
+  },
+  "inject_autorefresh_list_snackbar_action": {
+    "message": "Actualitza",
+    "description": "Button shown in a snackbar asking users to refresh/reload the list to show the new threads."
   }
 }
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index d969b51..1e05f3c 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -103,6 +103,10 @@
     "message": "Show avatars in thread lists in the Community Console.",
     "description": "Feature checkbox in the options page"
   },
+  "options_autorefreshlist": {
+    "message": "Autorefresh thread lists in the Community Console.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_header": {
     "message": "Indicator dot",
     "description": "Heading for the profile indicator feature options"
@@ -226,5 +230,13 @@
   "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'."
+  },
+  "inject_autorefresh_list_snackbar_message": {
+    "message": "There are new threads.",
+    "description": "Message shown in a snackbar when new threads are found in a thread list. Another button asks the user to refresh the list."
+  },
+  "inject_autorefresh_list_snackbar_action": {
+    "message": "Refresh",
+    "description": "Button shown in a snackbar asking users to refresh/reload the list to show the new threads."
   }
 }
diff --git a/src/_locales/es/messages.json b/src/_locales/es/messages.json
index 596b5ab..a591db4 100644
--- a/src/_locales/es/messages.json
+++ b/src/_locales/es/messages.json
@@ -103,6 +103,10 @@
     "message": "Muestra fotos de perfil en las listas de hilos de la Consola de la Comunidad.",
     "description": "Feature checkbox in the options page"
   },
+  "options_autorefreshlist": {
+    "message": "Actualiza las listas de hilos de la Consola de la Comunidad automáticamente.",
+    "description": "Feature checkbox in the options page"
+  },
   "options_profileindicator_moreinfo": {
     "message": "+info sobre las 2 opciones anteriores",
     "description": "Link to learn more about the profile indicator feature"
@@ -226,5 +230,13 @@
   "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'."
+  },
+  "inject_autorefresh_list_snackbar_message": {
+    "message": "Hay nuevos hilos.",
+    "description": "Message shown in a snackbar when new threads are found in a thread list. Another button asks the user to refresh the list."
+  },
+  "inject_autorefresh_list_snackbar_action": {
+    "message": "Actualizar",
+    "description": "Button shown in a snackbar asking users to refresh/reload the list to show the new threads."
   }
 }
diff --git a/src/common/common.js b/src/common/common.js
index 619d6d8..f897837 100644
--- a/src/common/common.js
+++ b/src/common/common.js
@@ -82,6 +82,10 @@
     defaultValue: false,
     context: 'experiments',
   },
+  'autorefreshlist': {
+    defaultValue: false,
+    context: 'experiments',
+  },
 
   // Internal options:
   'ccdarktheme_switch_enabled': {
diff --git a/src/content_scripts/console_inject.js b/src/content_scripts/console_inject.js
index ae10881..f9565d4 100644
--- a/src/content_scripts/console_inject.js
+++ b/src/content_scripts/console_inject.js
@@ -314,7 +314,7 @@
         for (var i = 0; i < avatarUrls.length; ++i) {
           var avatar = document.createElement('div');
           avatar.classList.add('TWPT-avatar');
-          avatar.style.backgroundImage = 'url(\''+avatarUrls[i]+'\')';
+          avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
           avatarsContainer.appendChild(avatar);
         }
 
@@ -322,6 +322,252 @@
       });
 }
 
+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 injectPreviousPostsLinks(nameElement) {
   var mainCardContent = getNParent(nameElement, 3);
   if (mainCardContent === null) {
@@ -376,6 +622,9 @@
 
   // Thread list items (used to inject the avatars)
   'li',
+
+  // Thread list (used for the autorefresh feature)
+  'ec-thread-list',
 ];
 
 function handleCandidateNode(node) {
@@ -450,6 +699,19 @@
         node.querySelector('ec-thread-summary') !== null) {
       injectAvatars(node);
     }
+
+    // Set up the autorefresh list feature
+    if (options.autorefreshlist && ('tagName' in node) &&
+        node.tagName == 'EC-THREAD-LIST') {
+      autoRefresh.setUp();
+    }
+  }
+}
+
+function handleRemovedNode(node) {
+  // Remove snackbar when exiting thread list view
+  if ('tagName' in node && node.tagName == 'EC-THREAD-LIST') {
+    autoRefresh.hideUpdatePrompt();
   }
 }
 
@@ -459,6 +721,10 @@
       mutation.addedNodes.forEach(function(node) {
         handleCandidateNode(node);
       });
+
+      mutation.removedNodes.forEach(function(node) {
+        handleRemovedNode(node);
+      });
     }
   });
 }
@@ -533,4 +799,8 @@
     injectStylesheet(
         chrome.runtime.getURL('injections/thread_list_avatars.css'));
   }
+
+  if (options.autorefreshlist) {
+    injectStylesheet(chrome.runtime.getURL('injections/autorefresh_list.css'));
+  }
 });
diff --git a/src/injections/autorefresh_list.css b/src/injections/autorefresh_list.css
new file mode 100644
index 0000000..56d3fda
--- /dev/null
+++ b/src/injections/autorefresh_list.css
@@ -0,0 +1,79 @@
+.TWPT-pane-for-snackbar {
+  height: 48px;
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 9999;
+  display: flex;
+  pointer-events: none;
+}
+
+.TWPT-snackbar {
+  display: flex;
+  align-self: flex-end;
+  justify-content: center;
+  flex: 1;
+  font-size: 15px;
+  font-weight: 400;
+  overflow: hidden;
+  pointer-events: none!important;
+}
+
+.TWPT-snackbar .TWPT-animation-container {
+  max-width: 616px;
+  min-width: 320px;
+  overflow: hidden;
+  pointer-events: auto;
+  transition: all 218ms cubic-bezier(.4, 0, 1, 1);
+}
+
+.TWPT-notification-bar {
+  background: #323232;
+  border-radius: 2px;
+  box-sizing: border-box;
+  color: #fff;
+  display: flex;
+  height: 48px;
+  padding: 6px 24px;
+}
+
+.TWPT-notification-bar focus-trap {
+  display: flex;
+  width: 100%;
+}
+
+.TWPT-notification-bar .TWPT-focus-content-wrapper {
+  display: flex;
+  height: 100%;
+  width: 100%;
+  max-height: inherit;
+  min-height: inherit;
+}
+
+.TWPT-notification-bar .TWPT-badge {
+  margin: auto 10px auto 0;
+}
+
+.TWPT-notification-bar .TWPT-message {
+  display: flex;
+  margin: auto;
+  flex: 1;
+  white-space: pre-line;
+}
+
+.TWPT-notification-bar .TWPT-action {
+  display: flex;
+  align-items: center;
+  color: #c6dafc;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 700;
+  margin-left: 24px;
+  padding: 0;
+  text-transform: uppercase;
+}
+
+.TWPT-snackbar.TWPT-hidden {
+  display: none;
+}
diff --git a/src/options/experiments.html b/src/options/experiments.html
index e25254b..d132305 100644
--- a/src/options/experiments.html
+++ b/src/options/experiments.html
@@ -13,6 +13,7 @@
       <p data-i18n="experiments_description"></p>
       <form>
         <div class="option"><input type="checkbox" id="threadlistavatars"> <label for="threadlistavatars" data-i18n="threadlistavatars"></label></div>
+        <div class="option"><input type="checkbox" id="autorefreshlist"> <label for="autorefreshlist" data-i18n="autorefreshlist"></label></div>
         <div class="actions"><button id="save" data-i18n="save"></button></div>
       </form>
       <div id="save-indicator"></div>