Avatars: cache whether the feature is enabled

Calling isOptionEnabled() every time the feature needs to inject avatars
next to a thread is very expensive, so this CL adds logic for caching
this value and updating it when necessary. It could be further
optimized, but I think we have achieved a sweet spot between
optimization and complexity (which could cause bugs).

Fixed: twpowertools:88
Change-Id: Ia7abba2579a00e14125d9912ea21fa953b3fcd53
diff --git a/package-lock.json b/package-lock.json
index 7a6ad3c..538a7f1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
       "license": "MIT",
       "dependencies": {
         "@material/tooltip": "^12.0.0",
+        "async-mutex": "^0.3.2",
         "google-protobuf": "^3.18.0-rc.2",
         "grpc-web": "^1.2.1",
         "idb": "^6.1.2",
@@ -467,6 +468,14 @@
         "node": ">=8"
       }
     },
+    "node_modules/async-mutex": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
+      "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==",
+      "dependencies": {
+        "tslib": "^2.3.1"
+      }
+    },
     "node_modules/binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -2652,6 +2661,14 @@
       "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
       "dev": true
     },
+    "async-mutex": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz",
+      "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==",
+      "requires": {
+        "tslib": "^2.3.1"
+      }
+    },
     "binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
diff --git a/package.json b/package.json
index 3263d41..5765554 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
   "private": true,
   "dependencies": {
     "@material/tooltip": "^12.0.0",
+    "async-mutex": "^0.3.2",
     "google-protobuf": "^3.18.0-rc.2",
     "grpc-web": "^1.2.1",
     "idb": "^6.1.2",
diff --git a/src/contentScripts/communityConsole/avatars.js b/src/contentScripts/communityConsole/avatars.js
index 03d6bcb..2936fac 100644
--- a/src/contentScripts/communityConsole/avatars.js
+++ b/src/contentScripts/communityConsole/avatars.js
@@ -1,3 +1,4 @@
+import {Mutex, withTimeout} from 'async-mutex';
 import {waitFor} from 'poll-until-promise';
 
 import {CCApi} from '../../common/api.js';
@@ -12,14 +13,45 @@
     this.isFilterSetUp = false;
     this.privateForums = [];
     this.db = new AvatarsDB();
+    this.featureEnabled = false;
+    this.featureEnabledIsStale = true;
+    this.featureEnabledMutex = withTimeout(new Mutex(), 60 * 1000);
 
     // 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
     // shift when injecting the first avatar.
-    isOptionEnabled('threadlistavatars').then(isEnabled => {
+    this.isEnabled().then(isEnabled => {
       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;
+      });
+    });
   }
 
   // Gets a list of private forums. If it is already cached, the cached list is
@@ -356,7 +388,7 @@
   // Inject avatars for thread summary (thread item) |node| in a thread list if
   // the threadlistavatars option is enabled.
   injectIfEnabled(node) {
-    isOptionEnabled('threadlistavatars').then(isEnabled => {
+    this.isEnabled().then(isEnabled => {
       if (isEnabled) {
         document.body.classList.add('TWPT-threadlistavatars-enabled');
         this.inject(node);