FEATURE: Cook drafts excerpt in user activity (#14315)

FEATURE: Cook drafts excerpt in user activity (#14315)

The previous excerpt was a simple truncated raw message. Starting with this commit, the raw content of the draft is cooked and an excerpt is extracted from it. The logic for extracting the excerpt mimics the the ExcerptParser class, but does not implement all functionality, being a much simpler implementation.

The two draft controllers have been merged into one and the /draft.json route has been changed to /drafts.json to be consistent with the other route names.

diff --git a/app/assets/javascripts/discourse/app/lib/text.js b/app/assets/javascripts/discourse/app/lib/text.js
index f83d506..ea40982 100644
--- a/app/assets/javascripts/discourse/app/lib/text.js
+++ b/app/assets/javascripts/discourse/app/lib/text.js
@@ -110,3 +110,53 @@ export function emojiUrlFor(code) {
     return buildEmojiUrl(code, opts);
   }
 }
+
+function encode(str) {
+  return str.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
+}
+
+function traverse(element, callback) {
+  if (callback(element)) {
+    element.childNodes.forEach((child) => traverse(child, callback));
+  }
+}
+
+export function excerpt(cooked, length) {
+  let result = "";
+  let resultLength = 0;
+
+  const div = document.createElement("div");
+  div.innerHTML = cooked;
+  traverse(div, (element) => {
+    if (resultLength >= length) {
+      return;
+    }
+
+    if (element.nodeType === Node.TEXT_NODE) {
+      if (resultLength + element.textContent.length > length) {
+        const text = element.textContent.substr(0, length - resultLength);
+        result += encode(text);
+        result += "&hellip;";
+        resultLength += text.length;
+      } else {
+        result += encode(element.textContent);
+        resultLength += element.textContent.length;
+      }
+    } else if (element.tagName === "A") {
+      element.innerHTML = element.innerText;
+      result += element.outerHTML;
+      resultLength += element.innerText.length;
+    } else if (element.tagName === "IMG") {
+      if (element.classList.contains("emoji")) {
+        result += element.outerHTML;
+      } else {
+        result += "[image]";
+        resultLength += "[image]".length;
+      }
+    } else {
+      return true;
+    }
+  });
+
+  return result;
+}
diff --git a/app/assets/javascripts/discourse/app/models/draft.js b/app/assets/javascripts/discourse/app/models/draft.js
index 37dda0b..4275cab 100644
--- a/app/assets/javascripts/discourse/app/models/draft.js
+++ b/app/assets/javascripts/discourse/app/models/draft.js
@@ -5,17 +5,14 @@ const Draft = EmberObject.extend();
 
 Draft.reopenClass({
   clear(key, sequence) {
-    return ajax("/draft.json", {
+    return ajax(`/drafts/${key}.json`, {
       type: "DELETE",
       data: { draft_key: key, sequence },
     });
   },
 
   get(key) {
-    return ajax("/draft.json", {
-      data: { draft_key: key },
-      dataType: "json",
-    });
+    return ajax(`/drafts/${key}.json`);
   },
 
   getLocal(key, current) {
@@ -25,7 +22,7 @@ Draft.reopenClass({
 
   save(key, sequence, data, clientId, { forceSave = false } = {}) {
     data = typeof data === "string" ? data : JSON.stringify(data);
-    return ajax("/draft.json", {
+    return ajax("/drafts.json", {
       type: "POST",
       data: {
         draft_key: key,
diff --git a/app/assets/javascripts/discourse/app/models/user-drafts-stream.js b/app/assets/javascripts/discourse/app/models/user-drafts-stream.js
index e703e5a..00d6f04 100644
--- a/app/assets/javascripts/discourse/app/models/user-drafts-stream.js
+++ b/app/assets/javascripts/discourse/app/models/user-drafts-stream.js
@@ -1,105 +1,100 @@
+import discourseComputed from "discourse-common/utils/decorators";
+import { ajax } from "discourse/lib/ajax";
+import { cookAsync, emojiUnescape, excerpt } from "discourse/lib/text";
+import { escapeExpression } from "discourse/lib/utilities";
 import {
   NEW_PRIVATE_MESSAGE_KEY,
   NEW_TOPIC_KEY,
 } from "discourse/models/composer";
-import { A } from "@ember/array";
-import { Promise } from "rsvp";
 import RestModel from "discourse/models/rest";
 import UserDraft from "discourse/models/user-draft";
-import { ajax } from "discourse/lib/ajax";
-import discourseComputed from "discourse-common/utils/decorators";
-import { emojiUnescape } from "discourse/lib/text";
-import { escapeExpression } from "discourse/lib/utilities";
-import { url } from "discourse/lib/computed";
+import { Promise } from "rsvp";
 
 export default RestModel.extend({
-  loaded: false,
+  limit: 30,
+
+  loading: false,
+  hasMore: false,
+  content: null,
 
   init() {
     this._super(...arguments);
-    this.setProperties({
-      itemsLoaded: 0,
-      content: [],
-      lastLoadedUrl: null,
-    });
+    this.reset();
   },
 
-  baseUrl: url(
-    "itemsLoaded",
-    "user.username_lower",
-    "/drafts.json?offset=%@&username=%@"
-  ),
-
-  load(site) {
+  reset() {
     this.setProperties({
-      itemsLoaded: 0,
+      loading: false,
+      hasMore: true,
       content: [],
-      lastLoadedUrl: null,
-      site: site,
     });
-    return this.findItems();
   },
 
-  @discourseComputed("content.length", "loaded")
-  noContent(contentLength, loaded) {
-    return loaded && contentLength === 0;
+  @discourseComputed("content.length", "loading")
+  noContent(contentLength, loading) {
+    return contentLength === 0 && !loading;
   },
 
   remove(draft) {
-    let content = this.content.filter(
-      (item) => item.draft_key !== draft.draft_key
+    this.set(
+      "content",
+      this.content.filter((item) => item.draft_key !== draft.draft_key)
     );
-    this.setProperties({ content, itemsLoaded: content.length });
   },
 
-  findItems() {
-    let findUrl = this.baseUrl;
-
-    const lastLoadedUrl = this.lastLoadedUrl;
-    if (lastLoadedUrl === findUrl) {
-      return Promise.resolve();
+  findItems(site) {
+    if (site) {
+      this.set("site", site);
     }
 
-    if (this.loading) {
+    if (this.loading || !this.hasMore) {
       return Promise.resolve();
     }
 
     this.set("loading", true);
 
-    return ajax(findUrl)
+    const url = `/drafts.json?offset=${this.content.length}&limit=${this.limit}`;
+    return ajax(url)
       .then((result) => {
-        if (result && result.no_results_help) {
+        if (!result) {
+          return;
+        }
+
+        if (result.no_results_help) {
           this.set("noContentHelp", result.no_results_help);
         }
-        if (result && result.drafts) {
-          const copy = A();
-          result.drafts.forEach((draft) => {
-            let draftData = JSON.parse(draft.data);
-            draft.post_number = draftData.postId || null;
+
+        if (!result.drafts) {
+          return;
+        }
+
+        this.set("hasMore", result.drafts.size >= this.limit);
+
+        const promises = result.drafts.map((draft) => {
+          draft.data = JSON.parse(draft.data);
+          return cookAsync(draft.data.reply).then((cooked) => {
+            draft.excerpt = excerpt(cooked.string, 300);
+            draft.post_number = draft.data.postId || null;
             if (
               draft.draft_key === NEW_PRIVATE_MESSAGE_KEY ||
               draft.draft_key === NEW_TOPIC_KEY
             ) {
-              draft.title = draftData.title;
+              draft.title = draft.data.title;
             }
             draft.title = emojiUnescape(escapeExpression(draft.title));
-            if (draft.category_id) {
+            if (draft.data.categoryId) {
               draft.category =
-                this.site.categories.findBy("id", draft.category_id) || null;
+                this.site.categories.findBy("id", draft.data.categoryId) ||
+                null;
             }
-
-            copy.pushObject(UserDraft.create(draft));
+            this.content.push(UserDraft.create(draft));
           });
-          this.content.pushObjects(copy);
-          this.setProperties({
-            loaded: true,
-            itemsLoaded: this.itemsLoaded + result.drafts.length,
-          });
-        }
+        });
+
+        return Promise.all(promises);
       })
       .finally(() => {
         this.set("loading", false);
-        this.set("lastLoadedUrl", findUrl);
       });
   },
 });
diff --git a/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js b/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js
index 840015b..1116dee 100644
--- a/app/assets/javascripts/discourse/app/routes/user-activity-drafts.js

[... diff too long, it was truncated ...]

GitHub sha: f517b6997cacbf9d5602ad07da5642b4af20b7fa

This commit appears in #14315 which was approved by ZogStriP. It was merged by udan11.