FEATURE: First pass of using uppy in the composer (#13935)

FEATURE: First pass of using uppy in the composer (#13935)

Adds uppy upload functionality behind a enable_experimental_composer_uploader site setting (default false, and hidden).

When enabled this site setting will make the composer-editor-uppy component be used within composer.hbs, which in turn points to a ComposerUploadUppy mixin which overrides the relevant functions from ComposerUpload. This uppy uploader has parity with all the features of jQuery file uploader in the original composer-editor, including:

progress tracking error handling number of files validation pasting files dragging and dropping files updating upload placeholders upload markdown resolvers processing actions (the only one we have so far is the media optimization worker by falco, this works) cancelling uploads For now all uploads still go via the /uploads.json endpoint, direct S3 support will be added later.

Also included in this PR are some changes to the media optimization service, to support uppy’s different file data structures, and also to make the promise tracking and resolving more robust. Currently it uses the file name to track promises, we can switch to something more unique later if needed.

Does not include custom upload handlers, that will come in a later PR, it is a tricky problem to handle.

Also, this new functionality will not be used in encrypted PMs because encrypted PM uploads rely on custom upload handlers.

diff --git a/app/assets/javascripts/discourse/app/components/composer-editor-uppy.js b/app/assets/javascripts/discourse/app/components/composer-editor-uppy.js
new file mode 100644
index 0000000..f079c47
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/composer-editor-uppy.js
@@ -0,0 +1,7 @@
+import ComposerEditor from "discourse/components/composer-editor";
+import ComposerUploadUppy from "discourse/mixins/composer-upload-uppy";
+
+export default ComposerEditor.extend(ComposerUploadUppy, {
+  layoutName: "components/composer-editor",
+  experimentalComposerUploads: true,
+});
diff --git a/app/assets/javascripts/discourse/app/controllers/composer.js b/app/assets/javascripts/discourse/app/controllers/composer.js
index 5f0f114..acd6a05 100644
--- a/app/assets/javascripts/discourse/app/controllers/composer.js
+++ b/app/assets/javascripts/discourse/app/controllers/composer.js
@@ -1,4 +1,5 @@
 import Composer, { SAVE_ICONS, SAVE_LABELS } from "discourse/models/composer";
+import { warn } from "@ember/debug";
 import Controller, { inject as controller } from "@ember/controller";
 import EmberObject, { action, computed } from "@ember/object";
 import { alias, and, or, reads } from "@ember/object/computed";
@@ -284,6 +285,22 @@ export default Controller.extend({
     return option;
   },
 
+  @discourseComputed("model.isEncrypted")
+  composerComponent(isEncrypted) {
+    const defaultComposer = "composer-editor";
+    if (this.siteSettings.enable_experimental_composer_uploader) {
+      if (isEncrypted) {
+        warn(
+          "Uppy cannot be used for composer uploads until upload handlers are developed, falling back to composer-editor.",
+          { id: "composer" }
+        );
+        return defaultComposer;
+      }
+      return "composer-editor-uppy";
+    }
+    return defaultComposer;
+  },
+
   @discourseComputed("model.composeState", "model.creatingTopic", "model.post")
   popupMenuOptions(composeState) {
     if (composeState === "open" || composeState === "fullscreen") {
@@ -482,14 +499,14 @@ export default Controller.extend({
             }
           }
 
-          const [warn, info] = linkLookup.check(post, href);
+          const [linkWarn, linkInfo] = linkLookup.check(post, href);
 
-          if (warn) {
+          if (linkWarn) {
             const body = I18n.t("composer.duplicate_link", {
-              domain: info.domain,
-              username: info.username,
-              post_url: topic.urlForPostNumber(info.post_number),
-              ago: shortDate(info.posted_at),
+              domain: linkInfo.domain,
+              username: linkInfo.username,
+              post_url: topic.urlForPostNumber(linkInfo.post_number),
+              ago: shortDate(linkInfo.posted_at),
             });
             this.appEvents.trigger("composer-messages:create", {
               extraClass: "custom-body",
diff --git a/app/assets/javascripts/discourse/app/lib/uploads.js b/app/assets/javascripts/discourse/app/lib/uploads.js
index 04500a0..19b78ec 100644
--- a/app/assets/javascripts/discourse/app/lib/uploads.js
+++ b/app/assets/javascripts/discourse/app/lib/uploads.js
@@ -343,3 +343,14 @@ function displayErrorByResponseStatus(status, body, fileName, siteSettings) {
 
   return;
 }
+
+export function bindFileInputChangeListener(element, fileCallbackFn) {
+  function changeListener(event) {
+    const files = Array.from(event.target.files);
+    files.forEach((file) => {
+      fileCallbackFn(file);
+    });
+  }
+  element.addEventListener("change", changeListener);
+  return changeListener;
+}
diff --git a/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js b/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js
index c14e03f..4cd0cea 100644
--- a/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js
+++ b/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js
@@ -6,47 +6,49 @@ export default class UppyChecksum extends Plugin {
   constructor(uppy, opts) {
     super(uppy, opts);
     this.id = opts.id || "uppy-checksum";
+    this.pluginClass = this.constructor.name;
     this.capabilities = opts.capabilities;
     this.type = "preprocessor";
   }
 
-  canUseSubtleCrypto() {
-    if (!window.isSecureContext) {
-      this.warnPrefixed(
-        "Cannot generate cryptographic digests in an insecure context (not HTTPS)."
+  _canUseSubtleCrypto() {
+    if (!this._secureContext()) {
+      warn(
+        "Cannot generate cryptographic digests in an insecure context (not HTTPS).",
+        {
+          id: "discourse.uppy-media-optimization",
+        }
       );
       return false;
     }
     if (this.capabilities.isIE11) {
-      this.warnPrefixed(
-        "The required cipher suite is unavailable in Internet Explorer 11."
+      warn(
+        "The required cipher suite is unavailable in Internet Explorer 11.",
+        {
+          id: "discourse.uppy-media-optimization",
+        }
       );
       return false;
     }
-    if (
-      !(window.crypto && window.crypto.subtle && window.crypto.subtle.digest)
-    ) {
-      this.warnPrefixed(
-        "The required cipher suite is unavailable in this browser."
-      );
+    if (!this._hasCryptoCipher()) {
+      warn("The required cipher suite is unavailable in this browser.", {
+        id: "discourse.uppy-media-optimization",
+      });
       return false;
     }
 
     return true;
   }
 
-  generateChecksum(fileIds) {
-    if (!this.canUseSubtleCrypto()) {
+  _generateChecksum(fileIds) {
+    if (!this._canUseSubtleCrypto()) {
       return Promise.resolve();
     }
 
     let promises = fileIds.map((fileId) => {
       let file = this.uppy.getFile(fileId);
 
-      this.uppy.emit("preprocess-progress", file, {
-        mode: "indeterminate",
-        message: "generating checksum",
-      });
+      this.uppy.emit("preprocess-progress", this.pluginClass, file);
 
       return file.data.arrayBuffer().then((arrayBuffer) => {
         return window.crypto.subtle
@@ -57,38 +59,41 @@ export default class UppyChecksum extends Plugin {
               .map((b) => b.toString(16).padStart(2, "0"))
               .join("");
             this.uppy.setFileMeta(fileId, { sha1_checksum: hashHex });
+            this.uppy.emit("preprocess-complete", this.pluginClass, file);
           })
           .catch((err) => {
             if (
               err.message.toString().includes("Algorithm: Unrecognized name")
             ) {
-              this.warnPrefixed(
-                "SHA-1 algorithm is unsupported in this browser."
-              );
+              warn("SHA-1 algorithm is unsupported in this browser.", {
+                id: "discourse.uppy-media-optimization",
+              });
+            } else {
+              warn(`Error encountered when generating digest: ${err.message}`, {
+                id: "discourse.uppy-media-optimization",
+              });
             }
+            this.uppy.emit("preprocess-complete", this.pluginClass, file);
           });
       });
     });
 
-    const emitPreprocessCompleteForAll = () => {
-      fileIds.forEach((fileId) => {
-        const file = this.uppy.getFile(fileId);
-        this.uppy.emit("preprocess-complete", file);
-      });
-    };
+    return Promise.all(promises);
+  }
 
-    return Promise.all(promises).then(emitPreprocessCompleteForAll);
+  _secureContext() {
+    return window.isSecureContext;
   }
 
-  warnPrefixed(message) {
-    warn(`[uppy-checksum-plugin] ${message}`);
+  _hasCryptoCipher() {
+    return window.crypto && window.crypto.subtle && window.crypto.subtle.digest;
   }
 
   install() {
-    this.uppy.addPreProcessor(this.generateChecksum.bind(this));
+    this.uppy.addPreProcessor(this._generateChecksum.bind(this));
   }
 
   uninstall() {
-    this.uppy.removePreProcessor(this.generateChecksum.bind(this));
+    this.uppy.removePreProcessor(this._generateChecksum.bind(this));
   }
 }

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

GitHub sha: b626373b31162984524c39ff877ab7f92ea314b1

This commit appears in #13935 which was approved by jjaffeux. It was merged by martin.