FEATURE: Decrypt notification titles before rendering (#16)

FEATURE: Decrypt notification titles before rendering (#16)

This aims to avoid the ‘jumping’ when opening the user menu.

diff --git a/assets/javascripts/discourse/initializers/hook-decrypt-topic.js.es6 b/assets/javascripts/discourse/initializers/hook-decrypt-topic.js.es6
index 880f712..19f6bbe 100644
--- a/assets/javascripts/discourse/initializers/hook-decrypt-topic.js.es6
+++ b/assets/javascripts/discourse/initializers/hook-decrypt-topic.js.es6
@@ -7,7 +7,9 @@ import {
   ENCRYPT_ACTIVE,
   getEncryptionStatus,
   getTopicTitle,
-  hasTopicTitle
+  syncGetTopicTitle,
+  hasTopicTitle,
+  waitForPendingTitles
 } from "discourse/plugins/discourse-encrypt/lib/discourse";
 
 /**
@@ -89,7 +91,54 @@ export default {
       }
     });
 
-    // Decrypt notifications when opening the user menu or searching.
+    withPluginApi("0.8.31", api => {
+      // All quick-access panels
+      api.reopenWidget("quick-access-panel", {
+        setItems() {
+          // Artificially delay loading until all titles are decrypted
+          return waitForPendingTitles().then(() => this._super(...arguments));
+        }
+      });
+
+      // Notification topic titles
+      api.reopenWidget("default-notification-item", {
+        description() {
+          if (
+            this.attrs.fancy_title &&
+            this.attrs.topic_id &&
+            this.attrs.topic_key
+          ) {
+            const decrypted = syncGetTopicTitle(this.attrs.topic_id);
+            if (decrypted)
+              return `<span data-topic-id="${
+                this.attrs.topic_id
+              }">${escapeExpression(decrypted)}</span>`;
+          }
+          return this._super(...arguments);
+        }
+      });
+
+      // Non-notification quick-access topic titles (assign, bookmarks, PMs)
+      api.reopenWidget("quick-access-item", {
+        _contentHtml() {
+          const href = this.attrs.href;
+          if (href) {
+            let topicId = href.match(/\/t\/.*?\/(\d+)/);
+            if (topicId && topicId[1]) {
+              topicId = parseInt(topicId[1], 10);
+              const decrypted = syncGetTopicTitle(topicId);
+              if (decrypted)
+                return `<span data-topic-id="${
+                  this.attrs.topic_id
+                }">${escapeExpression(decrypted)}</span>`;
+            }
+          }
+
+          return this._super(...arguments);
+        }
+      });
+    });
+
     withPluginApi("0.8.31", api => {
       api.decorateWidget("header:after", helper => {
         if (
@@ -113,7 +162,6 @@ export default {
     decryptElements("a.topic-link[data-topic-id]", "span");
     decryptElements("a.topic-link[data-topic-id]", { addIcon: true });
     decryptElements("a.raw-topic-link[data-topic-id]", { addIcon: true });
-    decryptElements(".quick-access-panel span[data-topic-id]");
     decryptElements(".search-result-topic span[data-topic-id]");
   },
 
diff --git a/assets/javascripts/lib/discourse.js.es6 b/assets/javascripts/lib/discourse.js.es6
index bee11f0..14aabfc 100644
--- a/assets/javascripts/lib/discourse.js.es6
+++ b/assets/javascripts/lib/discourse.js.es6
@@ -51,10 +51,28 @@ const userIdentities = {};
 const topicKeys = {};
 
 /**
- * @var {Object} topicTitles Dictionary of all encrypted topic titles.
+ * @var {Object} topicTitles Dictionary of all topic title objects (topic_id => TopicTitle).
  */
 const topicTitles = {};
 
+class TopicTitle {
+  constructor(topicId, encrypted) {
+    this.topicId = topicId;
+    this.encrypted = encrypted;
+  }
+
+  get promise() {
+    if (!this._promise) {
+      this._promise = getTopicKey(this.topicId)
+        .then(key => decrypt(key, this.encrypted))
+        .then(decrypted => decrypted.raw)
+        .then(result => (this.result = result));
+    }
+
+    return this._promise;
+  }
+}
+
 /**
  * Gets current user's identity from the database and caches it for future
  * usage.
@@ -176,9 +194,10 @@ export function hasTopicKey(topicId) {
  * @param {String} title
  */
 export function putTopicTitle(topicId, title) {
-  if (topicId && title) {
-    topicTitles[topicId] = title;
-  }
+  if (!(topicId && title)) return;
+  if (topicTitles[topicId] && topicTitles[topicId].encrypted === title) return;
+
+  topicTitles[topicId] = new TopicTitle(topicId, title);
 }
 
 /**
@@ -189,17 +208,22 @@ export function putTopicTitle(topicId, title) {
  * @return {Promise<String>}
  */
 export function getTopicTitle(topicId) {
-  let title = topicTitles[topicId];
-
-  if (!title) {
-    return Promise.reject();
-  } else if (!(title instanceof Promise || title instanceof window.Promise)) {
-    topicTitles[topicId] = getTopicKey(topicId)
-      .then(key => decrypt(key, title))
-      .then(decrypted => decrypted.raw);
-  }
+  const title = topicTitles[topicId];
+  if (!title) return Promise.reject();
+  return title.promise;
+}
 
-  return topicTitles[topicId];
+/**
+ * Gets a topic title from storage synchronously, returning null if missing or unresolved
+ *
+ * @param {Number|String} topicId
+ *
+ * @return {String|null}
+ */
+export function syncGetTopicTitle(topicId) {
+  const title = topicTitles[topicId];
+  if (!title) return null;
+  return title.result;
 }
 
 /**
@@ -213,6 +237,19 @@ export function hasTopicTitle(topicId) {
   return !!topicTitles[topicId];
 }
 
+/**
+ * Returns a promise which resolves when all stored  titles are decrypted
+ *
+ * @return {Promise}
+ */
+export function waitForPendingTitles() {
+  return Promise.all(
+    Object.values(topicTitles)
+      .filter(t => !t.result)
+      .map(t => t.promise)
+  );
+}
+
 /*
  * Plugin management
  */
diff --git a/test/javascripts/acceptance/encrypt-test.js.es6 b/test/javascripts/acceptance/encrypt-test.js.es6
index 54dd8ca..2b62491 100644
--- a/test/javascripts/acceptance/encrypt-test.js.es6
+++ b/test/javascripts/acceptance/encrypt-test.js.es6
@@ -24,7 +24,6 @@ import { parsePostData } from "helpers/create-pretender";
 import { acceptance, updateCurrentUser } from "helpers/qunit-helpers";
 import selectKit from "helpers/select-kit-helper";
 import { Promise } from "rsvp";
-
 /*
  * Checks if a string is not contained in a string.
  *
@@ -442,19 +441,18 @@ test("topic titles in notification panel are decrypted", async assert => {
     }
   ]);
 
-  const stub = sandbox.stub(EncryptLibDiscourse, "getTopicTitle");
-  stub.returns(Promise.resolve("Top Secret :male_detective:"));
+  const stub = sandbox.stub(EncryptLibDiscourse, "syncGetTopicTitle");
+  stub.returns("Top Secret :male_detective:");
+
+  const stub2 = sandbox.stub(EncryptLibDiscourse, "getTopicTitle");
+  stub2.returns(Promise.resolve("Top Secret :male_detective:"));
+
+  const stub3 = sandbox.stub(EncryptLibDiscourse, "waitForPendingTitles");
+  stub3.returns(Promise.resolve());
 
   await visit("/");
   await click(".header-dropdown-toggle.current-user");
 
-  // The plugin debounces the decryption operation to ensure that DOM elements
-  // containing encrypted information are displayed.
-  // In test environments, because `Ember.run.debounce` is aliased to `Ember.run`.
-  await window.Discourse.__container__
-    .lookup("service:app-events")
-    .trigger("encrypt:status-changed", true);
-
   assert.equal(
     find(".quick-access-panel span[data-topic-id]").text(),
     "Top Secret "

GitHub sha: f36a637d

1 Like

This commit appears in #16 which was merged by davidtaylorhq.