FEATURE: Uppy image uploader with UppyUploadMixin (#13656)

FEATURE: Uppy image uploader with UppyUploadMixin (#13656)

This PR adds the first use of Uppy in our codebase, hidden behind a enable_experimental_image_uploader site setting. When the setting is enabled only the user card background uploader will use the new uppy-image-uploader component added in this PR.

I’ve introduced an UppyUpload mixin that has feature parity with the existing Upload mixin, and improves it slightly to deal with multiple/single file distinctions and validations better. For now, this just supports the XHRUpload plugin for uppy, which keeps our existing POST to /uploads.json.

diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js
index 8a0cf56..adb7afa 100644
--- a/app/assets/javascripts/discourse-shims.js
+++ b/app/assets/javascripts/discourse-shims.js
@@ -45,10 +45,23 @@ define("@popperjs/core", ["exports"], function (__exports__) {
   __exports__.popperGenerator = window.Popper.popperGenerator;
 });
 
-define("uppy", ["exports"], function (__exports__) {
+define("@uppy/core", ["exports"], function (__exports__) {
   __exports__.default = window.Uppy.Core;
   __exports__.Plugin = window.Uppy.Plugin;
-  __exports__.XHRUpload = window.Uppy.XHRUpload;
-  __exports__.AwsS3 = window.Uppy.AwsS3;
-  __exports__.AwsS3Multipart = window.Uppy.AwsS3Multipart;
+});
+
+define("@uppy/aws-s3", ["exports"], function (__exports__) {
+  __exports__.default = window.Uppy.AwsS3;
+});
+
+define("@uppy/aws-s3-multipart", ["exports"], function (__exports__) {
+  __exports__.default = window.Uppy.AwsS3Multipart;
+});
+
+define("@uppy/xhr-upload", ["exports"], function (__exports__) {
+  __exports__.default = window.Uppy.XHRUpload;
+});
+
+define("@uppy/drop-target", ["exports"], function (__exports__) {
+  __exports__.default = window.Uppy.DropTarget;
 });
diff --git a/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
new file mode 100644
index 0000000..7e1ecbf
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/uppy-image-uploader.js
@@ -0,0 +1,133 @@
+import Component from "@ember/component";
+import UppyUploadMixin from "discourse/mixins/uppy-upload";
+import { ajax } from "discourse/lib/ajax";
+import discourseComputed from "discourse-common/utils/decorators";
+import { getURLWithCDN } from "discourse-common/lib/get-url";
+import { isEmpty } from "@ember/utils";
+import lightbox from "discourse/lib/lightbox";
+import { next } from "@ember/runloop";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+
+export default Component.extend(UppyUploadMixin, {
+  classNames: ["image-uploader"],
+  loadingLightbox: false,
+
+  init() {
+    this._super(...arguments);
+    this._applyLightbox();
+  },
+
+  willDestroyElement() {
+    this._super(...arguments);
+    const elem = $("a.lightbox");
+    if (elem && typeof elem.magnificPopup === "function") {
+      $("a.lightbox").magnificPopup("close");
+    }
+  },
+
+  @discourseComputed("imageUrl", "placeholderUrl")
+  showingPlaceholder(imageUrl, placeholderUrl) {
+    return !imageUrl && placeholderUrl;
+  },
+
+  @discourseComputed("placeholderUrl")
+  placeholderStyle(url) {
+    if (isEmpty(url)) {
+      return "".htmlSafe();
+    }
+    return `background-image: url(${url})`.htmlSafe();
+  },
+
+  @discourseComputed("imageUrl")
+  imageCDNURL(url) {
+    if (isEmpty(url)) {
+      return "".htmlSafe();
+    }
+
+    return getURLWithCDN(url);
+  },
+
+  @discourseComputed("imageCDNURL")
+  backgroundStyle(url) {
+    return `background-image: url(${url})`.htmlSafe();
+  },
+
+  @discourseComputed("imageUrl")
+  imageBaseName(imageUrl) {
+    if (isEmpty(imageUrl)) {
+      return;
+    }
+    return imageUrl.split("/").slice(-1)[0];
+  },
+
+  validateUploadedFilesOptions() {
+    return { imagesOnly: true };
+  },
+
+  uploadDone(upload) {
+    this.setProperties({
+      imageUrl: upload.url,
+      imageId: upload.id,
+      imageFilesize: upload.human_filesize,
+      imageFilename: upload.original_filename,
+      imageWidth: upload.width,
+      imageHeight: upload.height,
+    });
+
+    this._applyLightbox();
+
+    if (this.onUploadDone) {
+      this.onUploadDone(upload);
+    }
+  },
+
+  _openLightbox() {
+    next(() =>
+      $(this.element.querySelector("a.lightbox")).magnificPopup("open")
+    );
+  },
+
+  _applyLightbox() {
+    if (this.imageUrl) {
+      next(() => lightbox(this.element, this.siteSettings));
+    }
+  },
+
+  actions: {
+    toggleLightbox() {
+      if (this.imageFilename) {
+        this._openLightbox();
+      } else {
+        this.set("loadingLightbox", true);
+
+        ajax(`/uploads/lookup-metadata`, {
+          type: "POST",
+          data: { url: this.imageUrl },
+        })
+          .then((json) => {
+            this.setProperties({
+              imageFilename: json.original_filename,
+              imageFilesize: json.human_filesize,
+              imageWidth: json.width,
+              imageHeight: json.height,
+            });
+
+            this._openLightbox();
+            this.set("loadingLightbox", false);
+          })
+          .catch(popupAjaxError);
+      }
+    },
+
+    trash() {
+      this.setProperties({ imageUrl: null, imageId: null });
+
+      // uppy needs to be reset to allow for more uploads
+      this._reset();
+
+      if (this.onUploadDeleted) {
+        this.onUploadDeleted();
+      }
+    },
+  },
+});
diff --git a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js
index 444de26..d6a40c3 100644
--- a/app/assets/javascripts/discourse/app/controllers/preferences/profile.js
+++ b/app/assets/javascripts/discourse/app/controllers/preferences/profile.js
@@ -57,6 +57,10 @@ export default Controller.extend({
     "model.can_upload_user_card_background"
   ),
 
+  experimentalUserCardImageUpload: readOnly(
+    "siteSettings.enable_experimental_image_uploader"
+  ),
+
   actions: {
     showFeaturedTopicModal() {
       showModal("feature-topic-on-profile", {
diff --git a/app/assets/javascripts/discourse/app/lib/uploads.js b/app/assets/javascripts/discourse/app/lib/uploads.js
index 99d1127..480a300 100644
--- a/app/assets/javascripts/discourse/app/lib/uploads.js
+++ b/app/assets/javascripts/discourse/app/lib/uploads.js
@@ -29,30 +29,28 @@ export function validateUploadedFiles(files, opts) {
   }
 
   const upload = files[0];
+  return validateUploadedFile(upload, opts);
+}
 
+export function validateUploadedFile(file, opts) {
   // CHROME ONLY: if the image was pasted, sets its name to a default one
   if (typeof Blob !== "undefined" && typeof File !== "undefined") {
     if (
-      upload instanceof Blob &&
-      !(upload instanceof File) &&
-      upload.type === "image/png"
+      file instanceof Blob &&
+      !(file instanceof File) &&
+      file.type === "image/png"
     ) {
-      upload.name = "image.png";
+      file.name = "image.png";
     }
   }
 
   opts = opts || {};
-  opts.type = uploadTypeFromFileName(upload.name);
+  opts.type = uploadTypeFromFileName(file.name);
 
-  return validateUploadedFile(upload, opts);
-}
-
-function validateUploadedFile(file, opts) {
   if (opts.skipValidation) {
     return true;
   }
 
-  opts = opts || {};
   let user = opts.user;
   let staff = user && user.staff;
 
@@ -283,27 +281,14 @@ export function getUploadMarkdown(upload) {
 
 export function displayErrorForUpload(data, siteSettings) {
   if (data.jqXHR) {
-    switch (data.jqXHR.status) {
-      // didn't get headers from server, or browser refuses to tell us
-      case 0:
-        bootbox.alert(I18n.t("post.errors.upload"));
-        return;
-
-      // entity too large, usually returned from the web server
-      case 413:
-        const type = uploadTypeFromFileName(data.files[0].name);
-        const max_size_kb = siteSettings[`max_${type}_size_kb`];
-        bootbox.alert(I18n.t("post.errors.file_too_large", { max_size_kb }));
-        return;
-
-      // the error message is provided by the server
-      case 422:
-        if (data.jqXHR.responseJSON.message) {
-          bootbox.alert(data.jqXHR.responseJSON.message);
-        } else {
-          bootbox.alert(data.jqXHR.responseJSON.errors.join("\n"));
-        }
-        return;
+    const didError = displayErrorByResponseStatus(
+      data.jqXHR.status,
+      data.jqXHR.responseJSON,
+      data.files[0].name,
+      siteSettings
+    );
+    if (didError) {
+      return;
     }

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

GitHub sha: 7911124d3d93231f4afa628fa1f89a9fba65a210

This commit appears in #13656 which was approved by lis2. It was merged by martin.