Add OptionsWatcher class to cache options

This class is a generalization of the code added to the avatars feature
so other features can benefit from it. It has been changed so it can
watch several options at once, and not only true/false options.

Bug: twpowertools:88
Change-Id: Ifb3ae020ef61dac1e229a1893b9b88eb648e123e
diff --git a/src/common/optionsWatcher.js b/src/common/optionsWatcher.js
new file mode 100644
index 0000000..97a7ee8
--- /dev/null
+++ b/src/common/optionsWatcher.js
@@ -0,0 +1,48 @@
+import {Mutex, withTimeout} from 'async-mutex';
+
+import {getOptions} from './optionsUtils.js';
+
+export default class OptionsWatcher {
+  constructor(options) {
+    this.watchedOptions = options;
+    this.options = [];
+    for (let o of options) this.options[o] = false;
+    this.isStale = true;
+    this.mutex = withTimeout(new Mutex(), 60 * 1000);
+
+    // If the extension settings change, set the current cached value as stale.
+    // We could try only doing this only when we're sure it has changed, but
+    // there are many factors (if the user has changed it manually, if a kill
+    // switch was activated, etc.) so we'll do it every time.
+    chrome.storage.sync.onChanged.addListener(() => {
+      console.debug('[optionsWatcher] Marking options as stale.');
+      this.isStale = true;
+    });
+  }
+
+  // Returns a promise resolving to the value of option |option|.
+  getOption(option) {
+    if (!this.watchedOptions.includes(option))
+      return Promise.reject(new Error(
+          '[optionsWatcher] We\'re not watching option ' + option + '.'));
+
+    // When the cached value is marked as stale, it might be possible that there
+    // is a flood of calls to isEnabled(), which in turn causes a flood of calls
+    // to getOptions() because it takes some time for it to be marked as not
+    // stale. Thus, hiding the logic behind a mutex fixes this.
+    return this.mutex.runExclusive(() => {
+      if (!this.isStale) return Promise.resolve(this.options[option]);
+
+      return getOptions(this.watchedOptions).then(options => {
+        this.options = options;
+        this.isStale = false;
+        return this.options[option];
+      });
+    });
+  }
+
+  // Returns a promise resolving to whether the |feature| is enabled.
+  isEnabled(feature) {
+    return this.getOption(feature).then(option => option === true);
+  }
+}
diff --git a/src/contentScripts/communityConsole/avatars.js b/src/contentScripts/communityConsole/avatars.js
index 2936fac..30b55d0 100644
--- a/src/contentScripts/communityConsole/avatars.js
+++ b/src/contentScripts/communityConsole/avatars.js
@@ -1,9 +1,8 @@
-import {Mutex, withTimeout} from 'async-mutex';
 import {waitFor} from 'poll-until-promise';
 
 import {CCApi} from '../../common/api.js';
 import {parseUrl} from '../../common/commonUtils.js';
-import {isOptionEnabled} from '../../common/optionsUtils.js';
+import OptionsWatcher from '../../common/optionsWatcher.js';
 import {createPlainTooltip} from '../../common/tooltip.js';
 
 import AvatarsDB from './utils/AvatarsDB.js'
@@ -13,9 +12,7 @@
     this.isFilterSetUp = false;
     this.privateForums = [];
     this.db = new AvatarsDB();
-    this.featureEnabled = false;
-    this.featureEnabledIsStale = true;
-    this.featureEnabledMutex = withTimeout(new Mutex(), 60 * 1000);
+    this.optionsWatcher = new OptionsWatcher(['threadlistavatars']);
 
     // Preload whether the option is enabled or not. This is because in the case
     // avatars should be injected, if we don't preload this the layout will
@@ -24,34 +21,12 @@
       if (isEnabled)
         document.body.classList.add('TWPT-threadlistavatars-enabled');
     });
-
-    // If the extension settings change, set this.featureEnabled as stale. We
-    // could try only doing this only when we're sure it has changed, but there
-    // are many factors (if the user has changed it manually, if a kill switch
-    // was activated, etc.) so we'll do it every time.
-    chrome.storage.sync.onChanged.addListener(() => {
-      console.debug('[threadListAvatars] Marking featureEnabled as stale.');
-      this.featureEnabledIsStale = true;
-    });
   }
 
   // Returns a promise resolving to whether the threadlistavatars feature is
   // enabled.
   isEnabled() {
-    // When this.featureEnabled is marked as stale, the next time avatars are
-    // injected there is a flood of calls to isEnabled(), which in turn causes a
-    // flood of calls to isOptionEnabled() because it takes some time for it to
-    // be marked as not stale. Thus, hiding the logic behind a mutex fixes this.
-    return this.featureEnabledMutex.runExclusive(() => {
-      if (!this.featureEnabledIsStale)
-        return Promise.resolve(this.featureEnabled);
-
-      return isOptionEnabled('threadlistavatars').then(isEnabled => {
-        this.featureEnabled = isEnabled;
-        this.featureEnabledIsStale = false;
-        return isEnabled;
-      });
-    });
+    return this.optionsWatcher.isEnabled('threadlistavatars');
   }
 
   // Gets a list of private forums. If it is already cached, the cached list is